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