Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was guarded as meant. Fixed, each with a test: - login: a directory 503 counts as a failed sign-in; a locked directory account is a refused password, not "unavailable" - open redirect after OpenID sign-in (a tab in next) - directory email matching only for non-admins without their own password; others connect on My settings (POST /api/me/identities/ldap); only admins disconnect a directory account - OpenID session check reads the refreshed ID token's claims too - connect is POST /api/auth/oidc/connect (CSRF header) - stored bind password, client secret and SMTP password tied to their server - 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400 - @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a password asked again shares the login limiter Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
179 lines
7.0 KiB
TypeScript
179 lines
7.0 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// A small OpenID provider for tests (12 §6): discovery, JWKS, authorize, token (code + refresh,
|
|
// PKCE S256, client_secret_basic), userinfo, end session. `tamper` breaks one thing at a time.
|
|
import { createHash, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';
|
|
import { createServer, type IncomingMessage } from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
|
|
export type FakeUser = {
|
|
sub: string;
|
|
email?: string;
|
|
email_verified?: boolean;
|
|
/** Only in userinfo, as Keycloak's mappers and Authelia send them. */
|
|
groups?: string[];
|
|
/** Only in the ID token, as Entra's app roles. */
|
|
roles?: string[];
|
|
};
|
|
|
|
const b64u = (b: Buffer | string) => Buffer.from(b).toString('base64url');
|
|
|
|
export async function startFakeOidc(alg: 'RS256' | 'ES256' = 'RS256') {
|
|
const pair =
|
|
alg === 'RS256'
|
|
? generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
const other = generateKeyPairSync('rsa', { modulusLength: 2048 }); // signs "bad signature" tokens
|
|
const clientId = 'selfphoneng';
|
|
const clientSecret = 'fake-secret';
|
|
const st = {
|
|
user: { sub: 'u-1', email: 'ann@example.com', email_verified: true } as FakeUser,
|
|
tamper: {} as {
|
|
nonce?: boolean;
|
|
iss?: boolean;
|
|
aud?: boolean;
|
|
expired?: boolean;
|
|
signature?: boolean;
|
|
userinfoSub?: boolean;
|
|
},
|
|
/** false: refresh tokens are refused, as for a user disabled at the provider. */
|
|
refreshOk: true,
|
|
/** true: the token endpoint answers 503, as a provider that's down. */
|
|
down: false,
|
|
refreshes: 0,
|
|
/** false: a refresh brings no new ID token (allowed by OIDC Core §12.2). */
|
|
refreshIdToken: true,
|
|
};
|
|
const codes = new Map<string, { nonce: string; challenge: string; user: FakeUser }>();
|
|
const access = new Map<string, FakeUser>();
|
|
const refresh = new Map<string, FakeUser>();
|
|
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url!, issuer);
|
|
const json = (code: number, body: unknown) => {
|
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
res.end(JSON.stringify(body));
|
|
};
|
|
if (url.pathname === '/.well-known/openid-configuration')
|
|
return json(200, {
|
|
issuer,
|
|
authorization_endpoint: `${issuer}/authorize`,
|
|
token_endpoint: `${issuer}/token`,
|
|
userinfo_endpoint: `${issuer}/userinfo`,
|
|
jwks_uri: `${issuer}/jwks`,
|
|
end_session_endpoint: `${issuer}/logout`,
|
|
response_types_supported: ['code'],
|
|
subject_types_supported: ['public'],
|
|
id_token_signing_alg_values_supported: [alg],
|
|
code_challenge_methods_supported: ['S256'],
|
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
|
});
|
|
if (url.pathname === '/jwks')
|
|
return json(200, {
|
|
keys: [{ ...pair.publicKey.export({ format: 'jwk' }), kid: 'k1', alg, use: 'sig' }],
|
|
});
|
|
if (url.pathname === '/authorize') {
|
|
const p = url.searchParams;
|
|
if (p.get('client_id') !== clientId || p.get('code_challenge_method') !== 'S256')
|
|
return json(400, { error: 'invalid_request' });
|
|
const code = b64u(randomBytes(16));
|
|
codes.set(code, {
|
|
nonce: p.get('nonce')!,
|
|
challenge: p.get('code_challenge')!,
|
|
user: { ...st.user },
|
|
});
|
|
const back = new URL(p.get('redirect_uri')!);
|
|
back.searchParams.set('code', code);
|
|
back.searchParams.set('state', p.get('state')!);
|
|
res.writeHead(302, { location: back.href });
|
|
return res.end();
|
|
}
|
|
if (url.pathname === '/token' && req.method === 'POST') {
|
|
if (st.down) return json(503, { error: 'temporarily_unavailable' });
|
|
// client_secret_basic: both parts form-encoded before base64 (RFC 6749 §2.3.1).
|
|
const [id, secret] = Buffer.from(
|
|
(req.headers.authorization ?? '').replace(/^Basic /, ''),
|
|
'base64',
|
|
)
|
|
.toString()
|
|
.split(':')
|
|
.map((x) => decodeURIComponent(x.replace(/\+/g, ' ')));
|
|
if (id !== clientId || secret !== clientSecret) return json(401, { error: 'invalid_client' });
|
|
const form = new URLSearchParams(await body(req));
|
|
if (form.get('grant_type') === 'refresh_token') {
|
|
const user = refresh.get(form.get('refresh_token')!);
|
|
st.refreshes++;
|
|
if (!user || !st.refreshOk) return json(400, { error: 'invalid_grant' });
|
|
// Today's claims, as a real provider gives on refresh.
|
|
const t = tokens(st.user.sub === user.sub ? { ...st.user } : user, null);
|
|
return json(200, st.refreshIdToken ? t : { ...t, id_token: undefined });
|
|
}
|
|
const c = codes.get(form.get('code') ?? '');
|
|
codes.delete(form.get('code') ?? '');
|
|
const verifier = form.get('code_verifier') ?? '';
|
|
if (!c || b64u(createHash('sha256').update(verifier).digest()) !== c.challenge)
|
|
return json(400, { error: 'invalid_grant' });
|
|
return json(200, tokens(c.user, c.nonce));
|
|
}
|
|
if (url.pathname === '/userinfo') {
|
|
const user = access.get((req.headers.authorization ?? '').replace(/^Bearer /, ''));
|
|
if (!user) return json(401, { error: 'invalid_token' });
|
|
const { roles: _, ...info } = user;
|
|
return json(200, { ...info, ...(st.tamper.userinfoSub && { sub: 'someone-else' }) });
|
|
}
|
|
if (url.pathname === '/logout') return json(200, {});
|
|
json(404, {});
|
|
});
|
|
|
|
function tokens(user: FakeUser, nonce: string | null) {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const t = st.tamper;
|
|
const claims = {
|
|
iss: t.iss ? 'https://evil.example' : issuer,
|
|
sub: user.sub,
|
|
aud: t.aud ? 'another-client' : clientId,
|
|
iat: now,
|
|
exp: t.expired ? now - 3600 : now + 300,
|
|
...(nonce && { nonce: t.nonce ? 'wrong-nonce' : nonce }),
|
|
...(user.email && { email: user.email, email_verified: user.email_verified }),
|
|
...(user.roles && { roles: user.roles }),
|
|
};
|
|
const at = b64u(randomBytes(16));
|
|
const rt = b64u(randomBytes(16));
|
|
access.set(at, user);
|
|
refresh.set(rt, user);
|
|
return {
|
|
access_token: at,
|
|
token_type: 'Bearer',
|
|
expires_in: 300,
|
|
refresh_token: rt,
|
|
id_token: jwt(claims, t.signature ? other.privateKey : pair.privateKey),
|
|
};
|
|
}
|
|
|
|
function jwt(claims: object, key: KeyObject) {
|
|
const data = `${b64u(JSON.stringify({ alg, kid: 'k1', typ: 'JWT' }))}.${b64u(JSON.stringify(claims))}`;
|
|
const sig =
|
|
alg === 'RS256'
|
|
? sign('sha256', Buffer.from(data), key)
|
|
: sign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' });
|
|
return `${data}.${b64u(sig)}`;
|
|
}
|
|
|
|
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
|
const issuer = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
return {
|
|
issuer,
|
|
clientId,
|
|
clientSecret,
|
|
st,
|
|
close: () => new Promise<void>((r) => server.close(() => r())),
|
|
};
|
|
}
|
|
|
|
async function body(req: IncomingMessage): Promise<string> {
|
|
let s = '';
|
|
for await (const chunk of req) s += chunk;
|
|
return s;
|
|
}
|