http.test.js (32384B)
1 import test from 'node:test'; 2 import assert from 'node:assert/strict'; 3 import { spawn } from 'node:child_process'; 4 import crypto from 'node:crypto'; 5 import fs from 'node:fs/promises'; 6 import http from 'node:http'; 7 import net from 'node:net'; 8 import os from 'node:os'; 9 import path from 'node:path'; 10 import { DatabaseSync } from 'node:sqlite'; 11 import { fileURLToPath } from 'node:url'; 12 import { createLocalJWKSet, jwtVerify } from 'jose'; 13 14 const serverDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 15 16 function freePort() { 17 return new Promise((resolve, reject) => { 18 const server = net.createServer(); 19 server.once('error', reject); 20 server.listen(0, '127.0.0.1', () => { 21 const { port } = server.address(); 22 server.close(() => resolve(port)); 23 }); 24 }); 25 } 26 27 function runNode(script, env) { 28 return new Promise((resolve, reject) => { 29 const child = spawn(process.execPath, [script], { 30 cwd: serverDir, 31 env: { ...process.env, ...env }, 32 stdio: ['ignore', 'pipe', 'pipe'], 33 }); 34 let stdout = ''; 35 let stderr = ''; 36 child.stdout.on('data', (chunk) => { 37 stdout += chunk; 38 }); 39 child.stderr.on('data', (chunk) => { 40 stderr += chunk; 41 }); 42 child.once('error', reject); 43 child.once('exit', (code) => { 44 if (code === 0) resolve({ stdout, stderr }); 45 else reject(new Error(`${script} exited ${code}\n${stdout}\n${stderr}`)); 46 }); 47 }); 48 } 49 50 function startServer(env) { 51 return new Promise((resolve, reject) => { 52 const child = spawn(process.execPath, ['src/index.js'], { 53 cwd: serverDir, 54 env: { ...process.env, ...env }, 55 stdio: ['ignore', 'pipe', 'pipe'], 56 }); 57 let stdout = ''; 58 let stderr = ''; 59 child.stdout.on('data', (chunk) => { 60 stdout += chunk; 61 }); 62 child.stderr.on('data', (chunk) => { 63 stderr += chunk; 64 }); 65 child.once('error', reject); 66 child.once('exit', (code) => { 67 if (code !== 0 && !stdout.includes('Listening on')) { 68 reject(new Error(`server exited ${code}\n${stdout}\n${stderr}`)); 69 } 70 }); 71 const deadline = Date.now() + 15_000; 72 const timer = setInterval(() => { 73 if (stdout.includes('Listening on')) { 74 clearInterval(timer); 75 resolve(child); 76 return; 77 } 78 if (Date.now() > deadline) { 79 clearInterval(timer); 80 child.kill('SIGTERM'); 81 reject(new Error(`server did not start\n${stdout}\n${stderr}`)); 82 } 83 }, 50); 84 }); 85 } 86 87 function request( 88 port, 89 { method = 'GET', path: requestPath = '/', host, body, form, cookie, bearer, headers: extraHeaders = {} } = {} 90 ) { 91 return new Promise((resolve, reject) => { 92 let payload = null; 93 const headers = { host, ...extraHeaders }; 94 if (body !== undefined) { 95 payload = JSON.stringify(body); 96 headers['content-type'] = 'application/json'; 97 headers['content-length'] = Buffer.byteLength(payload); 98 } else if (form !== undefined) { 99 payload = new URLSearchParams(form).toString(); 100 headers['content-type'] = 'application/x-www-form-urlencoded'; 101 headers['content-length'] = Buffer.byteLength(payload); 102 } 103 if (cookie) headers.cookie = cookie; 104 if (bearer) headers.authorization = `Bearer ${bearer}`; 105 106 const req = http.request( 107 { host: '127.0.0.1', port, method, path: requestPath, headers }, 108 (res) => { 109 const chunks = []; 110 res.on('data', (chunk) => chunks.push(chunk)); 111 res.on('end', () => { 112 const text = Buffer.concat(chunks).toString('utf8'); 113 let json = null; 114 try { 115 json = JSON.parse(text); 116 } catch { 117 json = null; 118 } 119 resolve({ status: res.statusCode, headers: res.headers, text, json }); 120 }); 121 } 122 ); 123 req.once('error', reject); 124 if (payload !== null) req.write(payload); 125 req.end(); 126 }); 127 } 128 129 function responseCookies(response) { 130 const values = response.headers['set-cookie']; 131 return Array.isArray(values) ? values.map((value) => value.split(';')[0]) : []; 132 } 133 134 function mergeCookies(existing, response) { 135 const jar = new Map(); 136 for (const pair of existing ? existing.split('; ') : []) { 137 const index = pair.indexOf('='); 138 if (index !== -1) jar.set(pair.slice(0, index), pair.slice(index + 1)); 139 } 140 for (const pair of responseCookies(response)) { 141 const index = pair.indexOf('='); 142 if (index !== -1) jar.set(pair.slice(0, index), pair.slice(index + 1)); 143 } 144 return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join('; '); 145 } 146 147 function maxAge(response) { 148 const values = response.headers['set-cookie']; 149 if (!values || values.length === 0) return null; 150 const match = /Max-Age=(\d+)/.exec(values[0]); 151 return match ? Number(match[1]) : null; 152 } 153 154 function pkce() { 155 const verifier = crypto.randomBytes(32).toString('base64url'); 156 const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); 157 return { verifier, challenge }; 158 } 159 160 function locationQuery(response) { 161 return new URL(response.headers.location, 'http://localhost'); 162 } 163 164 test('HTTP realm, login, OIDC and logout flows', async (t) => { 165 const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-http-')); 166 const port = await freePort(); 167 const publicUrl = `http://identity.test:${port}`; 168 const env = { 169 AUTH_DATABASE_PATH: path.join(dir, 'system.sqlite'), 170 AUTH_REALMS_PATH: path.join(dir, 'realms'), 171 AUTH_PORT: String(port), 172 AUTH_PUBLIC_URL: publicUrl, 173 }; 174 175 const origin = (host) => `http://${host}:${port}`; 176 let server = null; 177 178 try { 179 await runNode('scripts/seed-demo.js', env); 180 server = await startServer(env); 181 182 const identity = (options = {}) => 183 request(port, { host: `identity.test:${port}`, ...options }); 184 const acme = (options = {}) => request(port, { host: `acme.localhost:${port}`, ...options }); 185 const globex = (options = {}) => request(port, { host: `globex.localhost:${port}`, ...options }); 186 const app = (options = {}) => request(port, { host: `app.acme.localhost:${port}`, ...options }); 187 188 await t.test('realm metadata is served for a realm hostname', async () => { 189 const response = await acme({ path: '/api/v1/realm' }); 190 assert.equal(response.status, 200); 191 assert.equal(response.json.slug, 'acme'); 192 assert.equal(response.json.allow_session_duration_choice, true); 193 }); 194 195 await t.test('hostname wins over a conflicting path slug', async () => { 196 const response = await acme({ path: '/api/v1/realm?realm=globex' }); 197 assert.equal(response.status, 404); 198 assert.equal(response.json.error.code, 'unknown_realm'); 199 }); 200 201 await t.test('client discovery works on identity, realm and client hosts', async () => { 202 const canonical = await identity({ 203 path: '/oidc/internal-app/openid-configuration', 204 }); 205 assert.equal(canonical.status, 200); 206 assert.equal(canonical.json.client_id, 'internal-app'); 207 assert.equal(canonical.json.issuer, `${publicUrl}/oidc/acme`); 208 assert.equal( 209 canonical.json.authorization_endpoint, 210 `${origin('identity.test')}/oidc/internal-app/authorize` 211 ); 212 213 const realmHost = await acme({ path: '/oidc/internal-app/openid-configuration' }); 214 assert.equal(realmHost.status, 200); 215 assert.equal( 216 realmHost.json.token_endpoint, 217 `${origin('acme.localhost')}/oidc/internal-app/token` 218 ); 219 220 const clientHost = await app({ path: '/oidc/openid-configuration' }); 221 assert.equal(clientHost.status, 200); 222 assert.equal(clientHost.json.client_id, 'internal-app'); 223 assert.equal(clientHost.json.token_endpoint, `${origin('app.acme.localhost')}/oidc/token`); 224 }); 225 226 await t.test('realm slugs are not accepted in OIDC paths', async () => { 227 const oldCanonical = await identity({ 228 path: '/oidc/acme/internal-app/openid-configuration', 229 }); 230 assert.equal(oldCanonical.status, 404); 231 232 const oldRealmHost = await acme({ 233 path: '/oidc/acme/internal-app/openid-configuration', 234 }); 235 assert.equal(oldRealmHost.status, 404); 236 }); 237 238 await t.test('client-to-realm mappings enforce host and canonical routing', async () => { 239 const mismatch = await globex({ path: '/oidc/internal-app/openid-configuration' }); 240 assert.equal(mismatch.status, 404); 241 assert.equal(mismatch.json.error.code, 'unknown_client'); 242 243 const unknownCanonicalHost = await request(port, { 244 host: `unknown.test:${port}`, 245 path: '/oidc/internal-app/openid-configuration', 246 }); 247 assert.equal(unknownCanonicalHost.status, 404); 248 249 const otherRealm = await identity({ 250 path: '/oidc/globex-members-app/openid-configuration', 251 }); 252 assert.equal(otherRealm.status, 200); 253 assert.equal(otherRealm.json.issuer, `${publicUrl}/oidc/globex`); 254 }); 255 256 await t.test('virtual clients are discoverable', async () => { 257 const admin = await acme({ path: '/oidc/admin/openid-configuration' }); 258 assert.equal(admin.status, 200); 259 assert.equal(admin.json.virtual, true); 260 assert.equal(admin.json.access_mode, 'admin'); 261 262 const account = await acme({ path: '/oidc/account/openid-configuration' }); 263 assert.equal(account.status, 200); 264 assert.equal(account.json.virtual, true); 265 assert.equal(account.json.access_mode, 'public'); 266 }); 267 268 await t.test('identifier resolution selects password or upstream OIDC', async () => { 269 const password = await acme({ 270 method: 'POST', 271 path: '/api/v1/login/resolve', 272 body: { client_id: 'internal-app', identifier: 'alice@acme.test' }, 273 }); 274 assert.equal(password.status, 200); 275 assert.equal(password.json.next, 'password'); 276 277 const upstream = await acme({ 278 method: 'POST', 279 path: '/api/v1/login/resolve', 280 body: { client_id: 'internal-app', identifier: 'someone@sso.acme.test' }, 281 }); 282 assert.equal(upstream.status, 200); 283 assert.equal(upstream.json.next, 'oidc'); 284 assert.equal(upstream.json.provider.display_name, 'Example SSO'); 285 }); 286 287 await t.test('password login creates a realm session with the short cookie', async () => { 288 const response = await acme({ 289 method: 'POST', 290 path: '/api/v1/login/password', 291 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 292 }); 293 assert.equal(response.status, 200); 294 assert.equal(response.json.user.username, 'alice'); 295 assert.equal(maxAge(response), 12 * 60 * 60); 296 assert.ok(responseCookies(response)[0].startsWith('auth_session=')); 297 298 const cookie = mergeCookies('', response); 299 const session = await acme({ path: '/api/v1/session', cookie }); 300 assert.equal(session.status, 200); 301 assert.equal(session.json.user.username, 'alice'); 302 assert.equal(session.json.scope.type, 'realm'); 303 assert.equal(session.json.scope.host, 'acme.localhost'); 304 assert.equal(session.json.realm.slug, 'acme'); 305 }); 306 307 await t.test('canonical and client hosts create their own session scopes', async () => { 308 const canonical = await identity({ 309 method: 'POST', 310 path: '/api/v1/login/password', 311 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 312 }); 313 assert.equal(canonical.status, 200); 314 const canonicalCookie = mergeCookies('', canonical); 315 const canonicalSession = await identity({ 316 path: '/api/v1/session', 317 cookie: canonicalCookie, 318 }); 319 assert.equal(canonicalSession.status, 200); 320 assert.equal(canonicalSession.json.scope.type, 'global'); 321 assert.equal(canonicalSession.json.scope.host, 'identity.test'); 322 assert.equal(canonicalSession.json.realm, null); 323 324 const client = await app({ 325 method: 'POST', 326 path: '/api/v1/login/password', 327 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 328 }); 329 assert.equal(client.status, 200); 330 const clientSession = await app({ 331 path: '/api/v1/session', 332 cookie: mergeCookies('', client), 333 }); 334 assert.equal(clientSession.status, 200); 335 assert.equal(clientSession.json.scope.type, 'client'); 336 assert.equal(clientSession.json.scope.host, 'app.acme.localhost'); 337 assert.equal(clientSession.json.scope.client_id, 'internal-app'); 338 assert.equal(clientSession.json.realm.slug, 'acme'); 339 }); 340 341 await t.test('sessions are scoped to their host and old cookies are rejected', async () => { 342 const login = await acme({ 343 method: 'POST', 344 path: '/api/v1/login/password', 345 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 346 }); 347 const cookie = mergeCookies('', login); 348 const token = cookie.slice('auth_session='.length); 349 350 const wrongHost = await globex({ path: '/api/v1/session', cookie }); 351 assert.equal(wrongHost.status, 401); 352 353 const oldCookie = `auth_session_acme=${token}`; 354 const oldStyle = await acme({ path: '/api/v1/session', cookie: oldCookie }); 355 assert.equal(oldStyle.status, 401); 356 }); 357 358 await t.test('a host group holds multiple accounts and can switch', async () => { 359 const aliceLogin = await acme({ 360 method: 'POST', 361 path: '/api/v1/login/password', 362 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 363 }); 364 assert.equal(aliceLogin.status, 200); 365 const aliceCookie = mergeCookies('', aliceLogin); 366 367 const itLogin = await acme({ 368 method: 'POST', 369 path: '/api/v1/login/password', 370 body: { client_id: 'internal-app', identifier: 'it@acme.test', password: 'demo' }, 371 cookie: aliceCookie, 372 }); 373 assert.equal(itLogin.status, 200); 374 assert.equal(responseCookies(itLogin)[0], responseCookies(aliceLogin)[0]); 375 const cookie = mergeCookies(aliceCookie, itLogin); 376 377 const session = await acme({ path: '/api/v1/session', cookie }); 378 assert.equal(session.status, 200); 379 assert.equal(session.json.user.username, 'acme-it'); 380 assert.deepEqual( 381 session.json.accounts.map((account) => account.user.username), 382 ['alice', 'acme-it'] 383 ); 384 assert.equal( 385 session.json.accounts.find((account) => account.user.username === 'acme-it').active, 386 true 387 ); 388 389 const aliceAccount = session.json.accounts.find( 390 (account) => account.user.username === 'alice' 391 ); 392 const selected = await acme({ 393 method: 'POST', 394 path: '/api/v1/session/select', 395 body: { public_id: aliceAccount.public_id, client_id: 'internal-app' }, 396 cookie, 397 }); 398 assert.equal(selected.status, 200); 399 assert.equal(selected.json.user.username, 'alice'); 400 assert.equal(selected.json.pending_auth_request, false); 401 402 const switched = await acme({ path: '/api/v1/session', cookie }); 403 assert.equal(switched.status, 200); 404 assert.equal(switched.json.user.username, 'alice'); 405 }); 406 407 await t.test('authorize asks which account to use when the group has multiple accounts', async () => { 408 const aliceLogin = await app({ 409 method: 'POST', 410 path: '/api/v1/login/password', 411 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 412 }); 413 let jar = mergeCookies('', aliceLogin); 414 const itLogin = await app({ 415 method: 'POST', 416 path: '/api/v1/login/password', 417 body: { client_id: 'internal-app', identifier: 'it@acme.test', password: 'demo' }, 418 cookie: jar, 419 }); 420 jar = mergeCookies(jar, itLogin); 421 422 const { verifier, challenge } = pkce(); 423 const redirectUri = `${origin('app.acme.localhost')}/callback`; 424 const authorizePath = 425 `/oidc/authorize?response_type=code&client_id=internal-app` + 426 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` + 427 `&state=state-select&code_challenge=${challenge}&code_challenge_method=S256`; 428 429 const first = await app({ path: authorizePath, cookie: jar }); 430 assert.equal(first.status, 302); 431 assert.equal(locationQuery(first).pathname, '/oidc/login'); 432 jar = mergeCookies(jar, first); 433 434 const session = await app({ path: '/api/v1/session', cookie: jar }); 435 const aliceAccount = session.json.accounts.find( 436 (account) => account.user.username === 'alice' 437 ); 438 const selected = await app({ 439 method: 'POST', 440 path: '/api/v1/session/select', 441 body: { public_id: aliceAccount.public_id, client_id: 'internal-app' }, 442 cookie: jar, 443 }); 444 assert.equal(selected.status, 200); 445 assert.equal(selected.json.pending_auth_request, true); 446 assert.equal(selected.json.user.username, 'alice'); 447 448 const second = await app({ path: authorizePath, cookie: jar }); 449 assert.equal(second.status, 302); 450 const redirected = locationQuery(second); 451 assert.equal(redirected.searchParams.get('state'), 'state-select'); 452 const code = redirected.searchParams.get('code'); 453 assert.ok(code); 454 455 const tokenResponse = await app({ 456 method: 'POST', 457 path: '/oidc/token', 458 form: { 459 grant_type: 'authorization_code', 460 code, 461 redirect_uri: redirectUri, 462 client_id: 'internal-app', 463 code_verifier: verifier, 464 }, 465 }); 466 assert.equal(tokenResponse.status, 200); 467 468 const jwks = await app({ path: '/oidc/jwks' }); 469 const keySet = createLocalJWKSet(jwks.json); 470 const { payload } = await jwtVerify(tokenResponse.json.id_token, keySet, { 471 issuer: `${publicUrl}/oidc/acme`, 472 audience: 'internal-app', 473 }); 474 assert.equal(payload.preferred_username, 'alice'); 475 }); 476 477 await t.test('unknown hosts are rejected by auth and session APIs', async () => { 478 const unknown = (options = {}) => 479 request(port, { host: `unknown.test:${port}`, ...options }); 480 481 const login = await unknown({ 482 method: 'POST', 483 path: '/api/v1/login/password', 484 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 485 }); 486 assert.equal(login.status, 404); 487 488 const session = await unknown({ path: '/api/v1/session' }); 489 assert.equal(session.status, 404); 490 }); 491 492 await t.test('long sessions are only issued when requested', async () => { 493 const response = await acme({ 494 method: 'POST', 495 path: '/api/v1/login/password', 496 body: { 497 client_id: 'internal-app', 498 identifier: 'alice@acme.test', 499 password: 'demo', 500 long_session: true, 501 }, 502 }); 503 assert.equal(response.status, 200); 504 assert.equal(maxAge(response), 7 * 24 * 60 * 60); 505 }); 506 507 await t.test('bad credentials are generic', async () => { 508 const response = await acme({ 509 method: 'POST', 510 path: '/api/v1/login/password', 511 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'wrong' }, 512 }); 513 assert.equal(response.status, 401); 514 assert.equal(response.json.error.code, 'invalid_credentials'); 515 }); 516 517 await t.test('membership is required where the client demands it', async () => { 518 const denied = await acme({ 519 method: 'POST', 520 path: '/api/v1/login/password', 521 body: { 522 client_id: 'acme-members-app', 523 identifier: 'contractor@personal.test', 524 password: 'demo', 525 }, 526 }); 527 assert.equal(denied.status, 401); 528 529 const allowed = await acme({ 530 method: 'POST', 531 path: '/api/v1/login/password', 532 body: { 533 client_id: 'acme-public-app', 534 identifier: 'guest@personal.test', 535 password: 'demo', 536 }, 537 }); 538 assert.equal(allowed.status, 200); 539 }); 540 541 await t.test('password login is rejected for upstream-mapped domains', async () => { 542 const response = await acme({ 543 method: 'POST', 544 path: '/api/v1/login/password', 545 body: { client_id: 'internal-app', identifier: 'someone@sso.acme.test', password: 'demo' }, 546 }); 547 assert.equal(response.status, 403); 548 assert.equal(response.json.error.code, 'provider_required'); 549 }); 550 551 await t.test('guests can hold realm-admin roles', async () => { 552 const response = await globex({ 553 method: 'POST', 554 path: '/api/v1/login/password', 555 body: { 556 client_id: 'globex-members-app', 557 identifier: 'contractor@personal.test', 558 password: 'demo', 559 }, 560 }); 561 assert.equal(response.status, 200); 562 const session = await globex({ 563 path: '/api/v1/session', 564 cookie: mergeCookies('', response), 565 }); 566 assert.equal(session.status, 200); 567 assert.ok(session.json.roles.includes('realm-admin')); 568 }); 569 570 await t.test('authorization code flow issues verifiable tokens', async () => { 571 const { verifier, challenge } = pkce(); 572 const redirectUri = `${origin('app.acme.localhost')}/callback`; 573 const authorizePath = 574 `/oidc/authorize?response_type=code&client_id=internal-app` + 575 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20profile%20email` + 576 `&state=state-1&nonce=nonce-1&code_challenge=${challenge}&code_challenge_method=S256`; 577 578 const first = await app({ path: authorizePath }); 579 assert.equal(first.status, 302); 580 assert.equal(locationQuery(first).pathname, '/oidc/login'); 581 let jar = mergeCookies('', first); 582 583 const login = await app({ 584 method: 'POST', 585 path: '/api/v1/login/password', 586 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 587 cookie: jar, 588 }); 589 assert.equal(login.status, 200); 590 assert.equal(login.json.pending_auth_request, true); 591 jar = mergeCookies(jar, login); 592 593 const second = await app({ path: authorizePath, cookie: jar }); 594 assert.equal(second.status, 302); 595 const redirected = locationQuery(second); 596 assert.equal(redirected.origin + redirected.pathname, redirectUri); 597 assert.equal(redirected.searchParams.get('state'), 'state-1'); 598 const code = redirected.searchParams.get('code'); 599 assert.ok(code); 600 601 const tokenResponse = await app({ 602 method: 'POST', 603 path: '/oidc/token', 604 form: { 605 grant_type: 'authorization_code', 606 code, 607 redirect_uri: redirectUri, 608 client_id: 'internal-app', 609 code_verifier: verifier, 610 }, 611 }); 612 assert.equal(tokenResponse.status, 200); 613 assert.ok(tokenResponse.json.id_token); 614 assert.ok(tokenResponse.json.access_token); 615 616 const jwks = await app({ path: '/oidc/jwks' }); 617 const keySet = createLocalJWKSet(jwks.json); 618 const { payload } = await jwtVerify(tokenResponse.json.id_token, keySet, { 619 issuer: `${publicUrl}/oidc/acme`, 620 audience: 'internal-app', 621 }); 622 assert.equal(payload.preferred_username, 'alice'); 623 assert.equal(payload.email, 'alice@acme.test'); 624 assert.equal(payload.nonce, 'nonce-1'); 625 626 const userinfo = await app({ 627 path: '/oidc/userinfo', 628 bearer: tokenResponse.json.access_token, 629 }); 630 assert.equal(userinfo.status, 200); 631 assert.equal(userinfo.json.preferred_username, 'alice'); 632 633 const reuse = await app({ 634 method: 'POST', 635 path: '/oidc/token', 636 form: { 637 grant_type: 'authorization_code', 638 code, 639 redirect_uri: redirectUri, 640 client_id: 'internal-app', 641 code_verifier: verifier, 642 }, 643 }); 644 assert.equal(reuse.status, 400); 645 assert.equal(reuse.json.error, 'invalid_grant'); 646 }); 647 648 await t.test('canonical realm-client flow resolves the realm from its mapping', async () => { 649 const { verifier, challenge } = pkce(); 650 const redirectUri = `${origin('app.acme.localhost')}/callback`; 651 const authorizePath = 652 `/oidc/internal-app/authorize?response_type=code&client_id=internal-app` + 653 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` + 654 `&state=state-canonical&code_challenge=${challenge}&code_challenge_method=S256`; 655 656 const first = await identity({ path: authorizePath }); 657 assert.equal(first.status, 302); 658 assert.equal(locationQuery(first).pathname, '/oidc/internal-app/login'); 659 let jar = mergeCookies('', first); 660 661 const login = await identity({ 662 method: 'POST', 663 path: '/api/v1/login/password', 664 body: { 665 client_id: 'internal-app', 666 identifier: 'alice@acme.test', 667 password: 'demo', 668 }, 669 cookie: jar, 670 }); 671 assert.equal(login.status, 200); 672 assert.equal(login.json.pending_auth_request, true); 673 jar = mergeCookies(jar, login); 674 675 const second = await identity({ path: authorizePath, cookie: jar }); 676 assert.equal(second.status, 302); 677 const redirected = locationQuery(second); 678 assert.equal(redirected.searchParams.get('state'), 'state-canonical'); 679 const code = redirected.searchParams.get('code'); 680 assert.ok(code); 681 682 const tokenResponse = await identity({ 683 method: 'POST', 684 path: '/oidc/internal-app/token', 685 form: { 686 grant_type: 'authorization_code', 687 code, 688 redirect_uri: redirectUri, 689 client_id: 'internal-app', 690 code_verifier: verifier, 691 }, 692 }); 693 assert.equal(tokenResponse.status, 200); 694 695 const database = new DatabaseSync(env.AUTH_DATABASE_PATH, { readOnly: true }); 696 try { 697 const binding = database 698 .prepare( 699 `SELECT COUNT(*) AS count 700 FROM session_realms sr 701 JOIN sessions s ON s.id = sr.session_id 702 JOIN realms r ON r.id = sr.realm_id 703 WHERE r.slug = 'acme' AND s.scope_type = 'global'` 704 ) 705 .get(); 706 assert.equal(binding.count, 1); 707 } finally { 708 database.close(); 709 } 710 }); 711 712 await t.test('public clients allow non-members through authorize', async () => { 713 const { verifier, challenge } = pkce(); 714 const redirectUri = `${origin('public.localhost')}/callback`; 715 const authorizePath = 716 `/oidc/acme-public-app/authorize?response_type=code&client_id=acme-public-app` + 717 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` + 718 `&state=state-2&code_challenge=${challenge}&code_challenge_method=S256`; 719 720 const first = await acme({ path: authorizePath }); 721 assert.equal(first.status, 302); 722 let jar = mergeCookies('', first); 723 724 const login = await acme({ 725 method: 'POST', 726 path: '/api/v1/login/password', 727 body: { 728 client_id: 'acme-public-app', 729 identifier: 'contractor@personal.test', 730 password: 'demo', 731 }, 732 cookie: jar, 733 }); 734 assert.equal(login.status, 200); 735 assert.equal(login.json.pending_auth_request, true); 736 jar = mergeCookies(jar, login); 737 738 const second = await acme({ path: authorizePath, cookie: jar }); 739 assert.equal(second.status, 302); 740 const code = locationQuery(second).searchParams.get('code'); 741 assert.ok(code); 742 743 const tokenResponse = await acme({ 744 method: 'POST', 745 path: '/oidc/acme-public-app/token', 746 form: { 747 grant_type: 'authorization_code', 748 code, 749 redirect_uri: redirectUri, 750 client_id: 'acme-public-app', 751 code_verifier: verifier, 752 }, 753 }); 754 assert.equal(tokenResponse.status, 200); 755 }); 756 757 await t.test('system admins can use the admin virtual client', async () => { 758 const redirectUri = `${origin('acme.localhost')}/oidc/admin/callback`; 759 const { verifier, challenge } = pkce(); 760 const authorizePath = 761 `/oidc/admin/authorize?response_type=code&client_id=admin` + 762 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20profile` + 763 `&state=state-3&code_challenge=${challenge}&code_challenge_method=S256`; 764 765 const first = await acme({ path: authorizePath }); 766 assert.equal(first.status, 302); 767 let jar = mergeCookies('', first); 768 769 const login = await acme({ 770 method: 'POST', 771 path: '/api/v1/login/password', 772 body: { client_id: 'admin', identifier: 'admin@system.test', password: 'demo' }, 773 cookie: jar, 774 }); 775 assert.equal(login.status, 200); 776 jar = mergeCookies(jar, login); 777 778 const second = await acme({ path: authorizePath, cookie: jar }); 779 assert.equal(second.status, 302); 780 const code = locationQuery(second).searchParams.get('code'); 781 assert.ok(code); 782 783 const tokenResponse = await acme({ 784 method: 'POST', 785 path: '/oidc/admin/token', 786 form: { 787 grant_type: 'authorization_code', 788 code, 789 redirect_uri: redirectUri, 790 client_id: 'admin', 791 code_verifier: verifier, 792 }, 793 }); 794 assert.equal(tokenResponse.status, 200); 795 }); 796 797 await t.test('confidential clients authenticate with client_secret_basic', async () => { 798 const { verifier, challenge } = pkce(); 799 const redirectUri = `${origin('confidential.localhost')}/callback`; 800 const authorizePath = 801 `/oidc/confidential-app/authorize?response_type=code&client_id=confidential-app` + 802 `&redirect_uri=${encodeURIComponent(redirectUri)}&scope=openid%20email` + 803 `&state=state-4&code_challenge=${challenge}&code_challenge_method=S256`; 804 805 const first = await acme({ path: authorizePath }); 806 let jar = mergeCookies('', first); 807 const login = await acme({ 808 method: 'POST', 809 path: '/api/v1/login/password', 810 body: { client_id: 'confidential-app', identifier: 'alice@acme.test', password: 'demo' }, 811 cookie: jar, 812 }); 813 jar = mergeCookies(jar, login); 814 const second = await acme({ path: authorizePath, cookie: jar }); 815 const code = locationQuery(second).searchParams.get('code'); 816 817 const tokenResponse = await acme({ 818 method: 'POST', 819 path: '/oidc/confidential-app/token', 820 form: { 821 grant_type: 'authorization_code', 822 code, 823 redirect_uri: redirectUri, 824 code_verifier: verifier, 825 }, 826 headers: { 827 authorization: `Basic ${Buffer.from('confidential-app:demo-secret').toString('base64')}`, 828 }, 829 }); 830 assert.equal(tokenResponse.status, 200); 831 }); 832 833 await t.test('logout removes only the current host session', async () => { 834 const acmeLogin = await acme({ 835 method: 'POST', 836 path: '/api/v1/login/password', 837 body: { client_id: 'internal-app', identifier: 'alice@acme.test', password: 'demo' }, 838 }); 839 const acmeCookie = mergeCookies('', acmeLogin); 840 841 const globexLogin = await globex({ 842 method: 'POST', 843 path: '/api/v1/login/password', 844 body: { 845 client_id: 'globex-members-app', 846 identifier: 'contractor@personal.test', 847 password: 'demo', 848 }, 849 }); 850 const globexCookie = mergeCookies('', globexLogin); 851 852 const logout = await acme({ 853 method: 'POST', 854 path: '/api/v1/logout', 855 body: {}, 856 cookie: acmeCookie, 857 }); 858 assert.equal(logout.status, 200); 859 860 const acmeSession = await acme({ path: '/api/v1/session', cookie: acmeCookie }); 861 assert.equal(acmeSession.status, 401); 862 863 const globexSession = await globex({ path: '/api/v1/session', cookie: globexCookie }); 864 assert.equal(globexSession.status, 200); 865 }); 866 867 await t.test('sessions require a cookie', async () => { 868 const response = await acme({ path: '/api/v1/session' }); 869 assert.equal(response.status, 401); 870 assert.equal(response.json.error.code, 'not_authenticated'); 871 }); 872 } finally { 873 if (server) { 874 server.kill('SIGTERM'); 875 await new Promise((resolve) => server.once('exit', resolve)); 876 } 877 await fs.rm(dir, { recursive: true, force: true }); 878 } 879 });