etherpad-lite/src/node/security/OAuth2Provider.ts
John McLear 682cb65625
security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins (#8070)
* security(oidc): stop shipping a hardcoded OIDC cookie key and reflecting all CORS origins

The embedded OIDC provider signed its interaction/session/grant cookies
with the committed literal key `['oidc']`, so anyone with the public
source could forge valid `.sig` cookies (defeating the provider's
cookie-integrity boundary), and `clientBasedCORS` returned `true` for
every origin, reflecting arbitrary `Origin` values into
`Access-Control-Allow-Origin` on the token/userinfo endpoints.

Adds OidcProviderSecurity.ts with two unit-tested pure helpers:

- resolveOidcCookieKeys(): prefers an operator-supplied
  `settings.sso.cookieKeys` (ordered array for rotation), otherwise
  derives a secret key from the persisted session secret via a
  domain-separated SHA-256 — stable across restarts and multi-pod, never
  committed to source; falls back to an ephemeral random key.
- isOriginAllowedForOidcClient(): allows a CORS origin only when it
  matches the origin of one of the client's registered redirect URIs.

Verified end-to-end against a real oidc-provider@9.9.1 instance
(cookies.keys accepted, Client.redirectUris read correctly, attacker
origin blocked). Reported privately by meifukun.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* security(oidc): use DB-backed SecretRotator for cookie keys under default rotation config

Addresses Qodo review on #8070. With Etherpad's default cookie settings
(keyRotationInterval + sessionLifetime set), key rotation is enabled and
`settings.sessionKey` stays null unless SESSIONKEY.txt is provisioned, so
the previous fallback handed the OIDC provider a per-process random key —
breaking in-flight OIDC cookies on restart and across horizontally-scaled
pods on a default install.

resolveOidcCookieKeys() now takes an optional rotatedSecrets array
(priority: operator cookieKeys > rotated secrets > session-key
derivation > random) and returns it by reference so a live rotation
propagates to keygrip. OAuth2Provider.expressCreateServer() creates a
dedicated `oidcCookieSecrets` SecretRotator — the same DB-backed
mechanism the Express session cookies use — when the operator hasn't
pinned settings.sso.cookieKeys and rotation is enabled.

Adds unit tests for the rotatedSecrets priority/by-reference behavior and
an integration test proving the default config yields stable DB-backed
secrets rather than a random key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(settings): give sso.cookieKeys a real key in the template

The doc block sat above `sso.clients` with no `cookieKeys` property, so the admin
UI's template-comment extractor (which attaches leading comments to the next
property node) would have shown it as documentation for `clients`. An unset
OIDC_COOKIE_KEY yields [""], which resolveOidcCookieKeys() filters out, so the
derived/rotated key path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
2026-07-27 20:36:27 +02:00

349 lines
14 KiB
TypeScript

import {ArgsExpressType} from "../types/ArgsExpressType";
import Provider, {Account, Configuration} from 'oidc-provider';
import {generateKeyPair, exportJWK, CryptoKey} from 'jose'
import MemoryAdapter from "./OIDCAdapter";
import path from "path";
import settings from '../utils/Settings';
import {IncomingForm} from 'formidable'
import express from 'express';
import {format} from 'url'
import {ParsedUrlQuery} from "node:querystring";
import {MapArrayType} from "../types/MapType";
import crypto from "node:crypto";
import {resolveOidcCookieKeys, isOriginAllowedForOidcClient} from "./OidcProviderSecurity";
import SecretRotator from "./SecretRotator";
// Held at module scope so the rotator (and its refresh timer) is not garbage
// collected for the lifetime of the process, mirroring express.ts.
let oidcCookieSecretRotator: SecretRotator | null = null;
// Small fixed delay applied to every failed interactive login, mirroring
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
const OAUTH_LOGIN_FAILURE_DELAY_MS = 1000;
// Constant-time string comparison using crypto.timingSafeEqual. Unequal-length
// inputs short-circuit to false (that length difference is covered by the
// uniform failure delay below). The raw bytes are compared directly — the
// values are not hashed/stored, this only avoids a content-dependent compare.
const constantTimeEquals = (a: string, b: string): boolean => {
const ba = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
};
const configuration: Configuration = {
scopes: ['openid', 'profile', 'email'],
findAccount: async (ctx, id) => {
const users = settings.users as {
[username: string]: {
password: string;
is_admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === id);
if(account === undefined) {
return undefined
}
if (account.is_admin ) {
return {
accountId: id,
claims: () => ({
sub: id,
admin: true
})
} as Account
} else {
return {
accountId: id,
claims: () => ({
sub: id,
})
} as Account
}
},
ttl: settings.ttl,
claims: {
openid: ['sub'],
email: ['email'],
profile: ['name'],
admin: ['admin']
},
// NOTE: cookies.keys is deliberately NOT set here. A committed literal key
// (historically ['oidc']) lets anyone with the public source forge valid
// OIDC provider `.sig` cookies. The real key material is resolved at
// provider-construction time in expressCreateServer() via
// resolveOidcCookieKeys(), which prefers an operator-supplied
// settings.sso.cookieKeys and otherwise derives a secret, stable key from
// the persisted session secret. See OidcProviderSecurity.ts.
features:{
devInteractions: {enabled: false},
},
adapter: MemoryAdapter
};
export let publicKeyExported: CryptoKey|null
export let privateKeyExported: CryptoKey|null
/*
This function is used to initialize the OAuth2 provider
*/
export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => {
const {privateKey, publicKey} = await generateKeyPair('RS256', {
extractable: true
});
const privateKeyJWK = await exportJWK(privateKey);
publicKeyExported = publicKey
privateKeyExported = privateKey
// Resolve the OIDC provider's cookie-signing keys. Never a committed literal.
// When the operator hasn't pinned settings.sso.cookieKeys and cookie key
// rotation is enabled (the default), reuse the same DB-backed SecretRotator
// mechanism as the Express session cookies so the key is stable across
// restarts and shared across horizontally-scaled pods. `settings.sessionKey`
// is commonly null under default rotation settings, so relying on it alone
// would hand the provider an unstable per-process random key.
const operatorCookieKeys = (settings.sso as {cookieKeys?: unknown}).cookieKeys;
const hasOperatorKeys = Array.isArray(operatorCookieKeys) &&
operatorCookieKeys.some((k) => typeof k === 'string' && k.length > 0);
const {keyRotationInterval, sessionLifetime} = settings.cookie;
let rotatedSecrets: string[] | null = null;
if (!hasOperatorKeys && keyRotationInterval && sessionLifetime) {
if (oidcCookieSecretRotator == null) {
oidcCookieSecretRotator = new SecretRotator(
'oidcCookieSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
await oidcCookieSecretRotator.start();
}
rotatedSecrets = oidcCookieSecretRotator.secrets;
}
const oidc = new Provider(settings.sso.issuer, {
...configuration,
cookies: {
// Secret, deployment-stable signing keys — never a committed literal.
// See resolveOidcCookieKeys().
keys: resolveOidcCookieKeys({
cookieKeys: operatorCookieKeys,
rotatedSecrets,
sessionKey: settings.sessionKey,
}),
},
jwks: {
keys: [
privateKeyJWK
],
},
conformIdTokenClaims: false,
claims: {
address: ['address'],
email: ['email', 'email_verified'],
phone: ['phone_number', 'phone_number_verified'],
profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name',
'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],
},
features:{
userinfo: {enabled: true},
claimsParameter: {enabled: true},
clientCredentials: {enabled: true},
devInteractions: {enabled: false},
resourceIndicators: {enabled: true, defaultResource(ctx) {
return ctx.origin;
},
getResourceServerInfo(ctx, resourceIndicator, client) {
return {
scope: "openid",
audience: 'account',
accessTokenFormat: 'jwt',
};
},
useGrantedResource(ctx, model) {
return true;
},
},
jwtResponseModes: {enabled: true},
},
clientBasedCORS: (ctx, origin, client) =>
// Only allow cross-origin reads from an origin registered as one of
// the client's redirect URIs. Returning `true` unconditionally
// reflected any Origin into Access-Control-Allow-Origin. See
// isOriginAllowedForOidcClient().
isOriginAllowedForOidcClient(origin, client as {redirectUris?: unknown}),
extraParams: [],
extraTokenClaims: async (ctx, token) => {
if(token.kind === 'AccessToken') {
// Add your custom claims here. For example:
const users = settings.users as {
[username: string]: {
password: string;
is_admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === token.accountId);
return {
admin: account?.is_admin
};
} else if (token.kind === "ClientCredentials") {
let extraParams: MapArrayType<string> = {}
settings.sso.clients && settings.sso.clients
.filter((client:any) => client.client_id === token.clientId)
.forEach((client:any) => {
if(client.extraParams !== undefined) {
client.extraParams.forEach((param:any) => {
extraParams[param.name] = param.value
})
}
})
return extraParams
}
},
clients: settings.sso.clients
});
args.app.post('/interaction/:uid', async (req, res, next) => {
const formid = new IncomingForm();
try {
// @ts-ignore
const {login, password} = (await formid.parse(req))[0]
const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res);
const client = await oidc.Client.find(params.client_id as string);
switch (prompt.name) {
case 'login': {
const users = settings.users as {
[username: string]: {
password: string;
admin: boolean;
}
}
const loginStr = String(login ?? '');
const passwordStr = String(password ?? '');
// Look up by own property only, then compare the password
// with constantTimeEquals.
const user = Object.prototype.hasOwnProperty.call(users, loginStr)
? users[loginStr] : undefined;
const passwordOk = user != null &&
constantTimeEquals(passwordStr, String(user.password));
const account = passwordOk ? {username: loginStr, ...user} : undefined;
if (!account) {
// Apply the failure delay and stop here (explicit break)
// so a failed login never reaches the grant branch.
await new Promise((resolve) =>
setTimeout(resolve, OAUTH_LOGIN_FAILURE_DELAY_MS));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: "Invalid login"}));
break;
}
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
break;
}
case 'consent': {
let grant;
if (grantId) {
// we'll be modifying existing grant in existing session
grant = await oidc.Grant.find(grantId);
} else {
// we're establishing a new grant
grant = new oidc.Grant({
accountId: session!.accountId,
clientId: params.client_id as string,
});
}
if (prompt.details.missingOIDCScope) {
// @ts-ignore
grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
}
if (prompt.details.missingOIDCClaims) {
grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]);
}
if (prompt.details.missingResourceScopes) {
for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) {
grant!.addResourceScope(indicator, scope.join(' '));
}
}
const result = {consent: {grantId: await grant!.save()}};
await oidc.interactionFinished(req, res, result, {
mergeWithLastSubmission: true,
});
break;
}
}
await next();
} catch (err:any) {
return res.writeHead(500).end(err.message);
}
})
args.app.get('/interaction/:uid', async (req, res, next) => {
try {
const {
uid, prompt, params, session,
} = await oidc.interactionDetails(req, res);
params["state"] = uid
switch (prompt.name) {
case 'login': {
res.redirect(format({
pathname: '/views/login.html',
query: params as ParsedUrlQuery
}))
break
}
case 'consent': {
res.redirect(format({
pathname: '/views/consent.html',
query: params as ParsedUrlQuery
}))
break
}
default:
return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html'));
}
} catch (err) {
return next(err);
}
});
args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24}));
oidc.on('authorization.error', (ctx, error) => {
console.log('authorization.error', error);
})
oidc.on('server_error', (ctx, error) => {
console.log('server_error', error);
})
oidc.on('grant.error', (ctx, error) => {
console.log('grant.error', error);
})
oidc.on('introspection.error', (ctx, error) => {
console.log('introspection.error', error);
})
oidc.on('revocation.error', (ctx, error) => {
console.log('revocation.error', error);
})
args.app.use("/oidc", oidc.callback());
//cb();
}