conductor

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

oidc.js (8070B)


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