112 lines
9.5 KiB
JavaScript
112 lines
9.5 KiB
JavaScript
#!/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; });
|