seamless-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +5 -6
  2. package/dist/commands/help.js +18 -1
  3. package/dist/commands/help.js.map +1 -1
  4. package/dist/commands/init.js +70 -22
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/verify.js +259 -0
  7. package/dist/commands/verify.js.map +1 -0
  8. package/dist/core/bootstrapSecret.js +12 -4
  9. package/dist/core/bootstrapSecret.js.map +1 -1
  10. package/dist/core/exec.js +2 -1
  11. package/dist/core/exec.js.map +1 -1
  12. package/dist/core/fetch.js +2 -1
  13. package/dist/core/fetch.js.map +1 -1
  14. package/dist/core/images.js +11 -0
  15. package/dist/core/images.js.map +1 -0
  16. package/dist/core/templates.js +185 -0
  17. package/dist/core/templates.js.map +1 -0
  18. package/dist/generators/auth/auth.js +38 -17
  19. package/dist/generators/auth/auth.js.map +1 -1
  20. package/dist/generators/config/config.js +3 -4
  21. package/dist/generators/config/config.js.map +1 -1
  22. package/dist/generators/docker/docker.js +24 -22
  23. package/dist/generators/docker/docker.js.map +1 -1
  24. package/dist/index.js +11 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/prompts/projectSetup.js +43 -19
  27. package/dist/prompts/projectSetup.js.map +1 -1
  28. package/package.json +6 -1
  29. package/verify/adapter-app/Dockerfile +14 -0
  30. package/verify/adapter-app/package.json +13 -0
  31. package/verify/adapter-app/server.mjs +62 -0
  32. package/verify/adapter-app/vendor/.gitignore +3 -0
  33. package/verify/adapter-app/vendor/.gitkeep +0 -0
  34. package/verify/docker-compose.verify.yml +154 -0
  35. package/verify/harness/adapter/emailOtpLogin.spec.ts +12 -0
  36. package/verify/harness/adapter/magicLink.spec.ts +14 -0
  37. package/verify/harness/adapter/oauth.spec.ts +11 -0
  38. package/verify/harness/adapter/registration.spec.ts +11 -0
  39. package/verify/harness/adapter/sessionLifecycle.spec.ts +20 -0
  40. package/verify/harness/api/adminBootstrap.spec.ts +34 -0
  41. package/verify/harness/api/emailOtp.spec.ts +17 -0
  42. package/verify/harness/api/jwks.spec.ts +17 -0
  43. package/verify/harness/api/magicLink.spec.ts +36 -0
  44. package/verify/harness/api/oauth.spec.ts +12 -0
  45. package/verify/harness/api/organizations.spec.ts +28 -0
  46. package/verify/harness/api/phoneOtp.spec.ts +19 -0
  47. package/verify/harness/api/sessionLifecycle.spec.ts +26 -0
  48. package/verify/harness/api/stepUp.spec.ts +39 -0
  49. package/verify/harness/api/totp.spec.ts +24 -0
  50. package/verify/harness/global-setup.ts +45 -0
  51. package/verify/harness/lib/adapterFlows.ts +57 -0
  52. package/verify/harness/lib/client.ts +32 -0
  53. package/verify/harness/lib/env.ts +45 -0
  54. package/verify/harness/lib/fixtures.ts +20 -0
  55. package/verify/harness/lib/flows.ts +204 -0
  56. package/verify/harness/lib/matrixReporter.ts +73 -0
  57. package/verify/harness/lib/reactFlows.ts +117 -0
  58. package/verify/harness/lib/serviceToken.ts +26 -0
  59. package/verify/harness/lib/totp.ts +36 -0
  60. package/verify/harness/mock-oidc.ts +112 -0
  61. package/verify/harness/package-lock.json +93 -0
  62. package/verify/harness/package.json +14 -0
  63. package/verify/harness/playwright.config.ts +30 -0
  64. package/verify/harness/react/emailOtpLogin.spec.ts +13 -0
  65. package/verify/harness/react/logout.spec.ts +17 -0
  66. package/verify/harness/react/magicLinkLogin.spec.ts +29 -0
  67. package/verify/harness/react/oauth.spec.ts +13 -0
  68. package/verify/harness/react/passkeyLogin.spec.ts +24 -0
  69. package/verify/harness/react/passkeyRegister.spec.ts +15 -0
  70. package/verify/harness/react/register.spec.ts +18 -0
  71. package/verify/react-vendor/.gitkeep +0 -0
@@ -0,0 +1,45 @@
1
+ import { request as playwrightRequest } from '@playwright/test';
2
+
3
+ import { ADAPTER_URL, API_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env';
4
+ import { startMockOidc } from './mock-oidc';
5
+
6
+ async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Promise<void> {
7
+ const ctx = await playwrightRequest.newContext();
8
+ const start = Date.now();
9
+ let lastError = '';
10
+ try {
11
+ while (Date.now() - start < timeoutMs) {
12
+ try {
13
+ const res = await ctx.get(url);
14
+ if (res.ok()) {
15
+ // eslint-disable-next-line no-console
16
+ console.log(`✔ ${name} healthy (${url})`);
17
+ return;
18
+ }
19
+ lastError = `status ${res.status()}`;
20
+ } catch (err) {
21
+ lastError = err instanceof Error ? err.message : String(err);
22
+ }
23
+ await new Promise((r) => setTimeout(r, 2000));
24
+ }
25
+ } finally {
26
+ await ctx.dispose();
27
+ }
28
+ throw new Error(`✖ ${name} not healthy at ${url} within ${timeoutMs}ms (last: ${lastError})`);
29
+ }
30
+
31
+ export default async function globalSetup(): Promise<void> {
32
+ // In-process mock OIDC provider for the OAuth flow (the API reaches it via
33
+ // host.docker.internal; the harness drives /authorize via localhost).
34
+ startMockOidc(MOCK_OIDC_PORT);
35
+ // eslint-disable-next-line no-console
36
+ console.log(`✔ mock OIDC listening (:${MOCK_OIDC_PORT})`);
37
+
38
+ await waitForHealth(`${API_URL}/health/status`, 'auth-api');
39
+ if (process.env.SEAMLESS_VERIFY_ADAPTER === '1') {
40
+ await waitForHealth(`${ADAPTER_URL}/`, 'adapter');
41
+ }
42
+ if (process.env.SEAMLESS_VERIFY_REACT === '1') {
43
+ await waitForHealth(`${REACT_URL}/health`, 'react');
44
+ }
45
+ }
@@ -0,0 +1,57 @@
1
+ import { APIRequestContext, expect } from '@playwright/test';
2
+
3
+ // Cookie-based flows against the adapter (baseURL = adapter). The adapter strips
4
+ // OTP/magic-link secrets from browser responses, so codes are read from the
5
+ // harness adapter app's /__captured readout.
6
+
7
+ async function readCapturedCode(ctx: APIRequestContext, email: string): Promise<string> {
8
+ const res = await ctx.get(`/__captured/${encodeURIComponent(email)}`);
9
+ expect(res.ok(), `captured lookup -> ${res.status()}`).toBeTruthy();
10
+ const body = await res.json();
11
+ expect(body?.token, `a captured code for ${email}`).toBeTruthy();
12
+ return String(body.token);
13
+ }
14
+
15
+ export async function registerAndVerifyEmail(ctx: APIRequestContext, email: string): Promise<void> {
16
+ let res = await ctx.post('/auth/registration/register', { data: { email } });
17
+ expect(res.ok(), `register -> ${res.status()}`).toBeTruthy();
18
+
19
+ res = await ctx.get('/auth/otp/generate-email-otp');
20
+ expect(res.ok(), `generate-email-otp -> ${res.status()}`).toBeTruthy();
21
+
22
+ const code = await readCapturedCode(ctx, email);
23
+ res = await ctx.post('/auth/otp/verify-email-otp', { data: { verificationToken: code } });
24
+ expect(res.ok(), `verify-email-otp -> ${res.status()}`).toBeTruthy();
25
+ }
26
+
27
+ export async function loginViaEmailOtp(ctx: APIRequestContext, email: string): Promise<void> {
28
+ let res = await ctx.post('/auth/login', { data: { identifier: email } });
29
+ expect(res.ok(), `login -> ${res.status()}`).toBeTruthy();
30
+
31
+ res = await ctx.get('/auth/otp/generate-login-email-otp');
32
+ expect(res.ok(), `generate-login-email-otp -> ${res.status()}`).toBeTruthy();
33
+
34
+ const code = await readCapturedCode(ctx, email);
35
+ res = await ctx.post('/auth/otp/verify-login-email-otp', { data: { verificationToken: code } });
36
+ expect(res.ok(), `verify-login-email-otp -> ${res.status()}`).toBeTruthy();
37
+ }
38
+
39
+ export async function loginViaMagicLink(ctx: APIRequestContext, email: string): Promise<void> {
40
+ let res = await ctx.post('/auth/login', { data: { identifier: email } });
41
+ expect(res.ok(), `login -> ${res.status()}`).toBeTruthy();
42
+
43
+ res = await ctx.get('/auth/magic-link');
44
+ expect(res.ok(), `magic-link request -> ${res.status()}`).toBeTruthy();
45
+ const token = await readCapturedCode(ctx, email);
46
+
47
+ // Polling before the link is verified must return 204 (still waiting).
48
+ const pending = await ctx.get('/auth/magic-link/check');
49
+ expect(pending.status(), 'poll before verify is 204').toBe(204);
50
+
51
+ res = await ctx.get(`/auth/magic-link/verify/${token}`);
52
+ expect(res.ok(), `verify magic link -> ${res.status()}`).toBeTruthy();
53
+
54
+ // Polling after verification issues the session (device binding must match).
55
+ const completed = await ctx.get('/auth/magic-link/check');
56
+ expect(completed.status(), 'poll after verify issues a session').toBe(200);
57
+ }
@@ -0,0 +1,32 @@
1
+ import { APIRequestContext, request as playwrightRequest } from '@playwright/test';
2
+
3
+ import { ADAPTER_URL, API_SERVICE_TOKEN, API_URL, uniqueClientIp, uniqueEmail } from './env';
4
+ import { mintServiceToken } from './serviceToken';
5
+
6
+ export interface Actor {
7
+ email: string;
8
+ ctx: APIRequestContext;
9
+ dispose: () => Promise<void>;
10
+ }
11
+
12
+ // An actor is one virtual user: a fresh request context bound to the API, with
13
+ // its own client IP + service token applied to every request. This both isolates
14
+ // rate-limit buckets and faithfully mirrors how the server adapter calls the API.
15
+ export async function newApiActor(prefix = 'verify'): Promise<Actor> {
16
+ const ctx = await playwrightRequest.newContext({
17
+ baseURL: API_URL,
18
+ extraHTTPHeaders: {
19
+ 'x-seamless-client-ip': uniqueClientIp(),
20
+ 'x-seamless-service-token': `Bearer ${mintServiceToken(API_SERVICE_TOKEN)}`,
21
+ },
22
+ });
23
+
24
+ return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
25
+ }
26
+
27
+ // A browser-like actor for the cookie path: a context bound to the adapter that
28
+ // persists cookies across requests (the adapter handles service tokens internally).
29
+ export async function newAdapterActor(prefix = 'verify'): Promise<Actor> {
30
+ const ctx = await playwrightRequest.newContext({ baseURL: ADAPTER_URL });
31
+ return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
32
+ }
@@ -0,0 +1,45 @@
1
+ // Shared config + helpers for the conformance harness.
2
+
3
+ import { randomInt, randomUUID } from 'crypto';
4
+
5
+ export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312';
6
+ export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000';
7
+ export const REACT_URL = process.env.SEAMLESS_REACT_URL ?? 'http://localhost:5173';
8
+ export const MOCK_OIDC_PORT = Number(process.env.SEAMLESS_MOCK_OIDC_PORT ?? 9000);
9
+
10
+ // Must match the API's API_SERVICE_TOKEN so the harness can mint M2M tokens.
11
+ export const API_SERVICE_TOKEN =
12
+ process.env.SEAMLESS_API_SERVICE_TOKEN ?? 'verify-dev-service-token';
13
+
14
+ // Must match the API's SEAMLESS_BOOTSTRAP_SECRET so the harness can mint the
15
+ // first admin invite (bootstrap-promotion flow).
16
+ export const BOOTSTRAP_SECRET =
17
+ process.env.SEAMLESS_BOOTSTRAP_SECRET ?? 'verify-dev-bootstrap-secret';
18
+
19
+ // Non-production seam: makes the API return OTP / magic-link tokens in the
20
+ // response `delivery` object instead of sending real email/SMS.
21
+ export const EXTERNAL_DELIVERY = {
22
+ 'x-seamless-auth-delivery-mode': 'external',
23
+ } as const;
24
+
25
+ const runId = process.env.SEAMLESS_RUN_ID ?? String(Date.now());
26
+
27
+ // A random suffix (not just a module-level counter) keeps emails unique even
28
+ // though Playwright re-evaluates this module per spec file, which resets the
29
+ // counter — duplicate emails otherwise collide on the API's per-email OTP limit.
30
+ export function uniqueEmail(prefix = 'verify'): string {
31
+ return `${prefix}.${runId}.${randomUUID().slice(0, 8)}@example.test`;
32
+ }
33
+
34
+ // Distinct client IP per actor so the API's per-IP rate limiters see separate
35
+ // buckets (honored only alongside a valid service token — see client.ts).
36
+ // Random (not a module-level counter) to survive per-file module re-evaluation.
37
+ export function uniqueClientIp(): string {
38
+ return `10.${randomInt(256)}.${randomInt(256)}.${randomInt(1, 255)}`;
39
+ }
40
+
41
+ // Valid-format US numbers (415 area, 7-digit subscriber). Random so they stay
42
+ // unique across runs and across per-file module resets without a shared counter.
43
+ export function uniquePhone(): string {
44
+ return `+1415${2_000_000 + randomInt(7_000_000)}`;
45
+ }
@@ -0,0 +1,20 @@
1
+ import { test as base } from '@playwright/test';
2
+
3
+ import { Actor, newAdapterActor, newApiActor } from './client';
4
+
5
+ // `actor` drives the API directly (Bearer + service token); `adapterActor` drives
6
+ // the adopter backend over cookies. Both are auto-created and disposed per test.
7
+ export const test = base.extend<{ actor: Actor; adapterActor: Actor }>({
8
+ actor: async ({}, use) => {
9
+ const actor = await newApiActor();
10
+ await use(actor);
11
+ await actor.dispose();
12
+ },
13
+ adapterActor: async ({}, use) => {
14
+ const actor = await newAdapterActor();
15
+ await use(actor);
16
+ await actor.dispose();
17
+ },
18
+ });
19
+
20
+ export { expect } from '@playwright/test';
@@ -0,0 +1,204 @@
1
+ import { APIRequestContext, expect } from '@playwright/test';
2
+
3
+ import { BOOTSTRAP_SECRET, EXTERNAL_DELIVERY } from './env';
4
+
5
+ export interface SessionTokens {
6
+ token: string; // signed access token
7
+ refreshToken: string; // opaque refresh token
8
+ sub?: string;
9
+ }
10
+
11
+ function bearer(token: string) {
12
+ return { Authorization: `Bearer ${token}` };
13
+ }
14
+
15
+ // All helpers take an actor's request context (baseURL = API, with client-IP +
16
+ // service-token headers already applied). Paths are therefore relative.
17
+
18
+ /** Register a new email user; returns the ephemeral (pre-auth) token. */
19
+ export async function registerEmail(ctx: APIRequestContext, email: string): Promise<string> {
20
+ const res = await ctx.post('/registration/register', {
21
+ headers: EXTERNAL_DELIVERY,
22
+ data: { email },
23
+ });
24
+ expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
25
+ const body = await res.json();
26
+ expect(body.token, 'register returns an ephemeral token').toBeTruthy();
27
+ return body.token as string;
28
+ }
29
+
30
+ /** Register a new user with email + phone; returns the ephemeral (pre-auth) token. */
31
+ export async function registerWithPhone(
32
+ ctx: APIRequestContext,
33
+ email: string,
34
+ phone: string,
35
+ ): Promise<string> {
36
+ const res = await ctx.post('/registration/register', {
37
+ headers: EXTERNAL_DELIVERY,
38
+ data: { email, phone },
39
+ });
40
+ expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
41
+ const body = await res.json();
42
+ expect(body.token, 'register returns an ephemeral token').toBeTruthy();
43
+ return body.token as string;
44
+ }
45
+
46
+ /**
47
+ * Mint the one-time bootstrap admin invite (returns the raw invite token via
48
+ * the external-delivery seam). Only succeeds before any admin exists — the API
49
+ * gates re-use with 410, so this assumes a fresh DB (what `seamless verify` runs).
50
+ */
51
+ export async function createBootstrapInvite(
52
+ ctx: APIRequestContext,
53
+ email: string,
54
+ ): Promise<string> {
55
+ const res = await ctx.post('/internal/bootstrap/admin-invite', {
56
+ headers: { Authorization: `Bearer ${BOOTSTRAP_SECRET}`, ...EXTERNAL_DELIVERY },
57
+ data: { email },
58
+ });
59
+ expect(res.ok(), `bootstrap invite -> ${res.status()} ${await res.text()}`).toBeTruthy();
60
+ const token = (await res.json())?.data?.token;
61
+ expect(token, 'bootstrap invite returns a token via external delivery').toBeTruthy();
62
+ return String(token);
63
+ }
64
+
65
+ /** Register a user carrying a bootstrap invite token; returns the ephemeral token. */
66
+ export async function registerWithBootstrapToken(
67
+ ctx: APIRequestContext,
68
+ email: string,
69
+ bootstrapToken: string,
70
+ ): Promise<string> {
71
+ const res = await ctx.post('/registration/register', {
72
+ headers: EXTERNAL_DELIVERY,
73
+ data: { email, bootstrapToken },
74
+ });
75
+ expect(res.ok(), `register ${email} -> ${res.status()} ${await res.text()}`).toBeTruthy();
76
+ const body = await res.json();
77
+ expect(body.token, 'register returns an ephemeral token').toBeTruthy();
78
+ return body.token as string;
79
+ }
80
+
81
+ /** Generate a phone OTP and return the raw code via the external-delivery seam. */
82
+ export async function requestPhoneOtp(ctx: APIRequestContext, ephemeral: string): Promise<string> {
83
+ const res = await ctx.get('/otp/generate-phone-otp', {
84
+ headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
85
+ });
86
+ expect(res.ok(), `generate-phone-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
87
+ const code = (await res.json())?.delivery?.token;
88
+ expect(code, 'external delivery returns the phone OTP code').toBeTruthy();
89
+ return String(code);
90
+ }
91
+
92
+ export function verifyPhoneOtp(ctx: APIRequestContext, ephemeral: string, code: string) {
93
+ return ctx.post('/otp/verify-phone-otp', {
94
+ headers: bearer(ephemeral),
95
+ data: { verificationToken: code },
96
+ });
97
+ }
98
+
99
+ /** Generate an email OTP and return the raw code via the external-delivery seam. */
100
+ export async function requestEmailOtp(ctx: APIRequestContext, ephemeral: string): Promise<string> {
101
+ const res = await ctx.get('/otp/generate-email-otp', {
102
+ headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
103
+ });
104
+ expect(res.ok(), `generate-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
105
+ const code = (await res.json())?.delivery?.token;
106
+ expect(code, 'external delivery returns the OTP code').toBeTruthy();
107
+ return String(code);
108
+ }
109
+
110
+ export function verifyEmailOtp(ctx: APIRequestContext, ephemeral: string, code: string) {
111
+ return ctx.post('/otp/verify-email-otp', {
112
+ headers: bearer(ephemeral),
113
+ data: { verificationToken: code },
114
+ });
115
+ }
116
+
117
+ /** Full email-OTP registration -> verified session tokens. */
118
+ export async function registerAndVerifyEmail(
119
+ ctx: APIRequestContext,
120
+ email: string,
121
+ ): Promise<SessionTokens> {
122
+ const ephemeral = await registerEmail(ctx, email);
123
+ const code = await requestEmailOtp(ctx, ephemeral);
124
+ const res = await verifyEmailOtp(ctx, ephemeral, code);
125
+ expect(res.ok(), `verify-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
126
+ const body = await res.json();
127
+ expect(body.token, 'verify returns an access token').toBeTruthy();
128
+ expect(body.refreshToken, 'verify returns a refresh token').toBeTruthy();
129
+ return { token: body.token, refreshToken: body.refreshToken, sub: body.sub };
130
+ }
131
+
132
+ /** Begin a login; returns the ephemeral (pre-auth) token. */
133
+ export async function login(ctx: APIRequestContext, identifier: string): Promise<string> {
134
+ const res = await ctx.post('/login', { data: { identifier } });
135
+ expect(res.ok(), `login ${identifier} -> ${res.status()} ${await res.text()}`).toBeTruthy();
136
+ const body = await res.json();
137
+ expect(body.token, 'login returns an ephemeral token').toBeTruthy();
138
+ return body.token as string;
139
+ }
140
+
141
+ /** Request a magic link; returns the raw token + URL via external delivery. */
142
+ export async function requestMagicLink(
143
+ ctx: APIRequestContext,
144
+ ephemeral: string,
145
+ ): Promise<{ token: string; magicLinkUrl: string }> {
146
+ const res = await ctx.get('/magic-link', {
147
+ headers: { ...bearer(ephemeral), ...EXTERNAL_DELIVERY },
148
+ });
149
+ expect(res.ok(), `magic-link request -> ${res.status()} ${await res.text()}`).toBeTruthy();
150
+ const body = await res.json();
151
+ expect(body?.delivery?.token, 'external delivery returns the magic-link token').toBeTruthy();
152
+ return { token: body.delivery.token, magicLinkUrl: body.delivery.magicLinkUrl };
153
+ }
154
+
155
+ export function verifyMagicLink(ctx: APIRequestContext, rawToken: string) {
156
+ return ctx.get(`/magic-link/verify/${rawToken}`);
157
+ }
158
+
159
+ export function pollMagicLink(ctx: APIRequestContext, ephemeral: string) {
160
+ return ctx.get('/magic-link/check', { headers: bearer(ephemeral) });
161
+ }
162
+
163
+ export function refresh(ctx: APIRequestContext, refreshToken: string) {
164
+ return ctx.post('/refresh', { headers: bearer(refreshToken) });
165
+ }
166
+
167
+ export function listSessions(ctx: APIRequestContext, accessToken: string) {
168
+ return ctx.get('/sessions', { headers: bearer(accessToken) });
169
+ }
170
+
171
+ export function logout(ctx: APIRequestContext, accessToken: string) {
172
+ return ctx.delete('/logout', { headers: bearer(accessToken) });
173
+ }
174
+
175
+ /**
176
+ * Full OAuth login against the mock IdP: start the flow, follow the authorize
177
+ * redirect to obtain the code (the mock mints a fresh user), then exchange it at
178
+ * the callback for a session. `pathPrefix` is '' for the API, '/auth' for the adapter.
179
+ */
180
+ export async function oauthLogin(
181
+ ctx: APIRequestContext,
182
+ providerId = 'mock',
183
+ pathPrefix = '',
184
+ ): Promise<SessionTokens & { email?: string }> {
185
+ const start = await ctx.post(`${pathPrefix}/oauth/${providerId}/start`, { data: {} });
186
+ expect(start.ok(), `oauth start -> ${start.status()} ${await start.text()}`).toBeTruthy();
187
+ const { authorizationUrl } = await start.json();
188
+
189
+ const authorize = await ctx.get(authorizationUrl, { maxRedirects: 0 });
190
+ expect(authorize.status(), `authorize redirects with a code (got ${authorize.status()})`).toBe(
191
+ 302,
192
+ );
193
+ const redirected = new URL(authorize.headers()['location']);
194
+ const code = redirected.searchParams.get('code');
195
+ const state = redirected.searchParams.get('state');
196
+ expect(code, 'authorize returns an auth code').toBeTruthy();
197
+
198
+ const callback = await ctx.post(`${pathPrefix}/oauth/${providerId}/callback`, {
199
+ data: { code, state },
200
+ });
201
+ expect(callback.ok(), `oauth callback -> ${callback.status()} ${await callback.text()}`).toBeTruthy();
202
+ const body = await callback.json();
203
+ return { token: body.token, refreshToken: body.refreshToken, sub: body.sub, email: body.email };
204
+ }
@@ -0,0 +1,73 @@
1
+ import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
2
+
3
+ // Prints a flow x layer conformance grid at the end of the run. Layer comes from
4
+ // the spec's directory (api/adapter/react); flow from the spec file name, with a
5
+ // few aliases folded together so the same flow lines up across layers.
6
+
7
+ const LAYERS = ['api', 'adapter', 'react'] as const;
8
+ type Layer = (typeof LAYERS)[number];
9
+
10
+ const FLOW_ALIASES: Record<string, string> = {
11
+ emailOtpLogin: 'emailOtp',
12
+ registration: 'register',
13
+ magicLinkLogin: 'magicLink',
14
+ passkeyRegister: 'passkey',
15
+ passkeyLogin: 'passkey',
16
+ adminBootstrap: 'admin',
17
+ sessionLifecycle: 'session',
18
+ };
19
+
20
+ interface Entry {
21
+ flow: string;
22
+ layer: Layer;
23
+ passed: boolean;
24
+ }
25
+
26
+ export default class MatrixReporter implements Reporter {
27
+ private tests = new Map<string, Entry>();
28
+
29
+ onTestEnd(test: TestCase, result: TestResult): void {
30
+ const match = test.location.file.match(/\/(api|adapter|react)\/([^/]+)\.spec\.[tj]s$/);
31
+ if (!match) return;
32
+ const layer = match[1] as Layer;
33
+ const base = match[2];
34
+ const flow = FLOW_ALIASES[base] ?? base;
35
+ // Keyed by test id so the final attempt (after retries) is the one that counts.
36
+ this.tests.set(test.id, { flow, layer, passed: result.status === 'passed' });
37
+ }
38
+
39
+ onEnd(_result: FullResult): void {
40
+ const cells = new Map<string, boolean>();
41
+ for (const { flow, layer, passed } of this.tests.values()) {
42
+ const key = `${flow}|${layer}`;
43
+ cells.set(key, (cells.get(key) ?? true) && passed);
44
+ }
45
+ if (cells.size === 0) return;
46
+
47
+ const flows = [...new Set([...cells.keys()].map((k) => k.split('|')[0]))].sort();
48
+ const symbol = (flow: string, layer: Layer): string => {
49
+ const value = cells.get(`${flow}|${layer}`);
50
+ return value === undefined ? '-' : value ? '✓' : '✗';
51
+ };
52
+
53
+ const flowWidth = Math.max('flow'.length, ...flows.map((f) => f.length));
54
+ const pad = (text: string, width: number) =>
55
+ text + ' '.repeat(Math.max(0, width - text.length));
56
+ const row = (label: string, get: (layer: Layer) => string) =>
57
+ ` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l), 9)).join('')}`;
58
+ const rule = ` ${'-'.repeat(flowWidth + 3 + LAYERS.length * 9)}`;
59
+
60
+ const lines = [
61
+ '',
62
+ ' Conformance matrix',
63
+ rule,
64
+ row('flow', (l) => l),
65
+ rule,
66
+ ...flows.map((f) => row(f, (l) => symbol(f, l))),
67
+ rule,
68
+ '',
69
+ ];
70
+ // eslint-disable-next-line no-console
71
+ console.log(lines.join('\n'));
72
+ }
73
+ }
@@ -0,0 +1,117 @@
1
+ import { BrowserContext, expect, Page, request } from '@playwright/test';
2
+
3
+ import { ADAPTER_URL } from './env';
4
+
5
+ // Browser-facing helpers for the React starter (served at REACT_URL, pointed at
6
+ // the adapter). The adapter strips OTP/magic-link secrets from browser responses,
7
+ // so codes are read from its /__captured readout (same seam as adapterFlows).
8
+
9
+ /** Read a code the adapter captured for `recipient` (email or phone), polling until present. */
10
+ export async function readCapturedCode(recipient: string, timeoutMs = 10_000): Promise<string> {
11
+ const ctx = await request.newContext({ baseURL: ADAPTER_URL });
12
+ try {
13
+ const deadline = Date.now() + timeoutMs;
14
+ let last = 'never set';
15
+ while (Date.now() < deadline) {
16
+ const res = await ctx.get(`/__captured/${encodeURIComponent(recipient)}`);
17
+ if (res.ok()) {
18
+ const body = await res.json();
19
+ if (body?.token) return String(body.token);
20
+ last = JSON.stringify(body);
21
+ } else {
22
+ last = `status ${res.status()}`;
23
+ }
24
+ await new Promise((r) => setTimeout(r, 300));
25
+ }
26
+ throw new Error(`no captured code for ${recipient} within ${timeoutMs}ms (last: ${last})`);
27
+ } finally {
28
+ await ctx.dispose();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Open the sign-in form. The SDK usually renders the register form by default, but
34
+ * lands directly on sign-in in some states (e.g. just after logout), so switch only
35
+ * when needed — and wait for the form to render first to avoid racing the SPA.
36
+ */
37
+ export async function gotoSignIn(page: Page): Promise<void> {
38
+ await page.goto('/login');
39
+ await expect(page.getByRole('heading', { name: /Sign In|Create Account/ })).toBeVisible();
40
+ if (await page.getByRole('heading', { name: 'Create Account' }).isVisible()) {
41
+ await page.getByRole('button', { name: /Already have an account/i }).click();
42
+ }
43
+ await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
44
+ }
45
+
46
+ /** Type a code into the SDK's six-box OTP input (one digit per box). */
47
+ export async function enterOtp(page: Page, code: string): Promise<void> {
48
+ const boxes = page.getByLabel(/^Digit \d$/);
49
+ await expect(boxes).toHaveCount(code.length);
50
+ await boxes.first().click();
51
+ await page.keyboard.type(code);
52
+ }
53
+
54
+ /**
55
+ * Attach a CTAP2 platform virtual authenticator (Chrome DevTools Protocol) so
56
+ * WebAuthn ceremonies auto-succeed — this is what makes `isUVPAA()` true (so the
57
+ * SDK offers passkeys) and lets navigator.credentials.create/get resolve headless.
58
+ */
59
+ export async function addVirtualAuthenticator(
60
+ context: BrowserContext,
61
+ page: Page,
62
+ ): Promise<void> {
63
+ const cdp = await context.newCDPSession(page);
64
+ await cdp.send('WebAuthn.enable');
65
+ await cdp.send('WebAuthn.addVirtualAuthenticator', {
66
+ options: {
67
+ protocol: 'ctap2',
68
+ transport: 'internal',
69
+ hasResidentKey: true,
70
+ hasUserVerification: true,
71
+ isUserVerified: true,
72
+ automaticPresenceSimulation: true,
73
+ },
74
+ });
75
+ }
76
+
77
+ /** Register a new user by email, then enroll a passkey (passkey support must be on). */
78
+ export async function registerWithPasskey(page: Page, email: string): Promise<void> {
79
+ await page.goto('/login');
80
+ await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
81
+ await page.locator('#email').fill(email);
82
+ await page.getByRole('button', { name: 'Register', exact: true }).click();
83
+
84
+ await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible();
85
+ await enterOtp(page, await readCapturedCode(email));
86
+ await page.getByRole('button', { name: /Verify & Continue/ }).click();
87
+
88
+ // Passkey support is detected, so registration continues to passkey enrollment.
89
+ await expect(
90
+ page.getByRole('heading', { name: /Secure Your Account with a Passkey/ }),
91
+ ).toBeVisible();
92
+ await page.getByRole('button', { name: 'Register Passkey' }).click();
93
+ await page.getByPlaceholder(/MacBook/).fill('Verify Device');
94
+ await page.getByRole('button', { name: 'Continue' }).click();
95
+ }
96
+
97
+ /** Sign in an existing user whose passkey is registered (the ceremony auto-runs). */
98
+ export async function loginWithPasskey(page: Page, email: string): Promise<void> {
99
+ await gotoSignIn(page);
100
+ await page.locator('#identifier').fill(email);
101
+ await page.getByRole('button', { name: 'Login', exact: true }).click();
102
+ }
103
+
104
+ /** Sign in an existing verified user through the email one-time-code path. */
105
+ export async function signInWithEmailOtp(page: Page, email: string): Promise<void> {
106
+ await gotoSignIn(page);
107
+ await page.locator('#identifier').fill(email);
108
+ await page.getByRole('button', { name: 'Login', exact: true }).click();
109
+
110
+ await page.getByRole('button', { name: /Email Code/ }).click();
111
+ await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible();
112
+
113
+ await enterOtp(page, await readCapturedCode(email));
114
+ await page.getByRole('button', { name: /Verify & Continue/ }).click();
115
+ await expect(page.getByText('You are signed in')).toBeVisible();
116
+ }
117
+
@@ -0,0 +1,26 @@
1
+ import { createHmac } from 'crypto';
2
+
3
+ function b64url(value: string): string {
4
+ return Buffer.from(value).toString('base64url');
5
+ }
6
+
7
+ // Mint an internal service token (HS256) the API trusts for client-IP
8
+ // attribution — the same M2M mechanism the real server adapter uses.
9
+ // Claims must be iss=seamless-portal-api, aud=seamless-auth (see the API's
10
+ // authenticateServiceToken / trustedClientIp middleware).
11
+ export function mintServiceToken(secret: string, sub = 'seamless-verify'): string {
12
+ const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
13
+ const now = Math.floor(Date.now() / 1000);
14
+ const payload = b64url(
15
+ JSON.stringify({
16
+ sub,
17
+ iss: 'seamless-portal-api',
18
+ aud: 'seamless-auth',
19
+ iat: now,
20
+ exp: now + 3600,
21
+ }),
22
+ );
23
+ const data = `${header}.${payload}`;
24
+ const sig = createHmac('sha256', secret).update(data).digest('base64url');
25
+ return `${data}.${sig}`;
26
+ }
@@ -0,0 +1,36 @@
1
+ import { createHmac } from 'crypto';
2
+
3
+ function base32Decode(input: string): Buffer {
4
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
5
+ const clean = input.replace(/=+$/, '').toUpperCase().replace(/\s/g, '');
6
+ let bits = 0;
7
+ let value = 0;
8
+ const out: number[] = [];
9
+ for (const ch of clean) {
10
+ const idx = alphabet.indexOf(ch);
11
+ if (idx === -1) continue;
12
+ value = (value << 5) | idx;
13
+ bits += 5;
14
+ if (bits >= 8) {
15
+ out.push((value >>> (bits - 8)) & 0xff);
16
+ bits -= 8;
17
+ }
18
+ }
19
+ return Buffer.from(out);
20
+ }
21
+
22
+ // RFC-6238 TOTP (SHA1, 6 digits, 30s) — matches the API's TOTP parameters.
23
+ export function totp(secretBase32: string, atMs = Date.now(), period = 30, digits = 6): string {
24
+ const key = base32Decode(secretBase32);
25
+ const counter = Math.floor(atMs / 1000 / period);
26
+ const buf = Buffer.alloc(8);
27
+ buf.writeBigInt64BE(BigInt(counter));
28
+ const hmac = createHmac('sha1', key).update(buf).digest();
29
+ const offset = hmac[hmac.length - 1] & 0x0f;
30
+ const bin =
31
+ ((hmac[offset] & 0x7f) << 24) |
32
+ ((hmac[offset + 1] & 0xff) << 16) |
33
+ ((hmac[offset + 2] & 0xff) << 8) |
34
+ (hmac[offset + 3] & 0xff);
35
+ return String(bin % 10 ** digits).padStart(digits, '0');
36
+ }