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
9 changes: 9 additions & 0 deletions .changeset/adapter-conformance-suite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"seamless-cli": minor
---

Add an adapter conformance suite that any server adapter can be held to (#246).

- `seamless verify --adapter-url=<url>` starts Postgres and the auth API and runs the adapter specs against a reference app you started at that URL, for adapters this repository does not build (Go, Rust, Python). `verify/CONFORMANCE.md` documents what the reference app must serve.
- The adapter specs, run against the Express and Fastify reference apps on every `seamless verify`, now cover cookie attributes and clearing, refresh rotation, concurrent refresh and refresh-token reuse, bearer transport, the adapter's own guard on an app route, error passthrough, the absence of tokens in cookie-transport bodies, and forwarding of the client's address and user agent to the auth API.
- The reference apps trust one proxy hop, so each virtual user gets its own client address and rate-limit bucket on the auth API, and pin the current adapter releases (`@seamless-auth/express` 0.19, `@seamless-auth/fastify` 0.10).
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,7 @@ seamless verify --dev # browser specs on dev servers too (Strict Mo
seamless verify --local # build @seamless-auth/* from source first
seamless verify --filter=passkey # one flow (the = form only)
seamless verify --keep-up # leave the stack running afterwards
seamless verify --adapter-url=http://localhost:8080 # one external adapter
```

`--local` is the pre-publish check: it builds and packs the local SDK source rather than
Expand All @@ -392,6 +393,12 @@ their own Dockerfile's `runtime` target and driven by specs written for their ow
Mobile templates are announced and skipped: the harness has no
simulator to drive, so a native app is checked by running it against a `--keep-up` stack.

`--adapter-url` holds a server adapter this repository does not build (Go, Rust, Python)
to the same contract as the Express and Fastify adapters. It starts only Postgres and the
auth API and runs the adapter specs against a reference app you started at that URL. What
the reference app must serve, and how it reaches the API, is in
[verify/CONFORMANCE.md](verify/CONFORMANCE.md).

`--dev` runs every browser template a second time on its development server (`vite`, or
`next dev`). Production React runs each effect once; Strict Mode in development runs it
twice, so an effect that is not idempotent (a single-use magic link verified twice, say)
Expand Down
11 changes: 10 additions & 1 deletion src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Every check runs, so one failure does not hide the rest.
name: "verify",
usage: [
"seamless verify [--local] [--api-only] [--no-react] [--dev] [--filter=<flow>] [--keep-up]",
"seamless verify --adapter-url=<url> [--filter=<flow>] [--keep-up]",
],
sections: [
{
Expand Down Expand Up @@ -184,7 +185,13 @@ a sibling seamless-auth-api checkout (override with SEAMLESS_API_DIR).
--filter <flow> is not parsed)

--keep-up
• Leave the Docker stack running after the suite finishes`,
• Leave the Docker stack running after the suite finishes

--adapter-url=<url>
• Run only the adapter conformance specs, against a reference app you
started at <url>. For server adapters this repository does not build
(Go, Rust, Python). Only Postgres and the auth API are started. The
reference app contract is in verify/CONFORMANCE.md`,
},
],
examples: [
Expand All @@ -194,6 +201,8 @@ a sibling seamless-auth-api checkout (override with SEAMLESS_API_DIR).
→ Run the passkey flows against locally built SDK source`,
`seamless verify --dev
→ Run the browser specs against production builds and dev servers`,
`seamless verify --adapter-url=http://localhost:8080
→ Hold a reference app on another adapter to the same contract`,
],
},
{
Expand Down
43 changes: 43 additions & 0 deletions src/commands/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,49 @@ describe("runVerify — flag parsing", () => {
});
});

describe("runVerify — --adapter-url (an external reference app)", () => {
it("starts only the API and runs the conformance project against the URL", async () => {
await runVerify(["--adapter-url=http://localhost:8080/"]);

const tails = dockerTails();
expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api"]);
expect(tails.some((t) => t.includes("adapter") && t.includes("up"))).toBe(false);
expect(tails.some((t) => t.includes("react") && t.includes("up"))).toBe(false);

const tests = vi
.mocked(runCommand)
.mock.calls.filter((c) => c[0] === "npm" && (c[1] as string[])[0] === "test");
expect(tests.map((c) => c[1])).toEqual([["test", "--", "--project", "conformance"]]);

const env = tests[0][3] as NodeJS.ProcessEnv;
expect(env.SEAMLESS_CONFORMANCE_ADAPTER_URL).toBe("http://localhost:8080");
expect(env.SEAMLESS_VERIFY_CONFORMANCE).toBe("1");
expect(exitSpy).not.toHaveBeenCalled();
});

it("does not pack the Node adapters, even with --local", async () => {
await runVerify(["--local", "--adapter-url=http://localhost:8080"]);

expect(callsFor("pnpm")).toHaveLength(0);
});

it("exits 1 when the reference app fails conformance", async () => {
vi.mocked(runCommand).mockImplementation(async (cmd, args) => {
if (cmd === "npm" && (args as string[])[0] === "test") throw new Error("failed");
});

await runVerify(["--adapter-url=http://localhost:8080"]);

expect(exitSpy).toHaveBeenCalledWith(1);
});

it("refuses a value that is not an http(s) URL", async () => {
await expect(runVerify(["--adapter-url=localhost:8080"])).rejects.toThrow(
/must be an http\(s\) URL/,
);
});
});

describe("runVerify — local mode", () => {
it("packs local server and react SDKs before starting the stack", async () => {
await runVerify(["--local"]);
Expand Down
65 changes: 47 additions & 18 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,30 @@ interface VerifyOptions {
// Also run every browser template on its development server (React Strict Mode).
dev: boolean;
grep?: string;
// Runs only the adapter conformance specs against a reference app the caller
// started, for adapters this repository does not build (Go, Rust, Python).
adapterUrl?: string;
}

function parseArgs(args: string[]): VerifyOptions {
const apiOnly = args.includes("--api-only");
const adapterUrl = args
.find((a) => a.startsWith("--adapter-url="))
?.slice("--adapter-url=".length)
.replace(/\/+$/, "");
if (adapterUrl !== undefined && !/^https?:\/\/[^/]+/.test(adapterUrl)) {
throw new Error(`--adapter-url must be an http(s) URL, got "${adapterUrl}".`);
}
return {
local: args.includes("--local"),
keepUp: args.includes("--keep-up"),
apiOnly,
// The browser layer runs by default; --no-react (or --api-only) skips it.
react: !apiOnly && !args.includes("--no-react"),
// The browser layer runs by default; --no-react (or --api-only) skips it, and so
// does --adapter-url, which tests one adapter and nothing else.
react: !apiOnly && !adapterUrl && !args.includes("--no-react"),
dev: args.includes("--dev"),
grep: args.find((a) => a.startsWith("--filter="))?.split("=")[1],
adapterUrl,
};
}

Expand Down Expand Up @@ -568,8 +580,9 @@ export async function runVerify(args: string[] = []): Promise<void> {
};

// The base stack (no browser layer). The react service is added per template below.
// An external adapter brings its own reference app, so only the API is started.
const baseServices = ["postgres", "auth-api"];
if (!opts.apiOnly) baseServices.push("adapter", "adapter-fastify");
if (!opts.apiOnly && !opts.adapterUrl) baseServices.push("adapter", "adapter-fastify");

let failed = false;
let setupError: Error | undefined;
Expand All @@ -578,7 +591,7 @@ export async function runVerify(args: string[] = []): Promise<void> {
try {
// The adapter / react images install local @seamless-auth/* tarballs when present.
cleanVendor();
if (opts.local) await packLocalSdks(baseEnv);
if (opts.local && !opts.adapterUrl) await packLocalSdks(baseEnv);
if (opts.local && opts.react) await packLocalReactSdk(baseEnv);

// Fresh volumes each run → deterministic system_config seed (e.g. LOGIN_METHODS).
Expand All @@ -597,20 +610,36 @@ export async function runVerify(args: string[] = []): Promise<void> {
await runCommand("npx", ["playwright", "install", "chromium"], HARNESS_DIR, baseEnv);
}

// API and adapter layers are template-independent, so they run once.
const apiEnv: NodeJS.ProcessEnv = {
...baseEnv,
...(opts.apiOnly
? {}
: { SEAMLESS_VERIFY_ADAPTER: "1", SEAMLESS_VERIFY_ADAPTER_FASTIFY: "1" }),
};
const apiProjects = ["api"];
// The adapter suite runs once per adopter framework, against the same specs.
if (!opts.apiOnly) apiProjects.push("adapter", "adapter-fastify");
const apiLabel = opts.apiOnly ? "API" : "API / adapter";
console.log(kleur.cyan("→ Running the API / adapter conformance…\n"));
if (!(await runLayer(results, apiLabel, () => runProjects(apiEnv, apiProjects, opts.grep)))) {
failed = true;
if (opts.adapterUrl) {
const conformanceEnv: NodeJS.ProcessEnv = {
...baseEnv,
SEAMLESS_CONFORMANCE_ADAPTER_URL: opts.adapterUrl,
SEAMLESS_VERIFY_CONFORMANCE: "1",
};
console.log(kleur.cyan(`→ Running adapter conformance against ${opts.adapterUrl}…\n`));
if (
!(await runLayer(results, `Adapter conformance · ${opts.adapterUrl}`, () =>
runProjects(conformanceEnv, ["conformance"], opts.grep),
))
) {
failed = true;
}
} else {
// API and adapter layers are template-independent, so they run once.
const apiEnv: NodeJS.ProcessEnv = {
...baseEnv,
...(opts.apiOnly
? {}
: { SEAMLESS_VERIFY_ADAPTER: "1", SEAMLESS_VERIFY_ADAPTER_FASTIFY: "1" }),
};
const apiProjects = ["api"];
// The adapter suite runs once per adopter framework, against the same specs.
if (!opts.apiOnly) apiProjects.push("adapter", "adapter-fastify");
const apiLabel = opts.apiOnly ? "API" : "API / adapter";
console.log(kleur.cyan("→ Running the API / adapter conformance…\n"));
if (!(await runLayer(results, apiLabel, () => runProjects(apiEnv, apiProjects, opts.grep)))) {
failed = true;
}
}

// The browser layer runs once per browser template, each pointed at its own
Expand Down
89 changes: 89 additions & 0 deletions verify/CONFORMANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Adapter conformance

A server adapter sits in an adopter's backend between the browser (or a native client)
and the Seamless Auth API. It holds the API's tokens in cookies, so it is part of the
security surface. The adapter conformance suite holds every adapter, in any language,
to the same behaviour, by driving a small **reference app** built on it over HTTP.

The Express and Fastify reference apps live in `adapter-app/` and
`adapter-fastify-app/` and run on every `seamless verify`. An adapter this repository
does not build runs the same specs with:

```bash
SEAMLESS_API_DIR=../seamless-auth-api seamless verify --adapter-url=http://localhost:8080
```

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.

## The reference app

### Configuration

| Variable | Value in the verify stack | Purpose |
| --- | --- | --- |
| `PORT` | your choice | Where the app listens |
| `AUTH_SERVER_URL` | `http://localhost:5312` from the host, `http://auth-api:5312` inside the compose network | Where the adapter reaches the API and its JWKS |
| `AUTH_SERVER_ISSUER` | `http://auth-api:5312` | Expected `iss` and `aud` of the API's tokens. It differs from `AUTH_SERVER_URL` when the app runs on the host |
| `API_SERVICE_TOKEN` | `verify-dev-service-token-not-a-real-secret` | The adapter's service secret. It signs the HS256 service token (`iss: seamless-portal-api`, `aud: seamless-auth`) the adapter sends as `x-seamless-service-token` |
| `COOKIE_SIGNING_KEY` | the same value as `API_SERVICE_TOKEN` | Signs the session cookies. At least 32 characters |
| `JWKS_KID` | `dev-main` | The `kid` header on the service token |

### Routes

| Route | Contract |
| --- | --- |
| `GET /` | Any 2xx. The harness waits on it before running |
| `/auth/*` | The adapter |
| `GET /api/me` | An app route behind the adapter's own guard. `200 { "id": "<user id>" }` for a valid session cookie or `Authorization: Bearer <access token>`, `401` otherwise |
| `GET /__captured/:recipient` | The most recent message the adapter delivered to `recipient` (an email address or phone number): `{ "token": "<code or link token>", "magicLinkUrl"?: "..." }`, or `null` |

`/__captured` is a test hook, never part of a real deployment. The adapter must ask the
API for external delivery (`x-seamless-auth-delivery-mode: external`) and hand each
delivery payload to the app, which records it instead of sending it.

### Proxy trust

The app trusts exactly one proxy hop. The harness sends each virtual user with its own
`X-Forwarded-For` and `User-Agent`, and the adapter forwards them to the API as
`x-seamless-client-ip` and `x-seamless-client-user-agent`. A blanket "trust every
proxy" setting must not be used: it makes the client address caller-controlled.

## What the specs hold an adapter to

**Flows** (cookie transport): email OTP signup and sign-in, magic link (`204` while
pending, `200` once verified), OAuth against the mock provider, logout (`204`).

**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
refresh with an expired `Set-Cookie` at `Path=/`.

**No tokens in bodies.** In cookie transport no response body carries `token` or
`refreshToken`. The cookies hold them.

**Refresh.**
- A request with only the refresh cookie is signed in, and both cookies rotate.
- Concurrent requests holding one refresh cookie all succeed. A refresh token presented
again within 5 seconds of its rotation gets the same rotation.
- After that window a used refresh token answers `401`.
- `POST /auth/refresh` rotates both cookies and returns no tokens.

**Bearer transport** (`x-seamless-auth-transport: bearer`). Tokens travel in bodies and
the `Authorization` header, and no cookies are set. The access token is accepted by
`/auth/users/me` and by `/api/me`. `POST /auth/refresh` with the refresh token rotates
the pair, and a used refresh token answers `401` after the reuse window. Logout ends the
session.

**Guard.** `/api/me` answers `401` with no session or an altered access cookie, and the
signed-in user's id with a valid one.

**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`.

**Forwarding.** The API records the browser's address and user agent against the
user's auth events, not the adapter's.

Not yet covered: a non-JSON error body from the API (such as a plaintext `429`), which
needs a fault-injecting proxy between the adapter and the API.
2 changes: 1 addition & 1 deletion verify/adapter-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.",
"dependencies": {
"@seamless-auth/express": "^0.16.0",
"@seamless-auth/express": "^0.19.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^5.1.0"
Expand Down
25 changes: 23 additions & 2 deletions verify/adapter-app/server.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import createSeamlessAuthServer from "@seamless-auth/express";
import createSeamlessAuthServer, { requireAuth } from "@seamless-auth/express";

// The adapter strips OTP/magic-link secrets before responding to the browser, so
// the conformance harness can't read codes from responses. These handlers receive
// the raw delivery payloads and stash them for the harness to read via /__captured.
// The API signs as its ISSUER, which differs from AUTH_SERVER_URL when this app runs
// on the host and reaches the API through a published port. See CONFORMANCE.md.
const ISSUER = process.env.AUTH_SERVER_ISSUER ?? process.env.AUTH_SERVER_URL;

const captured = new Map();
const ok = (channel) => ({ accepted: true, provider: "capture", channel });

Expand All @@ -29,6 +33,9 @@ const handlers = {
};

const app = express();
// One trusted hop, so each conformance actor's X-Forwarded-For becomes the client
// IP the adapter forwards upstream. A blanket `true` would make the adapter drop it.
app.set("trust proxy", 1);
// The React app (browser) calls the adapter cross-origin with credentials, so
// CORS must echo its origin and allow cookies. WEB_ORIGIN is the React app host.
app.use(
Expand All @@ -45,14 +52,28 @@ app.get("/__captured/:email", (req, res) =>
res.json(captured.get(req.params.email) ?? null),
);

// The conformance contract's protected endpoint: the adapter's own guard, in both
// transports, so a spec can tell a session the app accepts from one it does not.
app.get(
"/api/me",
requireAuth({
cookieSecret: process.env.COOKIE_SIGNING_KEY,
authServerUrl: process.env.AUTH_SERVER_URL,
authServerIssuer: ISSUER,
audience: ISSUER,
}),
(req, res) => res.json({ id: req.user.id }),
);

app.use(
"/auth",
createSeamlessAuthServer({
authServerUrl: process.env.AUTH_SERVER_URL,
cookieSecret: process.env.COOKIE_SIGNING_KEY,
serviceSecret: process.env.API_SERVICE_TOKEN,
issuer: process.env.APP_ORIGIN,
audience: process.env.AUTH_SERVER_URL,
authServerIssuer: ISSUER,
audience: ISSUER,
jwksKid: process.env.JWKS_KID,
messaging: { handlers, defaults: { appName: "Seamless Verify" } },
}),
Expand Down
2 changes: 1 addition & 1 deletion verify/adapter-fastify-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"description": "Minimal adopter backend for the conformance harness — real @seamless-auth/fastify with a capture transport.",
"dependencies": {
"@fastify/cors": "^11.0.1",
"@seamless-auth/fastify": "^0.7.0",
"@seamless-auth/fastify": "^0.10.1",
"fastify": "^5.2.0"
}
}
Loading
Loading