oidc-middleware.mjs 7.47 KB
import express from 'express'
import ExpressSession from 'express-session'
import fileStore from 'session-file-store'
import { merge } from 'lodash-es'
import path from 'path'
import { URL, fileURLToPath, parse, format } from 'url'
import { Issuer, Strategy } from 'openid-client'
import passport from 'passport'
import cors from 'cors'
import qs from 'qs'
import redirect from 'redirecter'

import FixRacyStore from './fix-racy-store.mjs'

const defaultOptions = {
  auth: {
    prefix: '/',
    login: '/login',
    loginReturnTo: '/',
    callback: '/login/callback',
    logout: '/logout',
    logoutReturnTo: '/',
    refreshToken: '/login/token/refresh',
    token: '/login/token',
  },
  session: {
    secret: 'some-secret',
    store: async (options) => new ExpressSession.MemoryStore(),
  },
  oidc: {
    enabled: true,
  },
}

export const fileStoreOptions = {
  session: {
    fileStore: {
      path: path.join(path.dirname(fileURLToPath(import.meta.url)), '../node_modules/.sessions'),
    },
    store: async (options) => {
      const racyStoreOptions = {
        store: new (fileStore(ExpressSession))(options.session.fileStore),
      }
      return new (FixRacyStore(ExpressSession))(racyStoreOptions)
    },
  },
}

const getQuery = (req) => {
  const { originalUrl } = req
  const url = new URL(originalUrl, 'http://localhost')
  const result = {}
  for(const [ key, value ] of url.searchParams) {
    result[ key ] = value;
  }
  return result;
}

const parseRawToken = (rawToken, def) => {
  return rawToken ? JSON.parse(Buffer.from(rawToken.split('.')[1], 'base64').toString()) : def
}

export default async (...options) => {
  options = merge({}, defaultOptions, ...options)
  console.log('options', options.oidc)
  const app = express.Router()

  const sessionStore = await options.session.store(options)

  app.use(cors({
    allowedHeaders: ['authorization', 'content-type'],
  }))
  app.use(ExpressSession({
    secret: options.session.secret,
    resave: false,
    saveUninitialized: true,
    store: sessionStore,
  }))

  const fetchOpenidClient = async () => {
    const issuer = await Issuer.discover(options.oidc.issuer)
    return new issuer.Client({
      client_id: options.oidc.clientId,
      client_secret: options.oidc.clientSecret,
      redirect_uris: [ options.oidc.clientUrlBase + (options.auth.prefix === '/' ? '' : options.auth.prefix) + '/login/callback' ],
      token_endpoint_auth_method: 'client_secret_post',
    });
  }

  let oidcClient
  const getOpenidClient = async () => {
    if (!oidcClient) {
      oidcClient = await fetchOpenidClient()
      setTimeout(() => oidcClient = null, 60000)
    }
    return oidcClient
  }

  const createPassportUser = (tokenSet, userInfo) => ({ claims: tokenSet.claims(), userInfo, tokenSet })
  const refreshToken = async (req, force = false) => {
    const { session } = req
    const { passport: { user: { tokenSet } = {} } = {} } = session
    try {
      if (tokenSet) {
        const now = Date.now() / 1000
        const expires_at = tokenSet.expires_at
        const { exp } = parseRawToken(tokenSet.refresh_token)
        //console.log('checking refresh', {now, expires_at, exp, force})
        if (force || now + 60 > expires_at) {
          //console.log('trying for token refresh', {refresh_token: tokenSet.refresh_token})
          const oidcClient = await getOpenidClient()
          const newTokenSet = await oidcClient.refresh(tokenSet.refresh_token)
          const { exp: newExp } = parseRawToken(newTokenSet.refresh_token)
          //console.log('new refresh_token', {newTokenSet, newExp})
          if (newTokenSet) {
            const newUserInfo = await oidcClient.userinfo(newTokenSet.access_token)
            delete session.passport
            session.passport = { user: createPassportUser(newTokenSet, newUserInfo) }
          }
        }
        return session.passport
      }
    } catch (e) {
      console.error(e)
      await new Promise((resolve, reject) => {
        req.logout(err => {
          if (err) {
            reject(err)
          } else {
            resolve()
          }
        })
      })
    }
  }

  if (options.oidc.enabled) {
    const auth = express.Router()
    if (options.auth.prefix !== '/') {
      app.use(options.auth.prefix, auth)
    } else {
      app.use(auth)
    }
    auth.use(passport.initialize())
    auth.use(passport.session())

    passport.use('oidc', new Strategy({ client: await getOpenidClient()}, (tokenSet, userInfo, done) => {
      done(null, createPassportUser(tokenSet, userInfo))
    }))
    passport.serializeUser((user, done) => {
      //console.log('serializeUser', user)
      done(null, user)
    })
    passport.deserializeUser((user, done) => {
      //console.log('deserializeUser', user)
      done(null, user)
    })
    auth.get(options.auth.login, (req, res, next) => {
      const { returnTo = options.auth.loginReturnTo } = getQuery(req)
      const state = Buffer.from(JSON.stringify({ returnTo })).toString('base64')
      passport.authenticate('oidc', { state })(req, res, next)
    })
    auth.get(options.auth.callback, passport.authenticate('oidc', { failureRedirect: '/' }), (req, res) => {
      try {
        const { state } = getQuery(req)
        const { returnTo } = state ? JSON.parse(Buffer.from(state, 'base64').toString()) : {}
        if (typeof returnTo === 'string' && returnTo.startsWith('/')) {
          return redirect(req, res, returnTo)
        }
      } catch (e) {
        console.error(e)
      }
      console.log('callback:redirect to /')
      redirect(req, res, '/')
    }, (err, req, res, next) => {
      if (err) {
        console.error('callback error', err)
        return redirect(req, res, '/')
      }
      next()
    })
    auth.get(options.auth.logout, (req, res, next) => {
      const { session } = req
      const { passport: { user: { tokenSet } = {} } = {} } = session
      const { returnTo = options.auth.logoutReturnTo } = getQuery(req)
      req.logout(async err => {
        if (!tokenSet) {
          return redirect(req, res, returnTo)
        }
        const oidcClient = await getOpenidClient()
        const originalUrl = `http${req.socket.encrypted ? 's' : ''}://${req.headers.host}${req.originalUrl}`
        const returnToURL = new URL(returnTo, originalUrl)
        const target = oidcClient.endSessionUrl({ id_token_hint: tokenSet.id_token, post_logout_redirect_uri: returnToURL.toString() })
        return redirect(req, res, target)
      })
    })
    auth.get(options.auth.refreshToken, async (req, res, next) => {
      await refreshToken(req, true)
      const { session } = req
      const { passport = {} } = session
      const { user: { tokenSet: { access_token, expires_at } = {} } = {} } = passport
      res.end(JSON.stringify({ access_token, expires_at }))
    })
    auth.get(options.auth.token, async (req, res, next) => {
      const passport = await refreshToken(req, true)
      if (passport) {
        const { user: { tokenSet } } = passport
        const { access_token, expires_at, token_type, id_token } = tokenSet
        res.writeHead(200)
        res.end(JSON.stringify({ access_token, expires_at, token_type, id_token }))
      } else {
        res.writeHead(401)
        res.end('')
      }
    })
  }
  app.use(async (req, res, next) => {
    await refreshToken(req, false)
    const { session } = req
    const { passport: { user: { tokenSet } = {} } = {} } = session
    res.locals = Object.assign(res.locals || {}, {
      session,
      async refreshToken() {
        return refreshToken(req, true)
      },
    })
    await next()
  })
  return app
}