'use strict'; exports = module.exports = { start, stop, revokeByUserId, clients: { add: clientsAdd, get: clientsGet, del: clientsDel, update: clientsUpdate, list: clientsList } }; const assert = require('assert'), BoxError = require('./boxerror.js'), blobs = require('./blobs.js'), constants = require('./constants.js'), database = require('./database.js'), debug = require('debug')('box:oidc'), ejs = require('ejs'), express = require('express'), fs = require('fs'), middleware = require('./middleware'), path = require('path'), paths = require('./paths.js'), http = require('http'), HttpError = require('connect-lastmile').HttpError, jose = require('jose'), safe = require('safetydance'), settings = require('./settings.js'), users = require('./users.js'), util = require('util'); const OIDC_CLIENTS_TABLE_NAME = 'oidcClients'; const OIDC_CLIENTS_FIELDS = [ 'id', 'secret', 'name', 'appId', 'loginRedirectUri', 'logoutRedirectUri', 'tokenSignatureAlgorithm' ]; const ROUTE_PREFIX = '/openid'; const DEFAULT_TOKEN_SIGNATURE_ALGORITHM='RS256'; let gHttpServer = null; // ----------------------------- // Database model // ----------------------------- function postProcess(result) { assert.strictEqual(typeof result, 'object'); result.tokenSignatureAlgorithm = result.tokenSignatureAlgorithm || DEFAULT_TOKEN_SIGNATURE_ALGORITHM; return result; } async function clientsAdd(id, data) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof data.secret, 'string'); assert.strictEqual(typeof data.loginRedirectUri, 'string'); assert.strictEqual(typeof data.logoutRedirectUri, 'string'); assert.strictEqual(typeof data.name, 'string'); assert.strictEqual(typeof data.appId, 'string'); assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA'); debug(`clientsAdd: id:${id} secret:${data.secret} name:${data.name} appId:${data.appId} loginRedirectUri:${data.loginRedirectUri} logoutRedirectUri:${data.logoutRedirectUri} tokenSignatureAlgorithm:${data.tokenSignatureAlgorithm}`); const query = `INSERT INTO ${OIDC_CLIENTS_TABLE_NAME} (id, secret, name, appId, loginRedirectUri, logoutRedirectUri, tokenSignatureAlgorithm) VALUES (?, ?, ?, ?, ?, ?, ?)`; const args = [ id, data.secret, data.name, data.appId, data.loginRedirectUri, data.logoutRedirectUri, data.tokenSignatureAlgorithm ]; const [error] = await safe(database.query(query, args)); if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'client already exists'); if (error) throw error; } async function clientsGet(id) { assert.strictEqual(typeof id, 'string'); debug(`clientsGet: id:${id}`); const result = await database.query(`SELECT ${OIDC_CLIENTS_FIELDS} FROM ${OIDC_CLIENTS_TABLE_NAME} WHERE id = ?`, [ id ]); if (result.length === 0) return null; return postProcess(result[0]); } async function clientsUpdate(id, data) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof data.secret, 'string'); assert.strictEqual(typeof data.loginRedirectUri, 'string'); assert.strictEqual(typeof data.logoutRedirectUri, 'string'); assert.strictEqual(typeof data.name, 'string'); assert.strictEqual(typeof data.appId, 'string'); assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA'); debug(`clientsUpdate: id:${id} secret:${data.secret} name:${data.name} appId:${data.appId} loginRedirectUri:${data.loginRedirectUri} logoutRedirectUri:${data.logoutRedirectUri} tokenSignatureAlgorithm:${data.tokenSignatureAlgorithm}`); const result = await database.query(`UPDATE ${OIDC_CLIENTS_TABLE_NAME} SET secret=?, name=?, appId=?, loginRedirectUri=?, logoutRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ?`, [ data.secret, data.name, data.appId, data.loginRedirectUri, data.logoutRedirectUri, data.tokenSignatureAlgorithm, id]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found'); } async function clientsDel(id) { assert.strictEqual(typeof id, 'string'); const result = await database.query(`DELETE FROM ${OIDC_CLIENTS_TABLE_NAME} WHERE id = ?`, [ id ]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found'); } async function clientsList() { const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME} ORDER BY id ASC`, []); results.forEach(postProcess); return results; } // ----------------------------- // Basic in-memory json file backed based data store // ----------------------------- const DATA_STORE = {}; function load(modelName) { assert.strictEqual(typeof modelName, 'string'); if (DATA_STORE[modelName]) return; const filePath = path.join(paths.OIDC_STORE_DIR, `${modelName}.json`); debug(`load: model ${modelName} based on ${filePath}.`); let data = {}; try { data = JSON.parse(fs.readFileSync(filePath), 'utf8'); } catch (e) { debug(`load: failed to read ${filePath}, start with new one.`, e); } DATA_STORE[modelName] = data; } function save(modelName) { assert.strictEqual(typeof modelName, 'string'); if (!DATA_STORE[modelName]) return; const filePath = path.join(paths.OIDC_STORE_DIR, `${modelName}.json`); debug(`save: model ${modelName} to ${filePath}.`); try { fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName]), 'utf8'); } catch (e) { debug(`revokeByUserId: failed to write ${filePath}`, e); } } // ----------------------------- // Session, Grant and Token management // ----------------------------- // This is based on the same storage as the below CloudronAdapter async function revokeByUserId(userId) { assert.strictEqual(typeof userId, 'string'); debug(`revokeByUserId: userId:${userId}`); function revokeObjects(modelName) { load(modelName); for (let id in DATA_STORE[modelName]) { if (DATA_STORE[modelName][id].payload?.accountId === userId) delete DATA_STORE[modelName][id]; } save(modelName); } revokeObjects('Session'); revokeObjects('Grant'); revokeObjects('AuthorizationCode'); revokeObjects('AccessToken'); } // ----------------------------- // Generic oidc node module data store model // ----------------------------- class CloudronAdapter { /** * * Creates an instance of MyAdapter for an oidc-provider model. * * @constructor * @param {string} name Name of the oidc-provider model. One of "Grant, "Session", "AccessToken", * "AuthorizationCode", "RefreshToken", "ClientCredentials", "Client", "InitialAccessToken", * "RegistrationAccessToken", "DeviceCode", "Interaction", "ReplayDetection", * "BackchannelAuthenticationRequest", or "PushedAuthorizationRequest" * */ constructor(name) { this.name = name; debug(`Creating storage adapter for ${name}`); if (this.name !== 'Client') { load(name); } } /** * * Update or Create an instance of an oidc-provider model. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier that oidc-provider will use to reference this model instance for * future operations. * @param {object} payload Object with all properties intended for storage. * @param {integer} expiresIn Number of seconds intended for this model to be stored. * */ async upsert(id, payload, expiresIn) { debug(`[${this.name}] upsert id:${id} expiresIn:${expiresIn}`, payload); if (this.name === 'Client') { console.log('WARNING!! this should not happen as it is stored in our db'); } else { DATA_STORE[this.name][id] = { id, expiresIn, payload, consumed: false }; save(this.name); } } /** * * Return previously stored instance of an oidc-provider model. * * @return {Promise} Promise fulfilled with what was previously stored for the id (when found and * not dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} id Identifier of oidc-provider model * */ async find(id) { debug(`[${this.name}] find id:${id}`); if (this.name === 'Client') { const [error, client] = await safe(clientsGet(id)); if (error) { console.log('Error getting client', error); return null; } if (!client) return null; debug(`[${this.name}] find id:${id}`, client); const tmp = { client_id: id, client_secret: client.secret, application_type: 'native', // default is web but we want more flexible redirectUris and this is only used in https://github.com/panva/node-oidc-provider/blob/03c9bc513860e68ee7be84f99bfc9dc930b224e8/lib/helpers/client_schema.js#L536 redirect_uris: client.loginRedirectUri.split(',').map(s => s.trim()), id_token_signed_response_alg: client.tokenSignatureAlgorithm || 'RS256' }; if (client.logoutRedirectUri) tmp.post_logout_redirect_uris = [ client.logoutRedirectUri ]; return tmp; } else { if (!DATA_STORE[this.name][id]) return null; debug(`[${this.name}] find id:${id}`, DATA_STORE[this.name][id]); return DATA_STORE[this.name][id].payload; } } /** * * Return previously stored instance of DeviceCode by the end-user entered user code. You only * need this method for the deviceFlow feature * * @return {Promise} Promise fulfilled with the stored device code object (when found and not * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} userCode the user_code value associated with a DeviceCode instance * */ async findByUserCode(userCode) { debug(`[${this.name}] FIXME findByUserCode userCode:${userCode}`); } /** * * Return previously stored instance of Session by its uid reference property. * * @return {Promise} Promise fulfilled with the stored session object (when found and not * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} uid the uid value associated with a Session instance * */ async findByUid(uid) { debug(`[${this.name}] findByUid uid:${uid}`); if (this.name === 'Client') { console.log('WARNING!! this should not happen as it is stored in our db'); } else { for (let d in DATA_STORE[this.name]) { if (DATA_STORE[this.name][d].payload.uid === uid) return DATA_STORE[this.name][d].payload; } return false; } } /** * * Mark a stored oidc-provider model as consumed (not yet expired though!). Future finds for this * id should be fulfilled with an object containing additional property named "consumed" with a * truthy value (timestamp, date, boolean, etc). * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier of oidc-provider model * */ async consume(id) { debug(`[${this.name}] consume id:${id}`); if (this.name === 'Client') { console.log('WARNING!! this should not happen as it is stored in our db'); } else { if (DATA_STORE[this.name][id]) DATA_STORE[this.name][id].consumed = true; save(this.name); } } /** * * Destroy/Drop/Remove a stored oidc-provider model. Future finds for this id should be fulfilled * with falsy values. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier of oidc-provider model * */ async destroy(id) { debug(`[${this.name}] destroy id:${id}`); if (this.name === 'Client') { console.log('WARNING!! this should not happen as it is stored in our db'); } else { delete DATA_STORE[this.name][id]; save(this.name); } } /** * * Destroy/Drop/Remove a stored oidc-provider model by its grantId property reference. Future * finds for all tokens having this grantId value should be fulfilled with falsy values. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} grantId the grantId value associated with a this model's instance * */ async revokeByGrantId(grantId) { debug(`[${this.name}] revokeByGrantId grantId:${grantId}`); if (this.name === 'Client') { console.log('WARNING!! this should not happen as it is stored in our db'); } else { for (let d in DATA_STORE[this.name]) { if (DATA_STORE[this.name][d].grantId === grantId) { delete DATA_STORE[this.name][d]; return save(this.name); } } } } } // ----------------------------- // Route handler // ----------------------------- function renderInteractionPage(provider) { assert.strictEqual(typeof provider, 'object'); return async function (req, res, next) { try { const { uid, prompt, params, session } = await provider.interactionDetails(req, res); console.log('details', await provider.interactionDetails(req, res)); debug(`route interaction get uid:${uid} prompt.name:${prompt.name} client_id:${params.client_id} session:${session}`); const [error, client] = await safe(clientsGet(params.client_id)); if (error) return next(error); switch (prompt.name) { case 'login': { return res.render('login', { submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`, name: client?.name || 'Cloudron' }); } case 'consent': { return res.render('interaction', { submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/confirm`, name: client?.name || 'Cloudron' }); } default: return undefined; } } catch (error) { debug('route interaction get error'); console.log(error); return next(error); } }; } function interactionLogin(provider) { assert.strictEqual(typeof provider, 'object'); return async function(req, res, next) { const [detailsError, details] = await safe(provider.interactionDetails(req, res)); if (detailsError) return next(new HttpError(500, detailsError)); const uid = details.uid; const prompt = details.prompt; const name = prompt.name; debug(`route interaction login post uid:${uid} prompt.name:${name}`, req.body); assert.equal(name, 'login'); if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string')); if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'A password must be non-empty string')); if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' )); const { username, password, totpToken } = req.body; const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail; const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken })); if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message)); if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Unauthorized')); if (verifyError) return next(new HttpError(500, verifyError)); if (!user) return next(new HttpError(401, 'Unauthorized')); // TODO we may have to check what else the Account class provides, in which case we have to map those things const result = { login: { accountId: user.id, }, }; const [interactionFinishError, redirectTo] = await safe(provider.interactionResult(req, res, result)); if (interactionFinishError) return next(new HttpError(500, interactionFinishError)); debug(`route interaction login post result redirectTo:${redirectTo}`); res.status(200).send({ redirectTo }); }; } function interactionConfirm(provider) { assert.strictEqual(typeof provider, 'object'); return async function (req, res, next) { try { const interactionDetails = await provider.interactionDetails(req, res); const { uid, prompt: { name, details }, params, session: { accountId } } = interactionDetails; debug(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`); assert.equal(name, 'consent'); let { grantId } = interactionDetails; let grant; if (grantId) { // we'll be modifying existing grant in existing session grant = await provider.Grant.find(grantId); } else { // we're establishing a new grant grant = new provider.Grant({ accountId, clientId: params.client_id, }); } if (details.missingOIDCScope) { grant.addOIDCScope(details.missingOIDCScope.join(' ')); } if (details.missingOIDCClaims) { grant.addOIDCClaims(details.missingOIDCClaims); } if (details.missingResourceScopes) { // eslint-disable-next-line no-restricted-syntax for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) { grant.addResourceScope(indicator, scopes.join(' ')); } } grantId = await grant.save(); const consent = {}; if (!interactionDetails.grantId) { // we don't have to pass grantId to consent, we're just modifying existing one consent.grantId = grantId; } const result = { consent }; await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true }); } catch (err) { next(err); } }; } function interactionAbort(provider) { assert.strictEqual(typeof provider, 'object'); return async function (req, res, next) { debug('route interaction abort'); try { const result = { error: 'access_denied', error_description: 'End-User aborted interaction', }; await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); } catch (err) { next(err); } }; } /** * @param use - can either be "id_token" or "userinfo", depending on * where the specific claims are intended to be put in. * @param scope - the intended scope, while oidc-provider will mask * claims depending on the scope automatically you might want to skip * loading some claims from external resources etc. based on this detail * or not return them in id tokens but only userinfo and so on. */ async function claims(userId, use, scope) { debug(`claims: userId:${userId} use:${use} scope:${scope}`); const [error, user] = await safe(users.get(userId)); if (error) return { error: 'user not found' }; const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null const nameParts = displayName.split(' '); const firstName = nameParts[0]; const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists const claims = { sub: user.username, // it is essential to always return a sub claim email: user.email, email_verified: true, family_name: lastName, given_name: firstName, locale: 'en-US', name: user.displayName, preferred_username: user.username }; debug(`claims: userId:${userId} result`, claims); return claims; } // @param form - form source (id="op.logoutForm") to be embedded in the page and submitted by the End-User async function logoutSource(ctx, form) { const data = { host: settings.dashboardFqdn(), form }; ctx.body = ejs.render(fs.readFileSync(path.join(__dirname, 'oidc_templates/logout.ejs'), 'utf8'), data, {}); } async function postLogoutSuccessSource(ctx) { // const client = ctx.oidc.client || {}; // client is defined if the user chose to stay logged in with the OP const data = { dashboardOrigin: settings.dashboardOrigin() }; ctx.body = ejs.render(fs.readFileSync(path.join(__dirname, 'oidc_templates/post_logout.ejs'), 'utf8'), data, {}); } async function findAccount(ctx, id) { debug(`findAccount id:${id}`); return { accountId: id, async claims(use, scope) { return await claims(id, use, scope); }, }; } async function renderError(ctx, out, error) { const data = { dashboardOrigin: settings.dashboardOrigin(), errorMessage: error.error_description || error.error_detail || 'Unknown error' }; debug('renderError:', error); ctx.type = 'html'; ctx.body = ejs.render(fs.readFileSync(path.join(__dirname, 'oidc_templates/error.ejs'), 'utf8'), data, {}); } async function start() { const app = express(); gHttpServer = http.createServer(app); const { Provider } = await import('oidc-provider'); // TODO we may want to rotate those in the future const jwksKeys = []; let keyEdDsa = await blobs.getString(blobs.OIDC_KEY_EDDSA); if (!keyEdDsa) { debug('Generating new OIDC EdDSA key'); const { privateKey } = await jose.generateKeyPair('EdDSA'); keyEdDsa = await jose.exportJWK(privateKey); await blobs.setString(blobs.OIDC_KEY_EDDSA, JSON.stringify(keyEdDsa)); jwksKeys.push(keyEdDsa); } else { debug('Using existing OIDC EdDSA key'); jwksKeys.push(JSON.parse(keyEdDsa)); } let keyRs256 = await blobs.getString(blobs.OIDC_KEY_RS256); if (!keyRs256) { debug('Generating new OIDC RS256 key'); const { privateKey } = await jose.generateKeyPair('RS256'); keyRs256 = await jose.exportJWK(privateKey); await blobs.setString(blobs.OIDC_KEY_RS256, JSON.stringify(keyRs256)); jwksKeys.push(keyRs256); } else { debug('Using existing OIDC RS256 key'); jwksKeys.push(JSON.parse(keyRs256)); } const configuration = { findAccount, renderError, adapter: CloudronAdapter, interactions: { url: async function(ctx, interaction) { return `${ROUTE_PREFIX}/interaction/${interaction.uid}`; } }, jwks: { jwksKeys }, claims: { email: ['email', 'email_verified'], profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username' ] }, features: { devInteractions: { enabled: false }, rpInitiatedLogout: { enabled: true, logoutSource, postLogoutSuccessSource }, }, // if a client only has one redirect uri specified, the client does not have to provide it in the request allowOmittingSingleRegisteredRedirectUri: true, clients: [], cookies: { // FIXME https://github.com/panva/node-oidc-provider/blob/b1c1a9318036c2d3793cc9e668f99937c5c36bc6/lib/helpers/defaults.js#L770 keys: [ 'cookiesecret1', 'cookiesecret2' ] }, pkce: { required: function pkceRequired(ctx, client) { return false; } }, ttl: { // in seconds, can also be a function returning the seconds https://github.com/panva/node-oidc-provider/blob/b1c1a9318036c2d3793cc9e668f99937c5c36bc6/docs/README.md#ttl AccessToken: 3600, // 1 hour IdToken: 3600, // 1 hour Grant: 1209600, // 14 days Session: 1209600, // 14 days Interaction: 3600 // 1 hour } }; debug(`start: create provider for ${settings.dashboardFqdn()} at ${ROUTE_PREFIX}`); const provider = new Provider(`https://${settings.dashboardFqdn()}${ROUTE_PREFIX}`, configuration); app.enable('trust proxy'); provider.proxy = true; app.set('views', path.join(__dirname, 'oidc_templates')); app.set('view engine', 'ejs'); const json = middleware.json({ strict: true, limit: '2mb' }); function setNoCache(req, res, next) { res.set('cache-control', 'no-store'); next(); } app.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage(provider)); app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin(provider)); app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm(provider)); app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort(provider)); app.use(ROUTE_PREFIX, provider.callback()); await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1'); } async function stop() { if (!gHttpServer) return; await util.promisify(gHttpServer.close.bind(gHttpServer))(); gHttpServer = null; }