conductor

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

oidc.test.js (13502B)


      1 // test/oidc.test.js - authentication against a real identity provider
      2 //
      3 // Jobs navikt/mock-oauth2-server in a container and verifies real signed
      4 // tokens against its real key set. Skipped when docker is unavailable.
      5 //
      6 // The two things worth proving here cannot be proven against a stub: that
      7 // the key set is located by discovery rather than by guessing a provider
      8 // specific path, and that somebody who exists only inside a token still
      9 // ends up able to own projects.
     10 
     11 import test, { before, after } from 'node:test';
     12 import assert from 'node:assert/strict';
     13 import { execFileSync } from 'node:child_process';
     14 import { startHarness } from './helpers/harness.js';
     15 import { startOidc, forgedToken } from './helpers/oidc.js';
     16 import { fetchDiscovery } from '../src/lib/auth/oidc.js';
     17 
     18 // Checked synchronously: the runner needs to know whether to skip while it
     19 // is collecting tests, and an unsettled top-level await at that point
     20 // aborts the whole file.
     21 function dockerAvailableSync() {
     22   if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false;
     23   try {
     24     execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], {
     25       timeout: 15000,
     26       stdio: 'ignore',
     27     });
     28     return true;
     29   } catch {
     30     return false;
     31   }
     32 }
     33 
     34 const available = dockerAvailableSync();
     35 const opts = { skip: available ? false : 'docker is not available' };
     36 
     37 // One provider for the whole file; each test gets a fresh conductor.
     38 let oidc = null;
     39 
     40 before(async () => {
     41   if (available) oidc = await startOidc();
     42 }, { timeout: 180000 });
     43 
     44 after(async () => {
     45   if (oidc) await oidc.stop();
     46 });
     47 
     48 async function withOidcConductor(options, fn) {
     49   const h = await startHarness({
     50     oidcDiscoveryUrl: oidc.discovery,
     51     oidcClientId: 'conductor',
     52     oidcAdminRole: 'conductor-admin',
     53     ...options,
     54   });
     55   try {
     56     return await fn(h);
     57   } finally {
     58     await h.stop();
     59   }
     60 }
     61 
     62 const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
     63 
     64 test('the endpoints and key set come from the discovery document', opts, async () => {
     65   const document = await fetchDiscovery(oidc.discovery);
     66 
     67   assert.equal(document.issuer, oidc.issuer);
     68   // Keycloak serves the key set from /protocol/openid-connect/certs. Reading
     69   // the advertised jwks_uri rather than guessing that path is the point.
     70   assert.equal(document.jwks_uri, `${oidc.issuer}/jwks`);
     71   assert.ok(document.authorization_endpoint.endsWith('/authorize'));
     72   assert.ok(document.token_endpoint.endsWith('/token'));
     73 });
     74 
     75 test('discovery reports a document that is missing an endpoint', async () => {
     76   const withoutKeys = async () => new Response(
     77     JSON.stringify({
     78       issuer: 'https://idp.example/realm',
     79       authorization_endpoint: 'https://idp.example/auth',
     80       token_endpoint: 'https://idp.example/token',
     81     }),
     82     { status: 200, headers: { 'content-type': 'application/json' } }
     83   );
     84   await assert.rejects(
     85     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: withoutKeys }),
     86     /did not advertise jwks_uri/
     87   );
     88 });
     89 
     90 test('discovery reports an unreachable provider and a bad status', async () => {
     91   const notFound = async () => new Response('nope', { status: 404, statusText: 'Not Found' });
     92   await assert.rejects(
     93     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: notFound }),
     94     /returned 404/
     95   );
     96 
     97   const offline = async () => { throw new Error('connection refused'); };
     98   await assert.rejects(
     99     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: offline }),
    100     /could not reach the OIDC discovery document/
    101   );
    102 });
    103 
    104 test('a token carrying the admin role authenticates as an administrator', opts, async () => {
    105   await withOidcConductor({}, async (h) => {
    106     const headers = await oidc.headers('persona-admin');
    107 
    108     const me = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers });
    109     assert.equal(me.statusCode, 200);
    110     assert.equal(me.json().data.username, 'alice');
    111     assert.equal(me.json().data.role, 'admin');
    112     assert.equal(me.json().data.source, 'oidc');
    113 
    114     const users = await h.app.inject({ method: 'GET', url: '/api/v1/users', headers });
    115     assert.equal(users.statusCode, 200);
    116     assert.ok(users.json().data.length >= 1);
    117   });
    118 });
    119 
    120 test('a token without the admin role is an ordinary user', opts, async () => {
    121   await withOidcConductor({}, async (h) => {
    122     const headers = await oidc.headers('persona-viewer');
    123 
    124     const me = await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers });
    125     assert.equal(me.json().data.username, 'bob');
    126     assert.equal(me.json().data.role, 'user');
    127 
    128     // Listing accounts returns only their own rather than refusing.
    129     const users = await h.app.inject({ method: 'GET', url: '/api/v1/users', headers });
    130     assert.equal(users.statusCode, 200);
    131     assert.deepEqual(users.json().data.map((u) => u.username), ['bob']);
    132   });
    133 });
    134 
    135 test('roles are recognised in the groups and resource_access shapes', opts, async () => {
    136   await withOidcConductor({}, async (h) => {
    137     for (const persona of ['persona-groups', 'persona-resource']) {
    138       const me = await h.app.inject({
    139         method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers(persona),
    140       });
    141       assert.equal(me.json().data.role, 'admin', `${persona} should map to admin`);
    142     }
    143   });
    144 });
    145 
    146 test('an OIDC user gets a local account and can own a project', opts, async () => {
    147   await withOidcConductor({}, async (h) => {
    148     const headers = await oidc.headers('persona-viewer');
    149 
    150     // Nothing exists for them before they appear.
    151     assert.equal(await h.services.users.byUsername('bob'), undefined);
    152 
    153     const created = await h.app.inject({
    154       method: 'POST',
    155       url: '/api/v1/projects',
    156       headers: json(headers),
    157       payload: JSON.stringify({ id: 'bobs-app', repo_url: 'https://git.example.com/bob.git' }),
    158     });
    159     // Without a local row this fails on the owner_id foreign key.
    160     assert.equal(created.statusCode, 201, created.body);
    161 
    162     const account = await h.services.users.byUsername('bob');
    163     assert.ok(account, 'a local account should have been provisioned');
    164     assert.equal(created.json().data.owner_id, account.id);
    165 
    166     // The account cannot be signed into with a password.
    167     assert.equal(await h.services.users.authenticate('bob', 'external'), null);
    168     assert.equal(await h.services.users.authenticate('bob', ''), null);
    169   });
    170 });
    171 
    172 test('the same subject keeps one account even when the display name changes', opts, async () => {
    173   await withOidcConductor({}, async (h) => {
    174     const first = await h.app.inject({
    175       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
    176     });
    177     const second = await h.app.inject({
    178       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin-renamed'),
    179     });
    180 
    181     // Identity is the subject, not the label the provider shows.
    182     assert.equal(second.json().data.id, first.json().data.id);
    183     assert.equal((await h.services.users.list()).filter((u) => u.external_id).length, 1);
    184   });
    185 });
    186 
    187 test('a display name colliding with a local account gets its own username', opts, async () => {
    188   await withOidcConductor({}, async (h) => {
    189     // A local account that predates OIDC already holds the name 'admin'.
    190     const local = await h.services.users.create({
    191       username: 'admin', password: 'bootstrap-password', role: 'admin',
    192     });
    193 
    194     const me = await h.app.inject({
    195       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-collision'),
    196     });
    197     assert.equal(me.statusCode, 200);
    198     assert.notEqual(me.json().data.id, local.id, 'the local account must not be taken over');
    199     assert.notEqual(me.json().data.username, 'admin');
    200     assert.match(me.json().data.username, /^admin-\d+$/);
    201   });
    202 });
    203 
    204 test('a role revoked at the provider is lost on the next request', opts, async () => {
    205   await withOidcConductor({}, async (h) => {
    206     const asAdmin = await h.app.inject({
    207       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
    208     });
    209     assert.equal(asAdmin.json().data.role, 'admin');
    210 
    211     // The provider stops asserting the role for that subject.
    212     const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`);
    213     await h.services.users.upsertExternal({
    214       externalId: `${oidc.issuer}|alice-sub`, username: 'alice', role: 'user',
    215     });
    216     const refreshed = await h.services.users.get(account.id);
    217     assert.equal(refreshed.role, 'user');
    218   });
    219 });
    220 
    221 test('an account disabled locally is refused despite a valid token', opts, async () => {
    222   await withOidcConductor({}, async (h) => {
    223     const headers = await oidc.headers('persona-admin');
    224     assert.equal((await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 200);
    225 
    226     const account = await h.services.users.byExternalId(`${oidc.issuer}|alice-sub`);
    227     await h.services.users.setDisabled(account.id, true);
    228 
    229     // The provider would still admit them; the conductor does not.
    230     assert.equal((await h.app.inject({ method: 'GET', url: '/api/v1/users/me', headers })).statusCode, 401);
    231   });
    232 });
    233 
    234 test('a forged signature is refused', opts, async () => {
    235   await withOidcConductor({}, async (h) => {
    236     const token = await forgedToken(oidc.issuer);
    237     const res = await h.app.inject({
    238       method: 'GET', url: '/api/v1/users/me', headers: { authorization: `Bearer ${token}` },
    239     });
    240     assert.equal(res.statusCode, 401);
    241     assert.equal(await h.services.users.byUsername('mallory'), undefined, 'no account should be created');
    242   });
    243 });
    244 
    245 test('an expired token is refused', opts, async () => {
    246   await withOidcConductor({}, async (h) => {
    247     const res = await h.app.inject({
    248       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-expired'),
    249     });
    250     assert.equal(res.statusCode, 401);
    251   });
    252 });
    253 
    254 test('a token from a different issuer is refused', opts, async () => {
    255   // The conductor trusts the issuer in its discovery document; a token
    256   // minted under another path of the same server must not be accepted.
    257   await withOidcConductor({
    258     oidcDiscoveryUrl: `${oidc.issuer.replace(/\/conductor$/, '/other')}/.well-known/openid-configuration`,
    259   }, async (h) => {
    260     const res = await h.app.inject({
    261       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-admin'),
    262     });
    263     assert.equal(res.statusCode, 401);
    264   });
    265 });
    266 
    267 // Keycloak puts the client in azp and leaves aud as the resource server, so
    268 // asserting aud alone rejects every real access token.
    269 test('a token naming the client only in azp is accepted', opts, async () => {
    270   await withOidcConductor({}, async (h) => {
    271     const res = await h.app.inject({
    272       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-azp'),
    273     });
    274     assert.equal(res.statusCode, 200, res.body);
    275     assert.equal(res.json().data.role, 'admin');
    276   });
    277 });
    278 
    279 test('a token issued to another client is refused', opts, async () => {
    280   await withOidcConductor({}, async (h) => {
    281     const res = await h.app.inject({
    282       method: 'GET', url: '/api/v1/users/me', headers: await oidc.headers('persona-other-client'),
    283     });
    284     assert.equal(res.statusCode, 401);
    285   });
    286 });
    287 
    288 test('discovery passes the provider through and names the client', opts, async () => {
    289   await withOidcConductor({}, async (h) => {
    290     const res = await h.discovery();
    291     assert.equal(res.statusCode, 200);
    292 
    293     const doc = res.json();
    294     assert.equal(doc.issuer, oidc.issuer);
    295     assert.equal(doc.authorization_endpoint, `${oidc.issuer}/authorize`);
    296     assert.equal(doc.client_id, 'conductor');
    297   });
    298 });
    299 
    300 // Only one provider is ever advertised, so only one may answer.
    301 test('the built-in provider is not mounted while a provider is configured', opts, async () => {
    302   await withOidcConductor({}, async (h) => {
    303     for (const url of ['/auth/authorize', '/auth/token', '/auth/jwks']) {
    304       assert.equal((await h.app.inject({ method: 'GET', url })).statusCode, 404, url);
    305     }
    306   });
    307 });
    308 
    309 test('worker ownership follows the OIDC identity', opts, async () => {
    310   await withOidcConductor({ visibility: 'private' }, async (h) => {
    311     const alice = await oidc.headers('persona-admin');
    312     const bob = await oidc.headers('persona-viewer');
    313 
    314     const project = await h.app.inject({
    315       method: 'POST',
    316       url: '/api/v1/projects',
    317       headers: json(bob),
    318       payload: JSON.stringify({ id: 'bob-proj', repo_url: 'https://git.example.com/bob.git' }),
    319     });
    320     assert.equal(project.statusCode, 201, project.body);
    321 
    322     const bobToken = await h.app.inject({
    323       method: 'POST', url: '/api/v1/workers', headers: json(bob), payload: JSON.stringify({ name: 'bob-pi' }),
    324     });
    325     const aliceToken = await h.app.inject({
    326       method: 'POST', url: '/api/v1/workers', headers: json(alice), payload: JSON.stringify({ name: 'alice-pi' }),
    327     });
    328 
    329     const aliceList = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: alice });
    330     const names = aliceList.json().data.map((t) => t.name);
    331     assert.ok(names.includes('bob-pi'), 'an administrator sees every worker');
    332 
    333     const bobList = await h.app.inject({ method: 'GET', url: '/api/v1/workers', headers: bob });
    334     assert.deepEqual(bobList.json().data.map((t) => t.name), ['bob-pi']);
    335 
    336     assert.ok(bobToken.json().data.token);
    337     assert.ok(aliceToken.json().data.token);
    338   });
    339 });