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 4693637..9c89633 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"],"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..a74ceab --- /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; +} 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(); + }); +});