timeoff-management / timeoff-management-application

Simple yet powerful absence management software for small and medium size business (community edition)
https://TimeOff.Management
MIT License
921 stars 568 forks source link

OIDC Integration (OpenID) example #559

Open vsychov opened 1 year ago

vsychov commented 1 year ago

Hello,

Because almost all pull requests to this repo stay ignored by maintainer for a long time, I'm creating this as issue, that may helpful for someone else. This is PoC integration oauth2-proxy, that allow to use OpenId providers (list can be found at https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/oauth_provider) with this app. PoC uses docker-compose, but can easy back ported to k8s or etc.

This is steps works on current master revision.

- File `lib/middleware/oidc_handler.js` should be created:
```javascript
"use strict";

module.exports = async function (req, res, next) {
    const oidcEmail = req?.headers?.["x-forwarded-email"].toLowerCase();
    const oidcName = req?.headers?.["x-forwarded-user"];
    const oidcGroups = req?.headers?.["x-forwarded-groups"]; //May be used to match with DepartmentId
    const models = req.app.get('db_model');

    if (!req.user) {
        let user = await models.User.find_by_email(oidcEmail);
        if (user === null) {
            const newUser = {
                email: oidcEmail,
                lastname: oidcName,
                name: oidcName,
                companyId: 1, //TODO: move to config
                DepartmentId: 1, //TODO: map to oidcGroups
                password: "null", // it should be string, not null
                admin: false,
                auto_approve: false,
                end_date: null,
                start_date: new Date(),
            }
            await models.User.create(newUser);
            user = await models.User.find_by_email(oidcEmail);
        }
        req.user = await user.reload_with_session_details();
    }

    next();
};

@@ -44,6 +42,7 @@ app.use(passport.initialize()); app.use(passport.session());

+app.use( require('./lib/middleware/oidc_handler') );

// Custom middlewares

- `docker-compose.yaml` example for simples https://oauth2-proxy.github.io/ integration:
```yaml
version: '3.8'
services:
  app:
    build: .

  oauth2-proxy:
    image: bitnami/oauth2-proxy
    depends_on:
      - app
    ports:
      - '3000:3000'
    command:
      - oauth2-proxy
      - --upstream=http://app:3000/
      - --http-address=:3000
      - --scope=email read_api read_user openid profile
      - --cookie-secure=true
      - --provider=gitlab
      - --email-domain=*
      - --oidc-issuer-url=https://gitlab.example.com
      - --redirect-url=http://localhost:3000/oauth2/callback
       # generate cookie-secret according to https://oauth2-proxy.github.io/oauth2-proxy/docs/configuration/overview#generating-a-cookie-secret
      - --cookie-secret=change_me
      - --client-id=change_me_to_gitlab_app_id
      - --client-secret=change_me_to_gitlab_app_secret

  db:
    image: mysql:8
    command:
      - mysqld
      - --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_ROOT_PASSWORD: 'password'
      MYSQL_PASSWORD: 'password'
      MYSQL_USER: 'user'
      MYSQL_DATABASE: 'timeoff'

Hope this project will be supported again. Big thanks to authors.

pwpbarney commented 7 months ago

@vsychov Thanks for sharing this. I'm trying to get it working with azure ad but I'm running into a problem with oidc_handler.js.

I'd really appreciate it if you could look at the below and point to what I'm doing wrong.

I'm not sure if the dependencies have changed since you originally did the design but I had to edit the Dockerfile to make it build:

FROM node:18-alpine

EXPOSE 3000

LABEL org.label-schema.schema-version="1.3.0"
LABEL org.label-schema.docker.cmd="docker run -d -p 3000:3000 --name timeoff-management"

RUN apk update
RUN apk upgrade
#Install dependencies
RUN apk add \
    git \
    make \
    python3 \
    g++ \
    gcc \
    libc-dev \
    clang 

#Add user so it doesn't run as root
RUN adduser --system app --home /app
USER app
WORKDIR /app

#clone app
RUN git clone https://github.com/timeoff-management/application.git timeoff-management

WORKDIR /app/timeoff-management

#Add in OIDC integration
COPY oidc_handler.js /app/timeoff-management/lib/middleware/oidc_handler.js
COPY app.js /app/timeoff-management/

RUN rm package-lock.json

#Update some dependencies
RUN sed -i 's/formidable"\: "~1.0.17/formidable"\: "1.1.1/' package.json
RUN sed -i 's/sqlite3"\: "^4.0.1/sqlite3"\: "^5.1.5/' package.json
RUN sed -i 's/node-sass"\: "^4.5.3/node-sass"\: "^7.0.3/' package.json
RUN sed -i 's/graceful-fs"\: "^4.4.2/graceful-fs"\: "4.4.2/' package.json

#install app
RUN npm install -y

CMD npm start

For simplicities sake I'm sticking with SQLite so haven't applied the MySQL patch.

The container runs but when you access the login page (original inbuilt one) it doesn't load and crashes the container. The logs show the following:

/app/timeoff-management/lib/middleware/oidc_handler.js:4
    const oidcEmail = req?.headers?.["x-forwarded-email"].toLowerCase();
                                                         ^

TypeError: Cannot read properties of undefined (reading 'toLowerCase')
    at module.exports (/app/timeoff-management/lib/middleware/oidc_handler.js:4:58)
    at Layer.handle [as handle_request] (/app/timeoff-management/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/app/timeoff-management/node_modules/express/lib/router/index.js:328:13)
    at /app/timeoff-management/node_modules/express/lib/router/index.js:286:9
    at Function.process_params (/app/timeoff-management/node_modules/express/lib/router/index.js:346:12)
    at next (/app/timeoff-management/node_modules/express/lib/router/index.js:280:10)
    at strategy.pass (/app/timeoff-management/node_modules/passport/lib/middleware/authenticate.js:325:9)
    at SessionStrategy.authenticate (/app/timeoff-management/node_modules/passport/lib/strategies/session.js:71:10)
    at attempt (/app/timeoff-management/node_modules/passport/lib/middleware/authenticate.js:348:16)
    at authenticate (/app/timeoff-management/node_modules/passport/lib/middleware/authenticate.js:349:7)
    at Layer.handle [as handle_request] (/app/timeoff-management/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/app/timeoff-management/node_modules/express/lib/router/index.js:328:13)
    at /app/timeoff-management/node_modules/express/lib/router/index.js:286:9
    at Function.process_params (/app/timeoff-management/node_modules/express/lib/router/index.js:346:12)
    at next (/app/timeoff-management/node_modules/express/lib/router/index.js:280:10)
    at initialize (/app/timeoff-management/node_modules/passport/lib/middleware/initialize.js:53:5)
    at Layer.handle [as handle_request] (/app/timeoff-management/node_modules/express/lib/router/layer.js:95:5)
    at trim_prefix (/app/timeoff-management/node_modules/express/lib/router/index.js:328:13)
    at /app/timeoff-management/node_modules/express/lib/router/index.js:286:9
    at Function.process_params (/app/timeoff-management/node_modules/express/lib/router/index.js:346:12)
    at next (/app/timeoff-management/node_modules/express/lib/router/index.js:280:10)
    at Model.<anonymous> (/app/timeoff-management/node_modules/express-session/index.js:506:7)
    at Model.tryCatcher (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/util.js:16:23)
    at Promise.successAdapter [as _fulfillmentHandler0] (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/nodeify.js:23:30)
    at Promise._settlePromise (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:601:21)
    at Promise._settlePromise0 (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:649:10)
    at Promise._settlePromises (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:729:18)
    at _drainQueueStep (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/async.js:93:12)

Node.js v18.19.0

If I take out .toLowerCase(); like so: const oidcEmail = req?.headers?.["x-forwarded-email"]; //.toLowerCase(); I get email cannot be null error as follows:

> TimeOff.Management@1.0.0 start
> node bin/wwww

node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);
            ^
Error [SequelizeValidationError]: notNull Violation: email cannot be null,
notNull Violation: name cannot be null,
notNull Violation: lastname cannot be null
    at /app/timeoff-management/node_modules/sequelize/lib/instance-validator.js:74:14
    at tryCatcher (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/util.js:16:23)
    at Promise._settlePromiseFromHandler (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:547:31)
    at Promise._settlePromise (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:604:18)
    at Promise._settlePromise0 (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:649:10)
    at Promise._settlePromises (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:729:18)
    at Promise._fulfill (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:673:18)
    at PromiseArray._resolve (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise_array.js:127:19)
    at PromiseArray._promiseFulfilled (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise_array.js:145:14)
    at Promise._settlePromise (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:609:26)
    at Promise._settlePromise0 (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:649:10)
    at Promise._settlePromises (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/promise.js:729:18)
    at _drainQueueStep (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/async.js:93:12)
    at _drainQueue (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/async.js:86:9)
    at Async._drainQueues (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/async.js:102:5)
    at Async.drainQueues [as _onImmediate] (/app/timeoff-management/node_modules/sequelize/node_modules/bluebird/js/release/async.js:15:14)
    at process.processImmediate (node:internal/timers:476:21) {
  errors: [
    {
      message: 'email cannot be null',
      type: 'notNull Violation',
      path: 'email',
      value: null
    },
    {
      message: 'name cannot be null',
      type: 'notNull Violation',
      path: 'name',
      value: null
    },
    {
      message: 'lastname cannot be null',
      type: 'notNull Violation',
      path: 'lastname',
      value: null
    }
  ]
}

Node.js v18.19.0

I'm not really sure where to go from here.

vsychov commented 7 months ago

@pwpbarney, it's simple, it seems you don't have the x-forwarded-email header, which should always be included, https://github.com/oauth2-proxy/oauth2-proxy/blob/5e30a6fe948fc470108d9e522fe00ea656c94785/docs/docs/configuration/overview.md?plain=1#L145