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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"files": [
"dist",
"templates",
"verify",
"README.md",
"LICENSE"
],
Expand Down
10 changes: 5 additions & 5 deletions src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ USAGE
seamless init [project-name]
seamless check
seamless bootstrap-admin [email]
seamless verify [--filter=<flow>] [--keep-up]
seamless verify [--api-only] [--filter=<flow>] [--keep-up]
seamless --help
seamless --version

Expand All @@ -33,10 +33,10 @@ COMMANDS
check
Validate project setup, Docker, and running services

verify [--filter=<flow>] [--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=<flow>] [--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
Expand Down
57 changes: 33 additions & 24 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ const HARNESS_DIR = path.join(VERIFY_DIR, "harness");
interface VerifyOptions {
released: boolean;
keepUp: boolean;
apiOnly: boolean;
grep?: string;
}

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],
};
}
Expand All @@ -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<void> {
return runCommand("docker", ["compose", "-f", COMPOSE_FILE, ...args], VERIFY_DIR, env);
}

export async function runVerify(args: string[] = []): Promise<void> {
Expand All @@ -58,40 +66,46 @@ export async function runVerify(args: string[] = []): Promise<void> {
}

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:
process.env.SEAMLESS_BOOTSTRAP_SECRET ?? "verify-dev-bootstrap-secret",
// 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…"));
await runCommand("npm", ["install"], HARNESS_DIR, env);
}

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"));
Expand All @@ -104,12 +118,7 @@ export async function runVerify(args: string[] = []): Promise<void> {
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);
}
}

Expand Down
8 changes: 8 additions & 0 deletions verify/adapter-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
12 changes: 12 additions & 0 deletions verify/adapter-app/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
53 changes: 53 additions & 0 deletions verify/adapter-app/server.mjs
Original file line number Diff line number Diff line change
@@ -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}`));
17 changes: 9 additions & 8 deletions verify/docker-compose.verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions verify/harness/adapter/emailOtpLogin.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions verify/harness/adapter/sessionLifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 37 additions & 0 deletions verify/harness/lib/adapterFlows.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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<void> {
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();
}
9 changes: 8 additions & 1 deletion verify/harness/lib/client.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -23,3 +23,10 @@ export async function newApiActor(prefix = 'verify'): Promise<Actor> {

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<Actor> {
const ctx = await playwrightRequest.newContext({ baseURL: ADAPTER_URL });
return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
}
13 changes: 9 additions & 4 deletions verify/harness/lib/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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';