auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

http.test.js (32384B)


      1 import test from 'node:test';
      2 import assert from 'node:assert/strict';
      3 import { spawn } from 'node:child_process';
      4 import crypto from 'node:crypto';
      5 import fs from 'node:fs/promises';
      6 import http from 'node:http';
      7 import net from 'node:net';
      8 import os from 'node:os';
      9 import path from 'node:path';
     10 import { DatabaseSync } from 'node:sqlite';
     11 import { fileURLToPath } from 'node:url';
     12 import { createLocalJWKSet, jwtVerify } from 'jose';
     13 
     14 const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
     15 
     16 function freePort() {
     17   return new Promise((resolve, reject) => {
     18     const server = net.createServer();
     19     server.once('error', reject);
     20     server.listen(0, '127.0.0.1', () => {
     21       const { port } = server.address();
     22       server.close(() => resolve(port));
     23     });
     24   });
     25 }
     26 
     27 function runNode(script, env) {
     28   return new Promise((resolve, reject) => {
     29     const child = spawn(process.execPath, [script], {
     30       cwd: serverDir,
     31       env: { ...process.env, ...env },
     32       stdio: ['ignore', 'pipe', 'pipe'],
     33     });
     34     let stdout = '';
     35     let stderr = '';
     36     child.stdout.on('data', (chunk) => {
     37       stdout += chunk;
     38     });
     39     child.stderr.on('data', (chunk) => {
     40       stderr += chunk;
     41     });
     42     child.once('error', reject);
     43     child.once('exit', (code) => {
     44       if (code === 0) resolve({ stdout, stderr });
     45       else reject(new Error(`${script} exited ${code}\n${stdout}\n${stderr}`));
     46     });
     47   });
     48 }
     49 
     50 function startServer(env) {
     51   return new Promise((resolve, reject) => {
     52     const child = spawn(process.execPath, ['src/index.js'], {
     53       cwd: serverDir,
     54       env: { ...process.env, ...env },
     55       stdio: ['ignore', 'pipe', 'pipe'],
     56     });
     57     let stdout = '';
     58     let stderr = '';
     59     child.stdout.on('data', (chunk) => {
     60       stdout += chunk;
     61     });
     62     child.stderr.on('data', (chunk) => {
     63       stderr += chunk;
     64     });
     65     child.once('error', reject);
     66     child.once('exit', (code) => {
     67       if (code !== 0 && !stdout.includes('Listening on')) {
     68         reject(new Error(`server exited ${code}\n${stdout}\n${stderr}`));
     69       }
     70     });
     71     const deadline = Date.now() + 15_000;
     72     const timer = setInterval(() => {
     73       if (stdout.includes('Listening on')) {
     74         clearInterval(timer);
     75         resolve(child);
     76         return;
     77       }
     78       if (Date.now() > deadline) {
     79         clearInterval(timer);
     80         child.kill('SIGTERM');
     81         reject(new Error(`server did not start\n${stdout}\n${stderr}`));
     82       }
     83     }, 50);
     84   });
     85 }
     86 
     87 function request(
     88   port,
     89   { method = 'GET', path: requestPath = '/', host, body, form, cookie, bearer, headers: extraHeaders = {} } = {}
     90 ) {
     91   return new Promise((resolve, reject) => {
     92     let payload = null;
     93     const headers = { host, ...extraHeaders };
     94     if (body !== undefined) {
     95       payload = JSON.stringify(body);
     96       headers['content-type'] = 'application/json';
     97       headers['content-length'] = Buffer.byteLength(payload);
     98     } else if (form !== undefined) {
     99       payload = new URLSearchParams(form).toString();
    100       headers['content-type'] = 'application/x-www-form-urlencoded';
    101       headers['content-length'] = Buffer.byteLength(payload);
    102     }
    103     if (cookie) headers.cookie = cookie;
    104     if (bearer) headers.authorization = `Bearer ${bearer}`;
    105 
    106     const req = http.request(
    107       { host: '127.0.0.1', port, method, path: requestPath, headers },
    108       (res) => {
    109         const chunks = [];
    110         res.on('data', (chunk) => chunks.push(chunk));
    111         res.on('end', () => {
    112           const text = Buffer.concat(chunks).toString('utf8');
    113           let json = null;
    114           try {
    115             json = JSON.parse(text);
    116           } catch {
    117             json = null;
    118           }
    119           resolve({ status: res.statusCode, headers: res.headers, text, json });
    120         });
    121       }
    122     );
    123     req.once('error', reject);
    124     if (payload !== null) req.write(payload);
    125     req.end();
    126   });
    127 }
    128 
    129 function responseCookies(response) {
    130   const values = response.headers['set-cookie'];
    131   return Array.isArray(values) ? values.map((value) => value.split(';')[0]) : [];
    132 }
    133 
    134 function mergeCookies(existing, response) {
    135   const jar = new Map();
    136   for (const pair of existing ? existing.split('; ') : []) {
    137     const index = pair.indexOf('=');
    138     if (index !== -1) jar.set(pair.slice(0, index), pair.slice(index + 1));
    139   }
    140   for (const pair of responseCookies(response)) {
    141     const index = pair.indexOf('=');
    142     if (index !== -1) jar.set(pair.slice(0, index), pair.slice(index + 1));
    143   }
    144   return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join('; ');
    145 }
    146 
    147 function maxAge(response) {
    148   const values = response.headers['set-cookie'];
    149   if (!values || values.length === 0) return null;
    150   const match = /Max-Age=(\d+)/.exec(values[0]);
    151   return match ? Number(match[1]) : null;
    152 }
    153 
    154 function pkce() {
    155   const verifier = crypto.randomBytes(32).toString('base64url');
    156   const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
    157   return { verifier, challenge };
    158 }
    159 
    160 function locationQuery(response) {
    161   return new URL(response.headers.location, 'http://localhost');
    162 }
    163 
    164 test('HTTP realm, login, OIDC and logout flows', async (t) => {
    165   const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-http-'));
    166   const port = await freePort();
    167   const publicUrl = `http://identity.test:${port}`;
    168   const env = {
    169     AUTH_DATABASE_PATH: path.join(dir, 'system.sqlite'),
    170     AUTH_REALMS_PATH: path.join(dir, 'realms'),
    171     AUTH_PORT: String(port),
    172     AUTH_PUBLIC_URL: publicUrl,
    173   };
    174 
    175   const origin = (host) => `http://${host}:${port}`;
    176   let server = null;
    177 
    178   try {
    179     await runNode('scripts/seed-demo.js', env);
    180     server = await startServer(env);
    181 
    182     const identity = (options = {}) =>
    183       request(port, { host: `identity.test:${port}`, ...options });
    184     const acme = (options = {}) => request(port, { host: `acme.localhost:${port}`, ...options });
    185     const globex = (options = {}) => request(port, { host: `globex.localhost:${port}`, ...options });
    186     const app = (options = {}) => request(port, { host: `app.acme.localhost:${port}`, ...options });
    187 
    188     await t.test('realm metadata is served for a realm hostname', async () => {
    189       const response = await acme({ path: '/api/v1/realm' });
    190       assert.equal(response.status, 200);
    191       assert.equal(response.json.slug, 'acme');
    192       assert.equal(response.json.allow_session_duration_choice, true);
    193     });
    194 
    195     await t.test('hostname wins over a conflicting path slug', async () => {
    196       const response = await acme({ path: '/api/v1/realm?realm=globex' });
    197       assert.equal(response.status, 404);
    198       assert.equal(response.json.error.code, 'unknown_realm');
    199     });
    200 
    201     await t.test('client discovery works on identity, realm and client hosts', async () => {
    202       const canonical = await identity({
    203         path: '/oidc/internal-app/openid-configuration',
    204       });
    205       assert.equal(canonical.status, 200);
    206       assert.equal(canonical.json.client_id, 'internal-app');
    207       assert.equal(canonical.json.issuer, `${publicUrl}/oidc/acme`);
    208       assert.equal(
    209         canonical.json.authorization_endpoint,
    210         `${origin('identity.test')}/oidc/internal-app/authorize`
    211       );
    212 
    213       const realmHost = await acme({ path: '/oidc/internal-app/openid-configuration' });
    214       assert.equal(realmHost.status, 200);
    215       assert.equal(
    216         realmHost.json.token_endpoint,
    217         `${origin('acme.localhost')}/oidc/internal-app/token`
    218       );
    219 
    220       const clientHost = await app({ path: '/oidc/openid-configuration' });
    221       assert.equal(clientHost.status, 200);
    222       assert.equal(clientHost.json.client_id, 'internal-app');
    223       assert.equal(clientHost.json.token_endpoint, `${origin('app.acme.localhost')}/oidc/token`);
    224     });
    225 
    226     await t.test('realm slugs are not accepted in OIDC paths', async () => {
    227       const oldCanonical = await identity({
    228         path: '/oidc/acme/internal-app/openid-configuration',
    229       });
    230       assert.equal(oldCanonical.status, 404);
    231 
    232       const oldRealmHost = await acme({
    233         path: '/oidc/acme/internal-app/openid-configuration',
    234       });
    235       assert.equal(oldRealmHost.status, 404);
    236     });
    237 
    238     await t.test('client-to-realm mappings enforce host and canonical routing', async () => {
    239       const mismatch = await globex({ path: '/oidc/internal-app/openid-configuration' });
    240       assert.equal(mismatch.status, 404);
    241       assert.equal(mismatch.json.error.code, 'unknown_client');
    242 
    243       const unknownCanonicalHost = await request(port, {
    244         host: `unknown.test:${port}`,
    245         path: '/oidc/internal-app/openid-configuration',
    246       });
    247       assert.equal(unknownCanonicalHost.status, 404);
    248 
    249       const otherRealm = await identity({
    250         path: '/oidc/globex-members-app/openid-configuration',
    251       });
    252       assert.equal(otherRealm.status, 200);
    253       assert.equal(otherRealm.json.issuer, `${publicUrl}/oidc/globex`);
    254     });
    255 
    256     await t.test('virtual clients are discoverable', async () => {
    257       const admin = await acme({ path: '/oidc/admin/openid-configuration' });
    258       assert.equal(admin.status, 200);
    259       assert.equal(admin.json.virtual, true);
    260       assert.equal(admin.json.access_mode, 'admin');
    261 
    262       const account = await acme({ path: '/oidc/account/openid-configuration' });
    263       assert.equal(account.status, 200);
    264       assert.equal(account.json.virtual, true);
    265       assert.equal(account.json.access_mode, 'public');
    266     });
    267 
    268     await t.test('identifier resolution selects password or upstream OIDC', async () => {
    269       const password = await acme({
    270         method: 'POST',
    271         path: '/api/v1/login/resolve',
    272         body: { client_id: 'internal-app', identifier: 'alice@acme.test' },
    273       });
    274       assert.equal(password.status, 200);
    275       assert.equal(password.json.next, 'password');
    276 
    277       const upstream = await acme({
    278         method: 'POST',
    279         path: '/api/v1/login/resolve',
    280         body: { client_id: 'internal-app', identifier: 'someone@sso.acme.test' },
    281       });
    282       assert.equal(upstream.status, 200);
    283       assert.equal(upstream.json.next, 'oidc');
    284       assert.equal(upstream.json.provider.display_name, 'Example SSO');
    285     });
    286 
    287     await t.test('password login creates a realm session with the short cookie', async () => {
    288       const response = await acme({
    289         method: 'POST',
    290         path: '/api/v1/login/password',
    291         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    292       });
    293       assert.equal(response.status, 200);
    294       assert.equal(response.json.user.username, 'alice');
    295       assert.equal(maxAge(response), 12 * 60 * 60);
    296       assert.ok(responseCookies(response)[0].startsWith('auth_session='));
    297 
    298       const cookie = mergeCookies('', response);
    299       const session = await acme({ path: '/api/v1/session', cookie });
    300       assert.equal(session.status, 200);
    301       assert.equal(session.json.user.username, 'alice');
    302       assert.equal(session.json.scope.type, 'realm');
    303       assert.equal(session.json.scope.host, 'acme.localhost');
    304       assert.equal(session.json.realm.slug, 'acme');
    305     });
    306 
    307     await t.test('canonical and client hosts create their own session scopes', async () => {
    308       const canonical = await identity({
    309         method: 'POST',
    310         path: '/api/v1/login/password',
    311         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    312       });
    313       assert.equal(canonical.status, 200);
    314       const canonicalCookie = mergeCookies('', canonical);
    315       const canonicalSession = await identity({
    316         path: '/api/v1/session',
    317         cookie: canonicalCookie,
    318       });
    319       assert.equal(canonicalSession.status, 200);
    320       assert.equal(canonicalSession.json.scope.type, 'global');
    321       assert.equal(canonicalSession.json.scope.host, 'identity.test');
    322       assert.equal(canonicalSession.json.realm, null);
    323 
    324       const client = await app({
    325         method: 'POST',
    326         path: '/api/v1/login/password',
    327         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    328       });
    329       assert.equal(client.status, 200);
    330       const clientSession = await app({
    331         path: '/api/v1/session',
    332         cookie: mergeCookies('', client),
    333       });
    334       assert.equal(clientSession.status, 200);
    335       assert.equal(clientSession.json.scope.type, 'client');
    336       assert.equal(clientSession.json.scope.host, 'app.acme.localhost');
    337       assert.equal(clientSession.json.scope.client_id, 'internal-app');
    338       assert.equal(clientSession.json.realm.slug, 'acme');
    339     });
    340 
    341     await t.test('sessions are scoped to their host and old cookies are rejected', async () => {
    342       const login = await acme({
    343         method: 'POST',
    344         path: '/api/v1/login/password',
    345         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    346       });
    347       const cookie = mergeCookies('', login);
    348       const token = cookie.slice('auth_session='.length);
    349 
    350       const wrongHost = await globex({ path: '/api/v1/session', cookie });
    351       assert.equal(wrongHost.status, 401);
    352 
    353       const oldCookie = `auth_session_acme=${token}`;
    354       const oldStyle = await acme({ path: '/api/v1/session', cookie: oldCookie });
    355       assert.equal(oldStyle.status, 401);
    356     });
    357 
    358     await t.test('a host group holds multiple accounts and can switch', async () => {
    359       const aliceLogin = await acme({
    360         method: 'POST',
    361         path: '/api/v1/login/password',
    362         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    363       });
    364       assert.equal(aliceLogin.status, 200);
    365       const aliceCookie = mergeCookies('', aliceLogin);
    366 
    367       const itLogin = await acme({
    368         method: 'POST',
    369         path: '/api/v1/login/password',
    370         body: { client_id: 'internal-app', identifier: 'it@acme.test', password: 'demo' },
    371         cookie: aliceCookie,
    372       });
    373       assert.equal(itLogin.status, 200);
    374       assert.equal(responseCookies(itLogin)[0], responseCookies(aliceLogin)[0]);
    375       const cookie = mergeCookies(aliceCookie, itLogin);
    376 
    377       const session = await acme({ path: '/api/v1/session', cookie });
    378       assert.equal(session.status, 200);
    379       assert.equal(session.json.user.username, 'acme-it');
    380       assert.deepEqual(
    381         session.json.accounts.map((account) => account.user.username),
    382         ['alice', 'acme-it']
    383       );
    384       assert.equal(
    385         session.json.accounts.find((account) => account.user.username === 'acme-it').active,
    386         true
    387       );
    388 
    389       const aliceAccount = session.json.accounts.find(
    390         (account) => account.user.username === 'alice'
    391       );
    392       const selected = await acme({
    393         method: 'POST',
    394         path: '/api/v1/session/select',
    395         body: { public_id: aliceAccount.public_id, client_id: 'internal-app' },
    396         cookie,
    397       });
    398       assert.equal(selected.status, 200);
    399       assert.equal(selected.json.user.username, 'alice');
    400       assert.equal(selected.json.pending_auth_request, false);
    401 
    402       const switched = await acme({ path: '/api/v1/session', cookie });
    403       assert.equal(switched.status, 200);
    404       assert.equal(switched.json.user.username, 'alice');
    405     });
    406 
    407     await t.test('authorize asks which account to use when the group has multiple accounts', async () => {
    408       const aliceLogin = await app({
    409         method: 'POST',
    410         path: '/api/v1/login/password',
    411         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    412       });
    413       let jar = mergeCookies('', aliceLogin);
    414       const itLogin = await app({
    415         method: 'POST',
    416         path: '/api/v1/login/password',
    417         body: { client_id: 'internal-app', identifier: 'it@acme.test', password: 'demo' },
    418         cookie: jar,
    419       });
    420       jar = mergeCookies(jar, itLogin);
    421 
    422       const { verifier, challenge } = pkce();
    423       const redirectUri = `${origin('app.acme.localhost')}/callback`;
    424       const authorizePath =
    425         `/oidc/authorize?response_type=code&client_id=internal-app` +
    426         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` +
    427         `&state=state-select&code_challenge=${challenge}&code_challenge_method=S256`;
    428 
    429       const first = await app({ path: authorizePath, cookie: jar });
    430       assert.equal(first.status, 302);
    431       assert.equal(locationQuery(first).pathname, '/oidc/login');
    432       jar = mergeCookies(jar, first);
    433 
    434       const session = await app({ path: '/api/v1/session', cookie: jar });
    435       const aliceAccount = session.json.accounts.find(
    436         (account) => account.user.username === 'alice'
    437       );
    438       const selected = await app({
    439         method: 'POST',
    440         path: '/api/v1/session/select',
    441         body: { public_id: aliceAccount.public_id, client_id: 'internal-app' },
    442         cookie: jar,
    443       });
    444       assert.equal(selected.status, 200);
    445       assert.equal(selected.json.pending_auth_request, true);
    446       assert.equal(selected.json.user.username, 'alice');
    447 
    448       const second = await app({ path: authorizePath, cookie: jar });
    449       assert.equal(second.status, 302);
    450       const redirected = locationQuery(second);
    451       assert.equal(redirected.searchParams.get('state'), 'state-select');
    452       const code = redirected.searchParams.get('code');
    453       assert.ok(code);
    454 
    455       const tokenResponse = await app({
    456         method: 'POST',
    457         path: '/oidc/token',
    458         form: {
    459           grant_type: 'authorization_code',
    460           code,
    461           redirect_uri: redirectUri,
    462           client_id: 'internal-app',
    463           code_verifier: verifier,
    464         },
    465       });
    466       assert.equal(tokenResponse.status, 200);
    467 
    468       const jwks = await app({ path: '/oidc/jwks' });
    469       const keySet = createLocalJWKSet(jwks.json);
    470       const { payload } = await jwtVerify(tokenResponse.json.id_token, keySet, {
    471         issuer: `${publicUrl}/oidc/acme`,
    472         audience: 'internal-app',
    473       });
    474       assert.equal(payload.preferred_username, 'alice');
    475     });
    476 
    477     await t.test('unknown hosts are rejected by auth and session APIs', async () => {
    478       const unknown = (options = {}) =>
    479         request(port, { host: `unknown.test:${port}`, ...options });
    480 
    481       const login = await unknown({
    482         method: 'POST',
    483         path: '/api/v1/login/password',
    484         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    485       });
    486       assert.equal(login.status, 404);
    487 
    488       const session = await unknown({ path: '/api/v1/session' });
    489       assert.equal(session.status, 404);
    490     });
    491 
    492     await t.test('long sessions are only issued when requested', async () => {
    493       const response = await acme({
    494         method: 'POST',
    495         path: '/api/v1/login/password',
    496         body: {
    497           client_id: 'internal-app',
    498           identifier: 'alice@acme.test',
    499           password: 'demo',
    500           long_session: true,
    501         },
    502       });
    503       assert.equal(response.status, 200);
    504       assert.equal(maxAge(response), 7 * 24 * 60 * 60);
    505     });
    506 
    507     await t.test('bad credentials are generic', async () => {
    508       const response = await acme({
    509         method: 'POST',
    510         path: '/api/v1/login/password',
    511         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'wrong' },
    512       });
    513       assert.equal(response.status, 401);
    514       assert.equal(response.json.error.code, 'invalid_credentials');
    515     });
    516 
    517     await t.test('membership is required where the client demands it', async () => {
    518       const denied = await acme({
    519         method: 'POST',
    520         path: '/api/v1/login/password',
    521         body: {
    522           client_id: 'acme-members-app',
    523           identifier: 'contractor@personal.test',
    524           password: 'demo',
    525         },
    526       });
    527       assert.equal(denied.status, 401);
    528 
    529       const allowed = await acme({
    530         method: 'POST',
    531         path: '/api/v1/login/password',
    532         body: {
    533           client_id: 'acme-public-app',
    534           identifier: 'guest@personal.test',
    535           password: 'demo',
    536         },
    537       });
    538       assert.equal(allowed.status, 200);
    539     });
    540 
    541     await t.test('password login is rejected for upstream-mapped domains', async () => {
    542       const response = await acme({
    543         method: 'POST',
    544         path: '/api/v1/login/password',
    545         body: { client_id: 'internal-app', identifier: 'someone@sso.acme.test', password: 'demo' },
    546       });
    547       assert.equal(response.status, 403);
    548       assert.equal(response.json.error.code, 'provider_required');
    549     });
    550 
    551     await t.test('guests can hold realm-admin roles', async () => {
    552       const response = await globex({
    553         method: 'POST',
    554         path: '/api/v1/login/password',
    555         body: {
    556           client_id: 'globex-members-app',
    557           identifier: 'contractor@personal.test',
    558           password: 'demo',
    559         },
    560       });
    561       assert.equal(response.status, 200);
    562       const session = await globex({
    563         path: '/api/v1/session',
    564         cookie: mergeCookies('', response),
    565       });
    566       assert.equal(session.status, 200);
    567       assert.ok(session.json.roles.includes('realm-admin'));
    568     });
    569 
    570     await t.test('authorization code flow issues verifiable tokens', async () => {
    571       const { verifier, challenge } = pkce();
    572       const redirectUri = `${origin('app.acme.localhost')}/callback`;
    573       const authorizePath =
    574         `/oidc/authorize?response_type=code&client_id=internal-app` +
    575         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20profile%20email` +
    576         `&state=state-1&nonce=nonce-1&code_challenge=${challenge}&code_challenge_method=S256`;
    577 
    578       const first = await app({ path: authorizePath });
    579       assert.equal(first.status, 302);
    580       assert.equal(locationQuery(first).pathname, '/oidc/login');
    581       let jar = mergeCookies('', first);
    582 
    583       const login = await app({
    584         method: 'POST',
    585         path: '/api/v1/login/password',
    586         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    587         cookie: jar,
    588       });
    589       assert.equal(login.status, 200);
    590       assert.equal(login.json.pending_auth_request, true);
    591       jar = mergeCookies(jar, login);
    592 
    593       const second = await app({ path: authorizePath, cookie: jar });
    594       assert.equal(second.status, 302);
    595       const redirected = locationQuery(second);
    596       assert.equal(redirected.origin + redirected.pathname, redirectUri);
    597       assert.equal(redirected.searchParams.get('state'), 'state-1');
    598       const code = redirected.searchParams.get('code');
    599       assert.ok(code);
    600 
    601       const tokenResponse = await app({
    602         method: 'POST',
    603         path: '/oidc/token',
    604         form: {
    605           grant_type: 'authorization_code',
    606           code,
    607           redirect_uri: redirectUri,
    608           client_id: 'internal-app',
    609           code_verifier: verifier,
    610         },
    611       });
    612       assert.equal(tokenResponse.status, 200);
    613       assert.ok(tokenResponse.json.id_token);
    614       assert.ok(tokenResponse.json.access_token);
    615 
    616       const jwks = await app({ path: '/oidc/jwks' });
    617       const keySet = createLocalJWKSet(jwks.json);
    618       const { payload } = await jwtVerify(tokenResponse.json.id_token, keySet, {
    619         issuer: `${publicUrl}/oidc/acme`,
    620         audience: 'internal-app',
    621       });
    622       assert.equal(payload.preferred_username, 'alice');
    623       assert.equal(payload.email, 'alice@acme.test');
    624       assert.equal(payload.nonce, 'nonce-1');
    625 
    626       const userinfo = await app({
    627         path: '/oidc/userinfo',
    628         bearer: tokenResponse.json.access_token,
    629       });
    630       assert.equal(userinfo.status, 200);
    631       assert.equal(userinfo.json.preferred_username, 'alice');
    632 
    633       const reuse = await app({
    634         method: 'POST',
    635         path: '/oidc/token',
    636         form: {
    637           grant_type: 'authorization_code',
    638           code,
    639           redirect_uri: redirectUri,
    640           client_id: 'internal-app',
    641           code_verifier: verifier,
    642         },
    643       });
    644       assert.equal(reuse.status, 400);
    645       assert.equal(reuse.json.error, 'invalid_grant');
    646     });
    647 
    648     await t.test('canonical realm-client flow resolves the realm from its mapping', async () => {
    649       const { verifier, challenge } = pkce();
    650       const redirectUri = `${origin('app.acme.localhost')}/callback`;
    651       const authorizePath =
    652         `/oidc/internal-app/authorize?response_type=code&client_id=internal-app` +
    653         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` +
    654         `&state=state-canonical&code_challenge=${challenge}&code_challenge_method=S256`;
    655 
    656       const first = await identity({ path: authorizePath });
    657       assert.equal(first.status, 302);
    658       assert.equal(locationQuery(first).pathname, '/oidc/internal-app/login');
    659       let jar = mergeCookies('', first);
    660 
    661       const login = await identity({
    662         method: 'POST',
    663         path: '/api/v1/login/password',
    664         body: {
    665           client_id: 'internal-app',
    666           identifier: 'alice@acme.test',
    667           password: 'demo',
    668         },
    669         cookie: jar,
    670       });
    671       assert.equal(login.status, 200);
    672       assert.equal(login.json.pending_auth_request, true);
    673       jar = mergeCookies(jar, login);
    674 
    675       const second = await identity({ path: authorizePath, cookie: jar });
    676       assert.equal(second.status, 302);
    677       const redirected = locationQuery(second);
    678       assert.equal(redirected.searchParams.get('state'), 'state-canonical');
    679       const code = redirected.searchParams.get('code');
    680       assert.ok(code);
    681 
    682       const tokenResponse = await identity({
    683         method: 'POST',
    684         path: '/oidc/internal-app/token',
    685         form: {
    686           grant_type: 'authorization_code',
    687           code,
    688           redirect_uri: redirectUri,
    689           client_id: 'internal-app',
    690           code_verifier: verifier,
    691         },
    692       });
    693       assert.equal(tokenResponse.status, 200);
    694 
    695       const database = new DatabaseSync(env.AUTH_DATABASE_PATH, { readOnly: true });
    696       try {
    697         const binding = database
    698           .prepare(
    699             `SELECT COUNT(*) AS count
    700                FROM session_realms sr
    701                JOIN sessions s ON s.id = sr.session_id
    702                JOIN realms r ON r.id = sr.realm_id
    703               WHERE r.slug = 'acme' AND s.scope_type = 'global'`
    704           )
    705           .get();
    706         assert.equal(binding.count, 1);
    707       } finally {
    708         database.close();
    709       }
    710     });
    711 
    712     await t.test('public clients allow non-members through authorize', async () => {
    713       const { verifier, challenge } = pkce();
    714       const redirectUri = `${origin('public.localhost')}/callback`;
    715       const authorizePath =
    716         `/oidc/acme-public-app/authorize?response_type=code&client_id=acme-public-app` +
    717         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` +
    718         `&state=state-2&code_challenge=${challenge}&code_challenge_method=S256`;
    719 
    720       const first = await acme({ path: authorizePath });
    721       assert.equal(first.status, 302);
    722       let jar = mergeCookies('', first);
    723 
    724       const login = await acme({
    725         method: 'POST',
    726         path: '/api/v1/login/password',
    727         body: {
    728           client_id: 'acme-public-app',
    729           identifier: 'contractor@personal.test',
    730           password: 'demo',
    731         },
    732         cookie: jar,
    733       });
    734       assert.equal(login.status, 200);
    735       assert.equal(login.json.pending_auth_request, true);
    736       jar = mergeCookies(jar, login);
    737 
    738       const second = await acme({ path: authorizePath, cookie: jar });
    739       assert.equal(second.status, 302);
    740       const code = locationQuery(second).searchParams.get('code');
    741       assert.ok(code);
    742 
    743       const tokenResponse = await acme({
    744         method: 'POST',
    745         path: '/oidc/acme-public-app/token',
    746         form: {
    747           grant_type: 'authorization_code',
    748           code,
    749           redirect_uri: redirectUri,
    750           client_id: 'acme-public-app',
    751           code_verifier: verifier,
    752         },
    753       });
    754       assert.equal(tokenResponse.status, 200);
    755     });
    756 
    757     await t.test('system admins can use the admin virtual client', async () => {
    758       const redirectUri = `${origin('acme.localhost')}/oidc/admin/callback`;
    759       const { verifier, challenge } = pkce();
    760       const authorizePath =
    761         `/oidc/admin/authorize?response_type=code&client_id=admin` +
    762         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20profile` +
    763         `&state=state-3&code_challenge=${challenge}&code_challenge_method=S256`;
    764 
    765       const first = await acme({ path: authorizePath });
    766       assert.equal(first.status, 302);
    767       let jar = mergeCookies('', first);
    768 
    769       const login = await acme({
    770         method: 'POST',
    771         path: '/api/v1/login/password',
    772         body: { client_id: 'admin', identifier: 'admin@system.test', password: 'demo' },
    773         cookie: jar,
    774       });
    775       assert.equal(login.status, 200);
    776       jar = mergeCookies(jar, login);
    777 
    778       const second = await acme({ path: authorizePath, cookie: jar });
    779       assert.equal(second.status, 302);
    780       const code = locationQuery(second).searchParams.get('code');
    781       assert.ok(code);
    782 
    783       const tokenResponse = await acme({
    784         method: 'POST',
    785         path: '/oidc/admin/token',
    786         form: {
    787           grant_type: 'authorization_code',
    788           code,
    789           redirect_uri: redirectUri,
    790           client_id: 'admin',
    791           code_verifier: verifier,
    792         },
    793       });
    794       assert.equal(tokenResponse.status, 200);
    795     });
    796 
    797     await t.test('confidential clients authenticate with client_secret_basic', async () => {
    798       const { verifier, challenge } = pkce();
    799       const redirectUri = `${origin('confidential.localhost')}/callback`;
    800       const authorizePath =
    801         `/oidc/confidential-app/authorize?response_type=code&client_id=confidential-app` +
    802         `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` +
    803         `&state=state-4&code_challenge=${challenge}&code_challenge_method=S256`;
    804 
    805       const first = await acme({ path: authorizePath });
    806       let jar = mergeCookies('', first);
    807       const login = await acme({
    808         method: 'POST',
    809         path: '/api/v1/login/password',
    810         body: { client_id: 'confidential-app', identifier: 'alice@acme.test', password: 'demo' },
    811         cookie: jar,
    812       });
    813       jar = mergeCookies(jar, login);
    814       const second = await acme({ path: authorizePath, cookie: jar });
    815       const code = locationQuery(second).searchParams.get('code');
    816 
    817       const tokenResponse = await acme({
    818         method: 'POST',
    819         path: '/oidc/confidential-app/token',
    820         form: {
    821           grant_type: 'authorization_code',
    822           code,
    823           redirect_uri: redirectUri,
    824           code_verifier: verifier,
    825         },
    826         headers: {
    827           authorization: `Basic ${Buffer.from('confidential-app:demo-secret').toString('base64')}`,
    828         },
    829       });
    830       assert.equal(tokenResponse.status, 200);
    831     });
    832 
    833     await t.test('logout removes only the current host session', async () => {
    834       const acmeLogin = await acme({
    835         method: 'POST',
    836         path: '/api/v1/login/password',
    837         body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' },
    838       });
    839       const acmeCookie = mergeCookies('', acmeLogin);
    840 
    841       const globexLogin = await globex({
    842         method: 'POST',
    843         path: '/api/v1/login/password',
    844         body: {
    845           client_id: 'globex-members-app',
    846           identifier: 'contractor@personal.test',
    847           password: 'demo',
    848         },
    849       });
    850       const globexCookie = mergeCookies('', globexLogin);
    851 
    852       const logout = await acme({
    853         method: 'POST',
    854         path: '/api/v1/logout',
    855         body: {},
    856         cookie: acmeCookie,
    857       });
    858       assert.equal(logout.status, 200);
    859 
    860       const acmeSession = await acme({ path: '/api/v1/session', cookie: acmeCookie });
    861       assert.equal(acmeSession.status, 401);
    862 
    863       const globexSession = await globex({ path: '/api/v1/session', cookie: globexCookie });
    864       assert.equal(globexSession.status, 200);
    865     });
    866 
    867     await t.test('sessions require a cookie', async () => {
    868       const response = await acme({ path: '/api/v1/session' });
    869       assert.equal(response.status, 401);
    870       assert.equal(response.json.error.code, 'not_authenticated');
    871     });
    872   } finally {
    873     if (server) {
    874       server.kill('SIGTERM');
    875       await new Promise((resolve) => server.once('exit', resolve));
    876     }
    877     await fs.rm(dir, { recursive: true, force: true });
    878   }
    879 });