UNPKG

@data-fair/sd-express

Version:

Middleware and router helpers to write expressjs applications that depend on simple-directory for authentication.

187 lines (170 loc) 8.54 kB
const util = require('util') const assert = require('assert') const URL = require('url').URL const axios = require('axios') const jwt = require('jsonwebtoken') const jwksRsa = require('jwks-rsa') const Cookies = require('cookies') jwt.verifyAsync = util.promisify(jwt.verify) const debug = require('debug')('session') module.exports = ({ directoryUrl, privateDirectoryUrl, publicUrl, cookieName, cookieDomain, sameSite }) => { assert.ok(!!directoryUrl, 'directoryUrl parameter is required') assert.ok(!publicUrl, 'publicUrl parameter is deprecated') assert.ok(!cookieDomain, 'cookieDomain parameter is deprecated') assert.ok(!sameSite, 'sameSite parameter is deprecated') cookieName = cookieName || 'id_token' debug('Init with parameters', { directoryUrl, cookieName }) privateDirectoryUrl = privateDirectoryUrl || directoryUrl const jwksClient = getJWKSClient(privateDirectoryUrl) // This middleware checks if a user has an active session with a valid token // it defines req.user and it can extend the session if necessary. const auth = asyncWrap(async (req, res, next) => { // JWT in a cookie = already active session const cookies = new Cookies(req, res) const token = getCookieToken(cookies, req, cookieName) if (token) { try { debug(`Verify JWT token from the ${cookieName} cookie`) req.user = await verifyToken(jwksClient, token) if (req.user.temporary) throw new Error('Temporary tokens should not be used in actual auth cookies') readOrganization(cookies, cookieName, req, req.user) debug('JWT token from cookie is ok', req.user) } catch (err) { // Token expired or bad in another way.. delete the cookie console.warn('JWT token from cookie is broken', err) cookies.set(cookieName, null) cookies.set(cookieName + '_sign', null) cookies.set(cookieName + '_org', null) cookies.set(cookieName + '_dep', null) cookies.set(cookieName + '_role', null) } } next() }) const requiredAuth = (req, res, next) => { auth(req, res, err => { if (err) return next(err) if (!req.user) return res.status(401).send() next() }) } return { auth, requiredAuth, verifyToken: (token) => verifyToken(jwksClient, token) } } // A cache of jwks clients, so that this module's main function can be called multiple times const jwksClients = {} function getJWKSClient (directoryUrl) { if (jwksClients[directoryUrl]) return jwksClients[directoryUrl] jwksClients[directoryUrl] = jwksRsa({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, jwksUri: directoryUrl + '/.well-known/jwks.json' }) jwksClients[directoryUrl].getSigningKeyAsync = util.promisify(jwksClients[directoryUrl].getSigningKey) return jwksClients[directoryUrl] } // Fetch a session token from cookies if the same site policy is respected function getCookieToken (cookies, req, cookieName) { let token = cookies.get(cookieName) if (!token) return null const signature = cookies.get(cookieName + '_sign') token += '.' + signature return token } // Fetch the public info of signing key from the directory that acts as jwks provider async function verifyToken (jwksClient, token) { const decoded = jwt.decode(token, { complete: true }) const signingKey = await jwksClient.getSigningKeyAsync(decoded.header.kid) return jwt.verifyAsync(token, signingKey.publicKey || signingKey.rsaPublicKey) } // Use complementary cookie id_token_org to set the current active organization of the user // also set consumerFlag that is used by applications to decide if they should ask confirmation to the user // of the right quotas or other organization related context to apply // it is 'user' if id_token_org is an empty string or is equal to 'user' // it is null if id_token_org is absent or if it does not match an organization of the current user // it is the id of the orga in id_token_org function readOrganization (cookies, cookieName, req, user) { if (!user) return // The order is important. The header can set explicitly on a query even if the cookie contradicts. const organizationId = req.headers['x-organizationid'] ? req.headers['x-organizationid'].split(':')[0] : cookies.get(cookieName + '_org') // we use decodeURIComponent on _dep cookie as older departments could have spacial chars (no longer, we use a slug now) and some client cookies libraries use encodeURIComponent const departmentId = req.headers['x-organizationid'] ? req.headers['x-organizationid'].split(':')[1] : (cookies.get(cookieName + '_dep') && decodeURIComponent(cookies.get(cookieName + '_dep'))) const role = cookies.get(cookieName + '_role') user.activeAccount = { type: 'user', id: user.id, name: user.name } user.accountOwner = { ...user.activeAccount } user.accountOwnerRole = 'admin' if (organizationId) { user.organization = (user.organizations || []).find(o => { if (o.id !== organizationId) return false if (departmentId && o.department !== departmentId) return false if (role && o.role !== role) return false return true }) if (user.organization) { user.consumerFlag = user.organization.id user.activeAccount = { ...user.organization, type: 'organization' } user.accountOwner = { type: 'organization', id: user.organization.id, name: user.organization.name } if (user.organization.department) { user.accountOwner.department = user.organization.department if (user.organization.departmentName) { user.accountOwner.departmentName = user.organization.departmentName } } user.accountOwnerRole = user.organization.role } else if (organizationId === '' || organizationId.toLowerCase() === 'user') { user.consumerFlag = 'user' } } } // Exchange a token (because if was a temporary auth token of because it is too old) /* async function _exchangeToken (privateDirectoryUrl, token, params) { const exchangeRes = await axios.post(privateDirectoryUrl + '/api/auth/exchange', null, { headers: { Authorization: 'Bearer ' + token }, params }) return exchangeRes.data } */ // small route wrapper for better use of async/await with express function asyncWrap (route) { return (req, res, next) => route(req, res, next).catch(next) } // Adding a few things for testing purposes module.exports.maildevAuth = async (email, sdUrl = 'http://localhost:8080', maildevUrl = 'http://localhost:1080', org) => { await axios.post(sdUrl + '/api/auth/passwordless', { email }, { params: { redirect: sdUrl + '?id_token=', org } }) const emails = (await axios.get(maildevUrl + '/email')).data const host = new URL(sdUrl).host const emailObj = emails .reverse() .find(e => e.subject.indexOf(host) !== -1 && e.to[0].address.toLowerCase() === email.toLowerCase()) if (!emailObj) throw new Error('Failed to find email sent to ' + email) const match = emailObj.text.split('\n').find(l => l.startsWith(sdUrl)) if (!match) throw new Error('Failed to extract id_token from mail content') return match } module.exports.passwordAuth = async (email, password, sdUrl = 'http://localhost:8080', adminMode = false, org) => { const res = await axios.post(sdUrl + '/api/auth/password', { email, password, adminMode, org }, { params: { redirect: sdUrl + '?id_token=' }, maxRedirects: 0 }) return res.data } const _axiosInstances = {} module.exports.axiosAuth = async (email, org, opts = {}, sdUrl = 'http://localhost:8080', maildevUrl = 'http://localhost:1080') => { if (!email) { _axiosInstances.anonymous = axios.create(opts) return _axiosInstances.anonymous } if (_axiosInstances[email]) return _axiosInstances[email] let callbackUrl if (email.indexOf(':') !== -1) { callbackUrl = await module.exports.passwordAuth(email.split(':')[0], email.split(':')[1], sdUrl, email.split(':').includes('adminMode'), org) } else { callbackUrl = await module.exports.maildevAuth(email, sdUrl, maildevUrl, org) } if (callbackUrl.startsWith(sdUrl + '/simple-directory')) { callbackUrl = callbackUrl.replace(sdUrl + '/simple-directory', sdUrl) } try { await axios.get(callbackUrl, { maxRedirects: 0 }) } catch (err) { if (!err.response || err.response.status !== 302) throw err opts.headers = opts.headers || {} opts.headers.Cookie = err.response.headers['set-cookie'].map(s => s.split(';')[0]).join(';') } _axiosInstances[email] = axios.create(opts) return _axiosInstances[email] }