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 });