From de10b699ac6c4ad91776e7697480a1ac1adae9f5 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 27 Aug 2026 21:40:57 +0900 Subject: [PATCH 1/3] fix(e2e): drive the real provider->verifier path instead of self-signed tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-component suite verified liveness against tokens it minted itself. It hand-signed a JWT with the shared HS256 secret, self-introspected it, and called that an end-to-end test — so the actual provider->verifier contract (claim names, `typ`, `iss`, `aud`, `sub`) was never exercised, and neither were the provider's Redis-backed grant paths. The old token-flow test even sent `scopes: ['read']` as an array while the verifier reads `scope` as a string, and nothing caught it. Every token on the happy path is now minted by the provider through `login -> /authorize (PKCE) -> /token`. Hand-signing survives only where the provider cannot produce the input — an expired token, a wrong issuer, a wrong audience, a scopeless token — and each of those says so. What the suite now proves that it did not before: - The full grant path runs: session login, /authorize with PKCE/S256 against a first-party client, code redemption, refresh rotation, /userinfo, /introspect, and policy-verifier /verify with the access token the provider actually minted. - The access-token envelope is the deployed one: `typ: at+jwt`, the canonical `iss`, an `aud` that came from the RFC 8707 `resource` parameter, and `scope` as a space-delimited string — with an explicit assertion that no `scopes` array exists. - `sub` survives the trip from the Store through the token to the verifier's decision, which is what an AT-sub-from-session regression would break. - Only access tokens are decision inputs. The id_token and the refresh token from the same grant are both rejected at /verify. The `typ` header is the only discriminator the verifier has, so all three `typ` values are pinned on the provider side too — a change to either end fails here. - Refresh rotates, and replaying a consumed refresh token is refused as `replay_detected` rather than a generic stale-token error. - Omitting `resource` on refresh silently drops the audience to the client id. That is RFC 8707 §2.2 behaving as written, and it makes the refreshed token useless at the resource server, so the branch is pinned rather than left to be rediscovered. - /authorize refuses a client not marked `firstParty: true` and refuses a user whose email the Store has not verified, both as redirect errors per RFC 6749 §4.1.2.1 with no code minted. - A code redeemed with the wrong PKCE verifier is refused. Infrastructure the real path required: - Sets `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`, which was missing. One ioredis socket backs the refresh-token-family store, the four user-session stores, the rate limiter and the authorization-code repository; it defaulted to `redis://localhost:6379`, nothing inside the container. The legacy `CLIENT_CODE_ENDPOINT_URI` sitting there instead is a no-op once `oauth.code.adapter = "redis"` routes codes onto that socket, and is deleted. - Moves the remaining shared stores onto Redis and declares `DEPLOYMENT_MODE=multi`, so the provider audits the block itself: boot fails naming every offender if a store drifts back into process memory. The drift this issue was filed for becomes a red build. - Adds tests/provider/clients.yaml and tests/provider/users.yaml. The template ships an empty clients.yaml, no users.yaml, and defaults the user repository to "http", so the real login path could not have run without them. - Enables `OAUTH_RESOURCE_INDICATOR_ENABLED` so the audience the verifier pins is reachable, and `OAUTH_REQUIRE_EMAIL_VERIFIED` so that gate is exercised rather than merely off. - Adds tests/shared/oauthFlow.js, a dependency-free driver both packages import. It sends `Origin` on /session/login, which the current provider accepts and auth.provider#344 will require. Local run: `make test-e2e` green from a clean state — 17 token-flow tests and 12 abac tests — against auth.provider b3317687 and auth.policy-verifier c2dba9e. Closes #3 Co-Authored-By: Claude Fable 5 --- tests/abac/index.test.js | 268 +++++++++++++++++----------- tests/docker-compose.yml | 73 +++++++- tests/provider/clients.yaml | 50 ++++++ tests/provider/users.yaml | 32 ++++ tests/shared/oauthFlow.js | 201 +++++++++++++++++++++ tests/token-flow/index.test.js | 308 +++++++++++++++++++++++++-------- 6 files changed, 754 insertions(+), 178 deletions(-) create mode 100644 tests/provider/clients.yaml create mode 100644 tests/provider/users.yaml create mode 100644 tests/shared/oauthFlow.js 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..27447c9 --- /dev/null +++ b/tests/shared/oauthFlow.js @@ -0,0 +1,201 @@ +/* + * 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', + }); + const cookie = (res.headers.getSetCookie?.() ?? []) + .map((c) => c.split(';')[0]) + .join('; '); + return { status: res.status, cookie, body: await res.json().catch(() => null) }; +} + +/** + * 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..d6cd582 100644 --- a/tests/token-flow/index.test.js +++ b/tests/token-flow/index.test.js @@ -1,84 +1,254 @@ /* - * 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', () => { + it('proxy allows a request carrying a provider-issued token', async () => { + const res = await proxy.get('/_healthcheck', { + headers: { Authorization: `Bearer ${grant.access_token}` }, + }); + expect(res.status).toBe(200); + }); + + it('proxy rejects request with invalid token', async () => { + // Use a proxied route (not /_healthcheck which bypasses auth) + const res = await proxy.get('/oauth', { + headers: { Authorization: 'Bearer invalid.token.here' }, + }); + expect(res.status).toBe(401); + }); + + it('proxy passes through request without Authorization header', async () => { + const res = await proxy.get('/_healthcheck'); + expect(res.status).toBe(200); + }); + + it('proxy rejects expired token', 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', { headers: { Authorization: `Bearer ${token}` } }); + expect(res.status).toBe(401); + }); }); From 6470b1663171c8c56e8ed213b473a2898e9731a2 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 27 Aug 2026 21:48:58 +0900 Subject: [PATCH 2/3] fix(e2e): prove the proxy validates, not just that it answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy's "allows a request carrying a provider-issued token" test hit /_healthcheck, which is mounted ahead of the auth middleware and never reaches it. A 200 there proves the process is up and nothing else — the token was never introspected, so the assertion held whether or not the proxy accepted it. That is the same shape of hole this PR exists to close: a test that reads like it proves something it does not. The proxy tests now run against /oauth/userinfo, a route the proxy actually authenticates, and assert the response body rather than the status alone. Which side rejected a request is decidable from the body — the proxy answers {"code":401,"message":"Invalid Token"} and the provider answers the RFC 6750 {"error":"invalid_token",...} — so every case now pins WHO made the decision. Status-only assertions would let "the proxy forwards everything and the upstream happens to reject it" pass as "the proxy validates". What this now proves: - A provider-minted access token sent through the proxy comes back with the upstream's own /oauth/userinfo body. That is the whole round-trip: the proxy introspected the token against the provider (AUTH_MODE=validation, INTROSPECT_URL), got `active: true`, forwarded with the Authorization header intact, and returned what the upstream produced. - A garbage token, an expired token and an id_token are each refused BY THE PROXY, with the proxy's own error shape — introspection returned inactive and the request never reached the upstream. The id_token case means the "only access tokens are credentials" rule holds at the proxy too, reached by introspection rather than by the `typ` pin the policy-verifier uses. - A non-Bearer scheme is refused as 400 Invalid Token Type. - An unauthenticated request is genuinely passed through: the proxy does not introspect, and the RFC 6750 error shape coming back is the evidence that the request reached the provider instead of being short-circuited. The /_healthcheck check is kept as what it always was — a liveness probe — renamed and commented so it cannot be misread as an auth assertion again. Local run: `make test-e2e` green from a clean state, 20 token-flow tests and 12 abac tests. Co-Authored-By: Claude Fable 5 --- tests/token-flow/index.test.js | 90 +++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/tests/token-flow/index.test.js b/tests/token-flow/index.test.js index d6cd582..ab9d349 100644 --- a/tests/token-flow/index.test.js +++ b/tests/token-flow/index.test.js @@ -217,28 +217,52 @@ describe('/authorize admission rules', () => { }, 30_000); }); -describe('Token flow: provider -> proxy', () => { - it('proxy allows a request carrying a provider-issued token', async () => { - const res = await proxy.get('/_healthcheck', { +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('proxy rejects request with invalid token', async () => { - // Use a proxied route (not /_healthcheck which bypasses auth) - const res = await proxy.get('/oauth', { + 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('proxy passes through request without Authorization header', async () => { - const res = await proxy.get('/_healthcheck'); - expect(res.status).toBe(200); - }); - - it('proxy rejects expired token', async () => { + 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 @@ -248,7 +272,47 @@ describe('Token flow: provider -> proxy', () => { expiresIn: -600, header: { typ: 'at+jwt' }, }); - const res = await proxy.get('/oauth', { headers: { Authorization: `Bearer ${token}` } }); + 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); }); }); From aa95292458396f2455a9f9a4cd51a7313a6f2435 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Thu, 27 Aug 2026 21:50:15 +0900 Subject: [PATCH 3/3] fix(e2e): read the login cookie compatibly and fail loudly when it is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `login()` read Set-Cookie only through `Headers#getSetCookie()` and fell back to `[]`, so a Node build without that method produced an empty cookie string and the failure surfaced much later as an unexplained "/authorize did not return a code". `getSetCookie()` stays the primary reader — it is the only one that keeps multiple Set-Cookie headers separate, where `get()` folds them into a single comma-joined string — with the plain header as a fallback for older Node/undici. A successful login that yields no readable session cookie now throws where it happened, naming the cause. Co-Authored-By: Claude Fable 5 --- tests/shared/oauthFlow.js | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/shared/oauthFlow.js b/tests/shared/oauthFlow.js index 27447c9..12568fa 100644 --- a/tests/shared/oauthFlow.js +++ b/tests/shared/oauthFlow.js @@ -65,10 +65,25 @@ export async function login(username = USERNAME, password = PASSWORD) { body: JSON.stringify({ username, password }), redirect: 'manual', }); - const cookie = (res.headers.getSetCookie?.() ?? []) - .map((c) => c.split(';')[0]) - .join('; '); - return { status: res.status, cookie, body: await res.json().catch(() => null) }; + // `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 }; } /**