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.
- 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 +70 -22
- 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/templates.js +185 -0
- package/dist/core/templates.js.map +1 -0
- package/dist/generators/auth/auth.js +38 -17
- 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 +24 -22
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seamless-cli",
|
|
3
|
-
"version": "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}`));
|
|
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
|
+
});
|