Add hooks for providing our own login UI

This commit is contained in:
Johannes Zellner
2023-03-10 17:13:33 +01:00
parent bab3de137b
commit 31e900cb9c
2 changed files with 241 additions and 9 deletions
+236 -8
View File
@@ -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();
}