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,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