seamless-cli 0.11.0 → 0.13.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 +99 -26
- package/dist/commands/check.js +67 -42
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/config.js +7 -2
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/helpTopics.js +28 -11
- package/dist/commands/helpTopics.js.map +1 -1
- package/dist/commands/init.js +71 -42
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/sessions.js +8 -3
- package/dist/commands/sessions.js.map +1 -1
- package/dist/commands/users.js +20 -7
- package/dist/commands/users.js.map +1 -1
- package/dist/commands/verify.js +41 -11
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/whoami.js +21 -3
- package/dist/commands/whoami.js.map +1 -1
- package/dist/core/admin.js +9 -2
- package/dist/core/admin.js.map +1 -1
- package/dist/core/authClient.js +71 -5
- package/dist/core/authClient.js.map +1 -1
- package/dist/core/config.js +55 -3
- package/dist/core/config.js.map +1 -1
- package/dist/core/errors.js +32 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/fetch.js +17 -1
- package/dist/core/fetch.js.map +1 -1
- package/dist/core/images.js +11 -2
- package/dist/core/images.js.map +1 -1
- package/dist/core/interactiveLogin.js +16 -14
- package/dist/core/interactiveLogin.js.map +1 -1
- package/dist/core/jwksKid.js +40 -0
- package/dist/core/jwksKid.js.map +1 -0
- package/dist/core/keychain.js +13 -26
- package/dist/core/keychain.js.map +1 -1
- package/dist/core/loginFlow.js +49 -8
- package/dist/core/loginFlow.js.map +1 -1
- package/dist/core/output.js +9 -29
- package/dist/core/output.js.map +1 -1
- package/dist/core/redact.js +68 -0
- package/dist/core/redact.js.map +1 -0
- package/dist/core/secrets.js +0 -3
- package/dist/core/secrets.js.map +1 -1
- package/dist/core/systemConfig.js +39 -4
- package/dist/core/systemConfig.js.map +1 -1
- package/dist/core/templates.js +6 -4
- package/dist/core/templates.js.map +1 -1
- package/dist/generators/admin/admin.js +102 -1
- package/dist/generators/admin/admin.js.map +1 -1
- package/dist/generators/auth/auth.js +2 -59
- package/dist/generators/auth/auth.js.map +1 -1
- package/dist/generators/docker/docker.js +5 -21
- package/dist/generators/docker/docker.js.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/prompts/projectSetup.js +1 -12
- package/dist/prompts/projectSetup.js.map +1 -1
- package/package.json +2 -2
- package/verify/adapter-fastify-app/Dockerfile +14 -0
- package/verify/adapter-fastify-app/package.json +12 -0
- package/verify/adapter-fastify-app/server.mjs +64 -0
- package/verify/adapter-fastify-app/vendor/.gitignore +3 -0
- package/verify/adapter-fastify-app/vendor/.gitkeep +0 -0
- package/verify/docker-compose.cache.yml +43 -0
- package/verify/docker-compose.verify.yml +52 -21
- package/verify/harness/api/loginEnumeration.spec.ts +139 -0
- package/verify/harness/global-setup.ts +4 -1
- package/verify/harness/lib/client.ts +7 -3
- package/verify/harness/lib/env.ts +4 -0
- package/verify/harness/lib/fixtures.ts +12 -3
- package/verify/harness/lib/matrixReporter.ts +19 -6
- package/verify/harness/lib/reactFlows.ts +0 -2
- package/verify/harness/playwright.config.ts +11 -2
- package/dist/core/jwks.js +0 -19
- package/dist/core/jwks.js.map +0 -1
- package/dist/utils/writeEnv.js +0 -9
- package/dist/utils/writeEnv.js.map +0 -1
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
# Conformance stack for `seamless verify`.
|
|
2
|
-
# Brings up Postgres + the auth API + the Express adapter from local source
|
|
3
|
-
# (paths supplied by the verify command via env) so the Playwright harness can
|
|
4
|
-
# drive every auth flow. NODE_ENV=test enables the API's external-delivery seam
|
|
5
|
-
# so OTP / magic-link tokens are returned in responses (no real email/SMS).
|
|
6
2
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
3
|
+
# Postgres, the auth API built from local source, and two adopter backends that are
|
|
4
|
+
# deliberate twins (Express on 3000, Fastify on 3001) so a failure is attributable to
|
|
5
|
+
# one framework. The web starter is behind the `react` profile, since the api and
|
|
6
|
+
# adapter layers need no browser.
|
|
7
|
+
#
|
|
8
|
+
# Env comes from the `seamless verify` process, not a file:
|
|
9
|
+
# SEAMLESS_API_DIR absolute path to the seamless-auth-api source (build context)
|
|
10
|
+
# SEAMLESS_REACT_DIR absolute path to the web template to serve (react profile only)
|
|
11
|
+
# API_SERVICE_TOKEN shared secret between the API and both adapters
|
|
12
|
+
# JWKS_KID key id the adapters expect (must match the API's active kid)
|
|
13
|
+
# OWNER_EMAIL tenant owner; this email gets the admin role at signup
|
|
14
|
+
#
|
|
15
|
+
# The adapters are built from ./adapter-app and ./adapter-fastify-app in this repo,
|
|
16
|
+
# so they take no path from the environment.
|
|
13
17
|
|
|
14
18
|
name: seamless-verify
|
|
15
19
|
|
|
16
20
|
services:
|
|
17
21
|
postgres:
|
|
18
|
-
|
|
22
|
+
# Matches POSTGRES_IMAGE in src/core/images.ts, so the harness runs the same
|
|
23
|
+
# major a fresh scaffold gets. Every run starts with `down -v`, so the volume
|
|
24
|
+
# is always new and a major bump needs no migration here.
|
|
25
|
+
image: postgres:18
|
|
19
26
|
environment:
|
|
20
27
|
POSTGRES_USER: seamless
|
|
21
28
|
POSTGRES_PASSWORD: seamless
|
|
@@ -27,19 +34,17 @@ services:
|
|
|
27
34
|
retries: 20
|
|
28
35
|
|
|
29
36
|
auth-api:
|
|
30
|
-
# Built with the production Dockerfile (compiles dist/)
|
|
31
|
-
#
|
|
32
|
-
# is skipped. validateEnvs.sh gates startup on the env below.
|
|
37
|
+
# Built with the production Dockerfile (compiles dist/). Its validateEnvs.sh
|
|
38
|
+
# entrypoint runs the migrations and gates startup on the env below.
|
|
33
39
|
build:
|
|
34
40
|
context: ${SEAMLESS_API_DIR}
|
|
35
41
|
dockerfile: Dockerfile
|
|
36
42
|
ports:
|
|
37
43
|
- '5312:5312'
|
|
38
44
|
environment:
|
|
39
|
-
# `development
|
|
40
|
-
# auto-generated dev public key
|
|
41
|
-
#
|
|
42
|
-
# JWKS publication working so the adapter/SDKs can verify tokens.
|
|
45
|
+
# `development`, not `test`: signing treats any non-production env as dev, but
|
|
46
|
+
# the JWKS endpoint only serves the auto-generated dev public key under
|
|
47
|
+
# `development`. The adapters and SDKs need that key to verify tokens.
|
|
43
48
|
NODE_ENV: development
|
|
44
49
|
PORT: '5312'
|
|
45
50
|
APP_NAME: Seamless Verify
|
|
@@ -51,9 +56,9 @@ services:
|
|
|
51
56
|
DEFAULT_ROLES: user
|
|
52
57
|
AVAILABLE_ROLES: user,admin
|
|
53
58
|
LOGIN_METHODS: passkey,magic_link,email_otp,phone_otp,oauth
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
59
|
+
# The base URL for emailed magic links, required at boot: with a fresh DB
|
|
60
|
+
# there is no seeded system_config row to fall back on, so the server exits
|
|
61
|
+
# without it. The browser-visible web app runs on 5173.
|
|
57
62
|
FRONTEND_URL: http://localhost:5173
|
|
58
63
|
DB_LOGGING: 'false'
|
|
59
64
|
ACCESS_TOKEN_TTL: 15m
|
|
@@ -117,6 +122,32 @@ services:
|
|
|
117
122
|
timeout: 3s
|
|
118
123
|
retries: 20
|
|
119
124
|
|
|
125
|
+
# The same adopter backend on Fastify, so the cookie path is conformance-tested
|
|
126
|
+
# for both adapters rather than only the one the harness happens to be written
|
|
127
|
+
# against. Identical env contract and capture transport; only the framework and
|
|
128
|
+
# the published port differ, which is what makes a failure here attributable.
|
|
129
|
+
adapter-fastify:
|
|
130
|
+
build:
|
|
131
|
+
context: ./adapter-fastify-app
|
|
132
|
+
ports:
|
|
133
|
+
- '3001:3001'
|
|
134
|
+
environment:
|
|
135
|
+
PORT: '3001'
|
|
136
|
+
AUTH_SERVER_URL: http://auth-api:5312
|
|
137
|
+
APP_ORIGIN: http://localhost:3001
|
|
138
|
+
WEB_ORIGIN: http://localhost:5173
|
|
139
|
+
API_SERVICE_TOKEN: ${API_SERVICE_TOKEN}
|
|
140
|
+
COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN}
|
|
141
|
+
JWKS_KID: ${JWKS_KID}
|
|
142
|
+
depends_on:
|
|
143
|
+
auth-api:
|
|
144
|
+
condition: service_healthy
|
|
145
|
+
healthcheck:
|
|
146
|
+
test: ['CMD', 'curl', '-fsS', 'http://localhost:3001/']
|
|
147
|
+
interval: 3s
|
|
148
|
+
timeout: 3s
|
|
149
|
+
retries: 20
|
|
150
|
+
|
|
120
151
|
# React starter app (built SPA on nginx), pointed at the adapter so the React
|
|
121
152
|
# SDK's `${apiHost}/auth/*` calls hit the adapter's mount. Browser-visible URL,
|
|
122
153
|
# so API_URL is the host-mapped adapter (localhost:3000), not the compose host.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { expect, test } from '../lib/fixtures';
|
|
2
|
+
import { registerAndVerifyEmail } from '../lib/flows';
|
|
3
|
+
|
|
4
|
+
// `POST /login` answers the same way for an identifier with an account and one without,
|
|
5
|
+
// and the endpoints that accept the resulting pre-auth token do too. The API has its own
|
|
6
|
+
// unit coverage for this; what only this harness can check is that the guarantee survives
|
|
7
|
+
// a real instance, with real signing keys and the real login policy in place.
|
|
8
|
+
//
|
|
9
|
+
// The specific failures worth catching are the ones where a decoy responder reproduces
|
|
10
|
+
// the success path and forgets a refusal, since that is the shape every regression here
|
|
11
|
+
// has taken so far.
|
|
12
|
+
|
|
13
|
+
const UNKNOWN = () => `nobody-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
|
|
14
|
+
|
|
15
|
+
async function startLogin(ctx: Parameters<typeof registerAndVerifyEmail>[0], identifier: string) {
|
|
16
|
+
const res = await ctx.post('/login', { data: { identifier } });
|
|
17
|
+
return { status: res.status(), body: await res.json() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test.describe('login enumeration (api)', () => {
|
|
21
|
+
test('an unknown identifier is answered in the same shape as a real one', async ({
|
|
22
|
+
actor,
|
|
23
|
+
}) => {
|
|
24
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
25
|
+
|
|
26
|
+
const real = await startLogin(actor.ctx, actor.email);
|
|
27
|
+
const unknown = await startLogin(actor.ctx, UNKNOWN());
|
|
28
|
+
|
|
29
|
+
expect(unknown.status).toBe(200);
|
|
30
|
+
expect(unknown.status).toBe(real.status);
|
|
31
|
+
// `sub` and `token` differ between them exactly as they differ between two real
|
|
32
|
+
// accounts, so the comparison is over everything else.
|
|
33
|
+
expect(Object.keys(unknown.body).sort()).toEqual(Object.keys(real.body).sort());
|
|
34
|
+
expect(unknown.body.identifierType).toBe(real.body.identifierType);
|
|
35
|
+
expect(unknown.body.ttl).toBe(real.body.ttl);
|
|
36
|
+
expect(typeof unknown.body.token).toBe('string');
|
|
37
|
+
// Deliberately not `loginMethods`. That list is filtered by what an account can do,
|
|
38
|
+
// and a decoy's capabilities are derived per identifier, so any one decoy and any
|
|
39
|
+
// one account can legitimately differ. What has to hold is the next test.
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a real account\'s method list is one a decoy can also produce', async ({ actor }) => {
|
|
43
|
+
// The guarantee is not that two given answers match, it is that a given answer does
|
|
44
|
+
// not identify an account. A decoy that always claimed everything would make any
|
|
45
|
+
// narrower list proof of existence, so the decoy's passkey and phone are derived per
|
|
46
|
+
// identifier and a narrow list has to be reachable without an account behind it.
|
|
47
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
48
|
+
const real = await startLogin(actor.ctx, actor.email);
|
|
49
|
+
const target = JSON.stringify(real.body.loginMethods);
|
|
50
|
+
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
for (let i = 0; i < 40; i += 1) {
|
|
53
|
+
const { body } = await startLogin(actor.ctx, UNKNOWN());
|
|
54
|
+
seen.add(JSON.stringify(body.loginMethods));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Each decoy draws two independent bits, so this account's exact list comes up about
|
|
58
|
+
// a quarter of the time; over 40 identifiers, missing it entirely is a 1-in-100,000
|
|
59
|
+
// event rather than a flake worth retrying.
|
|
60
|
+
expect(seen.has(target), `no decoy offered ${target}; saw ${[...seen].join(' | ')}`).toBe(
|
|
61
|
+
true,
|
|
62
|
+
);
|
|
63
|
+
expect(seen.size).toBeGreaterThan(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the same unknown identifier keeps the same subject', async ({ actor }) => {
|
|
67
|
+
const identifier = UNKNOWN();
|
|
68
|
+
|
|
69
|
+
const first = await startLogin(actor.ctx, identifier);
|
|
70
|
+
const second = await startLogin(actor.ctx, identifier);
|
|
71
|
+
const other = await startLogin(actor.ctx, UNKNOWN());
|
|
72
|
+
|
|
73
|
+
// A real identifier resolves to the same row every time and to a different one from
|
|
74
|
+
// anyone else's. A subject that rerolled, or that collided, would be the oracle again
|
|
75
|
+
// one request later.
|
|
76
|
+
expect(second.body.sub).toBe(first.body.sub);
|
|
77
|
+
expect(other.body.sub).not.toBe(first.body.sub);
|
|
78
|
+
// The offered methods have to be stable for the same reason: a list that changed
|
|
79
|
+
// between attempts would separate a decoy from an account on its own.
|
|
80
|
+
expect(second.body.loginMethods).toEqual(first.body.loginMethods);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an unknown identifier gets an OTP send that reports success and sends nothing', async ({
|
|
84
|
+
actor,
|
|
85
|
+
}) => {
|
|
86
|
+
const { body } = await startLogin(actor.ctx, UNKNOWN());
|
|
87
|
+
|
|
88
|
+
const res = await actor.ctx.get('/otp/generate-login-email-otp', {
|
|
89
|
+
headers: { Authorization: `Bearer ${body.token}` },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.status()).toBe(200);
|
|
93
|
+
expect((await res.json()).message).toBe('success');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a decoy OTP verify fails the way a wrong code fails', async ({ actor }) => {
|
|
97
|
+
const { body } = await startLogin(actor.ctx, UNKNOWN());
|
|
98
|
+
|
|
99
|
+
const res = await actor.ctx.post('/otp/verify-login-email-otp', {
|
|
100
|
+
headers: { Authorization: `Bearer ${body.token}` },
|
|
101
|
+
data: { verificationToken: 'ZZZZZZ' },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(res.status()).toBe(401);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a credential id that cannot exist is refused for real and unknown alike', async ({
|
|
108
|
+
actor,
|
|
109
|
+
}) => {
|
|
110
|
+
// The sharpest oracle this surface had. `/webauthn/login/start` filters the account's
|
|
111
|
+
// credentials by the requested id and refuses when none survive, so an id no
|
|
112
|
+
// credential can hold is refused by every real account. A decoy that answered with a
|
|
113
|
+
// challenge anyway was identifiable in two requests, whatever the policy.
|
|
114
|
+
await registerAndVerifyEmail(actor.ctx, actor.email);
|
|
115
|
+
|
|
116
|
+
const real = await startLogin(actor.ctx, actor.email);
|
|
117
|
+
const unknown = await startLogin(actor.ctx, UNKNOWN());
|
|
118
|
+
|
|
119
|
+
const ask = (token: string) =>
|
|
120
|
+
actor.ctx.post('/webauthn/login/start', {
|
|
121
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
122
|
+
data: { credentialId: 'not-a-real-credential-id' },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const realRes = await ask(real.body.token);
|
|
126
|
+
const unknownRes = await ask(unknown.body.token);
|
|
127
|
+
|
|
128
|
+
expect(unknownRes.status()).toBe(realRes.status());
|
|
129
|
+
expect(await unknownRes.text()).toBe(await realRes.text());
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a malformed identifier is still rejected', async ({ actor }) => {
|
|
133
|
+
// Not an enumeration signal: it does not depend on whether an account exists, and
|
|
134
|
+
// answering 200 here would leave a caller with no way to learn it typed nonsense.
|
|
135
|
+
const { status } = await startLogin(actor.ctx, 'not-an-identifier');
|
|
136
|
+
|
|
137
|
+
expect(status).toBe(400);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { request as playwrightRequest } from '@playwright/test';
|
|
2
2
|
|
|
3
|
-
import { ADAPTER_URL, API_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env';
|
|
3
|
+
import { ADAPTER_URL, API_URL, FASTIFY_ADAPTER_URL, MOCK_OIDC_PORT, REACT_URL } from './lib/env';
|
|
4
4
|
import { startMockOidc } from './mock-oidc';
|
|
5
5
|
|
|
6
6
|
async function waitForHealth(url: string, name: string, timeoutMs = 120_000): Promise<void> {
|
|
@@ -39,6 +39,9 @@ export default async function globalSetup(): Promise<void> {
|
|
|
39
39
|
if (process.env.SEAMLESS_VERIFY_ADAPTER === '1') {
|
|
40
40
|
await waitForHealth(`${ADAPTER_URL}/`, 'adapter');
|
|
41
41
|
}
|
|
42
|
+
if (process.env.SEAMLESS_VERIFY_ADAPTER_FASTIFY === '1') {
|
|
43
|
+
await waitForHealth(`${FASTIFY_ADAPTER_URL}/`, 'adapter-fastify');
|
|
44
|
+
}
|
|
42
45
|
if (process.env.SEAMLESS_VERIFY_REACT === '1') {
|
|
43
46
|
await waitForHealth(`${REACT_URL}/health`, 'react');
|
|
44
47
|
}
|
|
@@ -24,9 +24,13 @@ export async function newApiActor(prefix = 'verify'): Promise<Actor> {
|
|
|
24
24
|
return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// A browser-like actor for the cookie path: a context bound to
|
|
27
|
+
// A browser-like actor for the cookie path: a context bound to an adapter that
|
|
28
28
|
// persists cookies across requests (the adapter handles service tokens internally).
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// `baseURL` selects which adapter, so the same specs drive Express and Fastify.
|
|
30
|
+
export async function newAdapterActor(
|
|
31
|
+
baseURL: string = ADAPTER_URL,
|
|
32
|
+
prefix = 'verify',
|
|
33
|
+
): Promise<Actor> {
|
|
34
|
+
const ctx = await playwrightRequest.newContext({ baseURL });
|
|
31
35
|
return { email: uniqueEmail(prefix), ctx, dispose: () => ctx.dispose() };
|
|
32
36
|
}
|
|
@@ -4,6 +4,10 @@ import { randomInt, randomUUID } from 'crypto';
|
|
|
4
4
|
|
|
5
5
|
export const API_URL = process.env.SEAMLESS_API_URL ?? 'http://localhost:5312';
|
|
6
6
|
export const ADAPTER_URL = process.env.SEAMLESS_ADAPTER_URL ?? 'http://localhost:3000';
|
|
7
|
+
// The Fastify twin of the adapter, on its own port. The adapter specs run against
|
|
8
|
+
// both, so a regression in one framework is visible on its own row of the matrix.
|
|
9
|
+
export const FASTIFY_ADAPTER_URL =
|
|
10
|
+
process.env.SEAMLESS_FASTIFY_ADAPTER_URL ?? 'http://localhost:3001';
|
|
7
11
|
export const REACT_URL = process.env.SEAMLESS_REACT_URL ?? 'http://localhost:5173';
|
|
8
12
|
export const MOCK_OIDC_PORT = Number(process.env.SEAMLESS_MOCK_OIDC_PORT ?? 9000);
|
|
9
13
|
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import { test as base } from '@playwright/test';
|
|
2
2
|
|
|
3
3
|
import { Actor, newAdapterActor, newApiActor } from './client';
|
|
4
|
+
import { ADAPTER_URL } from './env';
|
|
5
|
+
|
|
6
|
+
// `adapterUrl` is a project option rather than something a spec sets: the adapter
|
|
7
|
+
// suite is written once and each project points it at a different adopter backend
|
|
8
|
+
// (Express, Fastify). A spec cannot tell which one answered, which is the point.
|
|
9
|
+
export interface AdapterOptions {
|
|
10
|
+
adapterUrl: string;
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
// `actor` drives the API directly (Bearer + service token); `adapterActor` drives
|
|
6
14
|
// the adopter backend over cookies. Both are auto-created and disposed per test.
|
|
7
|
-
export const test = base.extend<{ actor: Actor; adapterActor: Actor }>({
|
|
15
|
+
export const test = base.extend<AdapterOptions & { actor: Actor; adapterActor: Actor }>({
|
|
16
|
+
adapterUrl: [ADAPTER_URL, { option: true }],
|
|
8
17
|
actor: async ({}, use) => {
|
|
9
18
|
const actor = await newApiActor();
|
|
10
19
|
await use(actor);
|
|
11
20
|
await actor.dispose();
|
|
12
21
|
},
|
|
13
|
-
adapterActor: async ({}, use) => {
|
|
14
|
-
const actor = await newAdapterActor();
|
|
22
|
+
adapterActor: async ({ adapterUrl }, use) => {
|
|
23
|
+
const actor = await newAdapterActor(adapterUrl);
|
|
15
24
|
await use(actor);
|
|
16
25
|
await actor.dispose();
|
|
17
26
|
},
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
|
|
2
2
|
|
|
3
3
|
// Prints a flow x layer conformance grid at the end of the run. Layer comes from
|
|
4
|
-
// the
|
|
5
|
-
//
|
|
4
|
+
// the Playwright project; flow from the spec file name, with a few aliases folded
|
|
5
|
+
// together so the same flow lines up across layers.
|
|
6
6
|
|
|
7
|
-
const LAYERS = ['api', 'adapter', 'react'] as const;
|
|
7
|
+
const LAYERS = ['api', 'adapter', 'adapter-fastify', 'react'] as const;
|
|
8
8
|
type Layer = (typeof LAYERS)[number];
|
|
9
9
|
|
|
10
|
+
function isLayer(name: string): name is Layer {
|
|
11
|
+
return (LAYERS as readonly string[]).includes(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
const FLOW_ALIASES: Record<string, string> = {
|
|
11
15
|
emailOtpLogin: 'emailOtp',
|
|
12
16
|
registration: 'register',
|
|
@@ -29,7 +33,11 @@ export default class MatrixReporter implements Reporter {
|
|
|
29
33
|
onTestEnd(test: TestCase, result: TestResult): void {
|
|
30
34
|
const match = test.location.file.match(/\/(api|adapter|react)\/([^/]+)\.spec\.[tj]s$/);
|
|
31
35
|
if (!match) return;
|
|
32
|
-
|
|
36
|
+
// The project name, not the directory: the two adapter projects run the same
|
|
37
|
+
// specs from ./adapter, so the path cannot tell them apart. Falls back to the
|
|
38
|
+
// directory for a run driven by something that does not name its projects.
|
|
39
|
+
const projectName = test.parent.project()?.name ?? '';
|
|
40
|
+
const layer = isLayer(projectName) ? projectName : (match[1] as Layer);
|
|
33
41
|
const base = match[2];
|
|
34
42
|
const flow = FLOW_ALIASES[base] ?? base;
|
|
35
43
|
// Keyed by test id so the final attempt (after retries) is the one that counts.
|
|
@@ -51,11 +59,16 @@ export default class MatrixReporter implements Reporter {
|
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
const flowWidth = Math.max('flow'.length, ...flows.map((f) => f.length));
|
|
62
|
+
// Each column is as wide as its own header, so a long layer name (like
|
|
63
|
+
// adapter-fastify) widens its column instead of running into the next one.
|
|
64
|
+
const layerWidth = (layer: Layer) => layer.length + 3;
|
|
54
65
|
const pad = (text: string, width: number) =>
|
|
55
66
|
text + ' '.repeat(Math.max(0, width - text.length));
|
|
56
67
|
const row = (label: string, get: (layer: Layer) => string) =>
|
|
57
|
-
` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l),
|
|
58
|
-
const
|
|
68
|
+
` ${pad(label, flowWidth)} ${LAYERS.map((l) => pad(get(l), layerWidth(l))).join('')}`;
|
|
69
|
+
const totalWidth =
|
|
70
|
+
flowWidth + 3 + LAYERS.reduce((sum, l) => sum + layerWidth(l), 0);
|
|
71
|
+
const rule = ` ${'-'.repeat(totalWidth)}`;
|
|
59
72
|
|
|
60
73
|
const lines = [
|
|
61
74
|
'',
|
|
@@ -90,8 +90,6 @@ export async function registerWithPasskey(page: Page, email: string): Promise<vo
|
|
|
90
90
|
page.getByRole('heading', { name: /Secure Your Account with a Passkey/ }),
|
|
91
91
|
).toBeVisible();
|
|
92
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
93
|
}
|
|
96
94
|
|
|
97
95
|
/** Sign in an existing user whose passkey is registered (the ceremony auto-runs). */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { defineConfig, devices } from '@playwright/test';
|
|
2
2
|
|
|
3
|
-
import { REACT_URL } from './lib/env';
|
|
3
|
+
import { FASTIFY_ADAPTER_URL, REACT_URL } from './lib/env';
|
|
4
|
+
import type { AdapterOptions } from './lib/fixtures';
|
|
4
5
|
|
|
5
6
|
// One runner, multiple projects. `api` and `adapter` hit HTTP directly (no
|
|
6
7
|
// browser); `react` drives chromium against the starter SPA. global-setup
|
|
7
8
|
// health-gates the stack before any project runs.
|
|
8
|
-
export default defineConfig({
|
|
9
|
+
export default defineConfig<AdapterOptions>({
|
|
9
10
|
testDir: '.',
|
|
10
11
|
globalSetup: './global-setup.ts',
|
|
11
12
|
timeout: 30_000,
|
|
@@ -25,7 +26,15 @@ export default defineConfig({
|
|
|
25
26
|
// pulling browser specs into a project with no baseURL.
|
|
26
27
|
projects: [
|
|
27
28
|
{ name: 'api', testDir: './api' },
|
|
29
|
+
// Both adapter projects run the same specs from the same directory; only the
|
|
30
|
+
// adopter backend they point at differs. Adding a framework is a project
|
|
31
|
+
// entry plus a compose service, not a copy of the suite.
|
|
28
32
|
{ name: 'adapter', testDir: './adapter' },
|
|
33
|
+
{
|
|
34
|
+
name: 'adapter-fastify',
|
|
35
|
+
testDir: './adapter',
|
|
36
|
+
use: { adapterUrl: FASTIFY_ADAPTER_URL },
|
|
37
|
+
},
|
|
29
38
|
{
|
|
30
39
|
name: 'react',
|
|
31
40
|
testDir: './react',
|
package/dist/core/jwks.js
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { generateKeyPairSync } from "crypto";
|
|
2
|
-
export function generateJWKS() {
|
|
3
|
-
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
4
|
-
modulusLength: 2048,
|
|
5
|
-
publicKeyEncoding: {
|
|
6
|
-
type: "spki",
|
|
7
|
-
format: "pem",
|
|
8
|
-
},
|
|
9
|
-
privateKeyEncoding: {
|
|
10
|
-
type: "pkcs8",
|
|
11
|
-
format: "pem",
|
|
12
|
-
},
|
|
13
|
-
});
|
|
14
|
-
return {
|
|
15
|
-
publicKey,
|
|
16
|
-
privateKey,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
//# sourceMappingURL=jwks.js.map
|
package/dist/core/jwks.js.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"jwks.js","sourceRoot":"","sources":["../../src/core/jwks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,QAAQ,CAAC;AAE7C,MAAM,UAAU,YAAY;IAC1B,MAAM,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,mBAAmB,CAAC,KAAK,EAAE;QAC3D,aAAa,EAAE,IAAI;QACnB,iBAAiB,EAAE;YACjB,IAAI,EAAE,MAAM;YACZ,MAAM,EAAE,KAAK;SACd;QACD,kBAAkB,EAAE;YAClB,IAAI,EAAE,OAAO;YACb,MAAM,EAAE,KAAK;SACd;KACF,CAAC,CAAC;IAEH,OAAO;QACL,SAAS;QACT,UAAU;KACX,CAAC;AACJ,CAAC"}
|
package/dist/utils/writeEnv.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
export function writeEnv(dir, values) {
|
|
4
|
-
const env = Object.entries(values)
|
|
5
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
6
|
-
.join("\n");
|
|
7
|
-
fs.writeFileSync(path.join(dir, ".env"), env + "\n");
|
|
8
|
-
}
|
|
9
|
-
//# sourceMappingURL=writeEnv.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"writeEnv.js","sourceRoot":"","sources":["../../src/utils/writeEnv.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,MAAM,IAAI,CAAC;AAEpB,MAAM,UAAU,QAAQ,CAAC,GAAW,EAAE,MAA2B;IAC/D,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;SAC/B,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;SAC5B,IAAI,CAAC,IAAI,CAAC,CAAC;IAEd,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,GAAG,IAAI,CAAC,CAAC;AACvD,CAAC"}
|