diff --git a/tests/abac/index.test.js b/tests/abac/index.test.js index 08f7600..5eeb46b 100644 --- a/tests/abac/index.test.js +++ b/tests/abac/index.test.js @@ -1,117 +1,177 @@ /* - * Cross-component E2E: auth.policy-verifier ABAC verification + * Cross-component E2E: auth.policy-verifier ABAC verification. * * Prerequisites: docker compose up (policy-verifier + provider + redis) * Verifier: http://localhost:3097 + * + * The allow/deny decisions below run against tokens auth.provider actually + * minted through `login -> /authorize (PKCE) -> /token`. That is the point of + * o3co/auth#3: a self-signed token proves the verifier can validate a + * signature, not that the two components agree on what a token looks like. + * + * Hand-signed tokens remain only where the provider cannot be made to produce + * the input — a wrong issuer, a wrong audience, an expired token, a scopeless + * token. Those are envelope negatives, and each says so. */ -import { describe, it, expect } from 'vitest'; -import axios from 'axios'; +import { describe, it, expect, beforeAll } from 'vitest'; import jwt from 'jsonwebtoken'; - -const VERIFIER_URL = process.env.VERIFIER_URL || 'http://localhost:3097'; -const JWT_SECRET = process.env.OAUTH_JWT_SECRET || 'test-secret-for-e2e'; -const ISSUER = process.env.OAUTH_JWT_ISSUER || 'https://auth.e2e.test'; -const AUDIENCE = process.env.OAUTH_JWT_AUDIENCE || 'https://api.e2e.test'; - -/* - * The verifier validates `iss` / `aud` / the `typ` header alongside the - * signature (RFC 9068 §4 — auth.policy-verifier#105), so a token that carries - * only a scope is rejected as `invalid_token` before any rule runs. Mint every - * E2E token through here so the envelope matches what the deployment pins. +import { + AUDIENCE, + ISSUER, + JWT_SECRET, + codeFlow, + decodeJwt, + login, + verify, +} from '../shared/oauthFlow.js'; + +/** + * Envelope-correct hand-signed token, for the negatives the real flow cannot + * produce. Matches what the deployment issues (RFC 9068 §4: `iss`, `aud`, and + * the `at+jwt` header) so a rejection is attributable to the thing under test + * rather than to a malformed envelope. */ function signToken(claims, options = {}) { - return jwt.sign( - { iss: ISSUER, aud: AUDIENCE, ...claims }, - JWT_SECRET, - { expiresIn: 60, header: { typ: 'at+jwt' }, ...options }, - ); + return jwt.sign({ iss: ISSUER, aud: AUDIENCE, ...claims }, JWT_SECRET, { + expiresIn: 60, + header: { typ: 'at+jwt' }, + ...options, + }); } -const verifier = axios.create({ - baseURL: VERIFIER_URL, - validateStatus: () => true, +/** Grant carrying `read:project`. */ +let projectGrant; +/** Grant carrying only `read:project_member` — used for the deny case. */ +let memberGrant; + +beforeAll(async () => { + const session = await login(); + expect(session.status).toBe(200); + projectGrant = await codeFlow({ cookie: session.cookie, scope: 'openid read:project' }); + memberGrant = await codeFlow({ cookie: session.cookie, scope: 'openid read:project_member' }); +}, 30_000); + +describe('ABAC: POST /verify with provider-issued access tokens', () => { + it('allows when the minted scope matches the resource action', async () => { + const res = await verify({ token: projectGrant.access_token }); + expect(res.status).toBe(200); + expect(res.body.decision).toBe('allow'); + // The subject travels intact from the provider's `sub` claim to the + // verifier's decision — the cross-component identity contract. + expect(res.body.subject).toBe('user-e2e-1'); + }); + + it('denies when the minted scope does not match the resource action', async () => { + // A real token, correctly signed, simply not carrying `read:project`. + const res = await verify({ token: memberGrant.access_token }); + expect(res.status).toBe(403); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_scope'); + }); + + it('allows a nested resource when the minted scope matches', async () => { + const res = await verify({ + token: memberGrant.access_token, + resource: 'project:1.member:2', + }); + expect(res.status).toBe(200); + expect(res.body.decision).toBe('allow'); + }); +}); + +describe('ABAC: only access tokens are decision inputs', () => { + /* + * The provider mints three JWTs from one grant, all signed with the same + * key. Only the access token is a bearer credential for a resource server: + * an id_token is an assertion about an authentication event delivered to + * the client, and a refresh token is a credential for the token endpoint. + * Presenting either at /verify must fail. + * + * NOTE: the `typ` header is the ONLY thing that distinguishes them. There + * is no claim-level check — the verifier pins `at+jwt` and rejects + * everything else before any rule runs. So if auth.provider ever changes + * the `typ` it stamps, or the verifier ever relaxes the pin, these two + * tests are what catch it. Do not weaken them into "some 4xx". + */ + + it('rejects the id_token from the same grant', async () => { + const res = await verify({ token: projectGrant.id_token }); + expect(decodeJwt(projectGrant.id_token).header.typ).toBe('id+jwt'); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('rejects the refresh token from the same grant', async () => { + const res = await verify({ token: projectGrant.refresh_token }); + expect(decodeJwt(projectGrant.refresh_token).header.typ).toBe('rt+jwt'); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); }); -describe('ABAC: POST /verify', () => { - it('allows when scope matches resource action', async () => { - const token = signToken({ user: { id: 1 }, scope: 'read:project' }); - - const res = await verifier.post('/verify', { - resource: 'project:1', - action: 'read', - }, { - headers: { Authorization: `Bearer ${token}` }, - }); - - expect(res.status).toBe(200); - expect(res.data.decision).toBe('allow'); - }); - - it('denies when scope does not match resource action', async () => { - const token = signToken({ user: { id: 1 }, scope: 'write:project' }); - - const res = await verifier.post('/verify', { - resource: 'project:1', - action: 'read', - }, { - headers: { Authorization: `Bearer ${token}` }, - }); - - expect(res.status).toBe(403); - expect(res.data.decision).toBe('deny'); - expect(res.data.code).toBe('invalid_scope'); - }); - - it('denies with 401 when Authorization header is missing', async () => { - const res = await verifier.post('/verify', { - resource: 'project:1', - action: 'read', - }); - - expect(res.status).toBe(401); - expect(res.data.decision).toBe('deny'); - expect(res.data.code).toBe('missing_token'); - }); - - it('denies with 401 for invalid JWT', async () => { - const res = await verifier.post('/verify', { - resource: 'project:1', - action: 'read', - }, { - headers: { Authorization: 'Bearer invalid.token.here' }, - }); - - expect(res.status).toBe(401); - expect(res.data.decision).toBe('deny'); - expect(res.data.code).toBe('invalid_token'); - }); - - it('denies with 401 for expired JWT', async () => { - const token = signToken({ user: { id: 1 }, scope: 'read:project' }, { expiresIn: -1 }); - - const res = await verifier.post('/verify', { - resource: 'project:1', - action: 'read', - }, { - headers: { Authorization: `Bearer ${token}` }, - }); - - expect(res.status).toBe(401); - expect(res.data.decision).toBe('deny'); - expect(res.data.code).toBe('invalid_token'); - }); - - it('allows nested resource when scope matches', async () => { - const token = signToken({ user: { id: 1 }, scope: 'read:project_member' }); - - const res = await verifier.post('/verify', { - resource: 'project:1.member:2', - action: 'read', - }, { - headers: { Authorization: `Bearer ${token}` }, - }); - - expect(res.status).toBe(200); - expect(res.data.decision).toBe('allow'); - }); +describe('ABAC: RFC 9068 envelope validation', () => { + it('denies with 401 when Authorization header is missing', async () => { + const res = await verify({ token: undefined }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('missing_token'); + }); + + it('denies with 401 for invalid JWT', async () => { + const res = await verify({ token: 'invalid.token.here' }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('denies with 401 for expired JWT', async () => { + const res = await verify({ + token: signToken({ sub: 'user-e2e-1', scope: 'read:project' }, { expiresIn: -60 }), + }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('denies a token from a different issuer', async () => { + // RFC 9068 §4 — a correctly signed token is still not this + // deployment's token. Shared-secret HS256 makes this the only thing + // standing between two providers on the same key. + const res = await verify({ + token: signToken({ sub: 'user-e2e-1', scope: 'read:project', iss: 'https://evil.e2e.test' }), + }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('denies a token minted for a different audience', async () => { + const res = await verify({ + token: signToken({ sub: 'user-e2e-1', scope: 'read:project', aud: 'https://other.e2e.test' }), + }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('denies a token whose `typ` is not at+jwt', async () => { + const res = await verify({ + token: signToken({ sub: 'user-e2e-1', scope: 'read:project' }, { header: { typ: 'JWT' } }), + }); + expect(res.status).toBe(401); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_token'); + }); + + it('denies a scopeless token against a scope-only pipeline', async () => { + // The provider will not mint a scopeless token for this client, so the + // input is hand-signed. auth.policy-verifier#104: an empty rule set + // denies rather than allowing by vacuous truth. + const res = await verify({ token: signToken({ sub: 'user-e2e-1' }) }); + expect(res.status).toBe(403); + expect(res.body.decision).toBe('deny'); + expect(res.body.code).toBe('invalid_scope'); + }); }); diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 8d63fce..c6c8bef 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -18,6 +18,12 @@ services: - npmrc ports: - "3099:3000" + volumes: + # The template ships an EMPTY clients.yaml and no users.yaml at all, so + # the E2E supplies both. Paths are cwd-relative in application.conf and + # the image's WORKDIR is /home/node/templates/standalone. + - ./provider/clients.yaml:/home/node/templates/standalone/config/clients.yaml:ro + - ./provider/users.yaml:/home/node/templates/standalone/config/users.yaml:ro environment: - OAUTH_JWT_SECRET=test-secret-for-e2e # auth.provider requires a canonical issuer and never derives one from the @@ -25,13 +31,69 @@ services: # fetches, so the E2E pins a fixed one both services agree on. - OAUTH_JWT_ISSUER=https://auth.e2e.test - SESSION_SECRET=test-session-secret - - SESSION_STORAGE_REDIS_URL=redis://redis:6379 - - CLIENT_USER_AUTHENTICATE_URL=http://127.0.0.1:3000/authenticate - - CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=http://127.0.0.1:3000/authenticate/token - SESSION_SECURE=false # __Host- prefixed default requires secure=true; plain-HTTP E2E needs both overrides - SESSION_NAME=auth.session - - CLIENT_CODE_ENDPOINT_URI=redis://redis:6379 + + # ---- Redis-backed state ------------------------------------------- + # DEPLOYMENT_MODE=multi makes the provider itself audit this block: + # boot FAILS naming every offender if any shared store is still held in + # process memory (auth.provider#271/#315). The E2E declares it so a + # future drift back to an in-memory store is a red build, not a suite + # that silently stops exercising the Redis paths — the exact failure + # this issue was filed for. + - DEPLOYMENT_MODE=multi + # THE key the old compose was missing. One ioredis socket per replica + # backs the refresh-token-family store AND — via + # standaloneRedisClientsModule — the 4 user-session stores, the rate + # limiter, and the authorization-code repository. It defaults to + # redis://localhost:6379, which is nothing inside the container, so + # without this the provider retried a dead socket while the old suite + # stayed green. + - REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379 + # express-session's own store; a separate connection from the shared + # ioredis socket above, hence a separate URL. + - SESSION_STORAGE_REDIS_URL=redis://redis:6379 + # The four user-session stores (session, RP registry, family index, + # federation index) — memory by default, which DEPLOYMENT_MODE=multi + # refuses. + - USER_SESSION_STORES_ADAPTER=redis + # OAuth-endpoint rate-limit counters — memory by default, likewise + # refused under multi. + - RATE_LIMITER_ADAPTER=redis + # Authorization-code repository. Already the template default, pinned + # here because the whole point of the suite is that codes round-trip + # through Redis rather than a per-process Map. + - OAUTH_CODE_ADAPTER=redis + # NOTE: the legacy CLIENT_CODE_ENDPOINT_URI that used to sit here is + # gone. It feeds `repositories.code.redis.endpointUri`, which nothing + # reads once `oauth.code.adapter = "redis"` routes the code repository + # onto the shared socket above — it was a no-op pretending to be + # configuration. + + # ---- Repositories -------------------------------------------------- + # The template defaults the user repository to "http", which needs an + # external identity service. The E2E has none, so it uses the built-in + # YAML directory and mounts users.yaml above. + - CLIENT_USER_TYPE=yaml + + # ---- Issuance policy the suite means to exercise -------------------- + # RFC 8707 resource indicators are opt-in (secure-by-default). The E2E + # turns them on because that is how `/authorize?resource=...` stamps + # `aud: https://api.e2e.test` on the access token — the audience + # auth.policy-verifier pins. Without it the provider mints a token the + # verifier is right to reject, and the cross-component contract cannot + # be exercised at all. + - OAUTH_RESOURCE_INDICATOR_ENABLED=true + # #297/#320: refuse to mint for a user whose email the Store has not + # verified. On by default here (it ships off) so the happy path proves + # the gate lets a verified user through, and the negative case proves it + # stops an unverified one. + - OAUTH_REQUIRE_EMAIL_VERIFIED=true + # Public clients are forced onto S256 by the provider regardless; pinning + # it globally means a future confidential client in this suite cannot + # quietly fall back to `plain`. + - OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256=true depends_on: redis: condition: service_healthy @@ -79,7 +141,8 @@ services: - OAUTH_JWT_SECRET=test-secret-for-e2e # auth.policy-verifier validates iss / aud / typ per RFC 9068 §4 and # refuses to boot without them (auth.policy-verifier#105). The issuer must - # match what the provider stamps. + # match what the provider stamps, and the audience must match what the + # provider's `resource` parameter puts on the access token. - OAUTH_JWT_ISSUER=https://auth.e2e.test - OAUTH_JWT_AUDIENCE=https://api.e2e.test depends_on: diff --git a/tests/provider/clients.yaml b/tests/provider/clients.yaml new file mode 100644 index 0000000..50f0898 --- /dev/null +++ b/tests/provider/clients.yaml @@ -0,0 +1,50 @@ +# Client registrations for the cross-component E2E, mounted over the +# standalone template's `config/clients.yaml` (which ships empty). +# +# `firstParty: true` is REQUIRED for a client to use `/authorize` +# (auth.provider#267/#316/#330). `/authorize` mints a code as soon as the +# session is authenticated, with no consent step, so the provider admits only +# clients the operator vouches for. There is no opt-out: the one-time +# `allowUnmarkedClients` migration flag was removed in #330, and a config still +# setting it fails at boot. +# +# `tokenEndpointAuthMethod: "none"` makes this a public client, which is what +# the E2E wants: the provider then MANDATES PKCE/S256 at `/authorize` +# regardless of operator config (RFC 9700 §2.1.1), so the suite exercises the +# strict path rather than whatever `pkce.requireS256` happens to be set to. +# +# `allowedAudiences` is what lets the RFC 8707 `resource` parameter stamp +# `aud: https://api.e2e.test` on the access token — the value +# auth.policy-verifier pins via OAUTH_JWT_AUDIENCE. Without it the minted token +# carries no audience the verifier will accept. +e2e-app: + tokenEndpointAuthMethod: "none" + firstParty: true + allowedRedirectUris: + - "http://localhost:9099/callback" + allowedScopes: + - "openid" + - "email" + - "read:project" + - "read:project_member" + allowedAudiences: + - "https://api.e2e.test" + allowedGrantTypes: + - "authorization_code" + - "refresh_token" + +# A deliberately unmarked client. `/authorize` must refuse it with +# `unauthorized_client` — this is the negative half of the first-party +# invariant, and it is registered here so the suite can prove the refusal +# rather than assume it. +e2e-third-party: + tokenEndpointAuthMethod: "none" + firstParty: false + allowedRedirectUris: + - "http://localhost:9099/callback" + allowedScopes: + - "read:project" + allowedAudiences: + - "https://api.e2e.test" + allowedGrantTypes: + - "authorization_code" diff --git a/tests/provider/users.yaml b/tests/provider/users.yaml new file mode 100644 index 0000000..f45b1aa --- /dev/null +++ b/tests/provider/users.yaml @@ -0,0 +1,32 @@ +# User fixture for the cross-component E2E, consumed by the standalone +# template's YAML user repository (CLIENT_USER_TYPE=yaml). +# +# The template defaults `repositories.user.type` to "http", which needs a +# separate identity service to answer CLIENT_USER_AUTHENTICATE_URL. The E2E has +# no such service, so it switches the adapter to "yaml" and ships the directory +# here — `POST /session/login` then authenticates against this file. +# +# `emailVerified: true` (camelCase — the `User` field name; the OIDC claim it +# surfaces as is `email_verified`) is what satisfies the provider's +# `requireEmailVerified` gate at `/authorize` (auth.provider#297/#320). The +# compose turns that gate ON so the happy path proves the gate passes a +# verified user rather than proving nothing because the gate was off. +# +# The password is stored in plain text on purpose: the in-memory repository +# accepts either a bcrypt hash or a literal, and a literal keeps the fixture +# readable. Never do this outside a disposable test rig. +e2e-user: + id: "user-e2e-1" + password: "e2e-password" + email: "e2e-user@e2e.test" + emailVerified: true + name: "E2E User" + +# A second user whose email the Store has NOT verified. `/authorize` must +# refuse this one with `access_denied` while `requireEmailVerified` is on. +e2e-unverified: + id: "user-e2e-2" + password: "e2e-password" + email: "e2e-unverified@e2e.test" + emailVerified: false + name: "E2E Unverified User" diff --git a/tests/shared/oauthFlow.js b/tests/shared/oauthFlow.js new file mode 100644 index 0000000..12568fa --- /dev/null +++ b/tests/shared/oauthFlow.js @@ -0,0 +1,216 @@ +/* + * Shared driver for the REAL authorization-code flow against auth.provider. + * + * Both E2E packages (token-flow, abac) need a genuine provider-minted token, + * and neither should hand-sign one on its happy path — that was the defect + * behind o3co/auth#3: the suite verified liveness against tokens it minted + * itself, so the provider->verifier contract (claim names, `typ`, `iss`, + * `aud`) was never actually exercised. + * + * Deliberately dependency-free: it runs on node's built-in `fetch` and + * `crypto` so it can be imported across package boundaries (tests/token-flow + * and tests/abac are separate pnpm packages) without either one having to + * resolve a module from outside its own tree. + */ +import crypto from 'node:crypto'; + +export const PROVIDER_URL = process.env.PROVIDER_URL || 'http://localhost:3099'; +export const PROXY_URL = process.env.PROXY_URL || 'http://localhost:3098'; +export const VERIFIER_URL = process.env.VERIFIER_URL || 'http://localhost:3097'; + +export const ISSUER = process.env.OAUTH_JWT_ISSUER || 'https://auth.e2e.test'; +export const AUDIENCE = process.env.OAUTH_JWT_AUDIENCE || 'https://api.e2e.test'; +export const JWT_SECRET = process.env.OAUTH_JWT_SECRET || 'test-secret-for-e2e'; + +/** Marked `firstParty: true` in tests/provider/clients.yaml. */ +export const CLIENT_ID = 'e2e-app'; +/** Marked `firstParty: false` — /authorize must refuse it. */ +export const THIRD_PARTY_CLIENT_ID = 'e2e-third-party'; +export const REDIRECT_URI = 'http://localhost:9099/callback'; + +export const USERNAME = 'e2e-user'; +export const PASSWORD = 'e2e-password'; +/** `emailVerified: false` in tests/provider/users.yaml. */ +export const UNVERIFIED_USERNAME = 'e2e-unverified'; + +/** Split a JWT without verifying it — for asserting on the envelope. */ +export function decodeJwt(token) { + const [header, payload] = token.split('.'); + return { + header: JSON.parse(Buffer.from(header, 'base64url').toString()), + payload: JSON.parse(Buffer.from(payload, 'base64url').toString()), + }; +} + +/** RFC 7636 S256 pair. The provider MANDATES S256 for public clients. */ +export function pkce() { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +/** + * POST /session/login — returns the session cookie header value. + * + * The `Origin` header is sent deliberately. The current provider accepts a + * same-origin `Origin` (its CSRF check passes anything whose origin matches + * the server's, and no-Origin requests outright), and auth.provider#344 will + * make one of `Origin` / a signed double-submit token REQUIRED. Sending it now + * works against both, so this suite does not break when that lands. + */ +export async function login(username = USERNAME, password = PASSWORD) { + const res = await fetch(`${PROVIDER_URL}/session/login`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: PROVIDER_URL }, + body: JSON.stringify({ username, password }), + redirect: 'manual', + }); + // `Headers#getSetCookie()` is the correct reader — it keeps multiple + // Set-Cookie headers separate, which `get()` cannot (it folds them into one + // comma-joined string). It needs Node 20+/undici, so fall back to the plain + // header for anything older rather than silently producing no cookie. + const setCookie = + res.headers.getSetCookie?.() ?? + (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); + const cookie = setCookie.map((c) => c.split(';')[0]).join('; '); + const body = await res.json().catch(() => null); + // Every caller logs in expecting to get a session. Failing here names the + // cause; returning an empty cookie would surface later as an unexplained + // "/authorize did not return a code". + if (res.status === 200 && !cookie.includes('auth.session=')) { + throw new Error( + `login succeeded but no auth.session cookie was readable (got "${cookie}"). ` + + 'If this Node build lacks Headers#getSetCookie, the fallback above did not fire.', + ); + } + return { status: res.status, cookie, body }; +} + +/** + * GET /oauth/authorize with PKCE. Returns the raw redirect so callers can + * assert on the error branch as well as the success one — /authorize signals + * refusal by redirecting to the client's registered redirect_uri with an + * `error` query parameter (RFC 6749 §4.1.2.1), not by returning 4xx. + */ +export async function authorize({ + cookie, + challenge, + clientId = CLIENT_ID, + scope = 'openid email read:project', + resource = AUDIENCE, + state = 'e2e-state', +}) { + const url = new URL(`${PROVIDER_URL}/oauth/authorize`); + const params = { + response_type: 'code', + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + // RFC 8707. This is what stamps `aud: https://api.e2e.test` on the + // access token — the audience auth.policy-verifier pins. Without it the + // provider falls back to the client id and the verifier correctly + // rejects the token. + resource, + }; + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) url.searchParams.set(k, v); + } + const res = await fetch(url, { headers: { cookie }, redirect: 'manual' }); + const location = res.headers.get('location'); + return { status: res.status, location, query: location ? new URL(location).searchParams : null }; +} + +/** POST /oauth/token, grant_type=authorization_code. */ +export async function exchangeCode({ code, verifier, clientId = CLIENT_ID }) { + const res = await fetch(`${PROVIDER_URL}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: clientId, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }), + }); + return { status: res.status, body: await res.json() }; +} + +/** + * POST /oauth/token, grant_type=refresh_token. + * + * `resource` is passed by default and that matters: RFC 8707 §2.2 has the + * client repeat it on refresh, and the provider takes it literally — omit it + * and the refreshed access token falls back to `aud: `, which the + * resource server then rejects. The suite pins both branches. + * + * Pass `resource: null` to omit the parameter. Not `undefined`: a destructuring + * default fires on an explicit `undefined`, so that would silently send the + * default and test the opposite of what it looks like. + */ +export async function refresh({ refreshToken, clientId = CLIENT_ID, resource = AUDIENCE }) { + const body = { grant_type: 'refresh_token', client_id: clientId, refresh_token: refreshToken }; + if (resource !== null) body.resource = resource; + const res = await fetch(`${PROVIDER_URL}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, body: await res.json() }; +} + +/** + * The whole happy path in one call: /authorize -> code -> /token. + * + * Takes an existing session cookie rather than logging in each time — the + * login endpoint is brute-force rate limited (20 per 15 min, shared across + * replicas through Redis), and a suite that logs in per test would start + * failing on the limiter rather than on the contract. + */ +export async function codeFlow({ cookie, scope, resource, clientId } = {}) { + const { verifier, challenge } = pkce(); + const az = await authorize({ cookie, challenge, scope, resource, clientId }); + const code = az.query?.get('code'); + if (!code) { + throw new Error( + `/authorize did not return a code: ${az.status} ${az.location ?? '(no location)'}`, + ); + } + const token = await exchangeCode({ code, verifier, clientId }); + if (token.status !== 200) { + throw new Error(`/oauth/token failed: ${token.status} ${JSON.stringify(token.body)}`); + } + return { ...token.body, authorizeState: az.query.get('state') }; +} + +export async function userinfo(accessToken) { + const res = await fetch(`${PROVIDER_URL}/oauth/userinfo`, { + headers: { authorization: `Bearer ${accessToken}` }, + }); + return { status: res.status, body: await res.json().catch(() => null) }; +} + +export async function introspect(accessToken, token = accessToken) { + const res = await fetch(`${PROVIDER_URL}/oauth/introspect`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${accessToken}` }, + body: JSON.stringify({ token }), + }); + return { status: res.status, body: await res.json() }; +} + +/** POST /verify on auth.policy-verifier. */ +export async function verify({ token, resource = 'project:1', action = 'read' }) { + const res = await fetch(`${VERIFIER_URL}/verify`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ resource, action }), + }); + return { status: res.status, body: await res.json() }; +} diff --git a/tests/token-flow/index.test.js b/tests/token-flow/index.test.js index a02a40c..ab9d349 100644 --- a/tests/token-flow/index.test.js +++ b/tests/token-flow/index.test.js @@ -1,84 +1,318 @@ /* - * Cross-component E2E: auth.provider <-> auth.proxy token flow + * Cross-component E2E: the REAL auth.provider grant path, plus auth.proxy. * * Prerequisites: docker compose up (provider + proxy + redis) * Provider: http://localhost:3099 * Proxy: http://localhost:3098 (forwards to provider) + * + * Every token on the happy path below is minted by the provider through + * `login -> /authorize (PKCE) -> /token`. Nothing here hand-signs a token and + * calls the result an end-to-end test — that was o3co/auth#3. Hand-signing + * survives only in the negative cases, where the point IS to present something + * the provider would never issue (an expired token, a garbage token). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; import axios from 'axios'; import jwt from 'jsonwebtoken'; +import { + AUDIENCE, + CLIENT_ID, + ISSUER, + JWT_SECRET, + PROVIDER_URL, + PROXY_URL, + REDIRECT_URI, + THIRD_PARTY_CLIENT_ID, + UNVERIFIED_USERNAME, + authorize, + codeFlow, + decodeJwt, + introspect, + login, + pkce, + refresh, + userinfo, +} from '../shared/oauthFlow.js'; -const PROVIDER_URL = process.env.PROVIDER_URL || 'http://localhost:3099'; -const PROXY_URL = process.env.PROXY_URL || 'http://localhost:3098'; -const JWT_SECRET = process.env.OAUTH_JWT_SECRET || 'test-secret-for-e2e'; -const ISSUER = process.env.OAUTH_JWT_ISSUER || 'https://auth.e2e.test'; +const proxy = axios.create({ baseURL: PROXY_URL, validateStatus: () => true }); -/* - * The provider pins the RFC 9068 `typ` header, and since auth.provider#266 it - * also pins `iss` against its configured canonical issuer — introspection - * reports a token carrying neither as inactive. Mint every E2E token here so - * the envelope matches what the deployment issues. - */ -function signToken(claims, options = {}) { - return jwt.sign( - { iss: ISSUER, ...claims }, - JWT_SECRET, - { expiresIn: 60, header: { typ: 'at+jwt' }, ...options }, - ); -} - -const provider = axios.create({ - baseURL: PROVIDER_URL, - validateStatus: () => true, +let cookie; +/** One full grant, reused by the read-only assertions below. */ +let grant; + +beforeAll(async () => { + const session = await login(); + expect(session.status).toBe(200); + cookie = session.cookie; + expect(cookie).toMatch(/auth\.session=/); + grant = await codeFlow({ cookie }); +}, 30_000); + +describe('Real grant path: login -> /authorize (PKCE) -> /token', () => { + it('mints a code bound to the session and echoes state', () => { + expect(grant.authorizeState).toBe('e2e-state'); + expect(grant.access_token).toBeTruthy(); + }); + + it('returns an access token, a refresh token and an id_token', () => { + expect(grant.token_type).toBe('Bearer'); + expect(grant.access_token).toBeTruthy(); + expect(grant.refresh_token).toBeTruthy(); + expect(grant.id_token).toBeTruthy(); + }); + + it('stamps the RFC 9068 access-token envelope', () => { + const { header, payload } = decodeJwt(grant.access_token); + // RFC 9068 §2.1 — the media type that distinguishes an access token + // from every other JWT this provider mints. + expect(header.typ).toBe('at+jwt'); + expect(payload.iss).toBe(ISSUER); + // The RFC 8707 `resource` parameter, not the client id: this is what + // makes the token usable at the policy-verifier. + expect(payload.aud).toBe(AUDIENCE); + expect(payload.azp).toBe(CLIENT_ID); + expect(payload.sub).toBe('user-e2e-1'); + expect(payload.jti).toBeTruthy(); + }); + + it('carries `scope` as a space-delimited string, not a `scopes` array', () => { + const { payload } = decodeJwt(grant.access_token); + // The claim-shape drift o3co/auth#3 called out. The verifier reads + // `scope` (string); a `scopes` array would silently authorize nothing. + expect(typeof payload.scope).toBe('string'); + expect(payload.scope.split(' ')).toContain('read:project'); + expect(payload.scopes).toBeUndefined(); + }); + + it('gives the id_token and refresh token their own `typ`', () => { + // The verifier's ONLY discriminator between token kinds is this header + // (see the negative tests in tests/abac). Pinning all three here means + // a provider-side change to any of them fails on this repo's CI rather + // than silently widening what /verify accepts. + expect(decodeJwt(grant.id_token).header.typ).toBe('id+jwt'); + expect(decodeJwt(grant.refresh_token).header.typ).toBe('rt+jwt'); + }); + + it('binds the id_token to the client, not the resource', () => { + const { payload } = decodeJwt(grant.id_token); + // OIDC Core §2: an id_token's audience is the RP. Its audience being + // different from the access token's is exactly why one is a decision + // input at a resource server and the other is not. + expect(payload.aud).toBe(CLIENT_ID); + expect(payload.sub).toBe('user-e2e-1'); + expect(payload.email_verified).toBe(true); + }); }); -const proxy = axios.create({ - baseURL: PROXY_URL, - validateStatus: () => true, +describe('Real grant path: /userinfo and /introspect', () => { + it('returns the scope-filtered claims for the provider-issued token', async () => { + const res = await userinfo(grant.access_token); + expect(res.status).toBe(200); + // `sub` must be the user id the Store published, not the session id — + // the AT-sub-from-session defect (auth.provider#259) would surface here. + expect(res.body.sub).toBe('user-e2e-1'); + // Granted `email` scope, so these appear; `name` was never requested. + expect(res.body.email).toBe('e2e-user@e2e.test'); + expect(res.body.email_verified).toBe(true); + expect(res.body.name).toBeUndefined(); + }); + + it('introspects the provider-issued token as active with matching claims', async () => { + const res = await introspect(grant.access_token); + expect(res.status).toBe(200); + expect(res.body.active).toBe(true); + expect(res.body.iss).toBe(ISSUER); + expect(res.body.aud).toBe(AUDIENCE); + expect(res.body.sub).toBe('user-e2e-1'); + expect(res.body.client_id).toBe(CLIENT_ID); + }); }); -describe('Token Flow: provider -> proxy', () => { - it('proxy allows request with valid token from provider', async () => { - // 1. Create a valid token (simulating provider issuance). - const token = signToken({ user: { id: 1 }, scopes: ['read'] }); - - // 2. Verify provider introspects it as active (self-introspect via Bearer) - const introspectRes = await provider.post('/oauth/introspect', { token }, { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(introspectRes.status).toBe(200); - expect(introspectRes.data.active).toBe(true); - - // 3. Use the token to access proxy — should forward to downstream - const proxyRes = await proxy.get('/_healthcheck', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(proxyRes.status).toBe(200); - }); - - it('proxy rejects request with invalid token', async () => { - // Use a proxied route (not /_healthcheck which bypasses auth) - const proxyRes = await proxy.get('/oauth', { - headers: { Authorization: 'Bearer invalid.token.here' }, - }); - expect(proxyRes.status).toBe(401); - }); - - it('proxy passes through request without Authorization header', async () => { - const proxyRes = await proxy.get('/_healthcheck'); - expect(proxyRes.status).toBe(200); - }); - - it('proxy rejects expired token', async () => { - // The provider's verifier allows 5 minutes of clock skew on `exp` - // (DEFAULT_CLOCK_SKEW_MS), so expire well beyond that window. - const token = signToken({ user: { id: 1 } }, { expiresIn: -600 }); - - // Use a proxied route (not /_healthcheck which bypasses auth) - const proxyRes = await proxy.get('/oauth', { - headers: { Authorization: `Bearer ${token}` }, - }); - expect(proxyRes.status).toBe(401); - }); +describe('Real grant path: refresh rotation and replay', () => { + it('rotates the refresh token and rejects the replayed one', async () => { + const fresh = await codeFlow({ cookie }); + + const first = await refresh({ refreshToken: fresh.refresh_token }); + expect(first.status).toBe(200); + expect(first.body.access_token).toBeTruthy(); + // Rotation: the old refresh token must not come back. + expect(first.body.refresh_token).toBeTruthy(); + expect(first.body.refresh_token).not.toBe(fresh.refresh_token); + expect(decodeJwt(first.body.access_token).payload.aud).toBe(AUDIENCE); + + // Replaying the consumed token is reuse detection, not a stale-token + // shrug: the provider names it. + const replay = await refresh({ refreshToken: fresh.refresh_token }); + expect(replay.status).toBe(400); + expect(replay.body.error).toBe('invalid_grant'); + expect(replay.body.error_description).toBe('replay_detected'); + }, 30_000); + + it('drops the audience when `resource` is omitted on refresh', async () => { + const fresh = await codeFlow({ cookie }); + expect(decodeJwt(fresh.access_token).payload.aud).toBe(AUDIENCE); + + // RFC 8707 §2.2 has the client repeat `resource` on refresh, and the + // provider takes that literally — omitting it falls back to the client + // id. The refreshed token is then still perfectly valid and completely + // unusable at the resource server, which is a trap worth pinning: if + // the provider ever starts carrying the audience forward, this test + // fails and tells us the contract changed rather than letting a + // silently-broken refresh path ship. + const noResource = await refresh({ refreshToken: fresh.refresh_token, resource: null }); + expect(noResource.status).toBe(200); + expect(decodeJwt(noResource.body.access_token).payload.aud).toBe(CLIENT_ID); + }, 30_000); +}); + +describe('/authorize admission rules', () => { + it('refuses a client not marked first-party', async () => { + const { challenge } = pkce(); + const res = await authorize({ + cookie, + challenge, + clientId: THIRD_PARTY_CLIENT_ID, + scope: 'read:project', + }); + // auth.provider#316/#330: the invariant is unconditional, and the + // refusal is delivered as a redirect per RFC 6749 §4.1.2.1 — no code + // is minted. + expect(res.status).toBe(302); + expect(res.query.get('error')).toBe('unauthorized_client'); + expect(res.query.get('code')).toBeNull(); + }); + + it('refuses a user whose email the Store has not verified', async () => { + const unverified = await login(UNVERIFIED_USERNAME); + expect(unverified.status).toBe(200); + const { challenge } = pkce(); + const res = await authorize({ cookie: unverified.cookie, challenge }); + // auth.provider#297/#320, with OAUTH_REQUIRE_EMAIL_VERIFIED=true in + // the compose file. `access_denied` is RFC 6749 §4.1.2.1's code for a + // refusal by the authorization server, not a malformed request. + expect(res.status).toBe(302); + expect(res.query.get('error')).toBe('access_denied'); + expect(res.query.get('code')).toBeNull(); + }, 30_000); + + it('rejects a code redeemed with the wrong PKCE verifier', async () => { + const { challenge } = pkce(); + const az = await authorize({ cookie, challenge }); + const code = az.query.get('code'); + expect(code).toBeTruthy(); + + const res = await fetch(`${PROVIDER_URL}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: pkce().verifier, // a verifier for a different challenge + }), + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toBe('invalid_grant'); + }, 30_000); +}); + +describe('Token flow: provider -> proxy (AUTH_MODE=validation)', () => { + /* + * The proxy runs in `validation` mode: when a request carries an + * Authorization header, it introspects the token against INTROSPECT_URL + * (the provider's /oauth/introspect) and refuses the request unless the + * response says `active: true`. Only then does it forward upstream. + * + * Which side rejected a request is decidable from the body, and these + * tests assert on it rather than on the status alone: + * - proxy: {"code":401,"message":"Invalid Token"} (its own shape) + * - provider: {"error":"invalid_token", ...} (RFC 6750 shape) + * + * Asserting only the status would let "the proxy forwarded everything and + * the upstream happened to reject it" pass as "the proxy validates". + */ + + it('forwards a provider-issued token upstream and returns the upstream response', async () => { + // A route the proxy actually authenticates. NOT /_healthcheck: that is + // mounted ahead of the auth middleware and never reaches it, so a 200 + // there says nothing about whether a token was accepted. + const res = await proxy.get('/oauth/userinfo', { + headers: { Authorization: `Bearer ${grant.access_token}` }, + }); + expect(res.status).toBe(200); + // The body is the upstream's, so this proves the whole round-trip: + // proxy introspected the token against the provider, got `active:true`, + // forwarded the request with the Authorization header intact, and + // returned what /oauth/userinfo produced. + expect(res.data).toEqual({ + sub: 'user-e2e-1', + email: 'e2e-user@e2e.test', + email_verified: true, + }); + }); + + it('rejects a garbage token at the proxy, before the upstream', async () => { + const res = await proxy.get('/oauth/userinfo', { + headers: { Authorization: 'Bearer invalid.token.here' }, + }); + expect(res.status).toBe(401); + // The proxy's own error shape — introspection returned inactive and the + // request never reached the provider's /oauth/userinfo. + expect(res.data).toEqual({ code: 401, message: 'Invalid Token' }); + }); + + it('rejects an expired token at the proxy', async () => { + // Hand-signed on purpose: the provider will not mint an already-expired + // token, and the envelope still has to match what it issues or the + // rejection would prove nothing about expiry. The provider's verifier + // allows 5 minutes of clock skew on `exp` (DEFAULT_CLOCK_SKEW_MS), so + // expire well beyond that window. + const token = jwt.sign({ iss: ISSUER, aud: AUDIENCE, sub: 'user-e2e-1' }, JWT_SECRET, { + expiresIn: -600, + header: { typ: 'at+jwt' }, + }); + const res = await proxy.get('/oauth/userinfo', { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + expect(res.data).toEqual({ code: 401, message: 'Invalid Token' }); + }); + + it('rejects an id_token at the proxy', async () => { + // Introspection reports a non-access token as inactive, so the same + // "only access tokens are credentials" rule the policy-verifier + // enforces by `typ` also holds at the proxy — by a different mechanism. + const res = await proxy.get('/oauth/userinfo', { + headers: { Authorization: `Bearer ${grant.id_token}` }, + }); + expect(res.status).toBe(401); + expect(res.data).toEqual({ code: 401, message: 'Invalid Token' }); + }); + + it('rejects a non-Bearer authorization scheme', async () => { + const res = await proxy.get('/oauth/userinfo', { headers: { Authorization: 'Basic abc' } }); + expect(res.status).toBe(400); + expect(res.data).toEqual({ code: 400, message: 'Invalid Token Type' }); + }); + + it('passes an unauthenticated request through to the upstream', async () => { + // No Authorization header means the proxy does not introspect at all — + // it forwards, and the upstream decides. The RFC 6750 error shape is + // the proof that the request really did reach the provider rather than + // being short-circuited by the proxy. + const res = await proxy.get('/oauth/userinfo'); + expect(res.status).toBe(401); + expect(res.data.error).toBe('invalid_token'); + expect(res.data.code).toBeUndefined(); + }); + + it('serves its own liveness endpoint (not an auth assertion)', async () => { + // /_healthcheck is mounted ahead of the auth middleware, so it is only + // ever evidence that the proxy process is up. Do not add an + // Authorization header here and read a 200 as acceptance — that was the + // original defect in this suite. + const res = await proxy.get('/_healthcheck'); + expect(res.status).toBe(200); + }); });