diff --git a/src/oidc.js b/src/oidc.js index 720bd08d2..fb8369e98 100644 --- a/src/oidc.js +++ b/src/oidc.js @@ -2,10 +2,11 @@ exports = module.exports = { getProvider, - getMiddleware + attachInteractionRoutes }; -const debug = require('debug')('box:oidc'), +const assert = require('assert'), + debug = require('debug')('box:oidc'), fs = require('fs'), path = require('path'), paths = require('./paths.js'), @@ -167,10 +168,243 @@ class CloudronAdapter { } } +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); + + debug(`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, + 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, + 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) => { + try { + const { uid, prompt: { name } } = await provider.interactionDetails(req, res); + + debug(`interaction login post uid:${uid} prompt.name:${name} login:${req.body.login}`); + + assert.equal(name, 'login'); + const account = await Account.findByLogin(req.body.login); + + const result = { + login: { + accountId: account.accountId, + }, + }; + + await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); + } catch (err) { + next(err); + } + }); + + 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(`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(`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}`); @@ -202,9 +436,3 @@ async function getProvider(routePrefix) { return provider; } - -async function getMiddleware(routePrefix) { - const provider = await getProvider(routePrefix); - - return provider.callback(); -} diff --git a/src/server.js b/src/server.js index 33dd8c021..4babf9606 100644 --- a/src/server.js +++ b/src/server.js @@ -369,7 +369,11 @@ async function initializeExpressSync() { // well known router.get ('/well-known-handler/*', routes.wellknown.get); - app.use('/api/v1/oidc', await oidc.getMiddleware('/api/v1/oidc')); + // OpenID connect + const oidcPrefix = '/api/v1/oidc'; + const oidcProvider = await oidc.getProvider(oidcPrefix); + oidc.attachInteractionRoutes(oidcPrefix, app, oidcProvider); + app.use(oidcPrefix, oidcProvider.callback()); // disable server socket "idle" timeout. we use the timeout middleware to handle timeouts on a route level // we rely on nginx for timeouts on the TCP level (see client_header_timeout)