610 lines
36 KiB
JavaScript
610 lines
36 KiB
JavaScript
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
|
|
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
|
|
// no test reaches a real provider, the live Console or a real config or slot file. The public name
|
|
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
|
|
// refusal checks the status, that no session cookie was set and that the send stub never ran.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { startTesterSocket } from '../src/tester-socket.mjs';
|
|
import { testerRequest } from '../../cli/src/tester-http.mjs';
|
|
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
|
|
import { close } from './fixture.mjs';
|
|
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
|
|
|
|
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
|
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
|
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
|
|
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
|
|
// client secret and session id any test produced. The last test checks the one against the other.
|
|
const ALL_LINES = [], PRODUCED = new Set();
|
|
|
|
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
|
|
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
|
|
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
|
|
let text = '';
|
|
res.on('data', c => (text += c));
|
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(body);
|
|
});
|
|
}
|
|
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
|
|
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
|
|
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
|
|
|
function providerFor(iss, extra = {}) {
|
|
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
|
|
}
|
|
|
|
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
|
|
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
|
|
const op = await fakeOidc();
|
|
t.after(() => op.close());
|
|
const iss = issuers.map(o => op.issuer(o.name, o));
|
|
for (const i of iss) PRODUCED.add(i.clientSecret);
|
|
before?.(op, iss);
|
|
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
|
|
const lines = [];
|
|
const log = line => { lines.push(line); ALL_LINES.push(line); };
|
|
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
|
|
t.after(() => sso.close());
|
|
const sends = [];
|
|
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
|
|
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
|
|
t.after(() => close(web));
|
|
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
|
|
}
|
|
|
|
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
|
|
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
|
|
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
|
|
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
|
|
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
|
|
const cookie = setCookies(r)[0].split(';')[0];
|
|
const location = r.headers.location;
|
|
const params = new URL(location).searchParams;
|
|
PRODUCED.add(cookie.split('=')[1]);
|
|
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
|
|
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
|
|
return { r, cookie, location, params };
|
|
}
|
|
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
|
|
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
|
|
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
|
|
}
|
|
function grant(env, begun, iss, opts = {}) {
|
|
const g = env.op.grant(iss, begun.location, opts);
|
|
PRODUCED.add(g.code);
|
|
PRODUCED.add(g.idToken);
|
|
return g;
|
|
}
|
|
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
|
|
// session cookie (name=value) when one was set.
|
|
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
|
|
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
|
|
const g = grant(env, begun, iss, opts);
|
|
const r = await callback(env, begun, { code: g.code });
|
|
const session = sessionOf(r);
|
|
if (session) PRODUCED.add(session.split('=')[1]);
|
|
return { begun, g, r, session };
|
|
}
|
|
function refused(env, r, code) {
|
|
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
|
|
assert.equal(sessionOf(r), null, 'a session cookie was set');
|
|
assert.equal(env.sends.length, 0, 'the send stub ran');
|
|
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
|
|
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
|
|
}
|
|
|
|
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
|
|
const env = await setup(t);
|
|
assert.notEqual(env.port, 7332);
|
|
const { r, session } = await signIn(env, { ret: '/board?x=1' });
|
|
assert.equal(r.status, 303);
|
|
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
|
|
assert.ok(session);
|
|
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
|
|
assert.equal(send.status, 200, JSON.stringify(send.body));
|
|
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
|
|
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
|
|
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
|
|
assert.equal(read.status, 200);
|
|
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
|
|
assert.equal(config.body.sso, true);
|
|
assert.equal(config.body.send, true);
|
|
});
|
|
|
|
test('positive: an ES256 provider signs in and sends', async t => {
|
|
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
|
|
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
|
|
const { session } = await signIn(env);
|
|
assert.ok(session);
|
|
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
|
|
assert.equal(env.sends.length, 1);
|
|
});
|
|
|
|
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
|
|
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
|
|
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
|
|
assert.equal(list.status, 200);
|
|
assert.match(list.body, /provider=a&return=%2Fboard/);
|
|
assert.match(list.body, /provider=b&return=%2Fboard/);
|
|
const [a, b] = env.iss;
|
|
assert.ok((await signIn(env, { iss: a })).session);
|
|
assert.ok((await signIn(env, { iss: b })).session);
|
|
const names = [...env.sso.sessions.values()].map(s => s.name);
|
|
assert.deepEqual(names, ['person-a', 'person-b']);
|
|
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
|
|
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
|
|
});
|
|
|
|
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
|
|
const env = await setup(t);
|
|
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
|
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
|
|
const r = await call(env.port, { method, path, headers: forged, body });
|
|
assert.equal(r.status, 401, `${method} ${path}`);
|
|
assert.deepEqual(r.body, { error: 'sign-in required' });
|
|
}
|
|
const page = await call(env.port, { path: '/', headers: forged });
|
|
assert.equal(page.status, 303);
|
|
assert.equal(page.headers.location, '/auth/login?return=%2F');
|
|
const deep = await call(env.port, { path: '/board?x=1' });
|
|
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
|
|
assert.equal(env.sends.length, 0);
|
|
});
|
|
|
|
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
|
|
refused(env, r, 'id-token-invalid');
|
|
});
|
|
|
|
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
|
|
const env = await setup(t);
|
|
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
|
|
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
|
|
});
|
|
|
|
test('4: a token that expired 10 minutes ago is refused', async t => {
|
|
const env = await setup(t);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
|
|
});
|
|
|
|
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
|
|
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
|
|
});
|
|
|
|
test('6: a nonce mismatch and a missing nonce are refused', async t => {
|
|
const env = await setup(t);
|
|
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
|
|
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
|
|
});
|
|
|
|
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
let begun = await begin(env);
|
|
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
|
|
begun = await begin(env);
|
|
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
|
|
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
|
|
iss.acceptTwice = true;
|
|
begun = await begin(env);
|
|
const g = grant(env, begun, iss);
|
|
const first = await callback(env, begun, { code: g.code });
|
|
assert.equal(first.status, 303);
|
|
PRODUCED.add(sessionOf(first).split('=')[1]);
|
|
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
|
|
iss.acceptTwice = false;
|
|
begun = await begin(env);
|
|
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
|
|
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
|
|
begun = await begin(env);
|
|
const other = await begin(env);
|
|
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
|
|
});
|
|
|
|
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
const { r, begun } = await signIn(env);
|
|
assert.equal(r.status, 303);
|
|
const { form } = iss.tokenRequests.at(-1);
|
|
assert.equal(begun.params.get('code_challenge_method'), 'S256');
|
|
const { createHash } = await import('node:crypto');
|
|
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
|
|
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
|
|
});
|
|
|
|
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
// (a) alg none: the alg rule, before any signature check.
|
|
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
|
|
// (b) a real kid's signature over another payload: only the signature check catches it.
|
|
let begun = await begin(env);
|
|
const real = env.op.grant(iss, begun.location);
|
|
PRODUCED.add(real.idToken);
|
|
const [h, , s] = real.idToken.split('.');
|
|
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
|
|
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
|
|
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
|
|
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
|
const stranger = rsaKey('not-in-jwks');
|
|
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
|
const fetches = iss.jwksFetches;
|
|
t.mock.timers.tick(61000);
|
|
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
|
|
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
|
|
});
|
|
|
|
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
|
|
const env = await setup(t);
|
|
const before = env.lines.length;
|
|
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
|
|
refused(env, r, 'subject-unknown');
|
|
const added = env.lines.slice(before);
|
|
assert.equal(added.length, 1);
|
|
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
|
|
});
|
|
|
|
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
|
|
const op = await fakeOidc();
|
|
t.after(() => op.close());
|
|
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
|
|
const a = op.issuer('a');
|
|
PRODUCED.add(a.clientSecret);
|
|
for (const [extra, why] of [
|
|
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
|
|
[{ algs: ['none'] }, /algs\[0\] must be one of/],
|
|
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
|
|
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
|
|
]) await assert.rejects(start(a, extra), why);
|
|
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
|
|
// The mosaic-preview shape: HS256 only.
|
|
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
|
|
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
|
|
const empty = op.issuer('empty');
|
|
empty.jwksOverride = { keys: [] };
|
|
await assert.rejects(start(empty), refusedAtDiscovery);
|
|
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
|
|
await assert.rejects(start(plain), refusedAtDiscovery);
|
|
const moved = op.issuer('moved');
|
|
moved.discovery = { issuer: `${op.base}/elsewhere/` };
|
|
await assert.rejects(start(moved), refusedAtDiscovery);
|
|
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
|
|
await assert.rejects(start(small), refusedAtDiscovery);
|
|
// The control: the same OP and provider start.
|
|
const sso = await start(a);
|
|
assert.deepEqual(sso.health(), { a: 'ready' });
|
|
sso.close();
|
|
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
|
|
});
|
|
|
|
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
|
|
mkdirSync(slots, { recursive: true });
|
|
const provider = slot => ({ id: 'a', clientSecret: { slot } });
|
|
const value = 'slot-value-do-not-print';
|
|
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
|
|
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
|
|
assert.equal(readSlot(dir, provider('good'), opts), value);
|
|
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
|
|
symlinkSync(join(root, 'target'), join(slots, 'link'));
|
|
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
|
|
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
|
|
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
|
|
const cases = [
|
|
['missing', /slot is missing$/, opts],
|
|
['link', /must not be a symbolic link$/, opts],
|
|
['wide', /must have no group or other permission bits$/, opts],
|
|
['group', /must have no group or other permission bits$/, opts],
|
|
['two', /must hold one non-empty line$/, opts],
|
|
['empty', /must hold one non-empty line$/, opts],
|
|
// The config dir itself under the repository, and under a --checkout.
|
|
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
|
|
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
|
|
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
|
|
];
|
|
for (const [slot, why, o] of cases) {
|
|
let err = null;
|
|
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
|
|
assert.ok(err, `${slot} was read`);
|
|
assert.match(err.message, /^console\.sso provider a client secret slot /);
|
|
assert.match(err.message, why, slot);
|
|
assert.equal(err.message.includes(value), false);
|
|
assert.equal(err.message.includes(root), false);
|
|
}
|
|
});
|
|
|
|
test('11: the CLI refuses --sso with the old flags and without console.sso, then --sso --send starts and reaches the human transport', async t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
|
|
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
|
|
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
|
|
assert.equal(r.status, 2, flags.join(' '));
|
|
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
|
|
}
|
|
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
|
assert.equal(missing.status, 2);
|
|
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
|
|
const op = await fakeOidc();
|
|
t.after(() => op.close());
|
|
const a = op.issuer('a');
|
|
PRODUCED.add(a.clientSecret);
|
|
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
|
|
writeFileSync(join(root, 'ca.pem'), op.cert);
|
|
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
|
|
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
|
|
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
|
assert.equal(noKey.status, 2);
|
|
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
|
|
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
|
|
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
|
|
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o644); // writeFileSync applies the umask (row 243)
|
|
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
|
|
assert.equal(wide.status, 2);
|
|
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
|
|
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
|
|
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
|
|
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso', '--send'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
|
|
t.after(() => child.kill('SIGKILL'));
|
|
let out = '', err = '';
|
|
child.stderr.on('data', c => (err += c));
|
|
await new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
|
|
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
|
|
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
|
|
});
|
|
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), reads and human writes \\(--send\\), grant Approve and Deny$`, 'm'));
|
|
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
|
|
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
|
|
assert.equal(out.includes(a.clientId), false);
|
|
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
|
|
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
|
|
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
|
const login = await call(port, { path: '/auth/login' });
|
|
assert.equal(login.status, 303);
|
|
const loginCookie = setCookies(login)[0].split(';')[0];
|
|
const granted = op.grant(a, login.headers.location);
|
|
const state = new URL(login.headers.location).searchParams.get('state');
|
|
const signed = await call(port, { path: `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, headers: { cookie: loginCookie } });
|
|
const session = sessionOf(signed);
|
|
assert.ok(session);
|
|
const sent = await call(port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) });
|
|
assert.equal(sent.status, 503);
|
|
assert.deepEqual(sent.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false });
|
|
const sendLine = /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="person-a" to="coder" error=no-bus-host$/m;
|
|
for (let i = 0; i < 100 && !sendLine.test(out); i++) await new Promise(resolve => setTimeout(resolve, 20));
|
|
assert.match(out, sendLine);
|
|
assert.equal(out.includes('secret-body-text'), false);
|
|
child.kill('SIGTERM');
|
|
});
|
|
|
|
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
|
|
const env = await setup(t);
|
|
const dataRoot = mkdtempSync(join(tmpdir(),'g196-sso-'));
|
|
t.after(()=>rmSync(dataRoot,{recursive:true,force:true}));
|
|
const tester = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: env.bus, log: () => {} });
|
|
t.after(() => close(tester));
|
|
const callTester = options => testerRequest({dataRoot,...options});
|
|
const { session, g, begun } = await signIn(env);
|
|
assert.ok(session);
|
|
const plain = await callTester({ path: '/api/config' });
|
|
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
|
|
for (const headers of [
|
|
{ cookie: session },
|
|
{ cookie: `theme=dark; ${begun.cookie}` },
|
|
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
|
|
{ authorization: `Bearer ${g.idToken}` },
|
|
{ 'x-authentik-jwt': g.idToken },
|
|
{ 'X-Authentik-Username': 'jason.woltje' },
|
|
]) {
|
|
const r = await callTester({ path: '/api/config', headers });
|
|
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
|
|
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
|
|
}
|
|
assert.equal((await callTester({ path: '/auth/login' })).status, 404);
|
|
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
|
|
});
|
|
|
|
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
|
|
const env = await setup(t);
|
|
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
|
|
const record = () => [...env.sso.sessions.values()].at(-1);
|
|
// Background reads don't move `seen`; a page load does.
|
|
let { session } = await signIn(env);
|
|
const old = Date.now() - 59 * 60 * 1000;
|
|
record().seen = old;
|
|
assert.equal((await read(session)).status, 200);
|
|
assert.equal(record().seen, old, 'a background read refreshed the session');
|
|
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
|
|
assert.ok(record().seen > old, 'a page load did not refresh the session');
|
|
// Idle: 59 minutes of background reads, then one more past the hour.
|
|
record().seen = Date.now() - 61 * 60 * 1000;
|
|
assert.equal((await read(session)).status, 401);
|
|
// Absolute.
|
|
({ session } = await signIn(env));
|
|
record().created = Date.now() - 8 * 3600 * 1000;
|
|
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
|
|
// Logout needs same-origin; refused, the session stays.
|
|
({ session } = await signIn(env));
|
|
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
|
|
assert.equal(cross.status, 403);
|
|
assert.equal((await read(session)).status, 200);
|
|
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
|
|
assert.equal(noOrigin.status, 403);
|
|
assert.equal((await read(session)).status, 200);
|
|
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
|
|
assert.equal(out.status, 303);
|
|
const end = new URL(out.headers.location);
|
|
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
|
|
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
|
|
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
|
|
assert.ok(end.searchParams.get('id_token_hint'));
|
|
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
|
|
assert.equal((await read(session)).status, 401);
|
|
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
|
|
});
|
|
|
|
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
|
|
const env = await setup(t);
|
|
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
|
|
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
|
|
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
|
|
t.after(() => close(plain));
|
|
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
|
|
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
|
|
});
|
|
|
|
test('14: open redirects land on /, and a path on the Console lands there', async t => {
|
|
const env = await setup(t);
|
|
for (const [query, where] of [
|
|
['return=%2F%2Fevil.example', `${ORIGIN}/`],
|
|
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
|
|
['return=%2F%5Cevil.example', `${ORIGIN}/`],
|
|
['return=/%09/evil.example', `${ORIGIN}/`],
|
|
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
|
|
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
|
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
|
|
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
|
|
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
|
|
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
|
|
['', `${ORIGIN}/`],
|
|
]) {
|
|
const begun = await begin(env, { query });
|
|
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
|
assert.equal(r.status, 303);
|
|
assert.equal(r.headers.location, where, query);
|
|
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
|
|
PRODUCED.add(sessionOf(r).split('=')[1]);
|
|
}
|
|
});
|
|
|
|
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
|
|
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
|
|
const [, b] = env.iss;
|
|
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
|
|
const begun = await begin(env, { provider: 'a' });
|
|
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
|
|
// (this callback belongs to A) stands in the way.
|
|
const g = grant(env, begun, b, { sub: 'user-b' });
|
|
const r = await callback(env, begun, { code: g.code, extra });
|
|
refused(env, r);
|
|
}
|
|
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
|
|
});
|
|
|
|
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
|
|
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
|
|
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
|
|
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
|
|
assert.ok((await signIn(env, { opts: { key: ec } })).session);
|
|
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
|
|
});
|
|
|
|
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
|
|
const env = await setup(t);
|
|
const first = await begin(env);
|
|
for (let i = 0; i < 32; i++) await begin(env);
|
|
assert.equal(env.sso.logins.size, 32);
|
|
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
|
|
});
|
|
|
|
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
|
|
const env = await setup(t);
|
|
const [iss] = env.iss;
|
|
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
|
|
const old = iss.keys[0];
|
|
assert.ok((await signIn(env)).session);
|
|
const fetches = iss.jwksFetches;
|
|
env.op.rotate(iss);
|
|
t.mock.timers.tick(61000);
|
|
assert.ok((await signIn(env)).session);
|
|
assert.equal(iss.jwksFetches, fetches + 1);
|
|
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
|
|
});
|
|
|
|
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
|
|
const env = await setup(t);
|
|
const begun = await begin(env);
|
|
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
|
|
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
|
|
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
|
|
const set = setCookies(r);
|
|
assert.equal(set.length, 2);
|
|
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
|
|
const id = set[1].split(';')[0].split('=')[1];
|
|
PRODUCED.add(id);
|
|
for (const [key, value] of env.sso.sessions) {
|
|
assert.match(key, /^[0-9a-f]{64}$/);
|
|
assert.notEqual(key, id);
|
|
assert.equal(JSON.stringify(value).includes(id), false);
|
|
}
|
|
});
|
|
|
|
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
|
|
const env = await setup(t);
|
|
const { session } = await signIn(env);
|
|
const loop = `127.0.0.1:${env.port}`;
|
|
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
|
|
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
|
|
for (const host of [loop, `localhost:${env.port}`]) {
|
|
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
|
|
assert.equal(r.status, 403, `${method} ${path} ${host}`);
|
|
assert.deepEqual(r.body, { error: 'public Host required' });
|
|
}
|
|
}
|
|
const health = await call(env.port, { path: '/healthz', host: loop });
|
|
assert.equal(health.status, 200);
|
|
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
|
|
assert.equal(env.sends.length, 0);
|
|
const begun = await begin(env, { host: HOST.toUpperCase() });
|
|
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
|
|
});
|
|
|
|
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
|
|
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
|
|
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
|
|
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
|
|
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
|
|
env.iss[0].mode = 'ok';
|
|
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
|
|
assert.deepEqual(env.sso.health(), { a: 'ready' });
|
|
assert.ok((await signIn(env)).session);
|
|
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
|
|
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
|
|
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
|
|
// HS-only discovery at start refuses the start.
|
|
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
|
|
});
|
|
|
|
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
|
|
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
|
|
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
|
|
for (const line of ALL_LINES) {
|
|
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
|
|
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
|
|
}
|
|
});
|