Files
cloudron-box/src/oidcserver.js
T
Johannes Zellner 846986987d the oidc module expect accountId and sub to be the same
in our case sub is the username exposed to the app, not the userId
internal to Cloudron

Upstream behavior change https://github.com/panva/node-oidc-provider/commit/9b89153c0ea2f2280a26e35f3b66d1900aed7c79
2025-07-02 00:38:11 +02:00

685 lines
27 KiB
JavaScript

'use strict';
exports = module.exports = {
start,
stop,
revokeByUsername,
consumeAuthCode,
cleanupExpired,
};
const assert = require('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('crypto'),
dashboard = require('./dashboard.js'),
debug = require('debug')('box:oidcserver'),
dns = require('./dns.js'),
express = require('express'),
eventlog = require('./eventlog.js'),
fs = require('fs'),
marked = require('marked'),
middleware = require('./middleware'),
oidcClients = require('./oidcclients.js'),
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'),
tokens = require('./tokens.js'),
url = require('url'),
users = require('./users.js'),
groups = require('./groups.js'),
util = require('util');
// Index.vue starts the OIDC flow by navigating to /openid/auth. OIDC logic redirects to login.html // which is rendered by
// renderInteractionPage() with the submitUrl /interaction/:uid/login
// When submitted, it invokes interactionLogin() and validates user creds. At this point,// a grant is created by loadExistingGrant.
// For webadmin, we issue a grant with scopes and this skips // the confirmation flow. For apps (for no specific reason), we continue
// with confirmation flow which is rendered by renderInteractionPage() . The consent page renders oidc_interaction_confirm.html
// which immediately auto submits without user interaction. The page calls interactionConfirm() which finishes it via interactionFinished().
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 userId = null;
await StorageAdapter.updateData('AuthorizationCode', (data) => {
const authData = data[authCode];
if (authData) {
userId = authData.payload.accountId;
authData.consumed = true;
}
});
return userId;
}
// 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];
}
});
}
}
async function renderError(error) {
const data = {
ICON_URL: '/api/v1/cloudron/avatar',
NAME: 'Cloudron',
ERROR_MESSAGE: error.error_description || error.error_detail || error.message || 'Internal error',
FOOTER: marked.parse(await branding.renderFooter())
};
debug('renderError: %o', error);
let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8');
for (const key in data) {
html = html.replaceAll(`##${key}##`, data[key]);
}
return html;
}
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 options = {
SUBMIT_URL: `${ROUTE_PREFIX}/interaction/${uid}/login`,
ICON_URL: '/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}"` : '',
};
if (app) {
options.NAME = app.label || app.fqdn;
options.ICON_URL = app.iconUrl;
}
let html = fs.readFileSync(__dirname + '/../dashboard/dist/login.html', 'utf-8');
for (const key in options) {
html = html.replaceAll(`##${key}##`, options[key]);
}
return res.send(html);
} else if (prompt.name === 'consent') {
let hasAccess = false;
const options = {
ICON_URL: '/api/v1/cloudron/avatar',
NAME: client.name || '',
FOOTER: marked.parse(await branding.renderFooter())
};
// check if user has access to the app if client refers to an app
if (app) {
const user = await users.getByUsername(session.accountId);
options.NAME = app.label || app.fqdn;
options.ICON_URL = app.iconUrl;
hasAccess = apps.canAccess(app, user);
} else {
hasAccess = true;
}
options.SUBMIT_URL = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
let html = fs.readFileSync(path.join(__dirname, hasAccess ? '/../dashboard/dist/oidc_interaction_confirm.html' : '/../dashboard/dist/oidc_interaction_abort.html'), 'utf8');
for (const key in options) {
html = html.replaceAll(`##${key}##`, options[key]);
}
return res.send(html);
}
}
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.getByUsername(token.identifier);
if (!user) return next(new HttpError(401,'User not found'));
if (!user.active) return next(new HttpError(401,'User not active'));
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);
// create a grant with scopes to skip the consent dialog (https://github.com/panva/node-oidc-provider/discussions/1307)
if (ctx.oidc.client.clientId === oidcClients.ID_WEBADMIN || ctx.oidc.client.clientId === oidcClients.ID_DEVELOPMENT) {
const grant = new ctx.oidc.provider.Grant({
clientId: ctx.oidc.client.clientId,
accountId: ctx.oidc.session.accountId,
});
grant.addOIDCScope('openid email profile groups');
await grant.save();
return grant;
}
},
// 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;
}