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
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.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"
Expand Down
9 changes: 8 additions & 1 deletion verify/docker-compose.verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions verify/harness/adapter/oauth.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions verify/harness/api/oauth.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
9 changes: 8 additions & 1 deletion verify/harness/global-setup.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const ctx = await playwrightRequest.newContext();
Expand Down Expand Up @@ -28,6 +29,12 @@ async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Pr
}

export default async function globalSetup(): Promise<void> {
// 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');
Expand Down
1 change: 1 addition & 0 deletions verify/harness/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
31 changes: 31 additions & 0 deletions verify/harness/lib/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionTokens & { email?: string }> {
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 };
}
112 changes: 112 additions & 0 deletions verify/harness/mock-oidc.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, PendingCode>();
const tokens = new Map<string, { sub: string; email: string }>();

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;
}
13 changes: 13 additions & 0 deletions verify/harness/react/oauth.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});