'use strict'; exports = module.exports = { getProvider, attachInteractionRoutes }; const assert = require('assert'), debug = require('debug')('box:oidc'), fs = require('fs'), path = require('path'), paths = require('./paths.js'), BoxError = require('./boxerror.js'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, users = require('./users.js'), safe = require('safetydance'), settings = require('./settings.js'); 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; this.fileStorePath = path.join(paths.PLATFORM_DATA_DIR, `oidc-${name}.json`); debug(`Creating adapter for ${name} backed by ${this.fileStorePath}`); let data = {}; try { data = JSON.parse(fs.readFileSync(this.fileStorePath), 'utf8'); } catch (e) { debug(`filestore for adapter ${name} not found, start with new one`); } this.store = data; } /** * * 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); this.store[id] = { id, expiresIn, payload, consumed: false }; fs.writeFileSync(this.fileStorePath, JSON.stringify(this.store), 'utf8'); } /** * * 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.store[id]) return false; return this.store[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}`); for (let d in this.store) { if (this.store[d].payload.uid === uid) return this.store[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.store[id]) this.store[id].consumed = true; } /** * * 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}`); delete this.store[id]; } /** * * 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}`); for (let d in this.store) { if (this.store[d].grantId === grantId) { delete this.store[d]; return; } } } } const store = new Map(); const logins = new Map(); class Account { constructor(id, profile) { this.accountId = id || 'FIXME_someid'; this.profile = profile; store.set(this.accountId, this); } /** * @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 claims(use, scope) { // eslint-disable-line no-unused-vars if (this.profile) { return { sub: this.accountId, // it is essential to always return a sub claim email: this.profile.email, email_verified: this.profile.email_verified, family_name: this.profile.family_name, given_name: this.profile.given_name, locale: this.profile.locale, name: this.profile.name, }; } return { sub: this.accountId, // it is essential to always return a sub claim address: { country: '000', formatted: '000', locality: '000', postal_code: '000', region: '000', street_address: '000', }, birthdate: '1987-10-16', email: 'johndoe@example.com', email_verified: false, family_name: 'Doe', gender: 'male', given_name: 'John', locale: 'en-US', middle_name: 'Middle', name: 'John Doe', nickname: 'Johny', phone_number: '+49 000 000000', phone_number_verified: false, picture: 'http://lorempixel.com/400/200/', preferred_username: 'johnny', profile: 'https://johnswebsite.com', updated_at: 1454704946, website: 'http://example.com', zoneinfo: 'Europe/Berlin', }; } static async findByFederated(provider, claims) { const id = `${provider}.${claims.sub}`; if (!logins.get(id)) { logins.set(id, new Account(id, claims)); } return logins.get(id); } static async findByLogin(login) { if (!logins.get(login)) { logins.set(login, new Account(login)); } return logins.get(login); } static async findAccount(ctx, id, token) { // eslint-disable-line no-unused-vars // token is a reference to the token used for which a given account is being loaded, // it is undefined in scenarios where account claims are returned from authorization endpoint // ctx is the koa request context if (!store.get(id)) new Account(id); // eslint-disable-line no-new return store.get(id); } } function attachInteractionRoutes(routePrefix, app, provider) { assert.strictEqual(typeof routePrefix, 'string'); assert.strictEqual(typeof app, 'function'); // express app assert.strictEqual(typeof provider, 'object'); function setNoCache(req, res, next) { res.set('cache-control', 'no-store'); next(); } app.get(routePrefix + '/interaction/:uid', setNoCache, async (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 client = await provider.Client.find(params.client_id); switch (prompt.name) { case 'login': { return res.render('login', { client, submitUrl: `${routePrefix}/interaction/${uid}/login`, uid, details: prompt.details, params, title: 'Sign-in', session: session ? debug(session) : undefined, dbg: { params: debug(params), prompt: debug(prompt), }, }); } case 'consent': { return res.render('interaction', { client, submitUrl: `${routePrefix}/interaction/${uid}/confirm`, uid, details: prompt.details, params, title: 'Authorize', session: session ? debug(session) : undefined, dbg: { params: debug(params), prompt: debug(prompt), }, }); } default: return undefined; } } catch (err) { return next(err); } }); app.post(routePrefix + '/interaction/:uid/login', setNoCache, async (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, interaction] = await safe(provider.interactionFinished(req, res, result)); if (interactionFinishError) return next(new HttpError(500, interactionFinishError)); next(new HttpSuccess(200, { redirectTo: interaction.redirectTo })); }); app.post(routePrefix + '/interaction/:uid/confirm', setNoCache, async (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); } }); app.get(routePrefix + '/interaction/:uid/abort', setNoCache, async (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); } }); } async function getProvider(routePrefix) { assert.strictEqual(typeof routePrefix, 'string'); const { Provider } = await import('oidc-provider'); const configuration = { // use the one from Account class I guess? async findAccount(ctx, id) { debug(`findAccount ctx:${ctx} id:${id}`); return { accountId: id, async claims(use, scope) { return { sub: id }; }, }; }, adapter: CloudronAdapter, interactions: { url: async function(ctx, interaction) { return `${routePrefix}/interaction/${interaction.uid}`; } }, features: { devInteractions: { enabled: false } }, clients: [{ client_id: 'foo', client_secret: 'bar', redirect_uris: ['https://openidconnect.net/callback'], }], pkce: { required: function pkceRequired(ctx, client) { return false; } } }; const provider = new Provider(`https://${settings.dashboardFqdn()}`, configuration); provider.proxy = true return provider; }