conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

oidc.js (7590B)


      1 // test/helpers/oidc.js - a real identity provider for the tests
      2 //
      3 // Jobs navikt/mock-oauth2-server, which speaks real OIDC discovery and
      4 // signs real tokens with a real key set. Faking the provider would test the
      5 // fake: the interesting failures here are discovery paths, signature
      6 // verification and claim shapes, and none of those survive a stub.
      7 //
      8 // Personas are declared up front as token callbacks and requested by scope,
      9 // so a test can ask for a specific set of claims without a browser.
     10 
     11 import { execFile } from 'node:child_process';
     12 import { promisify } from 'node:util';
     13 import crypto from 'node:crypto';
     14 import { reapStale } from './containers.js';
     15 import { SESSION_COOKIE } from '../../src/lib/auth/token.js';
     16 
     17 const execFileAsync = promisify(execFile);
     18 
     19 export const IMAGE = 'ghcr.io/navikt/mock-oauth2-server:2.1.10';
     20 const ISSUER_ID = 'conductor';
     21 
     22 // Each persona is selected with scope=<name> at the token endpoint.
     23 const PERSONAS = {
     24   'persona-admin': {
     25     sub: 'alice-sub',
     26     preferred_username: 'alice',
     27     realm_access: { roles: ['conductor-admin'] },
     28     aud: ['conductor'],
     29   },
     30   'persona-viewer': {
     31     sub: 'bob-sub',
     32     preferred_username: 'bob',
     33     realm_access: { roles: ['developer'] },
     34     aud: ['conductor'],
     35   },
     36   // The same person as persona-admin, to prove identity is keyed on the
     37   // subject rather than on a display name the provider may change.
     38   'persona-admin-renamed': {
     39     sub: 'alice-sub',
     40     preferred_username: 'alice-moved',
     41     realm_access: { roles: ['conductor-admin'] },
     42     aud: ['conductor'],
     43   },
     44   // Roles arriving in the other shapes providers use.
     45   'persona-groups': {
     46     sub: 'carol-sub',
     47     preferred_username: 'carol',
     48     groups: ['conductor-admin'],
     49     aud: ['conductor'],
     50   },
     51   'persona-resource': {
     52     sub: 'dave-sub',
     53     preferred_username: 'dave',
     54     resource_access: { conductor: { roles: ['conductor-admin'] } },
     55     aud: ['conductor'],
     56   },
     57   // A display name that collides with an existing local account.
     58   'persona-collision': {
     59     sub: 'eve-sub',
     60     preferred_username: 'admin',
     61     realm_access: { roles: ['developer'] },
     62     aud: ['conductor'],
     63   },
     64   // Audience the conductor is not expecting.
     65   'persona-wrong-audience': {
     66     sub: 'frank-sub',
     67     preferred_username: 'frank',
     68     realm_access: { roles: ['conductor-admin'] },
     69     aud: ['some-other-service'],
     70   },
     71   // Already expired when issued.
     72   'persona-expired': {
     73     sub: 'grace-sub',
     74     preferred_username: 'grace',
     75     realm_access: { roles: ['conductor-admin'] },
     76     aud: ['conductor'],
     77     exp: 1000000000,
     78   },
     79 };
     80 
     81 export async function dockerAvailable() {
     82   if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false;
     83   try {
     84     await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 });
     85     return true;
     86   } catch {
     87     return false;
     88   }
     89 }
     90 
     91 async function freePort() {
     92   const net = await import('node:net');
     93   return new Promise((resolve, reject) => {
     94     const server = net.createServer();
     95     server.unref();
     96     server.on('error', reject);
     97     server.listen(0, '127.0.0.1', () => {
     98       const { port } = server.address();
     99       server.close(() => resolve(port));
    100     });
    101   });
    102 }
    103 
    104 // How long to wait for the container to answer. Generous, because it
    105 // competes with image builds for the machine.
    106 const READY_TIMEOUT = 120 * 1000;
    107 
    108 const NAME_PREFIX = 'conductor-oidc-test-';
    109 
    110 export async function startOidc() {
    111   await reapStale(NAME_PREFIX);
    112 
    113   const port = await freePort();
    114   const name = `${NAME_PREFIX}${crypto.randomBytes(4).toString('hex')}`;
    115   const issuer = `http://127.0.0.1:${port}/${ISSUER_ID}`;
    116 
    117   const config = {
    118     interactiveLogin: true,
    119     tokenCallbacks: [{
    120       issuerId: ISSUER_ID,
    121       requestMappings: Object.entries(PERSONAS).map(([scope, claims]) => ({
    122         requestParam: 'scope',
    123         match: scope,
    124         claims,
    125       })),
    126     }],
    127   };
    128 
    129   await execFileAsync('docker', [
    130     'run', '--detach', '--rm',
    131     '--name', name,
    132     '--publish', `${port}:8080`,
    133     '--env', `JSON_CONFIG=${JSON.stringify(config)}`,
    134     IMAGE,
    135   ], { timeout: 120000 });
    136 
    137   const discovery = `${issuer}/.well-known/openid-configuration`;
    138   // Deadline rather than a count of attempts. A refused connection comes
    139   // back instantly, so sixty attempts spaced by half a second gave up
    140   // after thirty seconds regardless of the interval, and a machine busy
    141   // pulling or building images needs longer than that. Failing there
    142   // looked like a broken test rather than a slow start.
    143   let ready = false;
    144   const deadline = Date.now() + READY_TIMEOUT;
    145   while (Date.now() < deadline) {
    146     try {
    147       const res = await fetch(discovery, { signal: AbortSignal.timeout(2000) });
    148       if (res.ok) {
    149         ready = true;
    150         break;
    151       }
    152     } catch {
    153       // Still starting.
    154     }
    155     await new Promise((r) => { setTimeout(r, 500); });
    156   }
    157 
    158   if (!ready) {
    159     await execFileAsync('docker', ['rm', '-f', name]).catch(() => {});
    160     throw new Error(`the OIDC provider did not become ready at ${discovery} within ${READY_TIMEOUT / 1000}s`);
    161   }
    162 
    163   return {
    164     issuer,
    165     port,
    166     name,
    167     discovery,
    168 
    169     // Obtains a signed token for one of the personas above.
    170     async token(persona) {
    171       if (!Object.hasOwn(PERSONAS, persona)) throw new Error(`unknown persona ${persona}`);
    172       const res = await fetch(`${issuer}/token`, {
    173         method: 'POST',
    174         headers: { 'content-type': 'application/x-www-form-urlencoded' },
    175         body: new URLSearchParams({
    176           grant_type: 'client_credentials',
    177           client_id: 'conductor',
    178           client_secret: 'secret',
    179           scope: persona,
    180         }),
    181       });
    182       if (!res.ok) throw new Error(`token request failed: ${res.status} ${await res.text()}`);
    183       return (await res.json()).access_token;
    184     },
    185 
    186     // A session cookie for a persona, as the browser would carry after
    187     // signing in. The conductor accepts no other credential.
    188     async headers(persona) {
    189       return { cookie: `${SESSION_COOKIE}=${await this.token(persona)}` };
    190     },
    191 
    192     // Completes the authorization code flow for a URL the conductor built,
    193     // by submitting the provider's login form with the given claims. Returns
    194     // the redirect the provider hands back to the callback.
    195     async signIn(authorizationUrl, { username = 'alice', claims = {} } = {}) {
    196       const body = new URLSearchParams({ username });
    197       if (Object.keys(claims).length > 0) body.set('claims', JSON.stringify(claims));
    198 
    199       const res = await fetch(authorizationUrl, {
    200         method: 'POST',
    201         headers: { 'content-type': 'application/x-www-form-urlencoded' },
    202         body,
    203         redirect: 'manual',
    204       });
    205       if (res.status !== 302) throw new Error(`provider login did not redirect: ${res.status}`);
    206       return res.headers.get('location');
    207     },
    208 
    209     async stop() {
    210       await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {});
    211     },
    212   };
    213 }
    214 
    215 // A token signed by a key the provider does not publish, for checking that
    216 // a forged signature is refused.
    217 export async function forgedToken(issuer, claims = {}) {
    218   const jose = await import('jose');
    219   const { privateKey } = await jose.generateKeyPair('RS256');
    220   return new jose.SignJWT({
    221     preferred_username: 'mallory',
    222     realm_access: { roles: ['conductor-admin'] },
    223     ...claims,
    224   })
    225     .setProtectedHeader({ alg: 'RS256', kid: 'forged' })
    226     .setSubject('mallory-sub')
    227     .setIssuer(issuer)
    228     .setAudience('conductor')
    229     .setIssuedAt()
    230     .setExpirationTime('1h')
    231     .sign(privateKey);
    232 }