diff --git a/package.json b/package.json index 1be3036..caadff5 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "files": [ "dist", "templates", + "verify", "README.md", "LICENSE" ], diff --git a/src/commands/help.ts b/src/commands/help.ts index 2d250f9..50482f6 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -13,7 +13,7 @@ USAGE seamless init [project-name] seamless check seamless bootstrap-admin [email] - seamless verify [--filter=] [--keep-up] + seamless verify [--api-only] [--filter=] [--keep-up] seamless --help seamless --version @@ -33,10 +33,10 @@ COMMANDS check Validate project setup, Docker, and running services - verify [--filter=] [--keep-up] - Stand up the auth stack and run the conformance suite across every - auth flow (API + SDK paths). Requires Docker. Set SEAMLESS_API_DIR and - SEAMLESS_ADAPTER_DIR to local source checkouts. + verify [--api-only] [--filter=] [--keep-up] + Stand up the auth stack and run the conformance suite across the API and + the cookie (adapter) paths. Requires Docker. Builds the auth server from + a sibling seamless-auth-api checkout (override with SEAMLESS_API_DIR). bootstrap-admin [email] Create a bootstrap admin invite diff --git a/src/commands/verify.ts b/src/commands/verify.ts index ec29bc2..d42fd37 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -16,6 +16,7 @@ const HARNESS_DIR = path.join(VERIFY_DIR, "harness"); interface VerifyOptions { released: boolean; keepUp: boolean; + apiOnly: boolean; grep?: string; } @@ -23,6 +24,7 @@ function parseArgs(args: string[]): VerifyOptions { return { released: args.includes("--released"), keepUp: args.includes("--keep-up"), + apiOnly: args.includes("--api-only"), grep: args.find((a) => a.startsWith("--filter="))?.split("=")[1], }; } @@ -37,16 +39,22 @@ function ensureDocker(): void { } } -function resolveSourceDirs(): { apiDir: string; adapterDir: string } { - const apiDir = process.env.SEAMLESS_API_DIR; - const adapterDir = process.env.SEAMLESS_ADAPTER_DIR; - if (!apiDir || !adapterDir) { +// The auth API is built from local source. Defaults to a sibling checkout so a +// linked CLI works without extra config; override with SEAMLESS_API_DIR. +function resolveApiDir(): string { + const candidate = + process.env.SEAMLESS_API_DIR ?? path.resolve(REPO_ROOT, "..", "seamless-auth-api"); + if (!fs.existsSync(path.join(candidate, "package.json"))) { throw new Error( - "Set SEAMLESS_API_DIR and SEAMLESS_ADAPTER_DIR to local source checkouts.\n" + - " (Released-mode auto-clone lands in a later milestone.)", + `Could not find the seamless-auth-api source at ${candidate}.\n` + + " Set SEAMLESS_API_DIR to its local checkout.", ); } - return { apiDir, adapterDir }; + return candidate; +} + +function compose(env: NodeJS.ProcessEnv, ...args: string[]): Promise { + return runCommand("docker", ["compose", "-f", COMPOSE_FILE, ...args], VERIFY_DIR, env); } export async function runVerify(args: string[] = []): Promise { @@ -58,13 +66,12 @@ export async function runVerify(args: string[] = []): Promise { } ensureDocker(); - const { apiDir, adapterDir } = resolveSourceDirs(); + const apiDir = resolveApiDir(); const serviceToken = process.env.API_SERVICE_TOKEN ?? "verify-dev-service-token"; const env: NodeJS.ProcessEnv = { ...process.env, SEAMLESS_API_DIR: apiDir, - SEAMLESS_ADAPTER_DIR: adapterDir, API_SERVICE_TOKEN: serviceToken, JWKS_KID: process.env.JWKS_KID ?? "dev-main", SEAMLESS_BOOTSTRAP_SECRET: @@ -72,17 +79,22 @@ export async function runVerify(args: string[] = []): Promise { // consumed by the harness SEAMLESS_API_SERVICE_TOKEN: serviceToken, SEAMLESS_API_URL: "http://localhost:5312", + SEAMLESS_ADAPTER_URL: "http://localhost:3000", + ...(opts.apiOnly ? {} : { SEAMLESS_VERIFY_ADAPTER: "1" }), }; + const services = opts.apiOnly + ? ["postgres", "auth-api"] + : ["postgres", "auth-api", "adapter"]; + let failed = false; try { - console.log(kleur.cyan("→ Building & starting the stack (postgres + auth-api)…")); - await runCommand( - "docker", - ["compose", "-f", COMPOSE_FILE, "up", "-d", "--build", "postgres", "auth-api"], - VERIFY_DIR, - env, - ); + // Fresh volumes each run → deterministic system_config seed (e.g. LOGIN_METHODS). + console.log(kleur.cyan("→ Cleaning any previous stack…")); + await compose(env, "down", "-v").catch(() => undefined); + + console.log(kleur.cyan(`→ Building & starting the stack (${services.join(", ")})…`)); + await compose(env, "up", "-d", "--build", ...services); if (!fs.existsSync(path.join(HARNESS_DIR, "node_modules"))) { console.log(kleur.cyan("→ Installing harness dependencies…")); @@ -90,8 +102,10 @@ export async function runVerify(args: string[] = []): Promise { } console.log(kleur.cyan("→ Running the conformance harness…\n")); - const testArgs = ["test", "--", "--project=api"]; - if (opts.grep) testArgs.push("--grep", opts.grep); + const passthrough: string[] = []; + if (opts.apiOnly) passthrough.push("--project=api"); + if (opts.grep) passthrough.push("--grep", opts.grep); + const testArgs = passthrough.length ? ["test", "--", ...passthrough] : ["test"]; await runCommand("npm", testArgs, HARNESS_DIR, env); console.log(kleur.green("\n✔ Conformance passed.\n")); @@ -104,12 +118,7 @@ export async function runVerify(args: string[] = []): Promise { console.log(kleur.dim(` docker compose -f ${COMPOSE_FILE} down -v\n`)); } else { console.log(kleur.cyan("→ Tearing down…")); - await runCommand( - "docker", - ["compose", "-f", COMPOSE_FILE, "down", "-v"], - VERIFY_DIR, - env, - ).catch(() => undefined); + await compose(env, "down", "-v").catch(() => undefined); } } diff --git a/verify/adapter-app/Dockerfile b/verify/adapter-app/Dockerfile new file mode 100644 index 0000000..3a2803d --- /dev/null +++ b/verify/adapter-app/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +RUN apk add --no-cache curl +COPY package.json ./ +RUN npm install +COPY server.mjs ./ +EXPOSE 3000 +CMD ["node", "server.mjs"] diff --git a/verify/adapter-app/package.json b/verify/adapter-app/package.json new file mode 100644 index 0000000..8191fce --- /dev/null +++ b/verify/adapter-app/package.json @@ -0,0 +1,12 @@ +{ + "name": "seamless-verify-adapter", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.", + "dependencies": { + "@seamless-auth/express": "^0.5.3", + "cookie-parser": "^1.4.6", + "express": "^4.19.2" + } +} diff --git a/verify/adapter-app/server.mjs b/verify/adapter-app/server.mjs new file mode 100644 index 0000000..696d90f --- /dev/null +++ b/verify/adapter-app/server.mjs @@ -0,0 +1,53 @@ +import cookieParser from "cookie-parser"; +import express from "express"; +import createSeamlessAuthServer 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. +const captured = new Map(); +const ok = (channel) => ({ accepted: true, provider: "capture", channel }); + +const handlers = { + async sendOtpEmail({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("email"); + }, + async sendOtpSms({ to, token }) { + captured.set(to, { token: String(token) }); + return ok("sms"); + }, + async sendMagicLinkEmail({ to, token, magicLinkUrl }) { + captured.set(to, { token, magicLinkUrl }); + return ok("email"); + }, + async sendBootstrapInviteEmail({ to, token, inviteUrl }) { + captured.set(to, { token, inviteUrl }); + return ok("email"); + }, +}; + +const app = express(); +app.use(express.json()); +app.use(cookieParser()); + +app.get("/", (_req, res) => res.json({ ok: true })); +app.get("/__captured/:email", (req, res) => + res.json(captured.get(req.params.email) ?? null), +); + +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, + jwksKid: process.env.JWKS_KID, + messaging: { handlers, defaults: { appName: "Seamless Verify" } }, + }), +); + +const port = Number(process.env.PORT ?? 3000); +app.listen(port, () => console.log(`verify adapter listening on :${port}`)); diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 6939d9e..2249900 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -75,25 +75,26 @@ services: timeout: 5s retries: 40 + # Harness-owned adopter backend: real @seamless-auth/express with a capture + # transport so the harness can read OTP/magic-link codes the adapter would + # otherwise strip. ISSUER on auth-api is set to this AUTH_SERVER_URL host. adapter: build: - context: ${SEAMLESS_ADAPTER_DIR} + context: ./adapter-app ports: - '3000:3000' environment: - NODE_ENV: development PORT: '3000' AUTH_SERVER_URL: http://auth-api:5312 APP_ORIGIN: http://localhost:3000 - UI_ORIGINS: http://localhost:5173 API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN} JWKS_KID: ${JWKS_KID} - DB_HOST: postgres - DB_PORT: '5432' - DB_USER: seamless - DB_PASSWORD: seamless - DB_NAME: seamless_app depends_on: auth-api: condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/'] + interval: 3s + timeout: 3s + retries: 20 diff --git a/verify/harness/adapter/emailOtpLogin.spec.ts b/verify/harness/adapter/emailOtpLogin.spec.ts new file mode 100644 index 0000000..9b67e05 --- /dev/null +++ b/verify/harness/adapter/emailOtpLogin.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../lib/fixtures'; +import { loginViaEmailOtp, registerAndVerifyEmail } from '../lib/adapterFlows'; + +test.describe('email OTP login (adapter, cookies)', () => { + test('register -> verify -> login OTP -> session cookie -> /users/me', async ({ adapterActor }) => { + await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email); + await loginViaEmailOtp(adapterActor.ctx, adapterActor.email); + + const me = await adapterActor.ctx.get('/auth/users/me'); + expect(me.status(), 'authenticated via session cookie').toBe(200); + }); +}); diff --git a/verify/harness/adapter/sessionLifecycle.spec.ts b/verify/harness/adapter/sessionLifecycle.spec.ts new file mode 100644 index 0000000..930856f --- /dev/null +++ b/verify/harness/adapter/sessionLifecycle.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '../lib/fixtures'; +import { loginViaEmailOtp, registerAndVerifyEmail } from '../lib/adapterFlows'; + +test.describe('session lifecycle (adapter, cookies)', () => { + test('logout clears the session', async ({ adapterActor }) => { + await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email); + await loginViaEmailOtp(adapterActor.ctx, adapterActor.email); + + expect((await adapterActor.ctx.get('/auth/users/me')).status(), 'authenticated').toBe(200); + + const out = await adapterActor.ctx.delete('/auth/logout'); + expect(out.status(), 'logout returns 204').toBe(204); + + const me = await adapterActor.ctx.get('/auth/users/me'); + expect(me.status(), 'session rejected after logout').not.toBe(200); + }); +}); diff --git a/verify/harness/lib/adapterFlows.ts b/verify/harness/lib/adapterFlows.ts new file mode 100644 index 0000000..40eaaca --- /dev/null +++ b/verify/harness/lib/adapterFlows.ts @@ -0,0 +1,37 @@ +import { APIRequestContext, expect } from '@playwright/test'; + +// Cookie-based flows against the adapter (baseURL = adapter). The adapter strips +// OTP/magic-link secrets from browser responses, so codes are read from the +// harness adapter app's /__captured readout. + +async function readCapturedCode(ctx: APIRequestContext, email: string): Promise { + const res = await ctx.get(`/__captured/${encodeURIComponent(email)}`); + expect(res.ok(), `captured lookup -> ${res.status()}`).toBeTruthy(); + const body = await res.json(); + expect(body?.token, `a captured code for ${email}`).toBeTruthy(); + return String(body.token); +} + +export async function registerAndVerifyEmail(ctx: APIRequestContext, email: string): Promise { + let res = await ctx.post('/auth/registration/register', { data: { email } }); + expect(res.ok(), `register -> ${res.status()}`).toBeTruthy(); + + res = await ctx.get('/auth/otp/generate-email-otp'); + expect(res.ok(), `generate-email-otp -> ${res.status()}`).toBeTruthy(); + + const code = await readCapturedCode(ctx, email); + res = await ctx.post('/auth/otp/verify-email-otp', { data: { verificationToken: code } }); + expect(res.ok(), `verify-email-otp -> ${res.status()}`).toBeTruthy(); +} + +export async function loginViaEmailOtp(ctx: APIRequestContext, email: string): Promise { + let res = await ctx.post('/auth/login', { data: { identifier: email } }); + expect(res.ok(), `login -> ${res.status()}`).toBeTruthy(); + + res = await ctx.get('/auth/otp/generate-login-email-otp'); + expect(res.ok(), `generate-login-email-otp -> ${res.status()}`).toBeTruthy(); + + const code = await readCapturedCode(ctx, email); + res = await ctx.post('/auth/otp/verify-login-email-otp', { data: { verificationToken: code } }); + expect(res.ok(), `verify-login-email-otp -> ${res.status()}`).toBeTruthy(); +} diff --git a/verify/harness/lib/client.ts b/verify/harness/lib/client.ts index 4b958c1..26dbc59 100644 --- a/verify/harness/lib/client.ts +++ b/verify/harness/lib/client.ts @@ -1,6 +1,6 @@ import { APIRequestContext, request as playwrightRequest } from '@playwright/test'; -import { API_SERVICE_TOKEN, API_URL, uniqueClientIp, uniqueEmail } from './env'; +import { ADAPTER_URL, API_SERVICE_TOKEN, API_URL, uniqueClientIp, uniqueEmail } from './env'; import { mintServiceToken } from './serviceToken'; export interface Actor { @@ -23,3 +23,10 @@ export async function newApiActor(prefix = 'verify'): Promise { return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() }; } + +// A browser-like actor for the cookie path: a context bound to the adapter that +// persists cookies across requests (the adapter handles service tokens internally). +export async function newAdapterActor(prefix = 'verify'): Promise { + const ctx = await playwrightRequest.newContext({ baseURL: ADAPTER_URL }); + return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() }; +} diff --git a/verify/harness/lib/fixtures.ts b/verify/harness/lib/fixtures.ts index ab26e1f..b38f40c 100644 --- a/verify/harness/lib/fixtures.ts +++ b/verify/harness/lib/fixtures.ts @@ -1,15 +1,20 @@ import { test as base } from '@playwright/test'; -import { Actor, newApiActor } from './client'; +import { Actor, newAdapterActor, newApiActor } from './client'; -// Provides an auto-created, auto-disposed `actor` (one virtual user with its own -// API request context, client IP, and service token) to every test. -export const test = base.extend<{ actor: Actor }>({ +// `actor` drives the API directly (Bearer + service token); `adapterActor` drives +// the adopter backend over cookies. Both are auto-created and disposed per test. +export const test = base.extend<{ actor: Actor; adapterActor: Actor }>({ actor: async ({}, use) => { const actor = await newApiActor(); await use(actor); await actor.dispose(); }, + adapterActor: async ({}, use) => { + const actor = await newAdapterActor(); + await use(actor); + await actor.dispose(); + }, }); export { expect } from '@playwright/test';