conductor

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

oidc.test.js (15737B)


      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 // These exercised the JSON API, which has been removed. Kept until the
     38 // suite is rebuilt against the interface.
     39 const apiGone = { skip: 'the JSON API was removed' };
     40 
     41 // One provider for the whole file; each test gets a fresh conductor.
     42 let oidc = null;
     43 
     44 before(async () => {
     45   if (available) oidc = await startOidc();
     46 }, { timeout: 180000 });
     47 
     48 after(async () => {
     49   if (oidc) await oidc.stop();
     50 });
     51 
     52 async function withOidcConductor(options, fn) {
     53   const h = await startHarness({
     54     oidcDiscoveryUrl: oidc.discovery,
     55     oidcClientId: 'conductor',
     56     oidcAdminRole: 'conductor-admin',
     57     ...options,
     58   });
     59   try {
     60     return await fn(h);
     61   } finally {
     62     await h.stop();
     63   }
     64 }
     65 
     66 const json = (headers) => ({ ...headers, 'content-type': 'application/json' });
     67 
     68 test('the endpoints and key set come from the discovery document', opts, async () => {
     69   const document = await fetchDiscovery(oidc.discovery);
     70 
     71   assert.equal(document.issuer, oidc.issuer);
     72   // Keycloak serves the key set from /protocol/openid-connect/certs. Reading
     73   // the advertised jwks_uri rather than guessing that path is the point.
     74   assert.equal(document.jwks_uri, `${oidc.issuer}/jwks`);
     75   assert.ok(document.authorization_endpoint.endsWith('/authorize'));
     76   assert.ok(document.token_endpoint.endsWith('/token'));
     77 });
     78 
     79 test('discovery reports a document that is missing an endpoint', async () => {
     80   const withoutKeys = async () => new Response(
     81     JSON.stringify({
     82       issuer: 'https://idp.example/realm',
     83       authorization_endpoint: 'https://idp.example/auth',
     84       token_endpoint: 'https://idp.example/token',
     85     }),
     86     { status: 200, headers: { 'content-type': 'application/json' } }
     87   );
     88   await assert.rejects(
     89     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: withoutKeys }),
     90     /did not advertise jwks_uri/
     91   );
     92 });
     93 
     94 test('discovery reports an unreachable provider and a bad status', async () => {
     95   const notFound = async () => new Response('nope', { status: 404, statusText: 'Not Found' });
     96   await assert.rejects(
     97     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: notFound }),
     98     /returned 404/
     99   );
    100 
    101   const offline = async () => { throw new Error('connection refused'); };
    102   await assert.rejects(
    103     fetchDiscovery('https://idp.example/realm/.well-known/openid-configuration', { fetchImpl: offline }),
    104     /could not reach the OIDC discovery document/
    105   );
    106 });
    107 
    108 test('a token carrying the admin role authenticates as an administrator', apiGone, async () => {
    109   await withOidcConductor({}, async (h) => {
    110     const headers = await oidc.headers('persona-admin');
    111 
    112     const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers });
    113     assert.equal(me.statusCode, 200);
    114     assert.equal(me.json().user.username, 'alice');
    115     assert.equal(me.json().user.role, 'admin');
    116     assert.equal(me.json().user.source, 'oidc');
    117 
    118     // And can reach the administrator only surface.
    119     assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 200);
    120   });
    121 });
    122 
    123 test('a token without the admin role is an ordinary user', apiGone, async () => {
    124   await withOidcConductor({}, async (h) => {
    125     const headers = await oidc.headers('persona-viewer');
    126 
    127     const me = await h.app.inject({ method: 'GET', url: '/api/auth/me', headers });
    128     assert.equal(me.json().user.username, 'bob');
    129     assert.equal(me.json().user.role, 'user');
    130 
    131     assert.equal((await h.app.inject({ method: 'GET', url: '/api/admin/users', headers })).statusCode, 403);
    132   });
    133 });
    134 
    135 test('roles are recognised in the groups and resource_access shapes', apiGone, 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/auth/me', headers: await oidc.headers(persona),
    140       });
    141       assert.equal(me.json().user.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', apiGone, 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/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().project.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', apiGone, async () => {
    173   await withOidcConductor({}, async (h) => {
    174     const first = await h.app.inject({
    175       method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'),
    176     });
    177     const second = await h.app.inject({
    178       method: 'GET', url: '/api/auth/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().user.id, first.json().user.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', apiGone, 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/auth/me', headers: await oidc.headers('persona-collision'),
    196     });
    197     assert.equal(me.statusCode, 200);
    198     assert.notEqual(me.json().user.id, local.id, 'the local account must not be taken over');
    199     assert.notEqual(me.json().user.username, 'admin');
    200     assert.match(me.json().user.username, /^admin-\d+$/);
    201   });
    202 });
    203 
    204 test('a role revoked at the provider is lost on the next request', apiGone, async () => {
    205   await withOidcConductor({}, async (h) => {
    206     const asAdmin = await h.app.inject({
    207       method: 'GET', url: '/api/auth/me', headers: await oidc.headers('persona-admin'),
    208     });
    209     assert.equal(asAdmin.json().user.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', apiGone, 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/auth/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/auth/me', headers })).statusCode, 401);
    231   });
    232 });
    233 
    234 test('a forged signature is refused', apiGone, 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/auth/me', headers: { cookie: `conductor_session=${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', apiGone, async () => {
    246   await withOidcConductor({}, async (h) => {
    247     const res = await h.app.inject({
    248       method: 'GET', url: '/api/auth/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', apiGone, 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/auth/me', headers: await oidc.headers('persona-admin'),
    262     });
    263     assert.equal(res.statusCode, 401);
    264   });
    265 });
    266 
    267 test('an anonymous visitor asking for a protected page is sent to the provider', opts, async () => {
    268   await withOidcConductor({}, async (h) => {
    269     const res = await h.app.inject({ method: 'GET', url: '/projects' });
    270     assert.equal(res.statusCode, 302);
    271     assert.ok(res.headers.location.startsWith(`${oidc.issuer}/authorize`), res.headers.location);
    272   });
    273 });
    274 
    275 test('a browser signs in at the provider and comes back with a session', opts, async () => {
    276   await withOidcConductor({}, async (h) => {
    277     // Asking for the login page is a redirect to the provider, built from
    278     // the endpoint its discovery document advertised.
    279     const login = await h.app.inject({ method: 'GET', url: '/login' });
    280     assert.equal(login.statusCode, 302);
    281     const authorization = new URL(login.headers.location);
    282     assert.equal(authorization.origin + authorization.pathname, `${oidc.issuer}/authorize`);
    283     assert.equal(authorization.searchParams.get('client_id'), 'conductor');
    284     assert.equal(authorization.searchParams.get('redirect_uri'), 'http://conductor.test/oidc/callback');
    285     assert.ok(authorization.searchParams.get('state'));
    286 
    287     // Sign in at the provider, which sends the browser back with a code.
    288     const callback = await oidc.signIn(login.headers.location, {
    289       username: 'alice',
    290       claims: {
    291         sub: 'alice-sub',
    292         preferred_username: 'alice',
    293         realm_access: { roles: ['conductor-admin'] },
    294       },
    295     });
    296     const returned = new URL(callback);
    297     assert.equal(returned.origin + returned.pathname, 'http://conductor.test/oidc/callback');
    298 
    299     const done = await h.app.inject({
    300       method: 'GET',
    301       url: returned.pathname + returned.search,
    302     });
    303     assert.equal(done.statusCode, 302, done.body);
    304     assert.equal(done.headers.location, '/');
    305 
    306     const session = done.headers['set-cookie'].split(';')[0];
    307     const home = await h.app.inject({ method: 'GET', url: '/', headers: { cookie: session } });
    308     assert.match(home.body, /href="\/projects"/, 'the signed in administrator sees the projects link');
    309   });
    310 });
    311 
    312 test('a callback with a forged state is refused', opts, async () => {
    313   await withOidcConductor({}, async (h) => {
    314     const res = await h.app.inject({ method: 'GET', url: '/oidc/callback?code=x&state=forged.signature' });
    315     assert.equal(res.statusCode, 400);
    316     assert.match(res.body, /did not start here/);
    317   });
    318 });
    319 
    320 test('local passwords are refused while OIDC is in charge', apiGone, async () => {
    321   await withOidcConductor({ bootstrap: true }, async (h) => {
    322     const res = await h.app.inject({
    323       method: 'POST',
    324       url: '/api/auth/login',
    325       headers: { 'content-type': 'application/json' },
    326       payload: JSON.stringify({ username: 'admin', password: 'bootstrap-password' }),
    327     });
    328     assert.equal(res.statusCode, 400);
    329     assert.match(res.json().error, /OIDC/);
    330   });
    331 });
    332 
    333 test('an OIDC administrator can drive the interface', opts, async () => {
    334   await withOidcConductor({}, async (h) => {
    335     const headers = await oidc.headers('persona-admin');
    336 
    337     const page = await h.app.inject({ method: 'GET', url: '/', headers });
    338     assert.equal(page.statusCode, 200);
    339     assert.match(page.body, /href="\/projects"/);
    340     // Accounts cannot sign in while the provider owns identity, so the users
    341     // page is kept off the nav even for an administrator.
    342     assert.ok(!page.body.includes('href="/users"'), 'the users link should be hidden');
    343 
    344     // It remains reachable directly, for preparing to move off OIDC.
    345     const users = await h.app.inject({ method: 'GET', url: '/users', headers });
    346     assert.equal(users.statusCode, 200);
    347     assert.match(users.body, /alice/);
    348   });
    349 });
    350 
    351 test('worker ownership follows the OIDC identity', apiGone, async () => {
    352   await withOidcConductor({ visibility: 'private' }, async (h) => {
    353     const alice = await oidc.headers('persona-admin');
    354     const bob = await oidc.headers('persona-viewer');
    355 
    356     // Bob owns a project and registers a worker for it.
    357     const project = await h.app.inject({
    358       method: 'POST',
    359       url: '/api/projects',
    360       headers: json(bob),
    361       payload: JSON.stringify({ id: 'bob-proj', repo_url: h.repoDir }),
    362     });
    363     assert.equal(project.statusCode, 201);
    364 
    365     const bobToken = await h.app.inject({
    366       method: 'POST', url: '/api/worker-tokens', headers: json(bob), payload: JSON.stringify({ name: 'bob-pi' }),
    367     });
    368     const aliceToken = await h.app.inject({
    369       method: 'POST', url: '/api/worker-tokens', headers: json(alice), payload: JSON.stringify({ name: 'alice-pi' }),
    370     });
    371 
    372     // Alice cannot see bob's worker, even as an administrator listing her
    373     // own, and bob cannot see the project he does not own.
    374     const aliceList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: alice });
    375     const names = aliceList.json().worker_tokens.map((t) => t.name);
    376     assert.ok(names.includes('bob-pi'), 'an administrator sees every worker');
    377 
    378     const bobList = await h.app.inject({ method: 'GET', url: '/api/worker-tokens', headers: bob });
    379     assert.deepEqual(bobList.json().worker_tokens.map((t) => t.name), ['bob-pi']);
    380 
    381     assert.ok(bobToken.json().token);
    382     assert.ok(aliceToken.json().token);
    383   });
    384 });