From dfe7e7fb8da00962eab7afca612fd2f484089e9d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:37:29 -0500 Subject: [PATCH 1/2] test(verify): send the cookie transport header and cover the request guards The adapter actor now sends x-seamless-auth-transport: cookie, as the official client does, so body-less unsafe requests pass the content-type gate adapters add in v0.23. New request guard cases assert that a cross-site unsafe request answers 403 and leaves the session alone, and that a body-less form POST to /auth/recovery-codes without the header answers 415. The 415 case is a pending contract: it skips unless SEAMLESS_VERIFY_CONTRACTS names it, because the Go, Python and Rust adapters run these specs from main unpinned. The matrix reporter no longer counts a skipped case as a failure. Refs fells-code/seamless-auth-api#558 --- .changeset/conformance-transport-header.md | 5 ++ AGENTS.md | 6 ++ verify/CONFORMANCE.md | 35 ++++++++++ verify/harness/adapter/requestGuards.spec.ts | 67 ++++++++++++++++++++ verify/harness/lib/client.ts | 6 +- verify/harness/lib/conformanceFlows.ts | 5 ++ verify/harness/lib/matrixReporter.ts | 3 + verify/harness/lib/pendingContracts.ts | 33 ++++++++++ 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 .changeset/conformance-transport-header.md create mode 100644 verify/harness/adapter/requestGuards.spec.ts create mode 100644 verify/harness/lib/pendingContracts.ts diff --git a/.changeset/conformance-transport-header.md b/.changeset/conformance-transport-header.md new file mode 100644 index 0000000..eae0313 --- /dev/null +++ b/.changeset/conformance-transport-header.md @@ -0,0 +1,5 @@ +--- +'seamless-cli': patch +--- + +The adapter conformance suite now sends `x-seamless-auth-transport: cookie` on its cookie-transport requests, as `@seamless-auth/client` does, so body-less `POST` and `DELETE` calls pass the content-type gate server adapters add in v0.23 (fells-code/seamless-auth-api#558). Two new cases hold adapters to the request guards: a cross-site unsafe request answers `403 cross_site_request_blocked` and leaves the session alone, and a body-less form-encoded `POST /auth/recovery-codes` without the transport header answers `415 unsupported_media_type`. The 415 case is a pending contract: it is skipped unless `SEAMLESS_VERIFY_CONTRACTS=content-type-gate` (or `all`) is set, so an adapter that has not released the gate yet keeps passing. `verify/CONFORMANCE.md` lists pending contracts. The conformance grid no longer counts a skipped case as a failure. diff --git a/AGENTS.md b/AGENTS.md index 2a1a7d7..77a3b87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,6 +280,12 @@ since the generated compose pins both the ports and the container names. - **Adapter OTP limiter**: the adapter funnels all OTP through one client IP, so the API's per-IP OTP limiter (10 per 15 minutes, hardcoded) bounds adapter / react OTP traffic. Keep specs off it where possible (for example, magic-link login instead of a second email-OTP round trip). +- **Pending conformance contracts**: the Go, Python and Rust adapters run the adapter specs from + this repo's `main` with no pin, so a new spec for a contract an adapter has not released would + fail it at once. List such a contract in + [verify/harness/lib/pendingContracts.ts](verify/harness/lib/pendingContracts.ts) with its ticket + and call `requireContract` in its cases: they skip unless `SEAMLESS_VERIFY_CONTRACTS` names it. + Remove the entry once every adapter has released it. `verify/CONFORMANCE.md` lists them. - **Version pins**: [verify/adapter-app](verify/adapter-app) pins `@seamless-auth/express`, [verify/adapter-fastify-app](verify/adapter-fastify-app) pins `@seamless-auth/fastify`, and each web template pins `@seamless-auth/react`. Bump these when new versions publish. diff --git a/verify/CONFORMANCE.md b/verify/CONFORMANCE.md index ca028eb..0609a7e 100644 --- a/verify/CONFORMANCE.md +++ b/verify/CONFORMANCE.md @@ -16,6 +16,26 @@ SEAMLESS_API_DIR=../seamless-auth-api seamless verify --adapter-url=http://local That starts Postgres and the auth API (published on `http://localhost:5312`), waits for `GET /` to answer, and runs `verify/harness/adapter/*.spec.ts` against the URL. +### Pending contracts + +A contract the specs describe before every adapter has released it is listed in +`verify/harness/lib/pendingContracts.ts`, with the ticket that tracks it. Its cases are +skipped unless the run names it in `SEAMLESS_VERIFY_CONTRACTS` (a comma list, or `all`), +so an adapter that runs this harness from its default branch is not failed by a case it +has not shipped. Once an adapter ships one, its CI sets the variable to hold itself to +it: + +```bash +SEAMLESS_VERIFY_CONTRACTS=content-type-gate seamless verify --adapter-url=http://localhost:8080 +``` + +When every adapter has released a contract it leaves the list and its cases run for +everyone. + +| Contract | Ticket | What it holds an adapter to | +| --- | --- | --- | +| `content-type-gate` | fells-code/seamless-auth-api#558 | A body-less unsafe request to `/auth/*` without `Content-Type: application/json` or `x-seamless-auth-transport` answers `415 unsupported_media_type` | + ## The reference app ### Configuration @@ -54,6 +74,11 @@ proxy" setting must not be used: it makes the client address caller-controlled. **Flows** (cookie transport): email OTP signup and sign-in, magic link (`204` while pending, `200` once verified), OAuth against the mock provider, logout (`204`). +The harness's cookie-transport requests carry `x-seamless-auth-transport: cookie`, as +the official client's unsafe requests do, so a body-less `POST` or `DELETE` passes the +content-type gate. Only `bearer` selects bearer transport; any other value is cookie +transport. + **Cookies.** `seamless-ephemeral` (pre-auth and registration), `seamless-access` and `seamless-refresh`, each `HttpOnly; Secure; SameSite=None; Path=/`, with `Max-Age` equal to the lifetime the API returned (`ttl`, `refreshTtl`). Logout clears access and @@ -79,6 +104,16 @@ session. sign-in flow's ephemeral cookie presented as the access cookie, and the signed-in user's id with a valid one. +**Request guards** (cookie transport). +- A request with `Sec-Fetch-Site: cross-site` and any method but `GET`, `HEAD` or + `OPTIONS` answers `403 cross_site_request_blocked` and reaches nothing: a cross-site + `DELETE /auth/logout` leaves the session signed in. The reference app's cookies are + `SameSite=None`; from v0.23 a `Lax` or `Strict` deployment is refused the same way, + which this suite cannot vary. +- Pending (`content-type-gate`): a body-less form-encoded `POST /auth/recovery-codes` + without the transport header answers `415 unsupported_media_type` and generates + nothing, and the same request with the header goes through. + **Errors.** An API validation failure keeps its `400` and body (`error: "invalid_request"` with `details.issues`). No session answers `401` with an `error` code. An unknown route answers `404`. diff --git a/verify/harness/adapter/requestGuards.spec.ts b/verify/harness/adapter/requestGuards.spec.ts new file mode 100644 index 0000000..7574fe5 --- /dev/null +++ b/verify/harness/adapter/requestGuards.spec.ts @@ -0,0 +1,67 @@ +import { request as playwrightRequest } from '@playwright/test'; + +import { cookieSignup } from '../lib/conformanceFlows'; +import { expect, test } from '../lib/fixtures'; +import { requireContract } from '../lib/pendingContracts'; + +// What an adapter refuses before a cookie-transport request reaches the auth API. +// A cross-site page can make a browser send a form POST with the victim's cookies +// and no CORS preflight; these are the two checks that stop it. +test.describe('request guards (adapter, cookies)', () => { + test('a body-less form POST without a transport header is refused with 415', async ({ + adapterActor, + adapterUrl, + }) => { + requireContract('content-type-gate'); + await cookieSignup(adapterActor.ctx, adapterActor.email); + + // The same session, but without the header the actor sends, as a cross-site + // form would arrive. No Sec-Fetch-Site or Origin, so only this check applies. + const { cookies } = await adapterActor.ctx.storageState(); + const form = await playwrightRequest.newContext({ + baseURL: adapterUrl, + userAgent: adapterActor.userAgent, + extraHTTPHeaders: { 'x-forwarded-for': adapterActor.clientIp }, + storageState: { cookies, origins: [] }, + }); + + try { + const refused = await form.post('/auth/recovery-codes', { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }); + expect(refused.status(), 'a body-less form POST').toBe(415); + expect((await refused.json()).error).toBe('unsupported_media_type'); + } finally { + await form.dispose(); + } + + // With the header the same request goes through. A 201 also shows the refused + // request made no codes: replacing an existing set needs a fresh step-up. + const accepted = await adapterActor.ctx.post('/auth/recovery-codes'); + expect(accepted.status(), 'the same request naming cookie transport').toBe(201); + }); + + // The reference app issues SameSite=None cookies (see CONFORMANCE.md), which a + // browser attaches to a cross-site request, so this is the case the guard exists + // for. A Lax or Strict deployment is refused the same way from v0.23. + test('a cross-site unsafe request is refused with 403 and changes nothing', async ({ + adapterActor, + }) => { + await cookieSignup(adapterActor.ctx, adapterActor.email); + const crossSite = { 'sec-fetch-site': 'cross-site', origin: 'https://attacker.example' }; + + const generate = await adapterActor.ctx.post('/auth/recovery-codes', { + headers: crossSite, + data: {}, + }); + expect(generate.status(), 'cross-site POST /auth/recovery-codes').toBe(403); + expect((await generate.json()).error).toBe('cross_site_request_blocked'); + + const logout = await adapterActor.ctx.delete('/auth/logout', { headers: crossSite }); + expect(logout.status(), 'cross-site DELETE /auth/logout').toBe(403); + expect((await logout.json()).error).toBe('cross_site_request_blocked'); + + const me = await adapterActor.ctx.get('/auth/users/me'); + expect(me.status(), 'the session survives the refused logout').toBe(200); + }); +}); diff --git a/verify/harness/lib/client.ts b/verify/harness/lib/client.ts index f007d3a..ea9fbd1 100644 --- a/verify/harness/lib/client.ts +++ b/verify/harness/lib/client.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'; import { APIRequestContext, request as playwrightRequest } from '@playwright/test'; +import { COOKIE_TRANSPORT } from './conformanceFlows'; import { ADAPTER_URL, API_SERVICE_TOKEN, API_URL, uniqueClientIp, uniqueEmail } from './env'; import { mintServiceToken } from './serviceToken'; @@ -34,6 +35,9 @@ export async function newApiActor(prefix = 'verify'): Promise { // User-Agent. Reference apps trust exactly that hop, so the adapter forwards them // upstream: the actor gets its own rate-limit bucket, and a spec can find what the // API recorded for it. +// +// It names cookie transport on every request, as the official client does on +// unsafe ones. A spec selects bearer transport per request, which overrides it. export async function newAdapterActor( baseURL: string = ADAPTER_URL, prefix = 'verify', @@ -43,7 +47,7 @@ export async function newAdapterActor( const ctx = await playwrightRequest.newContext({ baseURL, userAgent, - extraHTTPHeaders: { 'x-forwarded-for': clientIp }, + extraHTTPHeaders: { 'x-forwarded-for': clientIp, ...COOKIE_TRANSPORT }, }); return { email: uniqueEmail(prefix), ctx, clientIp, userAgent, dispose: () => ctx.dispose() }; diff --git a/verify/harness/lib/conformanceFlows.ts b/verify/harness/lib/conformanceFlows.ts index 2dc532f..8ce11e3 100644 --- a/verify/harness/lib/conformanceFlows.ts +++ b/verify/harness/lib/conformanceFlows.ts @@ -29,6 +29,11 @@ export async function waitOutReuseWindow(ctx: APIRequestContext): Promise export const BEARER_TRANSPORT = { 'x-seamless-auth-transport': 'bearer' } as const; +// What the official client sends on a cookie-transport request. Adapters refuse a +// body-less unsafe auth request that carries neither this header nor a JSON content +// type, so the adapter actor sends it on every request, as a browser client would. +export const COOKIE_TRANSPORT = { 'x-seamless-auth-transport': 'cookie' } as const; + export function bearer(token: string) { return { Authorization: `Bearer ${token}` }; } diff --git a/verify/harness/lib/matrixReporter.ts b/verify/harness/lib/matrixReporter.ts index b515c58..a94b3f4 100644 --- a/verify/harness/lib/matrixReporter.ts +++ b/verify/harness/lib/matrixReporter.ts @@ -45,6 +45,9 @@ export default class MatrixReporter implements Reporter { private tests = new Map(); onTestEnd(test: TestCase, result: TestResult): void { + // A skipped test (a pending contract the run did not opt into) is neither a + // pass nor a failure, so it leaves its cell alone. + if (result.status === 'skipped') return; const match = test.location.file.match(/\/(api|adapter|react|nextjs)\/([^/]+)\.spec\.[tj]s$/); if (!match) return; // The project name, not the directory: the two adapter projects run the same diff --git a/verify/harness/lib/pendingContracts.ts b/verify/harness/lib/pendingContracts.ts new file mode 100644 index 0000000..43acd55 --- /dev/null +++ b/verify/harness/lib/pendingContracts.ts @@ -0,0 +1,33 @@ +import { test } from '@playwright/test'; + +// Contracts the specs already describe but not every adapter has released. Their +// cases are skipped unless the run opts in, so an adapter that tracks this harness +// at its default branch is not failed by a case it has not shipped yet. An adapter +// opts in once it ships the contract, by setting SEAMLESS_VERIFY_CONTRACTS (a comma +// list of names, or `all`). Move a contract out of this list once every adapter has +// released it, which makes its cases run for everyone. +export const PENDING_CONTRACTS = { + // A body-less unsafe auth request needs a JSON content type or the transport + // header, or it is refused with 415. + 'content-type-gate': 'fells-code/seamless-auth-api#558', +} as const; + +export type PendingContract = keyof typeof PENDING_CONTRACTS; + +function optedIn(): Set { + return new Set( + (process.env.SEAMLESS_VERIFY_CONTRACTS ?? '') + .split(',') + .map((name) => name.trim()) + .filter(Boolean), + ); +} + +/** Skips the calling test unless the run opted into `contract`. */ +export function requireContract(contract: PendingContract): void { + const names = optedIn(); + test.skip( + !names.has('all') && !names.has(contract), + `${contract} is pending (${PENDING_CONTRACTS[contract]}); set SEAMLESS_VERIFY_CONTRACTS=${contract} to hold this adapter to it`, + ); +} From eb6d36905f35fe9e1ec26ed005b484052af905e8 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:00:17 -0500 Subject: [PATCH 2/2] test(verify): mint the external delivery subject for delivery requests Refs fells-code/seamless-auth-api#556 --- .changeset/conformance-transport-header.md | 2 ++ verify/harness/lib/env.ts | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.changeset/conformance-transport-header.md b/.changeset/conformance-transport-header.md index eae0313..202d96a 100644 --- a/.changeset/conformance-transport-header.md +++ b/.changeset/conformance-transport-header.md @@ -3,3 +3,5 @@ --- The adapter conformance suite now sends `x-seamless-auth-transport: cookie` on its cookie-transport requests, as `@seamless-auth/client` does, so body-less `POST` and `DELETE` calls pass the content-type gate server adapters add in v0.23 (fells-code/seamless-auth-api#558). Two new cases hold adapters to the request guards: a cross-site unsafe request answers `403 cross_site_request_blocked` and leaves the session alone, and a body-less form-encoded `POST /auth/recovery-codes` without the transport header answers `415 unsupported_media_type`. The 415 case is a pending contract: it is skipped unless `SEAMLESS_VERIFY_CONTRACTS=content-type-gate` (or `all`) is set, so an adapter that has not released the gate yet keeps passing. `verify/CONFORMANCE.md` lists pending contracts. The conformance grid no longer counts a skipped case as a failure. + +Requests that ask for external delivery (`x-seamless-auth-delivery-mode: external`) now carry a service token with the `seamless-auth-external-delivery` subject, as the server adapters send it, so the suite keeps passing once the API requires that subject (fells-code/seamless-auth-api#556). diff --git a/verify/harness/lib/env.ts b/verify/harness/lib/env.ts index b40cfac..a275d3d 100644 --- a/verify/harness/lib/env.ts +++ b/verify/harness/lib/env.ts @@ -2,6 +2,8 @@ import { randomInt, randomUUID } from 'crypto'; +import { mintServiceToken } from './serviceToken'; + export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312'; export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000'; // The Fastify twin of the adapter, on its own port. The adapter specs run against @@ -24,9 +26,13 @@ export const API_SERVICE_TOKEN = export const OWNER_EMAIL = process.env.SEAMLESS_OWNER_EMAIL ?? 'owner@verify.local'; // Non-production seam: makes the API return OTP / magic-link tokens in the -// response `delivery` object instead of sending real email/SMS. +// response `delivery` object instead of sending real email/SMS. External +// delivery is unlocked only by a service token for the delivery subject, as the +// server adapters send it, so these requests carry their own token. +export const EXTERNAL_DELIVERY_SUBJECT = 'seamless-auth-external-delivery'; export const EXTERNAL_DELIVERY = { 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': `Bearer ${mintServiceToken(API_SERVICE_TOKEN, EXTERNAL_DELIVERY_SUBJECT)}`, } as const; const runId = process.env.SEAMLESS_RUN_ID ?? String(Date.now());