#!/usr/bin/env node // Deliberately fake identity, real browser code flow and production OIDC validation. import { createHash, createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'; import { chmodSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { spawnSync } from 'node:child_process'; import { createServer } from 'node:https'; import { request } from 'node:http'; import { rsaKey, jwt } from '../../webui/tests/helpers/fake-oidc.mjs'; import { checkConsole } from '../../webui/src/sso-config.mjs'; import { exclusive, fail, isMain, options, outsideRuntime, plainPath, privateDir, readPrivate, testInstance } from './support-common.mjs'; export const SUBJECT = 'ui797-agent-owner', OWNER = 'ui797-owner'; const ORIGIN = 'https://127.0.0.1:17443', ISSUER = `${ORIGIN}/oidc/`, CLIENT = 'mosaic-ui797', SLOT = 'ui797-local'; export function prepare({ instance, config, owner, runtime, tlsPort }) { if (owner !== OWNER || String(tlsPort) !== '17443') fail('only ui797-owner and test TLS port 17443 supported'); const { system } = testInstance(instance, config); outsideRuntime(runtime, system, config); privateDir(runtime, true); // exclusive: never replace a previous identity or secret const secretDir = join(dirname(config), 'secrets', 'mosaic-stack'); plainPath(secretDir); mkdirSync(secretDir, { recursive: true, mode: 0o700 }); const secret = randomBytes(32).toString('base64url'); exclusive(join(secretDir, SLOT), secret + '\n'); const openssl = args => { const r = spawnSync('openssl', args, { cwd: runtime, encoding: 'utf8' }); if (r.status !== 0) fail('test certificate generation failed'); }; // All intermediates stay in the exclusive private runtime, never the repository. exclusive(join(runtime, 'extensions.cnf'), 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n'); openssl(['req','-x509','-newkey','rsa:2048','-nodes','-days','2','-subj','/CN=ui797 private test CA','-addext','basicConstraints=critical,CA:TRUE','-keyout','ca.key','-out','ca.pem']); openssl(['req','-new','-newkey','rsa:2048','-nodes','-subj','/CN=127.0.0.1','-keyout','tls.key','-out','tls.csr']); openssl(['x509','-req','-in','tls.csr','-CA','ca.pem','-CAkey','ca.key','-CAcreateserial','-days','2','-extfile','extensions.cnf','-out','tls.pem']); for (const name of ['ca.key','ca.pem','tls.key','tls.pem','tls.csr','ca.srl']) chmodSync(join(runtime, name), 0o600); const signing = rsaKey('ui797-rsa'); exclusive(join(runtime, 'signing.pem'), signing.privateKey.export({ type: 'pkcs8', format: 'pem' })); const console = checkConsole({ sso: { publicOrigin: ORIGIN, providers: [{ id: SLOT, issuer: ISSUER, clientId: CLIENT, clientSecret: { slot: SLOT }, algs: ['RS256'], subjects: [{ sub: SUBJECT, name: OWNER }] }] } }); exclusive(join(runtime, 'identity.json'), JSON.stringify({ version: 1, instance, config, owner, console }) + '\n'); return { console }; } export async function serve(runtime, { clock = Date.now } = {}) { privateDir(runtime); const state = JSON.parse(readPrivate(join(runtime, 'identity.json'))); const { system } = testInstance(state.instance, state.config); outsideRuntime(runtime, system, state.config); if (state.version !== 1 || state.owner !== OWNER || JSON.stringify(state.console) !== JSON.stringify(checkConsole(state.console)) || state.console.sso.publicOrigin !== ORIGIN || state.console.sso.providers.length !== 1 || state.console.sso.providers[0].issuer !== ISSUER || state.console.sso.providers[0].id !== SLOT || state.console.sso.providers[0].clientId !== CLIENT || state.console.sso.providers[0].clientSecret.slot !== SLOT || JSON.stringify(state.console.sso.providers[0].subjects) !== JSON.stringify([{ sub: SUBJECT, name: OWNER }])) fail('test identity metadata refused'); const secret = readPrivate(join(dirname(state.config), 'secrets/mosaic-stack', SLOT)).trim(); const privateKey = createPrivateKey(readPrivate(join(runtime, 'signing.pem'))); const jwk = { ...createPublicKey(privateKey).export({ format: 'jwk' }), kid: 'ui797-rsa', use: 'sig', alg: 'RS256' }; const codes = new Map(); const server = createServer({ key: readPrivate(join(runtime, 'tls.key')), cert: readPrivate(join(runtime, 'tls.pem')) + readPrivate(join(runtime, 'ca.pem')) }, (req, res) => { res.setHeader('cache-control', 'no-store'); const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); }; if (req.headers.host !== '127.0.0.1:17443') return json(403, { error: 'test_host_required' }); const url = new URL(req.url, ORIGIN); const path = url.pathname; if (!path.startsWith('/oidc/')) { // Fixed loopback upstream, original public Host/Origin and browser cookies intact. // No subject/authentication headers are manufactured here. const upstream = request({ hostname: '127.0.0.1', port: 17440, method: req.method, path: req.url, headers: req.headers }, reply => { res.writeHead(reply.statusCode, reply.headers); reply.pipe(res); }); upstream.setTimeout(30000, () => upstream.destroy()); upstream.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end(); }); req.on('aborted', () => upstream.destroy()); req.pipe(upstream); return; } if (req.method === 'GET' && path === '/oidc/.well-known/openid-configuration') return json(200, { issuer: ISSUER, authorization_endpoint: `${ISSUER}authorize`, token_endpoint: `${ISSUER}token`, jwks_uri: `${ISSUER}jwks`, response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_basic'], code_challenge_methods_supported: ['S256'], }); if (req.method === 'GET' && path === '/oidc/jwks') return json(200, { keys: [jwk] }); if (req.method === 'GET' && path === '/oidc/authorize') { const q = url.searchParams; for (const k of new Set(q.keys())) if (q.getAll(k).length !== 1) return json(400, { error: 'invalid_request' }); if (q.get('client_id') !== CLIENT || q.get('redirect_uri') !== `${ORIGIN}/auth/callback` || q.get('response_type') !== 'code' || !q.get('scope')?.split(' ').includes('openid') || q.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(q.get('code_challenge') ?? '') || !q.get('state') || q.get('state').length > 512 || !q.get('nonce') || q.get('nonce').length > 512 || q.has('login_hint')) return json(400, { error: 'invalid_request' }); const now = clock(); for (const [code, grant] of codes) if (grant.expires <= now) codes.delete(code); if (codes.size >= 100) return json(429, { error: 'too_many_grants' }); const code = randomBytes(32).toString('base64url'); codes.set(code, { expires: now + 60000, challenge: q.get('code_challenge'), nonce: q.get('nonce') }); const callback = new URL(`${ORIGIN}/auth/callback`); callback.searchParams.set('code', code); callback.searchParams.set('state', q.get('state')); res.writeHead(302, { location: callback.href }); res.end(); return; } if (req.method === 'POST' && path === '/oidc/token') { let body = ''; req.on('data', c => { body += c; if (body.length > 8192) req.destroy(); }); req.on('end', () => { let credentials; try { credentials = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString().split(':').map(decodeURIComponent); } catch { return json(401, { error: 'invalid_client' }); } if (!req.headers.authorization?.startsWith('Basic ') || credentials.length !== 2 || credentials[0] !== CLIENT || credentials[1] !== secret) return json(401, { error: 'invalid_client' }); const form = new URLSearchParams(body), code = form.get('code'), grant = codes.get(code); codes.delete(code); if (!grant || grant.expires <= clock() || form.get('grant_type') !== 'authorization_code' || form.get('redirect_uri') !== `${ORIGIN}/auth/callback` || createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant' }); const now = Math.floor(clock() / 1000); const id_token = jwt({ alg: 'RS256', kid: jwk.kid }, { iss: ISSUER, sub: SUBJECT, aud: CLIENT, name: OWNER, nonce: grant.nonce, iat: now, exp: now + 300 }, { key: privateKey }); return json(200, { access_token: randomBytes(32).toString('base64url'), token_type: 'Bearer', expires_in: 300, id_token }); }); return; } json(404, { error: 'not_found' }); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(17443, '127.0.0.1', resolve); }); return server; } export async function main(argv) { const command = argv.shift(), o = options(argv, command === 'prepare' ? ['--instance','--config','--owner','--runtime','--tls-port'] : ['--runtime']); if (command === 'prepare') console.log(JSON.stringify(prepare({ instance:o['--instance'], config:o['--config'], owner:o['--owner'], runtime:o['--runtime'], tlsPort:o['--tls-port'] }), null, 2)); else if (command === 'serve') { const server = await serve(o['--runtime']); for (const signal of ['SIGINT','SIGTERM']) process.once(signal, () => { server.closeAllConnections(); server.close(); }); } else fail('use prepare or serve'); } if (isMain(import.meta.url)) main(process.argv.slice(2)).catch(() => { console.error('isolated identity refused; check named test config, private runtime and free test ports'); process.exitCode = 1; });