Squash of Launchpad's launchpad/row192 53bb59462..37582e820, manifest round 2
6f8cee147713c53652b58d47c5efd15ed47da1efbef6a126464857269b0b7787 (18 paths).
Sage's gate on 088087ac9: package suites 2758 pass 0 fail, scripts/test-*.sh
429 pass 0 fail; mutants 22 of 23 killed, #23 (second origin check) declared
equivalent by Launchpad (sage-1407). New dependency oauth4webapi 3.8.8: the
canonical checkout and any stack copy need npm ci. No live change; the live
re-pin is a window with Mos (mos-1099 item 3).
156 lines
9.5 KiB
JavaScript
156 lines
9.5 KiB
JavaScript
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
|
|
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
|
|
// only that certificate, so the code under test needs no insecure flag. It serves any number of
|
|
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
|
|
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
|
|
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { createServer, request } from 'node:https';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
|
|
|
|
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
|
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
|
|
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
|
|
}
|
|
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
|
|
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
|
|
}
|
|
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
|
|
const k = rsaKey(kid);
|
|
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
|
|
}
|
|
|
|
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
|
|
export function jwt(header, payload, { key = null, hmac = null } = {}) {
|
|
const input = `${b64url(header)}.${b64url(payload)}`;
|
|
let sig = '';
|
|
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
|
|
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
|
|
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
|
|
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
|
return `${input}.${sig}`;
|
|
}
|
|
|
|
function certificate() {
|
|
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
|
|
try {
|
|
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
|
|
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
|
|
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
|
|
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
}
|
|
|
|
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
|
|
// destroyed) or 'html' (200 with an HTML page) for discovery.
|
|
function issuerState(name, base, opts) {
|
|
const keys = opts.keys ?? [rsaKey()];
|
|
return {
|
|
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
|
|
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
|
|
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
|
|
};
|
|
}
|
|
|
|
export async function fakeOidc() {
|
|
const { key, cert } = certificate();
|
|
const issuers = new Map();
|
|
const server = createServer({ key, cert }, (req, res) => {
|
|
const url = new URL(req.url, 'https://127.0.0.1');
|
|
const [, name, ...rest] = url.pathname.split('/');
|
|
const iss = issuers.get(name);
|
|
const route = rest.join('/');
|
|
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
|
|
if (!iss) return json(404, { error: 'no issuer' });
|
|
if (route === '.well-known/openid-configuration') {
|
|
iss.discoveryFetches++;
|
|
if (iss.mode === 'down') return req.socket.destroy();
|
|
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
|
|
return json(200, discovery(iss));
|
|
}
|
|
if (route === 'jwks') {
|
|
iss.jwksFetches++;
|
|
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
|
|
}
|
|
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
|
|
if (route === 'end-session') return json(200, { ok: true });
|
|
if (route === 'token' && req.method === 'POST') {
|
|
let text = '';
|
|
req.on('data', c => (text += c));
|
|
req.on('end', () => {
|
|
const form = new URLSearchParams(text);
|
|
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
|
|
const [id, secret] = basic.split(':').map(decodeURIComponent);
|
|
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
|
|
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
|
|
const grant = iss.codes.get(form.get('code'));
|
|
if (!grant) return json(400, { error: 'invalid_grant' });
|
|
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
|
|
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
|
|
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
|
|
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
|
|
});
|
|
return;
|
|
}
|
|
return json(404, { error: 'not found' });
|
|
});
|
|
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
|
const base = `https://127.0.0.1:${server.address().port}`;
|
|
|
|
const discovery = iss => ({
|
|
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
|
|
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
|
|
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
|
|
...iss.discovery,
|
|
});
|
|
|
|
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
|
|
// and a string or URLSearchParams body.
|
|
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
|
|
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
|
|
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
|
|
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
|
|
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
|
|
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
|
|
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
|
|
const chunks = [];
|
|
res.on('data', c => chunks.push(c));
|
|
res.on('end', () => {
|
|
const h = new Headers();
|
|
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
|
|
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
|
|
Object.defineProperty(response, 'url', { value: url.href });
|
|
resolve(response);
|
|
});
|
|
res.on('error', reject);
|
|
});
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
|
|
return {
|
|
base, fetch: opFetch, cert,
|
|
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
|
|
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
|
|
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
|
|
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
|
|
const q = new URL(authorizeUrl).searchParams;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
|
|
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
|
|
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
|
|
const code = randomBytes(12).toString('base64url');
|
|
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
|
|
return { code, idToken: token, payload };
|
|
},
|
|
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
|
|
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
|
|
};
|
|
}
|
|
|