conductor

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

provider.test.js (14521B)


      1 // test/provider.test.js - the built-in login provider
      2 
      3 import test from 'node:test';
      4 import assert from 'node:assert/strict';
      5 import crypto from 'node:crypto';
      6 import { startHarness } from './helpers/harness.js';
      7 
      8 const PUBLIC_URL = 'http://conductor.test';
      9 const REDIRECT = `${PUBLIC_URL}/callback`;
     10 
     11 function pkce() {
     12   const verifier = crypto.randomBytes(32).toString('base64url');
     13   return { verifier, challenge: crypto.createHash('sha256').update(verifier).digest('base64url') };
     14 }
     15 
     16 function authorizeQuery(overrides = {}) {
     17   return new URLSearchParams({
     18     client_id: 'conductor',
     19     redirect_uri: REDIRECT,
     20     response_type: 'code',
     21     code_challenge: pkce().challenge,
     22     code_challenge_method: 'S256',
     23     state: 'xyz',
     24     ...overrides,
     25   });
     26 }
     27 
     28 async function withConductor(fn) {
     29   const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL });
     30   try {
     31     return await fn(c);
     32   } finally {
     33     await c.stop();
     34   }
     35 }
     36 
     37 async function authorize(c, query, credentials = { username: 'admin', password: 'bootstrap-password' }) {
     38   return c.app.inject({
     39     method: 'POST',
     40     url: `/auth/authorize?${query}`,
     41     headers: { 'content-type': 'application/x-www-form-urlencoded' },
     42     payload: new URLSearchParams(credentials).toString(),
     43   });
     44 }
     45 
     46 test('discovery advertises the built-in endpoints and the client id', async () => {
     47   await withConductor(async (c) => {
     48     const res = await c.discovery();
     49     assert.equal(res.statusCode, 200);
     50 
     51     const doc = res.json();
     52     assert.equal(doc.issuer, PUBLIC_URL);
     53     assert.equal(doc.authorization_endpoint, `${PUBLIC_URL}/auth/authorize`);
     54     assert.equal(doc.token_endpoint, `${PUBLIC_URL}/auth/token`);
     55     assert.equal(doc.end_session_endpoint, `${PUBLIC_URL}/auth/logout`);
     56     assert.deepEqual(doc.code_challenge_methods_supported, ['S256']);
     57     assert.equal(doc.client_id, 'conductor');
     58   });
     59 });
     60 
     61 test('the login page is served for a valid authorization request', async () => {
     62   await withConductor(async (c) => {
     63     const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${authorizeQuery()}` });
     64     assert.equal(res.statusCode, 200);
     65     assert.match(res.headers['content-type'], /text\/html/);
     66     assert.match(res.body, /name="password"/);
     67     // The query string carries the parameters, so the form needs no action.
     68     assert.ok(!res.body.includes('action='));
     69   });
     70 });
     71 
     72 test('the whole code flow yields a working access token', async () => {
     73   await withConductor(async (c) => {
     74     const grant = await c.login();
     75     assert.equal(grant.token_type, 'Bearer');
     76     assert.equal(grant.expires_in, 900);
     77     assert.ok(grant.access_token);
     78     assert.ok(grant.refresh_token);
     79 
     80     const me = await c.app.inject({ method: 'GET', url: '/api/v1/users/me', headers: grant.headers });
     81     assert.equal(me.statusCode, 200);
     82     assert.equal(me.json().data.username, 'admin');
     83   });
     84 });
     85 
     86 test('a code is single use', async () => {
     87   await withConductor(async (c) => {
     88     const { verifier, challenge } = pkce();
     89     const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
     90     const code = new URL(res.headers.location).searchParams.get('code');
     91 
     92     const body = {
     93       grant_type: 'authorization_code', code, redirect_uri: REDIRECT, code_verifier: verifier,
     94     };
     95     assert.equal((await c.token(body)).statusCode, 200);
     96 
     97     const replay = await c.token(body);
     98     assert.equal(replay.statusCode, 400);
     99     assert.equal(replay.json().error, 'invalid_grant');
    100   });
    101 });
    102 
    103 test('a code will not redeem without the matching verifier', async () => {
    104   await withConductor(async (c) => {
    105     const { challenge } = pkce();
    106     const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
    107     const code = new URL(res.headers.location).searchParams.get('code');
    108 
    109     const wrong = await c.token({
    110       grant_type: 'authorization_code',
    111       code,
    112       redirect_uri: REDIRECT,
    113       code_verifier: crypto.randomBytes(32).toString('base64url'),
    114     });
    115     assert.equal(wrong.statusCode, 400);
    116     assert.equal(wrong.json().error, 'invalid_grant');
    117   });
    118 });
    119 
    120 test('a code will not redeem against a different redirect uri', async () => {
    121   await withConductor(async (c) => {
    122     const { verifier, challenge } = pkce();
    123     const res = await authorize(c, authorizeQuery({ code_challenge: challenge }));
    124     const code = new URL(res.headers.location).searchParams.get('code');
    125 
    126     const moved = await c.token({
    127       grant_type: 'authorization_code',
    128       code,
    129       redirect_uri: `${PUBLIC_URL}/elsewhere`,
    130       code_verifier: verifier,
    131     });
    132     assert.equal(moved.statusCode, 400);
    133   });
    134 });
    135 
    136 // Redirecting to an unregistered uri would make this an open redirect.
    137 test('an unregistered redirect uri is refused without redirecting', async () => {
    138   await withConductor(async (c) => {
    139     const query = authorizeQuery({ redirect_uri: 'https://evil.example/steal', code_challenge: pkce().challenge });
    140 
    141     const shown = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    142     assert.equal(shown.statusCode, 400);
    143     assert.equal(shown.headers.location, undefined);
    144 
    145     const posted = await authorize(c, query);
    146     assert.equal(posted.statusCode, 400);
    147     assert.equal(posted.headers.location, undefined);
    148   });
    149 });
    150 
    151 // localhost and 127.0.0.1 are different origins, which is the mistake this
    152 // message has to be able to explain.
    153 test('a refused redirect uri names what is registered', async () => {
    154   await withConductor(async (c) => {
    155     const query = authorizeQuery({ redirect_uri: 'http://localhost:8080/' });
    156     const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    157 
    158     assert.equal(res.statusCode, 400);
    159     assert.match(res.body, /localhost:8080/);
    160     assert.match(res.body, /Registered: http:\/\/conductor\.test/);
    161     assert.match(res.body, /CONDUCTOR_LOCAL_REDIRECT_URIS/);
    162   });
    163 });
    164 
    165 test('several origins may be registered at once', async () => {
    166   const c = await startHarness({
    167     bootstrap: true,
    168     publicUrl: PUBLIC_URL,
    169     redirectUris: 'http://127.0.0.1:18080,http://localhost:18080',
    170   });
    171   try {
    172     for (const origin of ['http://127.0.0.1:18080', 'http://localhost:18080']) {
    173       const query = authorizeQuery({ redirect_uri: `${origin}/` });
    174       const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    175       assert.equal(res.statusCode, 200, `${origin} should be registered`);
    176     }
    177 
    178     // Anything outside the list is still refused.
    179     for (const origin of ['http://127.0.0.1:19999', 'https://evil.example']) {
    180       const query = authorizeQuery({ redirect_uri: `${origin}/` });
    181       const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    182       assert.equal(res.statusCode, 400, `${origin} should be refused`);
    183     }
    184   } finally {
    185     await c.stop();
    186   }
    187 });
    188 
    189 test('a star registration allows any origin but still refuses nonsense', async () => {
    190   const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL, redirectUris: '*' });
    191   try {
    192     for (const uri of ['http://localhost:3000/', 'https://anything.example/callback']) {
    193       const query = authorizeQuery({ redirect_uri: uri });
    194       const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    195       assert.equal(res.statusCode, 200, `${uri} should be allowed`);
    196     }
    197 
    198     for (const uri of ['javascript:alert(1)', 'data:text/html,x', '']) {
    199       const query = authorizeQuery({ redirect_uri: uri });
    200       const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    201       assert.equal(res.statusCode, 400, `${JSON.stringify(uri)} must still be refused`);
    202     }
    203   } finally {
    204     await c.stop();
    205   }
    206 });
    207 
    208 test('a bad request with a good redirect uri comes back as an oauth error', async () => {
    209   await withConductor(async (c) => {
    210     for (const [override, error] of [
    211       [{ response_type: 'token' }, 'unsupported_response_type'],
    212       [{ code_challenge_method: 'plain' }, 'invalid_request'],
    213       [{ client_id: 'someone-else' }, 'unauthorized_client'],
    214     ]) {
    215       const query = authorizeQuery({ code_challenge: pkce().challenge, ...override });
    216       const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    217 
    218       assert.equal(res.statusCode, 303);
    219       const url = new URL(res.headers.location);
    220       assert.equal(url.origin + url.pathname, REDIRECT);
    221       assert.equal(url.searchParams.get('error'), error);
    222       assert.equal(url.searchParams.get('state'), 'xyz');
    223     }
    224   });
    225 });
    226 
    227 test('a request with no code challenge at all is refused', async () => {
    228   await withConductor(async (c) => {
    229     const query = authorizeQuery();
    230     query.delete('code_challenge');
    231     const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` });
    232 
    233     assert.equal(res.statusCode, 303);
    234     assert.equal(new URL(res.headers.location).searchParams.get('error'), 'invalid_request');
    235   });
    236 });
    237 
    238 test('wrong credentials redisplay the form rather than issuing a code', async () => {
    239   await withConductor(async (c) => {
    240     const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge }), {
    241       username: 'admin', password: 'not-the-password',
    242     });
    243 
    244     assert.equal(res.statusCode, 401);
    245     assert.equal(res.headers.location, undefined);
    246     assert.match(res.body, /did not match/);
    247   });
    248 });
    249 
    250 test('the state is handed back so the app can resume', async () => {
    251   await withConductor(async (c) => {
    252     const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge, state: 'resume-me' }));
    253     assert.equal(new URL(res.headers.location).searchParams.get('state'), 'resume-me');
    254   });
    255 });
    256 
    257 test('refreshing returns a new access token and keeps the refresh token', async () => {
    258   await withConductor(async (c) => {
    259     const grant = await c.login();
    260 
    261     const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
    262     assert.equal(res.statusCode, 200);
    263 
    264     const next = res.json();
    265     assert.equal(next.refresh_token, grant.refresh_token);
    266     assert.ok(next.access_token);
    267 
    268     const me = await c.app.inject({
    269       method: 'GET',
    270       url: '/api/v1/users/me',
    271       headers: { authorization: `Bearer ${next.access_token}` },
    272     });
    273     assert.equal(me.statusCode, 200);
    274   });
    275 });
    276 
    277 async function refreshedDeadline(sliding) {
    278   const c = await startHarness({
    279     bootstrap: true,
    280     publicUrl: PUBLIC_URL,
    281     refreshSliding: sliding,
    282     refreshTtl: 3600,
    283   });
    284   try {
    285     const grant = await c.login();
    286     const before = await c.services.refreshTokens.claim(grant.refresh_token);
    287 
    288     await new Promise((resolve) => setTimeout(resolve, 5));
    289     const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
    290     assert.equal(res.statusCode, 200, res.body);
    291 
    292     const after = await c.services.refreshTokens.claim(grant.refresh_token);
    293     return { before: before.expires_at, after: after.expires_at };
    294   } finally {
    295     await c.stop();
    296   }
    297 }
    298 
    299 test('a sliding session has its deadline extended by refreshing', async () => {
    300   const { before, after } = await refreshedDeadline(true);
    301   assert.ok(after > before, `${after} should be past ${before}`);
    302 });
    303 
    304 test('an absolute session keeps its deadline when refreshed', async () => {
    305   const { before, after } = await refreshedDeadline(false);
    306   assert.equal(after, before);
    307 });
    308 
    309 test('an unknown grant type or refresh token is refused', async () => {
    310   await withConductor(async (c) => {
    311     const bad = await c.token({ grant_type: 'password', username: 'admin', password: 'bootstrap-password' });
    312     assert.equal(bad.statusCode, 400);
    313     assert.equal(bad.json().error, 'unsupported_grant_type');
    314 
    315     const unknown = await c.token({ grant_type: 'refresh_token', refresh_token: 'a'.repeat(64) });
    316     assert.equal(unknown.statusCode, 400);
    317     assert.equal(unknown.json().error, 'invalid_grant');
    318   });
    319 });
    320 
    321 test('revoking ends the session and reports nothing about unknown tokens', async () => {
    322   await withConductor(async (c) => {
    323     const grant = await c.login();
    324 
    325     const unknown = await c.app.inject({
    326       method: 'POST',
    327       url: '/auth/revoke',
    328       headers: { 'content-type': 'application/x-www-form-urlencoded' },
    329       payload: new URLSearchParams({ token: 'b'.repeat(64) }).toString(),
    330     });
    331     assert.equal(unknown.statusCode, 200);
    332 
    333     const revoked = await c.app.inject({
    334       method: 'POST',
    335       url: '/auth/revoke',
    336       headers: { 'content-type': 'application/x-www-form-urlencoded' },
    337       payload: new URLSearchParams({ token: grant.refresh_token }).toString(),
    338     });
    339     assert.equal(revoked.statusCode, 200);
    340 
    341     const after = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
    342     assert.equal(after.statusCode, 400);
    343   });
    344 });
    345 
    346 test('logout redirects only to a registered uri', async () => {
    347   await withConductor(async (c) => {
    348     const ok = await c.app.inject({
    349       method: 'GET',
    350       url: `/auth/logout?post_logout_redirect_uri=${encodeURIComponent(PUBLIC_URL)}`,
    351     });
    352     assert.equal(ok.statusCode, 303);
    353     assert.equal(ok.headers.location, PUBLIC_URL);
    354 
    355     const bad = await c.app.inject({
    356       method: 'GET',
    357       url: '/auth/logout?post_logout_redirect_uri=https%3A%2F%2Fevil.example',
    358     });
    359     assert.equal(bad.statusCode, 400);
    360   });
    361 });
    362 
    363 test('logout ignores id_token_hint and client_id', async () => {
    364   await withConductor(async (c) => {
    365     const res = await c.app.inject({
    366       method: 'GET',
    367       url: `/auth/logout?post_logout_redirect_uri=${encodeURIComponent(PUBLIC_URL)}`
    368         + '&client_id=conductor&id_token_hint=whatever',
    369     });
    370     assert.equal(res.statusCode, 303);
    371     assert.equal(res.headers.location, PUBLIC_URL);
    372   });
    373 });
    374 
    375 test('the key set is advertised and empty', async () => {
    376   await withConductor(async (c) => {
    377     const res = await c.app.inject({ method: 'GET', url: '/auth/jwks' });
    378     assert.equal(res.statusCode, 200);
    379     assert.deepEqual(res.json(), { keys: [] });
    380   });
    381 });
    382 
    383 test('a disabled account cannot refresh an existing session', async () => {
    384   await withConductor(async (c) => {
    385     const grant = await c.login();
    386     const admin = await c.services.users.byUsername('admin');
    387     await c.services.users.setDisabled(admin.id, true);
    388 
    389     const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token });
    390     assert.equal(res.statusCode, 400);
    391     assert.equal(res.json().error, 'invalid_grant');
    392   });
    393 });