Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/conformance-transport-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'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.

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).
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions verify/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>/` 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
Expand Down Expand Up @@ -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
Expand All @@ -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`.
Expand Down
67 changes: 67 additions & 0 deletions verify/harness/adapter/requestGuards.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 5 additions & 1 deletion verify/harness/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -34,6 +35,9 @@ export async function newApiActor(prefix = 'verify'): Promise<Actor> {
// 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',
Expand All @@ -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() };
Expand Down
5 changes: 5 additions & 0 deletions verify/harness/lib/conformanceFlows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export async function waitOutReuseWindow(ctx: APIRequestContext): Promise<void>

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}` };
}
Expand Down
8 changes: 7 additions & 1 deletion verify/harness/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions verify/harness/lib/matrixReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default class MatrixReporter implements Reporter {
private tests = new Map<string, Entry>();

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
Expand Down
33 changes: 33 additions & 0 deletions verify/harness/lib/pendingContracts.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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`,
);
}
Loading