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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seamless-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Seamless Auth command-line interface",
5
5
  "homepage": "https://github.com/fells-code/seamless-cli#readme",
6
6
  "bugs": {
@@ -20,12 +20,16 @@
20
20
  "build": "tsc",
21
21
  "dev": "tsx src/index.ts",
22
22
  "clean": "rm -rf dist",
23
+ "changeset": "changeset",
24
+ "version-packages": "changeset version",
25
+ "release:stable": "npm run build && changeset publish",
23
26
  "prepublishOnly": "npm run build"
24
27
  },
25
28
  "main": "dist/index.js",
26
29
  "files": [
27
30
  "dist",
28
31
  "templates",
32
+ "verify",
29
33
  "README.md",
30
34
  "LICENSE"
31
35
  ],
@@ -35,6 +39,7 @@
35
39
  "kleur": "^4.1.5"
36
40
  },
37
41
  "devDependencies": {
42
+ "@changesets/cli": "^2.31.0",
38
43
  "@types/adm-zip": "^0.5.7",
39
44
  "@types/node": "^24.10.13",
40
45
  "tsx": "^4.21.0",
@@ -0,0 +1,14 @@
1
+ FROM node:20-alpine
2
+ WORKDIR /app
3
+ RUN apk add --no-cache curl
4
+ COPY package.json ./
5
+ RUN npm install
6
+ # --local mode: install locally-built @seamless-auth/* tarballs over the registry
7
+ # versions. vendor/ is empty in --released mode, so this is a no-op there.
8
+ COPY vendor/ ./vendor/
9
+ RUN if ls ./vendor/*.tgz >/dev/null 2>&1; then \
10
+ npm install ./vendor/seamless-auth-core-*.tgz ./vendor/seamless-auth-express-*.tgz; \
11
+ fi
12
+ COPY server.mjs ./
13
+ EXPOSE 3000
14
+ CMD ["node", "server.mjs"]
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "seamless-verify-adapter",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.",
7
+ "dependencies": {
8
+ "@seamless-auth/express": "^0.6.0",
9
+ "cookie-parser": "^1.4.6",
10
+ "cors": "^2.8.5",
11
+ "express": "^4.19.2"
12
+ }
13
+ }
@@ -0,0 +1,62 @@
1
+ import cookieParser from "cookie-parser";
2
+ import cors from "cors";
3
+ import express from "express";
4
+ import createSeamlessAuthServer from "@seamless-auth/express";
5
+
6
+ // The adapter strips OTP/magic-link secrets before responding to the browser, so
7
+ // the conformance harness can't read codes from responses. These handlers receive
8
+ // the raw delivery payloads and stash them for the harness to read via /__captured.
9
+ const captured = new Map();
10
+ const ok = (channel) => ({ accepted: true, provider: "capture", channel });
11
+
12
+ const handlers = {
13
+ async sendOtpEmail({ to, token }) {
14
+ captured.set(to, { token: String(token) });
15
+ return ok("email");
16
+ },
17
+ async sendOtpSms({ to, token }) {
18
+ captured.set(to, { token: String(token) });
19
+ return ok("sms");
20
+ },
21
+ async sendMagicLinkEmail({ to, token, magicLinkUrl }) {
22
+ captured.set(to, { token, magicLinkUrl });
23
+ return ok("email");
24
+ },
25
+ async sendBootstrapInviteEmail({ to, token, inviteUrl }) {
26
+ captured.set(to, { token, inviteUrl });
27
+ return ok("email");
28
+ },
29
+ };
30
+
31
+ const app = express();
32
+ // The React app (browser) calls the adapter cross-origin with credentials, so
33
+ // CORS must echo its origin and allow cookies. WEB_ORIGIN is the React app host.
34
+ app.use(
35
+ cors({
36
+ origin: process.env.WEB_ORIGIN ?? "http://localhost:5173",
37
+ credentials: true,
38
+ }),
39
+ );
40
+ app.use(express.json());
41
+ app.use(cookieParser());
42
+
43
+ app.get("/", (_req, res) => res.json({ ok: true }));
44
+ app.get("/__captured/:email", (req, res) =>
45
+ res.json(captured.get(req.params.email) ?? null),
46
+ );
47
+
48
+ app.use(
49
+ "/auth",
50
+ createSeamlessAuthServer({
51
+ authServerUrl: process.env.AUTH_SERVER_URL,
52
+ cookieSecret: process.env.COOKIE_SIGNING_KEY,
53
+ serviceSecret: process.env.API_SERVICE_TOKEN,
54
+ issuer: process.env.APP_ORIGIN,
55
+ audience: process.env.AUTH_SERVER_URL,
56
+ jwksKid: process.env.JWKS_KID,
57
+ messaging: { handlers, defaults: { appName: "Seamless Verify" } },
58
+ }),
59
+ );
60
+
61
+ const port = Number(process.env.PORT ?? 3000);
62
+ app.listen(port, () => console.log(`verify adapter listening on :${port}`));
@@ -0,0 +1,3 @@
1
+ *.tgz
2
+ !.gitkeep
3
+ !.gitignore
File without changes
@@ -0,0 +1,154 @@
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
+ #
7
+ # Required env (the `seamless verify` command writes these into .env.verify):
8
+ # SEAMLESS_API_DIR absolute path to seamless-auth-api source
9
+ # SEAMLESS_ADAPTER_DIR absolute path to the express adapter source
10
+ # API_SERVICE_TOKEN shared secret between API and adapter
11
+ # JWKS_KID key id the adapter expects (must match the API's active kid)
12
+ # SEAMLESS_BOOTSTRAP_SECRET bearer secret for POST /bootstrap/admin-invite
13
+
14
+ name: seamless-verify
15
+
16
+ services:
17
+ postgres:
18
+ image: postgres:16
19
+ environment:
20
+ POSTGRES_USER: seamless
21
+ POSTGRES_PASSWORD: seamless
22
+ POSTGRES_DB: seamless_auth
23
+ healthcheck:
24
+ test: ['CMD-SHELL', 'pg_isready -U seamless -d seamless_auth']
25
+ interval: 3s
26
+ timeout: 3s
27
+ retries: 20
28
+
29
+ auth-api:
30
+ # Built with the production Dockerfile (compiles dist/), but run with
31
+ # NODE_ENV=test so signing keys auto-generate and prod-only env (JWKS keys)
32
+ # is skipped. validateEnvs.sh gates startup on the env below.
33
+ build:
34
+ context: ${SEAMLESS_API_DIR}
35
+ dockerfile: Dockerfile
36
+ ports:
37
+ - '5312:5312'
38
+ environment:
39
+ # `development` (not `test`): the API's JWKS endpoint only serves the
40
+ # auto-generated dev public key when NODE_ENV === 'development', even though
41
+ # signing treats any non-production env as dev. Running as development keeps
42
+ # JWKS publication working so the adapter/SDKs can verify tokens.
43
+ NODE_ENV: development
44
+ PORT: '5312'
45
+ APP_NAME: Seamless Verify
46
+ APP_ID: seamless-verify
47
+ APP_ORIGINS: http://localhost:3000,http://localhost:5173
48
+ # Must equal the adapter's AUTH_SERVER_URL: the adapter validates the `iss`
49
+ # claim on API-signed tokens against the URL it reaches the API on.
50
+ ISSUER: http://auth-api:5312
51
+ DEFAULT_ROLES: user
52
+ AVAILABLE_ROLES: user,admin
53
+ LOGIN_METHODS: passkey,magic_link,email_otp,phone_otp,oauth
54
+ DB_LOGGING: 'false'
55
+ ACCESS_TOKEN_TTL: 15m
56
+ REFRESH_TOKEN_TTL: 1h
57
+ RATE_LIMIT: '100000'
58
+ DELAY_AFTER: '100000'
59
+ RPID: localhost
60
+ ORIGINS: http://localhost:5173,http://localhost:3000
61
+ DB_HOST: postgres
62
+ DB_PORT: '5432'
63
+ DB_USER: seamless
64
+ DB_PASSWORD: seamless
65
+ DB_NAME: seamless_auth
66
+ API_SERVICE_TOKEN: ${API_SERVICE_TOKEN}
67
+ SEAMLESS_BOOTSTRAP_ENABLED: 'true'
68
+ SEAMLESS_BOOTSTRAP_SECRET: ${SEAMLESS_BOOTSTRAP_SECRET}
69
+ # OAuth: a single "mock" provider backed by the harness's in-process OIDC.
70
+ # authorizationUrl is browser/harness-visible (localhost); token/userInfo are
71
+ # called server-side from the container, so they go via host.docker.internal.
72
+ MOCK_CLIENT_SECRET: mock-secret
73
+ OAUTH_PROVIDERS: '[{"id":"mock","name":"Mock OIDC","enabled":true,"clientId":"mock-client","clientSecretEnv":"MOCK_CLIENT_SECRET","authorizationUrl":"http://localhost:9000/authorize","tokenUrl":"http://host.docker.internal:9000/token","userInfoUrl":"http://host.docker.internal:9000/userinfo","scopes":["openid","email"],"redirectUri":"http://localhost:5173/oauth/callback","redirectUris":["http://localhost:5173/oauth/callback"],"allowSignup":true,"accountLinking":"email"}]'
74
+ extra_hosts:
75
+ - 'host.docker.internal:host-gateway'
76
+ depends_on:
77
+ postgres:
78
+ condition: service_healthy
79
+ healthcheck:
80
+ test: ['CMD', 'node', 'dist/healthCheck.js']
81
+ interval: 3s
82
+ timeout: 5s
83
+ retries: 40
84
+
85
+ # Harness-owned adopter backend: real @seamless-auth/express with a capture
86
+ # transport so the harness can read OTP/magic-link codes the adapter would
87
+ # otherwise strip. ISSUER on auth-api is set to this AUTH_SERVER_URL host.
88
+ adapter:
89
+ build:
90
+ context: ./adapter-app
91
+ ports:
92
+ - '3000:3000'
93
+ environment:
94
+ PORT: '3000'
95
+ AUTH_SERVER_URL: http://auth-api:5312
96
+ APP_ORIGIN: http://localhost:3000
97
+ # Browser origin allowed by CORS (the React app, served below).
98
+ WEB_ORIGIN: http://localhost:5173
99
+ API_SERVICE_TOKEN: ${API_SERVICE_TOKEN}
100
+ COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN}
101
+ JWKS_KID: ${JWKS_KID}
102
+ depends_on:
103
+ auth-api:
104
+ condition: service_healthy
105
+ healthcheck:
106
+ test: ['CMD', 'curl', '-fsS', 'http://localhost:3000/']
107
+ interval: 3s
108
+ timeout: 3s
109
+ retries: 20
110
+
111
+ # React starter app (built SPA on nginx), pointed at the adapter so the React
112
+ # SDK's `${apiHost}/auth/*` calls hit the adapter's mount. Browser-visible URL,
113
+ # so API_URL is the host-mapped adapter (localhost:3000), not the compose host.
114
+ # Gated behind the `react` profile so the api/adapter-only runs skip the build.
115
+ #
116
+ # The inline Dockerfile mirrors the starter's own Dockerfile, but additionally
117
+ # installs a locally-built @seamless-auth/react tarball when present (--local
118
+ # mode packs it into ./react-vendor; that dir is empty otherwise → published SDK).
119
+ react:
120
+ profiles: ['react']
121
+ build:
122
+ context: ${SEAMLESS_REACT_DIR}
123
+ additional_contexts:
124
+ sdk: ./react-vendor
125
+ dockerfile_inline: |
126
+ FROM node:20-slim AS builder
127
+ WORKDIR /app
128
+ COPY package.json package-lock.json ./
129
+ RUN npm ci
130
+ COPY . .
131
+ COPY --from=sdk . /tmp/sdk/
132
+ RUN if ls /tmp/sdk/seamless-auth-react-*.tgz >/dev/null 2>&1; then \
133
+ npm install /tmp/sdk/seamless-auth-react-*.tgz; \
134
+ fi
135
+ RUN npm run build
136
+ FROM nginx:alpine
137
+ COPY ./nginx.conf /etc/nginx/conf.d/default.conf
138
+ COPY --from=builder /app/dist /usr/share/nginx/html
139
+ COPY entrypoint.sh /entrypoint.sh
140
+ RUN chmod +x /entrypoint.sh
141
+ EXPOSE 80
142
+ CMD ["/entrypoint.sh"]
143
+ ports:
144
+ - '5173:80'
145
+ environment:
146
+ API_URL: http://localhost:3000
147
+ depends_on:
148
+ adapter:
149
+ condition: service_healthy
150
+ healthcheck:
151
+ test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost/health']
152
+ interval: 3s
153
+ timeout: 3s
154
+ retries: 20
@@ -0,0 +1,12 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { loginViaEmailOtp, registerAndVerifyEmail } from '../lib/adapterFlows';
3
+
4
+ test.describe('email OTP login (adapter, cookies)', () => {
5
+ test('register -> verify -> login OTP -> session cookie -> /users/me', async ({ adapterActor }) => {
6
+ await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email);
7
+ await loginViaEmailOtp(adapterActor.ctx, adapterActor.email);
8
+
9
+ const me = await adapterActor.ctx.get('/auth/users/me');
10
+ expect(me.status(), 'authenticated via session cookie').toBe(200);
11
+ });
12
+ });
@@ -0,0 +1,14 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { loginViaMagicLink, registerAndVerifyEmail } from '../lib/adapterFlows';
3
+
4
+ test.describe('magic link login (adapter, cookies)', () => {
5
+ test('register -> verify -> magic-link request/verify/poll -> session -> /users/me', async ({
6
+ adapterActor,
7
+ }) => {
8
+ await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email);
9
+ await loginViaMagicLink(adapterActor.ctx, adapterActor.email);
10
+
11
+ const me = await adapterActor.ctx.get('/auth/users/me');
12
+ expect(me.status(), 'authenticated via magic-link session cookie').toBe(200);
13
+ });
14
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { oauthLogin } from '../lib/flows';
3
+
4
+ test.describe('OAuth login (adapter, cookies)', () => {
5
+ test('oauth callback sets a session cookie -> /users/me', async ({ adapterActor }) => {
6
+ await oauthLogin(adapterActor.ctx, 'mock', '/auth');
7
+
8
+ const me = await adapterActor.ctx.get('/auth/users/me');
9
+ expect(me.status(), 'authenticated via the oauth session cookie').toBe(200);
10
+ });
11
+ });
@@ -0,0 +1,11 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { registerAndVerifyEmail } from '../lib/adapterFlows';
3
+
4
+ test.describe('registration (adapter, cookies)', () => {
5
+ test('register + verify email OTP issues a session cookie', async ({ adapterActor }) => {
6
+ await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email);
7
+
8
+ const me = await adapterActor.ctx.get('/auth/users/me');
9
+ expect(me.status(), 'authenticated via the registration session cookie').toBe(200);
10
+ });
11
+ });
@@ -0,0 +1,20 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { loginViaMagicLink, registerAndVerifyEmail } from '../lib/adapterFlows';
3
+
4
+ test.describe('session lifecycle (adapter, cookies)', () => {
5
+ test('logout clears the session', async ({ adapterActor }) => {
6
+ // Establish the session via magic-link login: it sets the session cookie and
7
+ // uses the magic-link rate limiter, keeping this spec off the OTP limiter so
8
+ // the adapter project's aggregate OTP traffic stays under the per-IP cap.
9
+ await registerAndVerifyEmail(adapterActor.ctx, adapterActor.email);
10
+ await loginViaMagicLink(adapterActor.ctx, adapterActor.email);
11
+
12
+ expect((await adapterActor.ctx.get('/auth/users/me')).status(), 'authenticated').toBe(200);
13
+
14
+ const out = await adapterActor.ctx.delete('/auth/logout');
15
+ expect(out.status(), 'logout returns 204').toBe(204);
16
+
17
+ const me = await adapterActor.ctx.get('/auth/users/me');
18
+ expect(me.status(), 'session rejected after logout').not.toBe(200);
19
+ });
20
+ });
@@ -0,0 +1,34 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import {
3
+ createBootstrapInvite,
4
+ pollMagicLink,
5
+ registerWithBootstrapToken,
6
+ requestMagicLink,
7
+ verifyMagicLink,
8
+ } from '../lib/flows';
9
+
10
+ test.describe('admin bootstrap (api)', () => {
11
+ // The first-admin invite is single-use per DB (the API returns 410 once an
12
+ // admin exists), so this relies on the fresh DB `seamless verify` provisions.
13
+ test('invite -> register -> magic-link completion promotes the user to admin', async ({
14
+ actor,
15
+ }) => {
16
+ const inviteToken = await createBootstrapInvite(actor.ctx, actor.email);
17
+ const ephemeral = await registerWithBootstrapToken(actor.ctx, actor.email, inviteToken);
18
+
19
+ const { token } = await requestMagicLink(actor.ctx, ephemeral);
20
+ const verified = await verifyMagicLink(actor.ctx, token);
21
+ expect(verified.ok(), `verify -> ${verified.status()}`).toBeTruthy();
22
+
23
+ const completed = await pollMagicLink(actor.ctx, ephemeral);
24
+ expect(completed.status(), 'poll after verify issues a session').toBe(200);
25
+
26
+ const body = await completed.json();
27
+ expect(body.token, 'session access token').toBeTruthy();
28
+ const roles = (body.roles ?? []) as string[];
29
+ expect(
30
+ roles.includes('admin'),
31
+ `bootstrap promotion grants the admin role (got ${JSON.stringify(roles)})`,
32
+ ).toBeTruthy();
33
+ });
34
+ });
@@ -0,0 +1,17 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { registerAndVerifyEmail, registerEmail, requestEmailOtp, verifyEmailOtp } from '../lib/flows';
3
+
4
+ test.describe('email OTP (api)', () => {
5
+ test('register -> request OTP -> verify -> session issued', async ({ actor }) => {
6
+ const session = await registerAndVerifyEmail(actor.ctx, actor.email);
7
+ expect(session.token).toBeTruthy();
8
+ expect(session.refreshToken).toBeTruthy();
9
+ });
10
+
11
+ test('a wrong OTP code is rejected with 401', async ({ actor }) => {
12
+ const ephemeral = await registerEmail(actor.ctx, actor.email);
13
+ await requestEmailOtp(actor.ctx, ephemeral); // issue a real code we deliberately won't use
14
+ const res = await verifyEmailOtp(actor.ctx, ephemeral, 'ZZZZZZ');
15
+ expect(res.status()).toBe(401);
16
+ });
17
+ });
@@ -0,0 +1,17 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+
3
+ test.describe('JWKS (api)', () => {
4
+ test('publishes the active signing key as a JWK', async ({ actor }) => {
5
+ const res = await actor.ctx.get('/.well-known/jwks.json');
6
+ expect(res.status(), 'JWKS endpoint is available').toBe(200);
7
+
8
+ const body = await res.json();
9
+ expect(Array.isArray(body.keys), 'has a keys array').toBeTruthy();
10
+
11
+ const key = body.keys.find((k: { kid?: string }) => k.kid === 'dev-main');
12
+ expect(key, 'publishes the dev-main signing key').toBeTruthy();
13
+ expect(key.kty).toBe('RSA');
14
+ expect(key.use).toBe('sig');
15
+ expect(key.alg).toBe('RS256');
16
+ });
17
+ });
@@ -0,0 +1,36 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import {
3
+ login,
4
+ pollMagicLink,
5
+ registerAndVerifyEmail,
6
+ requestMagicLink,
7
+ verifyMagicLink,
8
+ } from '../lib/flows';
9
+
10
+ test.describe('magic link (api)', () => {
11
+ test('request -> poll(waiting) -> verify -> poll issues a session', async ({ actor }) => {
12
+ await registerAndVerifyEmail(actor.ctx, actor.email); // user must exist + be verified
13
+ const ephemeral = await login(actor.ctx, actor.email);
14
+
15
+ const { token } = await requestMagicLink(actor.ctx, ephemeral);
16
+
17
+ // Polling before the link is verified must return 204 (still waiting) — never 500.
18
+ // This is the exact regression that created the otp-sanatization branch.
19
+ const pending = await pollMagicLink(actor.ctx, ephemeral);
20
+ expect(pending.status(), 'poll before verify is 204, not 500').toBe(204);
21
+
22
+ const verified = await verifyMagicLink(actor.ctx, token);
23
+ expect(verified.ok(), `verify -> ${verified.status()}`).toBeTruthy();
24
+
25
+ const completed = await pollMagicLink(actor.ctx, ephemeral);
26
+ expect(completed.status(), 'poll after verify issues a session').toBe(200);
27
+ const body = await completed.json();
28
+ expect(body.token, 'session access token').toBeTruthy();
29
+ expect(body.refreshToken, 'session refresh token').toBeTruthy();
30
+ });
31
+
32
+ test('an invalid magic-link token is rejected', async ({ actor }) => {
33
+ const res = await verifyMagicLink(actor.ctx, 'not-a-real-token');
34
+ expect(res.status()).toBe(400);
35
+ });
36
+ });
@@ -0,0 +1,12 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { oauthLogin } from '../lib/flows';
3
+
4
+ test.describe('OAuth (api)', () => {
5
+ test('start -> authorize -> callback issues a session', async ({ actor }) => {
6
+ const session = await oauthLogin(actor.ctx);
7
+
8
+ expect(session.token, 'oauth issues an access token').toBeTruthy();
9
+ expect(session.refreshToken, 'oauth issues a refresh token').toBeTruthy();
10
+ expect(session.sub, 'oauth resolves a user').toBeTruthy();
11
+ });
12
+ });
@@ -0,0 +1,28 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { registerAndVerifyEmail } from '../lib/flows';
3
+
4
+ test.describe('organizations (api)', () => {
5
+ test('create -> list -> switch active', async ({ actor }) => {
6
+ const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
7
+ const auth = { Authorization: `Bearer ${token}` };
8
+
9
+ const created = await actor.ctx.post('/organizations', {
10
+ headers: auth,
11
+ data: { name: 'Acme Inc' },
12
+ });
13
+ expect(created.ok(), `create org -> ${created.status()}`).toBeTruthy();
14
+ const orgId = (await created.json()).organization.id;
15
+ expect(orgId, 'create returns an organization id').toBeTruthy();
16
+
17
+ const list = await actor.ctx.get('/organizations', { headers: auth });
18
+ expect(list.ok()).toBeTruthy();
19
+ const orgs = (await list.json()).organizations as Array<{ id: string }>;
20
+ expect(orgs.some((o) => o.id === orgId), 'created org appears in the list').toBeTruthy();
21
+
22
+ const switched = await actor.ctx.post(`/organizations/${orgId}/switch`, {
23
+ headers: auth,
24
+ data: {},
25
+ });
26
+ expect(switched.ok(), `switch org -> ${switched.status()}`).toBeTruthy();
27
+ });
28
+ });
@@ -0,0 +1,19 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { uniquePhone } from '../lib/env';
3
+ import { registerWithPhone, requestPhoneOtp, verifyPhoneOtp } from '../lib/flows';
4
+
5
+ test.describe('phone OTP (api)', () => {
6
+ test('register with phone -> request OTP -> verify', async ({ actor }) => {
7
+ const ephemeral = await registerWithPhone(actor.ctx, actor.email, uniquePhone());
8
+ const code = await requestPhoneOtp(actor.ctx, ephemeral);
9
+ const res = await verifyPhoneOtp(actor.ctx, ephemeral, code);
10
+ expect(res.ok(), `verify-phone-otp -> ${res.status()}`).toBeTruthy();
11
+ });
12
+
13
+ test('a wrong phone OTP code is rejected with 401', async ({ actor }) => {
14
+ const ephemeral = await registerWithPhone(actor.ctx, actor.email, uniquePhone());
15
+ await requestPhoneOtp(actor.ctx, ephemeral); // issue a real code we deliberately won't use
16
+ const res = await verifyPhoneOtp(actor.ctx, ephemeral, '000000');
17
+ expect(res.status()).toBe(401);
18
+ });
19
+ });
@@ -0,0 +1,26 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { listSessions, logout, refresh, registerAndVerifyEmail } from '../lib/flows';
3
+
4
+ test.describe('session lifecycle (api)', () => {
5
+ test('refresh issues a new access token', async ({ actor }) => {
6
+ const { refreshToken } = await registerAndVerifyEmail(actor.ctx, actor.email);
7
+ const res = await refresh(actor.ctx, refreshToken);
8
+ expect(res.status()).toBe(200);
9
+ const body = await res.json();
10
+ expect(body.token, 'refresh returns a new access token').toBeTruthy();
11
+ });
12
+
13
+ test('sessions list shows the active session; logout revokes it', async ({ actor }) => {
14
+ const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
15
+
16
+ const list = await listSessions(actor.ctx, token);
17
+ expect(list.status()).toBe(200);
18
+ expect((await list.json()).total).toBeGreaterThanOrEqual(1);
19
+
20
+ const out = await logout(actor.ctx, token);
21
+ expect(out.ok(), `logout -> ${out.status()}`).toBeTruthy();
22
+
23
+ const after = await listSessions(actor.ctx, token);
24
+ expect(after.status(), 'access token rejected after logout').toBe(401);
25
+ });
26
+ });
@@ -0,0 +1,39 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { registerAndVerifyEmail } from '../lib/flows';
3
+ import { totp } from '../lib/totp';
4
+
5
+ test.describe('step-up (api)', () => {
6
+ test('TOTP MFA freshens the session step-up status', async ({ actor }) => {
7
+ const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
8
+ const auth = { Authorization: `Bearer ${token}` };
9
+
10
+ // Enroll TOTP (enrollment alone must not satisfy step-up).
11
+ const start = await actor.ctx.post('/totp/enroll/start', { headers: auth, data: {} });
12
+ expect(start.ok(), `enroll/start -> ${start.status()}`).toBeTruthy();
13
+ const { secret } = await start.json();
14
+ const enroll = await actor.ctx.post('/totp/enroll/verify', {
15
+ headers: auth,
16
+ data: { code: totp(secret) },
17
+ });
18
+ expect(enroll.ok(), `enroll/verify -> ${enroll.status()}`).toBeTruthy();
19
+
20
+ const before = await actor.ctx.get('/step-up/status', { headers: auth });
21
+ expect(before.ok()).toBeTruthy();
22
+ expect((await before.json()).fresh, 'step-up is not fresh before MFA').toBe(false);
23
+
24
+ // Verifying TOTP MFA records a step-up. Use the next window's code: the API
25
+ // rejects a code whose counter is not strictly greater than the last used one
26
+ // (enrollment just consumed the current window), and accepts up to +1 of skew.
27
+ const mfa = await actor.ctx.post('/totp/verify-mfa', {
28
+ headers: auth,
29
+ data: { code: totp(secret, Date.now() + 30_000) },
30
+ });
31
+ expect(mfa.ok(), `verify-mfa -> ${mfa.status()} ${await mfa.text()}`).toBeTruthy();
32
+ const mfaBody = await mfa.json();
33
+ expect(mfaBody.fresh, 'verify-mfa returns a fresh step-up').toBe(true);
34
+ expect(mfaBody.method, 'step-up method is totp').toBe('totp');
35
+
36
+ const after = await actor.ctx.get('/step-up/status', { headers: auth });
37
+ expect((await after.json()).fresh, 'step-up is fresh after MFA').toBe(true);
38
+ });
39
+ });
@@ -0,0 +1,24 @@
1
+ import { expect, test } from '../lib/fixtures';
2
+ import { registerAndVerifyEmail } from '../lib/flows';
3
+ import { totp } from '../lib/totp';
4
+
5
+ test.describe('TOTP (api)', () => {
6
+ test('enroll -> verify enrollment with a computed code -> status enabled', async ({ actor }) => {
7
+ const { token } = await registerAndVerifyEmail(actor.ctx, actor.email);
8
+ const auth = { Authorization: `Bearer ${token}` };
9
+
10
+ const start = await actor.ctx.post('/totp/enroll/start', { headers: auth, data: {} });
11
+ expect(start.ok(), `enroll/start -> ${start.status()}`).toBeTruthy();
12
+ const { secret } = await start.json();
13
+ expect(secret, 'enroll/start returns a base32 secret').toBeTruthy();
14
+
15
+ const verify = await actor.ctx.post('/totp/enroll/verify', {
16
+ headers: auth,
17
+ data: { code: totp(secret) },
18
+ });
19
+ expect(verify.ok(), `enroll/verify -> ${verify.status()}`).toBeTruthy();
20
+
21
+ const status = await actor.ctx.get('/totp/status', { headers: auth });
22
+ expect((await status.json()).enabled, 'TOTP is enabled after enrollment').toBe(true);
23
+ });
24
+ });