mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
673 lines
27 KiB
JavaScript
673 lines
27 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
start,
|
|
stop,
|
|
revokeByUsername,
|
|
consumeAuthCode,
|
|
|
|
cleanupExpired,
|
|
};
|
|
|
|
const assert = require('node:assert'),
|
|
apps = require('./apps.js'),
|
|
AuditSource = require('./auditsource.js'),
|
|
BoxError = require('./boxerror.js'),
|
|
blobs = require('./blobs.js'),
|
|
branding = require('./branding.js'),
|
|
constants = require('./constants.js'),
|
|
crypto = require('node:crypto'),
|
|
dashboard = require('./dashboard.js'),
|
|
debug = require('debug')('box:oidcserver'),
|
|
dns = require('./dns.js'),
|
|
ejs = require('ejs'),
|
|
express = require('express'),
|
|
eventlog = require('./eventlog.js'),
|
|
fs = require('node:fs'),
|
|
marked = require('marked'),
|
|
middleware = require('./middleware'),
|
|
oidcClients = require('./oidcclients.js'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
http = require('node:http'),
|
|
HttpError = require('@cloudron/connect-lastmile').HttpError,
|
|
jose = require('jose'),
|
|
safe = require('safetydance'),
|
|
settings = require('./settings.js'),
|
|
tokens = require('./tokens.js'),
|
|
url = require('node:url'),
|
|
users = require('./users.js'),
|
|
groups = require('./groups.js'),
|
|
util = require('node:util');
|
|
|
|
// 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin sets callback url to authcallback.html + implicit flow
|
|
// 2. oidcserver starts an interaction and redirects to oidc_login.html
|
|
// 3. oidc_login.html is rendered by renderInteractionPage() with the form submit url /interaction/:uid/login
|
|
// 4. When form is submitted, it invokes interactionLogin(). This validates user creds
|
|
// 5. We enter the scopes confirmation flow which is oidc_interaction_confirm.html rendered by renderInteractionPage()
|
|
// 6. We have no concept of confirmation. The page auto-submits the form immediately without user interaction
|
|
// 7. oidcserver calls interactionConfirm() which finishes it via interactionFinished().
|
|
|
|
// FIXME: webadmin's implicit flow (response_type=code token) results in authcallback.html being called with access_token query param. We should remove this
|
|
|
|
const ROUTE_PREFIX = '/openid';
|
|
|
|
let gHttpServer = null, gOidcProvider = null;
|
|
|
|
// Client data store is part of the database, so it's not saved in files
|
|
// https://github.com/panva/node-oidc-provider/blob/183dc4f4b1ec1a53c5254d809091737a95c31f14/example/my_adapter.js
|
|
class StorageAdapter {
|
|
static #database = {}; // indexed by name. The format of entry is { id, expiresAt, payload, consumed }
|
|
|
|
static async getData(name) {
|
|
if (name === 'Client') throw new Error(`${name} is a database model`);
|
|
|
|
if (StorageAdapter.#database[name]) return StorageAdapter.#database[name];
|
|
|
|
StorageAdapter.#database[name] = {}; // init with empty table
|
|
|
|
const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`);
|
|
const [error, data] = await safe(fs.promises.readFile(filePath, 'utf8'));
|
|
if (!error) StorageAdapter.#database[name] = safe.JSON.parse(data) || {}; // reset table if file corrupt
|
|
|
|
return StorageAdapter.#database[name];
|
|
}
|
|
|
|
static async saveData(name) {
|
|
if (name === 'Client') throw new Error(`${name} is a database model`);
|
|
|
|
const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`);
|
|
await fs.promises.writeFile(filePath, JSON.stringify(StorageAdapter.#database[name], null, 2), 'utf8');
|
|
}
|
|
|
|
static async updateData(name, action) {
|
|
const data = await StorageAdapter.getData(name);
|
|
await action(data);
|
|
await StorageAdapter.saveData(name);
|
|
}
|
|
|
|
constructor(name) {
|
|
debug(`Creating OpenID storage adapter for ${name}`);
|
|
this.name = name;
|
|
}
|
|
|
|
async upsert(id, payload, expiresIn) {
|
|
debug(`[${this.name}] upsert: ${id}`);
|
|
|
|
const expiresAt = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : 0;
|
|
|
|
// only AccessToken of webadmin are stored in the db. Dashboard uses REST API and the token middleware looks up tokens in db
|
|
if (this.name === 'AccessToken' && (payload.clientId === oidcClients.ID_WEBADMIN || payload.clientId === oidcClients.ID_DEVELOPMENT)) {
|
|
const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS;
|
|
|
|
// oidc uses the username as accountId but accesstoken identifiers are userIds
|
|
const user = await users.getByUsername(payload.accountId);
|
|
if (!user) throw new Error(`user for username ${payload.accountId} not found`);
|
|
|
|
const [error] = await safe(tokens.add({ clientId: payload.clientId, identifier: user.id, expires, accessToken: id, allowedIpRanges: '' }));
|
|
if (error) {
|
|
console.log('Error adding access token', error);
|
|
throw error;
|
|
}
|
|
} else {
|
|
await StorageAdapter.updateData(this.name, (data) => data[id] = { id, expiresAt, payload, consumed: false });
|
|
}
|
|
}
|
|
|
|
async find(id) {
|
|
debug(`[${this.name}] find: ${id}`);
|
|
|
|
if (this.name === 'Client') {
|
|
const [error, client] = await safe(oidcClients.get(id));
|
|
if (error || !client) {
|
|
debug('find: error getting client', error);
|
|
return null;
|
|
}
|
|
|
|
const tmp = {};
|
|
tmp.application_type = client.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
|
|
tmp.client_id = id;
|
|
tmp.client_secret = client.secret;
|
|
tmp.id_token_signed_response_alg = client.tokenSignatureAlgorithm || 'RS256';
|
|
|
|
if (client.response_types) tmp.response_types = client.response_types;
|
|
if (client.grant_types) tmp.grant_types = client.grant_types;
|
|
|
|
if (client.appId) {
|
|
const [error, app] = await safe(apps.get(client.appId));
|
|
if (error || !app) {
|
|
debug(`find: Unknown app for client with appId ${client.appId}`);
|
|
return null;
|
|
}
|
|
|
|
const domains = [ app.fqdn ].concat(app.aliasDomains.map(d => d.fqdn));
|
|
|
|
// prefix login redirect uris with app.fqdn if it is just a path without a schema
|
|
// native callbacks for apps have custom schema like app.immich:/
|
|
tmp.redirect_uris = [];
|
|
client.loginRedirectUri.split(',').map(s => s.trim()).forEach((s) => {
|
|
if (url.parse(s).protocol) tmp.redirect_uris.push(s);
|
|
else tmp.redirect_uris = tmp.redirect_uris.concat(domains.map(fqdn => `https://${fqdn}${s}`));
|
|
});
|
|
} else {
|
|
tmp.redirect_uris = client.loginRedirectUri.split(',').map(s => s.trim());
|
|
}
|
|
|
|
return tmp;
|
|
} else if (this.name === 'AccessToken') {
|
|
// dashboard AccessToken are in the db. the app tokens are in the json files
|
|
const [error, result] = await safe(tokens.getByAccessToken(id));
|
|
if (!error && result) {
|
|
// translate from userId in the token to username for oidc
|
|
const user = await users.get(result.identifier);
|
|
if (user) {
|
|
return {
|
|
accountId: user.username,
|
|
clientId: result.clientId
|
|
};
|
|
}
|
|
}
|
|
} else if (this.name === 'Session') {
|
|
const data = await StorageAdapter.getData(this.name);
|
|
const session = data[id];
|
|
if (!session) return null;
|
|
|
|
if (session.payload.accountId) {
|
|
// check if the session user still exists and is active
|
|
const user = await users.getByUsername(session.payload.accountId);
|
|
if (!user || !user.active) return null;
|
|
}
|
|
|
|
return session.payload;
|
|
}
|
|
|
|
const data = await StorageAdapter.getData(this.name);
|
|
if (!data[id]) return null;
|
|
return data[id].payload;
|
|
}
|
|
|
|
async findByUserCode(userCode) {
|
|
debug(`[${this.name}] FIXME findByUserCode userCode:${userCode}`);
|
|
}
|
|
|
|
// this is called only on Session store. there is a payload.uid
|
|
async findByUid(uid) {
|
|
debug(`[${this.name}] findByUid: ${uid}`);
|
|
|
|
const data = await StorageAdapter.getData(this.name);
|
|
for (const d in data) {
|
|
if (data[d].payload.uid === uid) return data[d].payload;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async consume(id) {
|
|
debug(`[${this.name}] consume: ${id}`);
|
|
|
|
await StorageAdapter.updateData(this.name, (data) => data[id].consumed = true);
|
|
}
|
|
|
|
async destroy(id) {
|
|
debug(`[${this.name}] destroy: ${id}`);
|
|
|
|
await StorageAdapter.updateData(this.name, (data) => delete data[id]);
|
|
}
|
|
|
|
async revokeByGrantId(grantId) {
|
|
debug(`[${this.name}] revokeByGrantId: ${grantId}`);
|
|
|
|
await StorageAdapter.updateData(this.name, (data) => {
|
|
for (const d in data) {
|
|
if (data[d].grantId === grantId) {
|
|
delete data[d];
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Session, Grant and Token management. This is based on the same storage as the below CloudronAdapter
|
|
async function revokeByUsername(username) {
|
|
assert.strictEqual(typeof username, 'string');
|
|
|
|
const types = [ 'Session', 'Grant', 'AuthorizationCode', 'AccessToken' ];
|
|
for (const type of types) {
|
|
await StorageAdapter.updateData(type, (data) => {
|
|
for (const id in data) {
|
|
if (data[id].payload?.accountId === username) delete data[id];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// used by proxyauth logic to authenticate using a one time code
|
|
async function consumeAuthCode(authCode) {
|
|
assert.strictEqual(typeof authCode, 'string');
|
|
|
|
let username = null;
|
|
await StorageAdapter.updateData('AuthorizationCode', (data) => {
|
|
const authData = data[authCode];
|
|
if (authData) {
|
|
username = authData.payload.accountId;
|
|
authData.consumed = true;
|
|
}
|
|
});
|
|
|
|
return username;
|
|
}
|
|
|
|
// This exposed to run on a cron job
|
|
async function cleanupExpired() {
|
|
debug('cleanupExpired');
|
|
|
|
const types = [ 'AuthorizationCode', 'AccessToken', 'Grant', 'Interaction', 'RefreshToken', 'Session' ];
|
|
for (const type of types) {
|
|
await StorageAdapter.updateData(type, (data) => {
|
|
for (const key in data) {
|
|
if (!data[key].expiresAt || data[key].expiresAt < Date.now()) delete data[key];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const TEMPLATE_LOGIN = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_login.html'), 'utf-8');
|
|
const TEMPLATE_INTERACTION_CONFIRM = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_interaction_confirm.html'), 'utf8');
|
|
const TEMPLATE_INTERACTION_ABORT = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_interaction_abort.html'), 'utf8');
|
|
const TEMPLATE_ERROR = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_error.html'), 'utf8');
|
|
|
|
async function renderError(error) {
|
|
const data = {
|
|
iconUrl: '/api/v1/cloudron/avatar',
|
|
name: 'Cloudron',
|
|
errorMessage: error.error_description || error.error_detail || error.message || 'Internal error',
|
|
footer: marked.parse(await branding.renderFooter()),
|
|
language: await settings.get(settings.LANGUAGE_KEY),
|
|
};
|
|
|
|
debug('renderError: %o', error);
|
|
|
|
return ejs.render(TEMPLATE_ERROR, data);
|
|
}
|
|
|
|
async function renderInteractionPage(req, res, next) {
|
|
const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res));
|
|
if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description));
|
|
|
|
const { uid, prompt, params, session } = details;
|
|
|
|
const client = await oidcClients.get(params.client_id);
|
|
if (!client) return res.send(await renderError(new Error('Client not found')));
|
|
|
|
const app = client.appId ? await apps.get(client.appId) : null;
|
|
if (client.appId && !app) return res.send(await renderError(new Error('App not found')));
|
|
|
|
res.set('Content-Type', 'text/html');
|
|
|
|
if (prompt.name === 'login') {
|
|
const data = {
|
|
submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`,
|
|
iconUrl: '/api/v1/cloudron/avatar',
|
|
name: client.name || await branding.getCloudronName(),
|
|
footer: marked.parse(await branding.renderFooter()),
|
|
note: constants.DEMO ? `This is a demo. Username and password is "${constants.DEMO_USERNAME}"` : '',
|
|
language: await settings.get(settings.LANGUAGE_KEY),
|
|
};
|
|
|
|
if (app) {
|
|
data.name = app.label || app.subdomain || app.fqdn;
|
|
data.iconUrl = app.iconUrl;
|
|
}
|
|
|
|
return res.send(ejs.render(TEMPLATE_LOGIN, data));
|
|
} else if (prompt.name === 'consent') {
|
|
let hasAccess = false;
|
|
|
|
const data = {
|
|
iconUrl: '/api/v1/cloudron/avatar',
|
|
name: client.name || '',
|
|
footer: marked.parse(await branding.renderFooter()),
|
|
language: await settings.get(settings.LANGUAGE_KEY),
|
|
};
|
|
|
|
// check if user has access to the app if client refers to an app
|
|
if (app) {
|
|
const user = await users.getByUsername(session.accountId);
|
|
|
|
data.name = app.label || app.fqdn;
|
|
data.iconUrl = app.iconUrl;
|
|
hasAccess = apps.canAccess(app, user);
|
|
} else {
|
|
hasAccess = true;
|
|
}
|
|
|
|
data.submitUrl = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
|
|
|
|
return res.send(ejs.render(hasAccess ? TEMPLATE_INTERACTION_CONFIRM : TEMPLATE_INTERACTION_ABORT, data));
|
|
}
|
|
}
|
|
|
|
async function interactionLogin(req, res, next) {
|
|
const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res));
|
|
if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description));
|
|
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
|
|
const clientId = details.params.client_id;
|
|
|
|
debug(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
|
|
|
|
if (req.body.autoLoginToken) { // auto login for first admin/owner
|
|
if (typeof req.body.autoLoginToken !== 'string') return next(new HttpError(400, 'autoLoginToken must be string if provided'));
|
|
|
|
const token = await tokens.getByAccessToken(req.body.autoLoginToken);
|
|
if (!token) return next(new HttpError(401, 'No such token'));
|
|
|
|
const user = await users.get(token.identifier);
|
|
if (!user) return next(new HttpError(401,'User not found'));
|
|
|
|
const result = {
|
|
login: {
|
|
accountId: user.username,
|
|
},
|
|
};
|
|
|
|
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
|
|
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
|
|
|
|
await tokens.delByAccessToken(req.body.autoLoginToken); // clear token as it is one-time use
|
|
|
|
return res.status(200).send({ redirectTo });
|
|
}
|
|
|
|
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, skipTotpCheck: false }));
|
|
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, 'Username and password does not match'));
|
|
if (verifyError) return next(new HttpError(500, verifyError));
|
|
if (!user) return next(new HttpError(401, 'Username and password does not match'));
|
|
|
|
// this is saved as part of interaction.lastSubmission
|
|
const result = {
|
|
login: {
|
|
accountId: user.username,
|
|
},
|
|
ghost: !!user.ghost
|
|
};
|
|
|
|
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
|
|
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
|
|
|
|
res.status(200).send({ redirectTo });
|
|
}
|
|
|
|
async function interactionConfirm(req, res, next) {
|
|
const interactionDetails = await gOidcProvider.interactionDetails(req, res);
|
|
const { grantId, uid, prompt: { name, details }, params, session: { accountId }, lastSubmission } = interactionDetails;
|
|
|
|
debug(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`);
|
|
|
|
const client = await oidcClients.get(params.client_id);
|
|
if (!client) return next(new Error('Client not found'));
|
|
|
|
const user = await users.getByUsername(accountId);
|
|
if (!user) return next(new Error('User not found'));
|
|
user.ghost = !!lastSubmission?.ghost; // restore ghost flag. lastSubmission can be empty if login interaction was skipped (already logged in)
|
|
|
|
// Check if user has access to the app if client refers to an app
|
|
if (client.appId) {
|
|
const app = await apps.get(client.appId);
|
|
if (!app) return next(new Error('App not found'));
|
|
|
|
if (!apps.canAccess(app, user)) {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'User has no access to this app',
|
|
};
|
|
|
|
return await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
|
|
}
|
|
}
|
|
|
|
let grant;
|
|
if (grantId) {
|
|
grant = await gOidcProvider.Grant.find(grantId);
|
|
} else {
|
|
grant = new gOidcProvider.Grant({
|
|
accountId,
|
|
clientId: params.client_id,
|
|
});
|
|
}
|
|
|
|
// just confirm everything
|
|
if (details.missingOIDCScope) grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
if (details.missingOIDCClaims) grant.addOIDCClaims(details.missingOIDCClaims);
|
|
|
|
if (details.missingResourceScopes) {
|
|
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
}
|
|
|
|
const savedGrantId = await grant.save();
|
|
|
|
const consent = {};
|
|
if (!interactionDetails.grantId) consent.grantId = savedGrantId;
|
|
|
|
// create login event
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
const auditSource = AuditSource.fromOidcRequest(req);
|
|
|
|
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: params.client_id });
|
|
await users.notifyLoginLocation(user, ip, userAgent, auditSource);
|
|
|
|
const result = { consent };
|
|
await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
|
|
}
|
|
|
|
async function interactionAbort(req, res, next) {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'End-User aborted interaction',
|
|
};
|
|
const [error] = await safe(gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false }));
|
|
if (error) return next(error);
|
|
}
|
|
|
|
async function getClaims(username/*, use, scope*/) {
|
|
const [error, user] = await safe(users.getByUsername(username));
|
|
if (error) return { error: 'user not found' };
|
|
|
|
const [groupsError, allGroups] = await safe(groups.listWithMembers());
|
|
if (groupsError) return { error: groupsError.message };
|
|
|
|
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
|
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
|
|
|
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
|
|
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
const claims = {
|
|
sub: user.username, // it is essential to always return a sub claim
|
|
email: user.email,
|
|
email_verified: true,
|
|
family_name: lastName,
|
|
middle_name: middleName,
|
|
given_name: firstName,
|
|
locale: 'en-US',
|
|
name: user.displayName,
|
|
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}.png`, // some apps get surprised if we respond with a svg
|
|
preferred_username: user.username,
|
|
groups: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `${g.name}`; })
|
|
};
|
|
|
|
return claims;
|
|
}
|
|
|
|
async function start() {
|
|
assert(gHttpServer === null, 'OIDC server already started');
|
|
assert(gOidcProvider === null, 'OIDC provider already started');
|
|
|
|
const app = express();
|
|
|
|
gHttpServer = http.createServer(app);
|
|
|
|
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', { extractable: true });
|
|
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', { extractable: true });
|
|
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));
|
|
}
|
|
|
|
let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY);
|
|
if (!cookieSecret) {
|
|
debug('Generating new cookie secret');
|
|
cookieSecret = crypto.randomBytes(256).toString('base64');
|
|
await settings.set(settings.OIDC_COOKIE_SECRET_KEY, cookieSecret);
|
|
}
|
|
|
|
const configuration = {
|
|
findAccount: async function (ctx, id) {
|
|
return {
|
|
accountId: id,
|
|
claims: async (use, scope) => await getClaims(id, use, scope)
|
|
};
|
|
},
|
|
renderError: async function (ctx, out, error) {
|
|
ctx.type = 'html';
|
|
ctx.body = await renderError(error);
|
|
},
|
|
adapter: StorageAdapter,
|
|
interactions: {
|
|
url: async function (ctx, interaction) {
|
|
return `${ROUTE_PREFIX}/interaction/${interaction.uid}`;
|
|
}
|
|
},
|
|
jwks: {
|
|
keys: jwksKeys
|
|
},
|
|
claims: {
|
|
email: ['email', 'email_verified'],
|
|
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ],
|
|
groups: [ 'groups' ]
|
|
},
|
|
features: {
|
|
rpInitiatedLogout: { enabled: false },
|
|
devInteractions: { enabled: false }
|
|
},
|
|
clientDefaults: {
|
|
response_types: ['code', 'id_token'],
|
|
grant_types: ['authorization_code', 'implicit', 'refresh_token']
|
|
},
|
|
responseTypes: [
|
|
'code',
|
|
'id_token', 'id_token token',
|
|
'code id_token', 'code token', 'code id_token token',
|
|
'none',
|
|
],
|
|
// if a client only has one redirect uri specified, the client does not have to provide it in the request
|
|
allowOmittingSingleRegisteredRedirectUri: true,
|
|
clients: [],
|
|
cookies: {
|
|
keys: [ cookieSecret ]
|
|
},
|
|
pkce: {
|
|
required: function pkceRequired(/*ctx, client*/) {
|
|
return false;
|
|
}
|
|
},
|
|
clientBasedCORS(ctx, origin, client) {
|
|
// allow CORS for clients where at least the origin matches where we redirect back to
|
|
if (client.redirectUris.find((u) => u.indexOf(origin) === 0)) return true;
|
|
|
|
return false;
|
|
},
|
|
conformIdTokenClaims: false,
|
|
loadExistingGrant: async function (ctx) {
|
|
const grantId = ctx.oidc.result?.consent?.grantId || ctx.oidc.session.grantIdFor(ctx.oidc.client.clientId);
|
|
|
|
if (grantId) return await ctx.oidc.provider.Grant.find(grantId);
|
|
|
|
// if required, we can skip the consent screen altogether. See https://github.com/panva/node-oidc-provider/discussions/1307 . but then we have to raise login events here
|
|
return null;
|
|
},
|
|
// https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#issuerefreshtoken
|
|
async issueRefreshToken(ctx, client, code) {
|
|
if (!client.grantTypeAllowed('refresh_token') && !client.grantTypeAllowed('authorization_code')) {
|
|
return false;
|
|
}
|
|
return code.scopes.has('offline_access') || (client.applicationType === 'native' && client.clientAuthMethod === 'client_secret_basic');
|
|
},
|
|
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
|
|
RefreshToken: 1209600 // 14 days
|
|
}
|
|
};
|
|
|
|
const { subdomain, domain } = await dashboard.getLocation();
|
|
const fqdn = dns.fqdn(subdomain, domain);
|
|
debug(`start: create provider for ${fqdn} at ${ROUTE_PREFIX}`);
|
|
|
|
const Provider = (await import('oidc-provider')).default;
|
|
gOidcProvider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration);
|
|
|
|
app.enable('trust proxy');
|
|
gOidcProvider.proxy = true;
|
|
|
|
const json = express.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);
|
|
app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin);
|
|
app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm);
|
|
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort);
|
|
|
|
app.use(ROUTE_PREFIX, gOidcProvider.callback());
|
|
app.use(middleware.lastMile());
|
|
|
|
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;
|
|
gOidcProvider = null;
|
|
}
|