From 669e0d9a6e31d1e4add23c47c4730ffff19b4b59 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 10:45:27 +0200 Subject: [PATCH 1/4] test(verify): add OAuth conformance via in-process mock OIDC (M4) - mock-oidc.ts: minimal OIDC IdP (/authorize, /token with PKCE S256, /userinfo), started in global-setup; the API reaches it via host.docker.internal, the harness drives /authorize via localhost. - compose: a 'mock' OAUTH_PROVIDERS entry (+ oauth in LOGIN_METHODS, extra_hosts). - oauthLogin flow helper (generic over api '' / adapter '/auth'): start -> follow the authorize redirect for the code -> callback -> session. - api/oauth + adapter/oauth specs. Full suite 24/24. React OAuth is deferred (the starter/SDK has no provider UI yet) per scope. --- verify/docker-compose.verify.yml | 9 ++- verify/harness/adapter/oauth.spec.ts | 11 +++ verify/harness/api/oauth.spec.ts | 12 +++ verify/harness/global-setup.ts | 9 ++- verify/harness/lib/env.ts | 1 + verify/harness/lib/flows.ts | 31 ++++++++ verify/harness/mock-oidc.ts | 112 +++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 verify/harness/adapter/oauth.spec.ts create mode 100644 verify/harness/api/oauth.spec.ts create mode 100644 verify/harness/mock-oidc.ts diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 4693637..6d8b620 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -50,7 +50,7 @@ services: ISSUER: http://auth-api:5312 DEFAULT_ROLES: user AVAILABLE_ROLES: user,admin - LOGIN_METHODS: passkey,magic_link,email_otp,phone_otp + LOGIN_METHODS: passkey,magic_link,email_otp,phone_otp,oauth DB_LOGGING: 'false' ACCESS_TOKEN_TTL: 15m REFRESH_TOKEN_TTL: 1h @@ -66,6 +66,13 @@ services: API_SERVICE_TOKEN: ${API_SERVICE_TOKEN} SEAMLESS_BOOTSTRAP_ENABLED: 'true' SEAMLESS_BOOTSTRAP_SECRET: ${SEAMLESS_BOOTSTRAP_SECRET} + # OAuth: a single "mock" provider backed by the harness's in-process OIDC. + # authorizationUrl is browser/harness-visible (localhost); token/userInfo are + # called server-side from the container, so they go via host.docker.internal. + MOCK_CLIENT_SECRET: mock-secret + OAUTH_PROVIDERS: '[{"id":"mock","name":"Mock OIDC","enabled":true,"clientId":"mock-client","clientSecretEnv":"MOCK_CLIENT_SECRET","authorizationUrl":"http://localhost:9000/authorize","tokenUrl":"http://host.docker.internal:9000/token","userInfoUrl":"http://host.docker.internal:9000/userinfo","scopes":["openid","email"],"redirectUri":"http://localhost:5173/oauth/callback","redirectUris":["http://localhost:5173/oauth/callback"],"subjectJsonPath":"sub","emailJsonPath":"email","emailVerifiedJsonPath":"email_verified","nameJsonPath":"name","allowSignup":true,"accountLinking":"email"}]' + extra_hosts: + - 'host.docker.internal:host-gateway' depends_on: postgres: condition: service_healthy diff --git a/verify/harness/adapter/oauth.spec.ts b/verify/harness/adapter/oauth.spec.ts new file mode 100644 index 0000000..b9786fe --- /dev/null +++ b/verify/harness/adapter/oauth.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from '../lib/fixtures'; +import { oauthLogin } from '../lib/flows'; + +test.describe('OAuth login (adapter, cookies)', () => { + test('oauth callback sets a session cookie -> /users/me', async ({ adapterActor }) => { + await oauthLogin(adapterActor.ctx, 'mock', '/auth'); + + const me = await adapterActor.ctx.get('/auth/users/me'); + expect(me.status(), 'authenticated via the oauth session cookie').toBe(200); + }); +}); diff --git a/verify/harness/api/oauth.spec.ts b/verify/harness/api/oauth.spec.ts new file mode 100644 index 0000000..e805331 --- /dev/null +++ b/verify/harness/api/oauth.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '../lib/fixtures'; +import { oauthLogin } from '../lib/flows'; + +test.describe('OAuth (api)', () => { + test('start -> authorize -> callback issues a session', async ({ actor }) => { + const session = await oauthLogin(actor.ctx); + + expect(session.token, 'oauth issues an access token').toBeTruthy(); + expect(session.refreshToken, 'oauth issues a refresh token').toBeTruthy(); + expect(session.sub, 'oauth resolves a user').toBeTruthy(); + }); +}); diff --git a/verify/harness/global-setup.ts b/verify/harness/global-setup.ts index 1dbcda6..5ebad29 100644 --- a/verify/harness/global-setup.ts +++ b/verify/harness/global-setup.ts @@ -1,6 +1,7 @@ import { request as playwrightRequest } from '@playwright/test'; -import { ADAPTER_URL, API_URL, REACT_URL } from './lib/env'; +import { ADAPTER_URL, API_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env'; +import { startMockOidc } from './mock-oidc'; async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Promise { const ctx = await playwrightRequest.newContext(); @@ -28,6 +29,12 @@ async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Pr } export default async function globalSetup(): Promise { + // In-process mock OIDC provider for the OAuth flow (the API reaches it via + // host.docker.internal; the harness drives /authorize via localhost). + startMockOidc(MOCK_OIDC_PORT); + // eslint-disable-next-line no-console + console.log(`✔ mock OIDC listening (:${MOCK_OIDC_PORT})`); + await waitForHealth(`${API_URL}/health/status`, 'auth-api'); if (process.env.SEAMLESS_VERIFY_ADAPTER === '1') { await waitForHealth(`${ADAPTER_URL}/`, 'adapter'); diff --git a/verify/harness/lib/env.ts b/verify/harness/lib/env.ts index 672bc24..10d640d 100644 --- a/verify/harness/lib/env.ts +++ b/verify/harness/lib/env.ts @@ -5,6 +5,7 @@ import { randomInt, randomUUID } from 'crypto'; export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312'; export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000'; export const REACT_URL = process.env.SEAMLESS_REACT_URL ?? 'http://localhost:5173'; +export const MOCK_OIDC_PORT = Number(process.env.SEAMLESS_MOCK_OIDC_PORT ?? 9000); // Must match the API's API_SERVICE_TOKEN so the harness can mint M2M tokens. export const API_SERVICE_TOKEN = diff --git a/verify/harness/lib/flows.ts b/verify/harness/lib/flows.ts index ca1dde6..35d555f 100644 --- a/verify/harness/lib/flows.ts +++ b/verify/harness/lib/flows.ts @@ -171,3 +171,34 @@ export function listSessions(ctx: APIRequestContext, accessToken: string) { export function logout(ctx: APIRequestContext, accessToken: string) { return ctx.delete('/logout', { headers: bearer(accessToken) }); } + +/** + * Full OAuth login against the mock IdP: start the flow, follow the authorize + * redirect to obtain the code (the mock mints a fresh user), then exchange it at + * the callback for a session. `pathPrefix` is '' for the API, '/auth' for the adapter. + */ +export async function oauthLogin( + ctx: APIRequestContext, + providerId = 'mock', + pathPrefix = '', +): Promise { + const start = await ctx.post(`${pathPrefix}/oauth/${providerId}/start`, { data: {} }); + expect(start.ok(), `oauth start -> ${start.status()} ${await start.text()}`).toBeTruthy(); + const { authorizationUrl } = await start.json(); + + const authorize = await ctx.get(authorizationUrl, { maxRedirects: 0 }); + expect(authorize.status(), `authorize redirects with a code (got ${authorize.status()})`).toBe( + 302, + ); + const redirected = new URL(authorize.headers()['location']); + const code = redirected.searchParams.get('code'); + const state = redirected.searchParams.get('state'); + expect(code, 'authorize returns an auth code').toBeTruthy(); + + const callback = await ctx.post(`${pathPrefix}/oauth/${providerId}/callback`, { + data: { code, state }, + }); + expect(callback.ok(), `oauth callback -> ${callback.status()} ${await callback.text()}`).toBeTruthy(); + const body = await callback.json(); + return { token: body.token, refreshToken: body.refreshToken, sub: body.sub, email: body.email }; +} diff --git a/verify/harness/mock-oidc.ts b/verify/harness/mock-oidc.ts new file mode 100644 index 0000000..7b48e85 --- /dev/null +++ b/verify/harness/mock-oidc.ts @@ -0,0 +1,112 @@ +import { createHash, randomUUID } from 'crypto'; +import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; + +// Minimal OIDC identity provider for the OAuth conformance flow. The API only uses +// the authorization code, the token exchange (PKCE), and userinfo — it does not +// validate id_token signatures — so /authorize, /token, /userinfo is all we need. + +interface PendingCode { + codeChallenge?: string; + redirectUri: string; + profile: { sub: string; email: string }; +} + +const sha256Base64Url = (value: string): string => + createHash('sha256').update(value).digest('base64url'); + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let data = ''; + req.on('data', (chunk) => (data += chunk)); + req.on('end', () => resolve(data)); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +export function startMockOidc(port: number): Server { + const codes = new Map(); + const tokens = new Map(); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${port}`); + + // Authorization endpoint: mint a code bound to the PKCE challenge + a fresh + // user, then redirect back to the app's redirect_uri with code + state. + if (req.method === 'GET' && url.pathname === '/authorize') { + const redirectUri = url.searchParams.get('redirect_uri'); + if (!redirectUri) { + sendJson(res, 400, { error: 'invalid_request', error_description: 'missing redirect_uri' }); + return; + } + const code = randomUUID(); + codes.set(code, { + codeChallenge: url.searchParams.get('code_challenge') ?? undefined, + redirectUri, + profile: { + sub: `mock-${randomUUID()}`, + email: `oauth.${randomUUID().slice(0, 12)}@example.test`, + }, + }); + const location = new URL(redirectUri); + location.searchParams.set('code', code); + location.searchParams.set('state', url.searchParams.get('state') ?? ''); + res.writeHead(302, { Location: location.toString() }); + res.end(); + return; + } + + // Token endpoint: validate PKCE (S256), consume the code, issue an opaque token. + if (req.method === 'POST' && url.pathname === '/token') { + void readBody(req).then((raw) => { + const params = new URLSearchParams(raw); + const code = params.get('code') ?? ''; + const pending = codes.get(code); + if (!pending) { + sendJson(res, 400, { error: 'invalid_grant' }); + return; + } + codes.delete(code); + if ( + pending.codeChallenge && + sha256Base64Url(params.get('code_verifier') ?? '') !== pending.codeChallenge + ) { + sendJson(res, 400, { error: 'invalid_grant', error_description: 'PKCE mismatch' }); + return; + } + const accessToken = randomUUID(); + tokens.set(accessToken, pending.profile); + sendJson(res, 200, { access_token: accessToken, token_type: 'Bearer', expires_in: 3600 }); + }); + return; + } + + // Userinfo endpoint: return the profile for the bearer access token. + if (req.method === 'GET' && url.pathname === '/userinfo') { + const token = (req.headers.authorization ?? '').replace(/^Bearer /, ''); + const profile = tokens.get(token); + if (!profile) { + sendJson(res, 401, { error: 'invalid_token' }); + return; + } + sendJson(res, 200, { + sub: profile.sub, + email: profile.email, + email_verified: true, + name: 'OAuth User', + }); + return; + } + + sendJson(res, 404, { error: 'not_found' }); + }); + + // Bind on all interfaces so the API container can reach it via host.docker.internal; + // unref so it never keeps the Playwright process alive after the run. + server.listen(port, '0.0.0.0'); + server.unref(); + return server; +} From ce7c81c41385f095a8bc8b9b06fb3f2ce6b5fb8f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 11:19:11 +0200 Subject: [PATCH 2/4] test(verify): add React browser OAuth case Drives the provider button -> IdP redirect -> /oauth/callback -> signed-in flow. Green under --local against the local @seamless-auth/react OAuth UI; released-green once that ships (seamless-auth-react#44) and the starter bumps to it. --- verify/harness/react/oauth.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 verify/harness/react/oauth.spec.ts diff --git a/verify/harness/react/oauth.spec.ts b/verify/harness/react/oauth.spec.ts new file mode 100644 index 0000000..c76efd4 --- /dev/null +++ b/verify/harness/react/oauth.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from '../lib/fixtures'; + +test.describe('OAuth login (react, browser)', () => { + test('continue with a provider -> IdP redirect -> signed in', async ({ page }) => { + await page.goto('/login'); + + // The provider button redirects to the (mock) IdP, which redirects back to + // /oauth/callback; the callback finishes the login and lands on the app. + await page.getByRole('button', { name: /Continue with Mock OIDC/ }).click(); + + await expect(page.getByText('You are signed in')).toBeVisible(); + }); +}); From 0bfb821db12f5d2ae12641ca17826604b9a80e65 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 11:20:46 +0200 Subject: [PATCH 3/4] style(verify): drop em dash from mock-oidc comment --- verify/harness/mock-oidc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/verify/harness/mock-oidc.ts b/verify/harness/mock-oidc.ts index 7b48e85..a74ceab 100644 --- a/verify/harness/mock-oidc.ts +++ b/verify/harness/mock-oidc.ts @@ -2,8 +2,8 @@ import { createHash, randomUUID } from 'crypto'; import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; // Minimal OIDC identity provider for the OAuth conformance flow. The API only uses -// the authorization code, the token exchange (PKCE), and userinfo — it does not -// validate id_token signatures — so /authorize, /token, /userinfo is all we need. +// the authorization code, the token exchange (PKCE), and userinfo (it does not +// validate id_token signatures), so /authorize, /token, /userinfo is all we need. interface PendingCode { codeChallenge?: string; From 59ee26908ccdaf38a878ab571f5677c62a927c9c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 29 Jun 2026 17:06:59 +0200 Subject: [PATCH 4/4] chore(verify): bump express beta + drop OAuth JSON-path workaround Bump the adapter to @seamless-auth/express 0.6.0-beta.20260629083811 (latest beta, includes the non-JSON-response fix). Drop the explicit OAuth provider JSON paths now that the API applies the schema defaults (seamless-auth-api#50), so the harness config relies on those defaults. Released and --local runs are both 25/25. --- verify/adapter-app/package.json | 2 +- verify/docker-compose.verify.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/verify/adapter-app/package.json b/verify/adapter-app/package.json index c41ff38..97c7381 100644 --- a/verify/adapter-app/package.json +++ b/verify/adapter-app/package.json @@ -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.6.0-beta.20260628220124", + "@seamless-auth/express": "0.6.0-beta.20260629083811", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "express": "^4.19.2" diff --git a/verify/docker-compose.verify.yml b/verify/docker-compose.verify.yml index 6d8b620..9c89633 100644 --- a/verify/docker-compose.verify.yml +++ b/verify/docker-compose.verify.yml @@ -70,7 +70,7 @@ services: # authorizationUrl is browser/harness-visible (localhost); token/userInfo are # called server-side from the container, so they go via host.docker.internal. MOCK_CLIENT_SECRET: mock-secret - OAUTH_PROVIDERS: '[{"id":"mock","name":"Mock OIDC","enabled":true,"clientId":"mock-client","clientSecretEnv":"MOCK_CLIENT_SECRET","authorizationUrl":"http://localhost:9000/authorize","tokenUrl":"http://host.docker.internal:9000/token","userInfoUrl":"http://host.docker.internal:9000/userinfo","scopes":["openid","email"],"redirectUri":"http://localhost:5173/oauth/callback","redirectUris":["http://localhost:5173/oauth/callback"],"subjectJsonPath":"sub","emailJsonPath":"email","emailVerifiedJsonPath":"email_verified","nameJsonPath":"name","allowSignup":true,"accountLinking":"email"}]' + OAUTH_PROVIDERS: '[{"id":"mock","name":"Mock OIDC","enabled":true,"clientId":"mock-client","clientSecretEnv":"MOCK_CLIENT_SECRET","authorizationUrl":"http://localhost:9000/authorize","tokenUrl":"http://host.docker.internal:9000/token","userInfoUrl":"http://host.docker.internal:9000/userinfo","scopes":["openid","email"],"redirectUri":"http://localhost:5173/oauth/callback","redirectUris":["http://localhost:5173/oauth/callback"],"allowSignup":true,"accountLinking":"email"}]' extra_hosts: - 'host.docker.internal:host-gateway' depends_on: