seamless-cli 0.2.0 → 0.5.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.
- package/README.md +5 -6
- package/dist/commands/help.js +18 -1
- package/dist/commands/help.js.map +1 -1
- package/dist/commands/init.js +104 -21
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/verify.js +259 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/core/bootstrapSecret.js +12 -4
- package/dist/core/bootstrapSecret.js.map +1 -1
- package/dist/core/exec.js +2 -1
- package/dist/core/exec.js.map +1 -1
- package/dist/core/fetch.js +2 -1
- package/dist/core/fetch.js.map +1 -1
- package/dist/core/images.js +11 -0
- package/dist/core/images.js.map +1 -0
- package/dist/core/oauthProviders.js +89 -0
- package/dist/core/oauthProviders.js.map +1 -0
- package/dist/core/templates.js +185 -0
- package/dist/core/templates.js.map +1 -0
- package/dist/generators/auth/auth.js +40 -19
- package/dist/generators/auth/auth.js.map +1 -1
- package/dist/generators/config/config.js +3 -4
- package/dist/generators/config/config.js.map +1 -1
- package/dist/generators/docker/docker.js +51 -31
- package/dist/generators/docker/docker.js.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/prompts/oauthSetup.js +36 -0
- package/dist/prompts/oauthSetup.js.map +1 -0
- package/dist/prompts/projectSetup.js +43 -19
- package/dist/prompts/projectSetup.js.map +1 -1
- package/package.json +6 -1
- package/verify/adapter-app/Dockerfile +14 -0
- package/verify/adapter-app/package.json +13 -0
- package/verify/adapter-app/server.mjs +62 -0
- package/verify/adapter-app/vendor/.gitignore +3 -0
- package/verify/adapter-app/vendor/.gitkeep +0 -0
- package/verify/docker-compose.verify.yml +154 -0
- package/verify/harness/adapter/emailOtpLogin.spec.ts +12 -0
- package/verify/harness/adapter/magicLink.spec.ts +14 -0
- package/verify/harness/adapter/oauth.spec.ts +11 -0
- package/verify/harness/adapter/registration.spec.ts +11 -0
- package/verify/harness/adapter/sessionLifecycle.spec.ts +20 -0
- package/verify/harness/api/adminBootstrap.spec.ts +34 -0
- package/verify/harness/api/emailOtp.spec.ts +17 -0
- package/verify/harness/api/jwks.spec.ts +17 -0
- package/verify/harness/api/magicLink.spec.ts +36 -0
- package/verify/harness/api/oauth.spec.ts +12 -0
- package/verify/harness/api/organizations.spec.ts +28 -0
- package/verify/harness/api/phoneOtp.spec.ts +19 -0
- package/verify/harness/api/sessionLifecycle.spec.ts +26 -0
- package/verify/harness/api/stepUp.spec.ts +39 -0
- package/verify/harness/api/totp.spec.ts +24 -0
- package/verify/harness/global-setup.ts +45 -0
- package/verify/harness/lib/adapterFlows.ts +57 -0
- package/verify/harness/lib/client.ts +32 -0
- package/verify/harness/lib/env.ts +45 -0
- package/verify/harness/lib/fixtures.ts +20 -0
- package/verify/harness/lib/flows.ts +204 -0
- package/verify/harness/lib/matrixReporter.ts +73 -0
- package/verify/harness/lib/reactFlows.ts +117 -0
- package/verify/harness/lib/serviceToken.ts +26 -0
- package/verify/harness/lib/totp.ts +36 -0
- package/verify/harness/mock-oidc.ts +112 -0
- package/verify/harness/package-lock.json +93 -0
- package/verify/harness/package.json +14 -0
- package/verify/harness/playwright.config.ts +30 -0
- package/verify/harness/react/emailOtpLogin.spec.ts +13 -0
- package/verify/harness/react/logout.spec.ts +17 -0
- package/verify/harness/react/magicLinkLogin.spec.ts +29 -0
- package/verify/harness/react/oauth.spec.ts +13 -0
- package/verify/harness/react/passkeyLogin.spec.ts +24 -0
- package/verify/harness/react/passkeyRegister.spec.ts +15 -0
- package/verify/harness/react/register.spec.ts +18 -0
- package/verify/react-vendor/.gitkeep +0 -0
- package/dist/commands/contribute.js +0 -121
- package/dist/commands/contribute.js.map +0 -1
- package/dist/commands/deploy.js +0 -190
- package/dist/commands/deploy.js.map +0 -1
- package/dist/commands/destroy.js +0 -122
- package/dist/commands/destroy.js.map +0 -1
- package/dist/contributor/catalog.js +0 -129
- package/dist/contributor/catalog.js.map +0 -1
- package/dist/contributor/workspace.js +0 -551
- package/dist/contributor/workspace.js.map +0 -1
- package/dist/core/acm.js +0 -24
- package/dist/core/acm.js.map +0 -1
- package/dist/core/aws.js +0 -374
- package/dist/core/aws.js.map +0 -1
- package/dist/core/buildPush.js +0 -91
- package/dist/core/buildPush.js.map +0 -1
- package/dist/core/configure.js +0 -25
- package/dist/core/configure.js.map +0 -1
- package/dist/core/deployConfig.js +0 -66
- package/dist/core/deployConfig.js.map +0 -1
- package/dist/core/deployManifest.js +0 -163
- package/dist/core/deployManifest.js.map +0 -1
- package/dist/core/deploySecrets.js +0 -31
- package/dist/core/deploySecrets.js.map +0 -1
- package/dist/core/destroy.js +0 -38
- package/dist/core/destroy.js.map +0 -1
- package/dist/core/envDiscovery.js +0 -153
- package/dist/core/envDiscovery.js.map +0 -1
- package/dist/core/envRegistry.js +0 -76
- package/dist/core/envRegistry.js.map +0 -1
- package/dist/core/health.js +0 -11
- package/dist/core/health.js.map +0 -1
- package/dist/core/help.js +0 -75
- package/dist/core/help.js.map +0 -1
- package/dist/core/preReqs.js +0 -14
- package/dist/core/preReqs.js.map +0 -1
- package/dist/core/terraform.js +0 -34
- package/dist/core/terraform.js.map +0 -1
- package/dist/core/tfVars.js +0 -69
- package/dist/core/tfVars.js.map +0 -1
- package/dist/generators/backend/express.js +0 -13
- package/dist/generators/backend/express.js.map +0 -1
- package/dist/generators/deploy/aws/dev/files.js +0 -36
- package/dist/generators/deploy/aws/dev/files.js.map +0 -1
- package/dist/generators/deploy/aws/dev/template.js +0 -1240
- package/dist/generators/deploy/aws/dev/template.js.map +0 -1
- package/dist/generators/frontend/react.js +0 -13
- package/dist/generators/frontend/react.js.map +0 -1
- package/dist/prompts/contributeSetup.js +0 -39
- package/dist/prompts/contributeSetup.js.map +0 -1
- package/dist/prompts/deploySetup.js +0 -244
- package/dist/prompts/deploySetup.js.map +0 -1
- package/dist/utils/repoUtils.js +0 -23
- package/dist/utils/repoUtils.js.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
|
3
|
+
|
|
4
|
+
// Minimal OIDC identity provider for the OAuth conformance flow. The API only uses
|
|
5
|
+
// the authorization code, the token exchange (PKCE), and userinfo (it does not
|
|
6
|
+
// validate id_token signatures), so /authorize, /token, /userinfo is all we need.
|
|
7
|
+
|
|
8
|
+
interface PendingCode {
|
|
9
|
+
codeChallenge?: string;
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
profile: { sub: string; email: string };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const sha256Base64Url = (value: string): string =>
|
|
15
|
+
createHash('sha256').update(value).digest('base64url');
|
|
16
|
+
|
|
17
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let data = '';
|
|
20
|
+
req.on('data', (chunk) => (data += chunk));
|
|
21
|
+
req.on('end', () => resolve(data));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
26
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify(body));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function startMockOidc(port: number): Server {
|
|
31
|
+
const codes = new Map<string, PendingCode>();
|
|
32
|
+
const tokens = new Map<string, { sub: string; email: string }>();
|
|
33
|
+
|
|
34
|
+
const server = createServer((req, res) => {
|
|
35
|
+
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
|
|
36
|
+
|
|
37
|
+
// Authorization endpoint: mint a code bound to the PKCE challenge + a fresh
|
|
38
|
+
// user, then redirect back to the app's redirect_uri with code + state.
|
|
39
|
+
if (req.method === 'GET' && url.pathname === '/authorize') {
|
|
40
|
+
const redirectUri = url.searchParams.get('redirect_uri');
|
|
41
|
+
if (!redirectUri) {
|
|
42
|
+
sendJson(res, 400, { error: 'invalid_request', error_description: 'missing redirect_uri' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const code = randomUUID();
|
|
46
|
+
codes.set(code, {
|
|
47
|
+
codeChallenge: url.searchParams.get('code_challenge') ?? undefined,
|
|
48
|
+
redirectUri,
|
|
49
|
+
profile: {
|
|
50
|
+
sub: `mock-${randomUUID()}`,
|
|
51
|
+
email: `oauth.${randomUUID().slice(0, 12)}@example.test`,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const location = new URL(redirectUri);
|
|
55
|
+
location.searchParams.set('code', code);
|
|
56
|
+
location.searchParams.set('state', url.searchParams.get('state') ?? '');
|
|
57
|
+
res.writeHead(302, { Location: location.toString() });
|
|
58
|
+
res.end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Token endpoint: validate PKCE (S256), consume the code, issue an opaque token.
|
|
63
|
+
if (req.method === 'POST' && url.pathname === '/token') {
|
|
64
|
+
void readBody(req).then((raw) => {
|
|
65
|
+
const params = new URLSearchParams(raw);
|
|
66
|
+
const code = params.get('code') ?? '';
|
|
67
|
+
const pending = codes.get(code);
|
|
68
|
+
if (!pending) {
|
|
69
|
+
sendJson(res, 400, { error: 'invalid_grant' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
codes.delete(code);
|
|
73
|
+
if (
|
|
74
|
+
pending.codeChallenge &&
|
|
75
|
+
sha256Base64Url(params.get('code_verifier') ?? '') !== pending.codeChallenge
|
|
76
|
+
) {
|
|
77
|
+
sendJson(res, 400, { error: 'invalid_grant', error_description: 'PKCE mismatch' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const accessToken = randomUUID();
|
|
81
|
+
tokens.set(accessToken, pending.profile);
|
|
82
|
+
sendJson(res, 200, { access_token: accessToken, token_type: 'Bearer', expires_in: 3600 });
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Userinfo endpoint: return the profile for the bearer access token.
|
|
88
|
+
if (req.method === 'GET' && url.pathname === '/userinfo') {
|
|
89
|
+
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
|
|
90
|
+
const profile = tokens.get(token);
|
|
91
|
+
if (!profile) {
|
|
92
|
+
sendJson(res, 401, { error: 'invalid_token' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
sendJson(res, 200, {
|
|
96
|
+
sub: profile.sub,
|
|
97
|
+
email: profile.email,
|
|
98
|
+
email_verified: true,
|
|
99
|
+
name: 'OAuth User',
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
sendJson(res, 404, { error: 'not_found' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Bind on all interfaces so the API container can reach it via host.docker.internal;
|
|
108
|
+
// unref so it never keeps the Playwright process alive after the run.
|
|
109
|
+
server.listen(port, '0.0.0.0');
|
|
110
|
+
server.unref();
|
|
111
|
+
return server;
|
|
112
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seamless-verify-harness",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "seamless-verify-harness",
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@playwright/test": "^1.49.0",
|
|
12
|
+
"typescript": "^5.9.3"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"node_modules/@playwright/test": {
|
|
16
|
+
"version": "1.61.1",
|
|
17
|
+
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
|
18
|
+
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
|
19
|
+
"dev": true,
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"playwright": "1.61.1"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"playwright": "cli.js"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"node_modules/fsevents": {
|
|
32
|
+
"version": "2.3.2",
|
|
33
|
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
34
|
+
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
35
|
+
"dev": true,
|
|
36
|
+
"hasInstallScript": true,
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"optional": true,
|
|
39
|
+
"os": [
|
|
40
|
+
"darwin"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"node_modules/playwright": {
|
|
47
|
+
"version": "1.61.1",
|
|
48
|
+
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
|
49
|
+
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
|
50
|
+
"dev": true,
|
|
51
|
+
"license": "Apache-2.0",
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"playwright-core": "1.61.1"
|
|
54
|
+
},
|
|
55
|
+
"bin": {
|
|
56
|
+
"playwright": "cli.js"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"fsevents": "2.3.2"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"node_modules/playwright-core": {
|
|
66
|
+
"version": "1.61.1",
|
|
67
|
+
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
|
68
|
+
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
|
69
|
+
"dev": true,
|
|
70
|
+
"license": "Apache-2.0",
|
|
71
|
+
"bin": {
|
|
72
|
+
"playwright-core": "cli.js"
|
|
73
|
+
},
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=18"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"node_modules/typescript": {
|
|
79
|
+
"version": "5.9.3",
|
|
80
|
+
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
81
|
+
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
82
|
+
"dev": true,
|
|
83
|
+
"license": "Apache-2.0",
|
|
84
|
+
"bin": {
|
|
85
|
+
"tsc": "bin/tsc",
|
|
86
|
+
"tsserver": "bin/tsserver"
|
|
87
|
+
},
|
|
88
|
+
"engines": {
|
|
89
|
+
"node": ">=14.17"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seamless-verify-harness",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Playwright conformance harness driven by `seamless verify`.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "playwright test",
|
|
8
|
+
"test:api": "playwright test --project=api"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@playwright/test": "^1.49.0",
|
|
12
|
+
"typescript": "^5.9.3"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
import { REACT_URL } from './lib/env';
|
|
4
|
+
|
|
5
|
+
// One runner, multiple projects. `api` and `adapter` hit HTTP directly (no
|
|
6
|
+
// browser); `react` drives chromium against the starter SPA. global-setup
|
|
7
|
+
// health-gates the stack before any project runs.
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
testDir: '.',
|
|
10
|
+
globalSetup: './global-setup.ts',
|
|
11
|
+
timeout: 30_000,
|
|
12
|
+
expect: { timeout: 10_000 },
|
|
13
|
+
fullyParallel: false,
|
|
14
|
+
workers: 1,
|
|
15
|
+
reporter: [
|
|
16
|
+
['list'],
|
|
17
|
+
['./lib/matrixReporter.ts'],
|
|
18
|
+
['junit', { outputFile: 'results/junit.xml' }],
|
|
19
|
+
['html', { outputFolder: 'results/html', open: 'never' }],
|
|
20
|
+
],
|
|
21
|
+
projects: [
|
|
22
|
+
{ name: 'api', testMatch: /api\/.*\.spec\.ts$/ },
|
|
23
|
+
{ name: 'adapter', testMatch: /adapter\/.*\.spec\.ts$/ },
|
|
24
|
+
{
|
|
25
|
+
name: 'react',
|
|
26
|
+
testMatch: /react\/.*\.spec\.ts$/,
|
|
27
|
+
use: { ...devices['Desktop Chrome'], baseURL: REACT_URL },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
import { signInWithEmailOtp } from '../lib/reactFlows';
|
|
4
|
+
|
|
5
|
+
test.describe('email OTP login (react, browser)', { tag: '@login' }, () => {
|
|
6
|
+
test('sign in with an email one-time code -> authenticated home', async ({ page, actor }) => {
|
|
7
|
+
// Seed a verified user via the API, then sign in through the browser UI.
|
|
8
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
9
|
+
|
|
10
|
+
await signInWithEmailOtp(page, actor.email);
|
|
11
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
import { signInWithEmailOtp } from '../lib/reactFlows';
|
|
4
|
+
|
|
5
|
+
test.describe('logout (react, browser)', { tag: '@login' }, () => {
|
|
6
|
+
test('signing out from the account menu clears the session', async ({ page, actor }) => {
|
|
7
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
8
|
+
await signInWithEmailOtp(page, actor.email);
|
|
9
|
+
|
|
10
|
+
await page.getByRole('button', { name: 'Open account menu' }).click();
|
|
11
|
+
await page.getByRole('button', { name: 'Logout' }).click();
|
|
12
|
+
|
|
13
|
+
// Session cleared: authenticated home is gone and the auth form reappears.
|
|
14
|
+
await expect(page.getByText('You are signed in')).toBeHidden();
|
|
15
|
+
await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
import { gotoSignIn, readCapturedCode } from '../lib/reactFlows';
|
|
4
|
+
|
|
5
|
+
test.describe('magic link login (react, browser)', { tag: '@login' }, () => {
|
|
6
|
+
test('request a magic link, open it in a second tab -> original tab authenticates', async ({
|
|
7
|
+
page,
|
|
8
|
+
context,
|
|
9
|
+
actor,
|
|
10
|
+
}) => {
|
|
11
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
12
|
+
|
|
13
|
+
await gotoSignIn(page);
|
|
14
|
+
await page.locator('#identifier').fill(actor.email);
|
|
15
|
+
await page.getByRole('button', { name: 'Login', exact: true }).click();
|
|
16
|
+
|
|
17
|
+
await page.getByRole('button', { name: /Email Magic Link/ }).click();
|
|
18
|
+
await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
|
|
19
|
+
|
|
20
|
+
// "Click" the emailed link in a second tab; the original tab completes via
|
|
21
|
+
// BroadcastChannel + polling (same device binding — same browser/adapter).
|
|
22
|
+
const token = await readCapturedCode(actor.email);
|
|
23
|
+
const linkTab = await context.newPage();
|
|
24
|
+
await linkTab.goto(`/verify-magiclink?token=${encodeURIComponent(token)}`);
|
|
25
|
+
|
|
26
|
+
await expect(page.getByText('You are signed in')).toBeVisible({ timeout: 15_000 });
|
|
27
|
+
await linkTab.close();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
|
|
3
|
+
test.describe('OAuth login (react, browser)', { tag: '@oauth' }, () => {
|
|
4
|
+
test('continue with a provider -> IdP redirect -> signed in', async ({ page }) => {
|
|
5
|
+
await page.goto('/login');
|
|
6
|
+
|
|
7
|
+
// The provider button redirects to the (mock) IdP, which redirects back to
|
|
8
|
+
// /oauth/callback; the callback finishes the login and lands on the app.
|
|
9
|
+
await page.getByRole('button', { name: /Continue with Mock OIDC/ }).click();
|
|
10
|
+
|
|
11
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import {
|
|
3
|
+
addVirtualAuthenticator,
|
|
4
|
+
loginWithPasskey,
|
|
5
|
+
registerWithPasskey,
|
|
6
|
+
} from '../lib/reactFlows';
|
|
7
|
+
|
|
8
|
+
test.describe('passkey login (react, browser)', { tag: '@login' }, () => {
|
|
9
|
+
test('a registered passkey signs the user back in', async ({ page, context, actor }) => {
|
|
10
|
+
await addVirtualAuthenticator(context, page);
|
|
11
|
+
|
|
12
|
+
// Enroll a passkey, then sign out.
|
|
13
|
+
await registerWithPasskey(page, actor.email);
|
|
14
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
15
|
+
|
|
16
|
+
await page.getByRole('button', { name: 'Open account menu' }).click();
|
|
17
|
+
await page.getByRole('button', { name: 'Logout' }).click();
|
|
18
|
+
await expect(page.getByText('You are signed in')).toBeHidden();
|
|
19
|
+
|
|
20
|
+
// Sign in with only the passkey (identifier -> the ceremony runs automatically).
|
|
21
|
+
await loginWithPasskey(page, actor.email);
|
|
22
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { addVirtualAuthenticator, registerWithPasskey } from '../lib/reactFlows';
|
|
3
|
+
|
|
4
|
+
test.describe('passkey registration (react, browser)', { tag: '@login' }, () => {
|
|
5
|
+
test('register, then enroll a passkey via a virtual authenticator -> signed in', async ({
|
|
6
|
+
page,
|
|
7
|
+
context,
|
|
8
|
+
actor,
|
|
9
|
+
}) => {
|
|
10
|
+
await addVirtualAuthenticator(context, page);
|
|
11
|
+
|
|
12
|
+
await registerWithPasskey(page, actor.email);
|
|
13
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { enterOtp, readCapturedCode } from '../lib/reactFlows';
|
|
3
|
+
|
|
4
|
+
test.describe('registration (react, browser)', { tag: '@login' }, () => {
|
|
5
|
+
test('register with just an email -> verify email OTP -> signed in', async ({ page, actor }) => {
|
|
6
|
+
await page.goto('/login');
|
|
7
|
+
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
|
|
8
|
+
|
|
9
|
+
await page.locator('#email').fill(actor.email);
|
|
10
|
+
await page.getByRole('button', { name: 'Register', exact: true }).click();
|
|
11
|
+
|
|
12
|
+
await expect(page.getByRole('heading', { name: 'Verify Your Email' })).toBeVisible();
|
|
13
|
+
await enterOtp(page, await readCapturedCode(actor.email));
|
|
14
|
+
await page.getByRole('button', { name: /Verify & Continue/ }).click();
|
|
15
|
+
|
|
16
|
+
await expect(page.getByText('You are signed in')).toBeVisible();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
File without changes
|