seamless-cli 0.10.2 → 0.12.1
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 +198 -29
- package/dist/commands/apps.js +147 -0
- package/dist/commands/apps.js.map +1 -0
- package/dist/commands/config.js +16 -15
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/help.js +65 -187
- package/dist/commands/help.js.map +1 -1
- package/dist/commands/helpTopics.js +376 -0
- package/dist/commands/helpTopics.js.map +1 -0
- package/dist/commands/init.js +295 -76
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/instanceLogin.js +55 -0
- package/dist/commands/instanceLogin.js.map +1 -0
- package/dist/commands/login.js +42 -80
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.js +18 -8
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/org.js +5 -4
- package/dist/commands/org.js.map +1 -1
- package/dist/commands/profile.js +29 -2
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/sessions.js +9 -7
- package/dist/commands/sessions.js.map +1 -1
- package/dist/commands/templates.js +58 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/users.js +9 -7
- package/dist/commands/users.js.map +1 -1
- package/dist/commands/verify.js +43 -11
- package/dist/commands/verify.js.map +1 -1
- package/dist/commands/whoami.js +13 -5
- package/dist/commands/whoami.js.map +1 -1
- package/dist/core/args.js +11 -0
- package/dist/core/args.js.map +1 -1
- package/dist/core/authClient.js +29 -6
- package/dist/core/authClient.js.map +1 -1
- package/dist/core/cancel.js +32 -0
- package/dist/core/cancel.js.map +1 -0
- package/dist/core/config.js +45 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/confirmAction.js +26 -0
- package/dist/core/confirmAction.js.map +1 -0
- package/dist/core/images.js +7 -4
- package/dist/core/images.js.map +1 -1
- package/dist/core/interactiveLogin.js +75 -0
- package/dist/core/interactiveLogin.js.map +1 -0
- package/dist/core/loginFlow.js +24 -8
- package/dist/core/loginFlow.js.map +1 -1
- package/dist/core/output.js +23 -9
- package/dist/core/output.js.map +1 -1
- package/dist/core/portal.js +112 -8
- package/dist/core/portal.js.map +1 -1
- package/dist/core/templates.js +10 -0
- package/dist/core/templates.js.map +1 -1
- package/dist/core/tty.js +25 -0
- package/dist/core/tty.js.map +1 -0
- package/dist/generators/auth/auth.js +8 -7
- package/dist/generators/auth/auth.js.map +1 -1
- package/dist/generators/docker/docker.js +32 -22
- package/dist/generators/docker/docker.js.map +1 -1
- package/dist/index.js +89 -21
- package/dist/index.js.map +1 -1
- package/dist/prompts/appSelect.js +16 -10
- package/dist/prompts/appSelect.js.map +1 -1
- package/dist/prompts/initMode.js +77 -0
- package/dist/prompts/initMode.js.map +1 -0
- package/dist/prompts/oauthSetup.js +4 -3
- package/dist/prompts/oauthSetup.js.map +1 -1
- package/dist/prompts/projectSetup.js +77 -50
- package/dist/prompts/projectSetup.js.map +1 -1
- package/dist/testSetup.js +9 -0
- package/dist/testSetup.js.map +1 -0
- package/package.json +1 -1
- package/verify/adapter-app/package.json +1 -1
- 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 +34 -4
- package/verify/harness/api/loginEnumeration.spec.ts +139 -0
- package/verify/harness/api/ownerAdmin.spec.ts +24 -0
- package/verify/harness/global-setup.ts +4 -1
- package/verify/harness/lib/client.ts +7 -3
- package/verify/harness/lib/env.ts +7 -4
- package/verify/harness/lib/fixtures.ts +12 -3
- package/verify/harness/lib/flows.ts +1 -36
- package/verify/harness/lib/matrixReporter.ts +19 -6
- package/verify/harness/lib/reactFlows.ts +0 -2
- package/verify/harness/playwright.config.ts +19 -5
- package/dist/commands/bootstrapAdmin.js +0 -96
- package/dist/commands/bootstrapAdmin.js.map +0 -1
- package/dist/core/bootstrapSecret.js +0 -39
- package/dist/core/bootstrapSecret.js.map +0 -1
- package/verify/harness/api/adminBootstrap.spec.ts +0 -34
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import { confirm, select } from "@clack/prompts";
|
|
1
|
+
import { confirm, select, text } from "@clack/prompts";
|
|
2
|
+
import { orCancel } from "../core/cancel.js";
|
|
3
|
+
import { requireInteractive } from "../core/tty.js";
|
|
4
|
+
export const AUTH_MODES = ["docker", "local"];
|
|
5
|
+
export const ADMIN_MODES = ["api", "image", "source", "none"];
|
|
6
|
+
// What each question falls back to when --yes answers it. These match the
|
|
7
|
+
// options labelled "(recommended)" in the prompts below, so an unattended run
|
|
8
|
+
// gets the same stack a developer pressing Enter would.
|
|
9
|
+
const DEFAULT_AUTH_MODE = "docker";
|
|
10
|
+
const DEFAULT_ADMIN_MODE = "api";
|
|
2
11
|
// Builds the framework choices for one layer (web or api) from the registry, so
|
|
3
12
|
// adding a template is a registry edit, not a code change here. coming-soon
|
|
4
13
|
// templates show as disabled; beta templates are selectable but labelled.
|
|
@@ -17,57 +26,45 @@ function toOptions(templates, kind) {
|
|
|
17
26
|
disabled: t.status === "coming-soon",
|
|
18
27
|
}));
|
|
19
28
|
}
|
|
29
|
+
// The registry lists templates in the order the prompt shows them, so the first
|
|
30
|
+
// selectable one of a kind is what a developer pressing Enter would land on.
|
|
31
|
+
function defaultTemplateId(templates, kind) {
|
|
32
|
+
const entry = templates.find((t) => t.kind === kind && t.status !== "coming-soon");
|
|
33
|
+
if (!entry) {
|
|
34
|
+
throw new Error(`The template registry has no selectable ${kind} templates, so --yes has nothing to choose. Run \`seamless templates list\` to see what is available.`);
|
|
35
|
+
}
|
|
36
|
+
return entry.id;
|
|
37
|
+
}
|
|
20
38
|
function labelFor(templates, id) {
|
|
21
39
|
return templates.find((t) => t.id === id)?.label ?? id;
|
|
22
40
|
}
|
|
41
|
+
async function resolveTemplateId(templates, kind, preselected, message, echoLabel, assumeYes) {
|
|
42
|
+
const chosen = preselected ?? (assumeYes ? defaultTemplateId(templates, kind) : undefined);
|
|
43
|
+
if (chosen) {
|
|
44
|
+
console.log(`${echoLabel}: ${labelFor(templates, chosen)}`);
|
|
45
|
+
return chosen;
|
|
46
|
+
}
|
|
47
|
+
requireInteractive(message, `Pass --${kind}=<id> to choose one (see \`seamless templates list\`), or --yes to take the recommended template.`);
|
|
48
|
+
return orCancel(await select({ message, options: toOptions(templates, kind) }));
|
|
49
|
+
}
|
|
23
50
|
// Managed connect only needs the web and api templates: the auth server is the
|
|
24
51
|
// developer's managed instance, so the auth-mode, Docker, and admin-dashboard
|
|
25
52
|
// questions (all local-stack concerns) do not apply.
|
|
26
|
-
export async function runManagedTemplatePrompts(templates, preselect = {}) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
console.log(`Web example: ${labelFor(templates, webTemplateId)}`);
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
webTemplateId = (await select({
|
|
33
|
-
message: "Web example",
|
|
34
|
-
options: toOptions(templates, "web"),
|
|
35
|
-
}));
|
|
36
|
-
}
|
|
37
|
-
let apiTemplateId = preselect.apiTemplateId;
|
|
38
|
-
if (apiTemplateId) {
|
|
39
|
-
console.log(`Backend: ${labelFor(templates, apiTemplateId)}`);
|
|
40
|
-
}
|
|
41
|
-
else {
|
|
42
|
-
apiTemplateId = (await select({
|
|
43
|
-
message: "Backend framework",
|
|
44
|
-
options: toOptions(templates, "api"),
|
|
45
|
-
}));
|
|
46
|
-
}
|
|
53
|
+
export async function runManagedTemplatePrompts(templates, preselect = {}, assumeYes = false) {
|
|
54
|
+
const webTemplateId = await resolveTemplateId(templates, "web", preselect.webTemplateId, "Web example", "Web example", assumeYes);
|
|
55
|
+
const apiTemplateId = await resolveTemplateId(templates, "api", preselect.apiTemplateId, "Backend framework", "Backend", assumeYes);
|
|
47
56
|
return { webTemplateId, apiTemplateId };
|
|
48
57
|
}
|
|
49
|
-
export async function runProjectSetupPrompts(templates, preselect = {}) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
let apiTemplateId = preselect.apiTemplateId;
|
|
61
|
-
if (apiTemplateId) {
|
|
62
|
-
console.log(`Backend: ${labelFor(templates, apiTemplateId)}`);
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
apiTemplateId = (await select({
|
|
66
|
-
message: "Backend framework",
|
|
67
|
-
options: toOptions(templates, "api"),
|
|
68
|
-
}));
|
|
69
|
-
}
|
|
70
|
-
const authMode = (await select({
|
|
58
|
+
export async function runProjectSetupPrompts(templates, preselect = {}, knownEmail, assumeYes = false) {
|
|
59
|
+
const webTemplateId = await resolveTemplateId(templates, "web", preselect.webTemplateId, "Web example", "Web example", assumeYes);
|
|
60
|
+
const apiTemplateId = await resolveTemplateId(templates, "api", preselect.apiTemplateId, "Backend framework", "Backend", assumeYes);
|
|
61
|
+
// Written to the auth server as OWNER_EMAIL, which grants the admin role to
|
|
62
|
+
// this address at signup. Asking here means registering in the scaffolded app
|
|
63
|
+
// is the only step between `docker compose up` and a working admin. There is
|
|
64
|
+
// no sane default for it, so --yes takes it from the flag or the portal
|
|
65
|
+
// session and otherwise refuses to guess.
|
|
66
|
+
const ownerEmail = await resolveOwnerEmail(preselect.ownerEmail ?? (assumeYes ? knownEmail : undefined), knownEmail, assumeYes);
|
|
67
|
+
const authMode = await resolveChoice(preselect.authMode, assumeYes ? DEFAULT_AUTH_MODE : undefined, "Auth server", "How would you like to run SeamlessAuth?", "--auth", AUTH_MODES, async () => orCancel(await select({
|
|
71
68
|
message: "How would you like to run SeamlessAuth?",
|
|
72
69
|
options: [
|
|
73
70
|
{
|
|
@@ -79,8 +76,8 @@ export async function runProjectSetupPrompts(templates, preselect = {}) {
|
|
|
79
76
|
label: "Local dev server (advanced)",
|
|
80
77
|
},
|
|
81
78
|
],
|
|
82
|
-
}));
|
|
83
|
-
const adminMode = (await select({
|
|
79
|
+
})));
|
|
80
|
+
const adminMode = await resolveChoice(preselect.adminMode, assumeYes ? DEFAULT_ADMIN_MODE : undefined, "Admin console", "How would you like to host the admin console?", "--admin", ADMIN_MODES, async () => orCancel(await select({
|
|
84
81
|
message: "How would you like to host the admin console?",
|
|
85
82
|
options: [
|
|
86
83
|
{
|
|
@@ -101,12 +98,13 @@ export async function runProjectSetupPrompts(templates, preselect = {}) {
|
|
|
101
98
|
},
|
|
102
99
|
],
|
|
103
100
|
initialValue: "api",
|
|
104
|
-
}));
|
|
105
|
-
if (authMode === "local") {
|
|
106
|
-
|
|
101
|
+
})));
|
|
102
|
+
if (authMode === "local" && !assumeYes) {
|
|
103
|
+
requireInteractive("Auth server still requires Docker for full stack. Enable Docker?", "Pass --yes; Docker is enabled either way.");
|
|
104
|
+
const confirmDocker = orCancel(await confirm({
|
|
107
105
|
message: "Auth server still requires Docker for full stack. Enable Docker?",
|
|
108
106
|
initialValue: true,
|
|
109
|
-
});
|
|
107
|
+
}));
|
|
110
108
|
if (!confirmDocker) {
|
|
111
109
|
console.log("\nDocker is required for full seamless stack. Enabling automatically.\n");
|
|
112
110
|
}
|
|
@@ -119,6 +117,35 @@ export async function runProjectSetupPrompts(templates, preselect = {}) {
|
|
|
119
117
|
authMode,
|
|
120
118
|
useDocker: true,
|
|
121
119
|
adminMode,
|
|
120
|
+
ownerEmail: ownerEmail.trim(),
|
|
122
121
|
};
|
|
123
122
|
}
|
|
123
|
+
// A flag answers the question outright; --yes falls back to the recommended
|
|
124
|
+
// option. Either way the choice is echoed, so an unattended run still reports
|
|
125
|
+
// what it picked.
|
|
126
|
+
async function resolveChoice(supplied, fallback, echoLabel, question, flag, allowed, ask) {
|
|
127
|
+
const chosen = supplied ?? fallback;
|
|
128
|
+
if (chosen) {
|
|
129
|
+
console.log(`${echoLabel}: ${chosen}`);
|
|
130
|
+
return chosen;
|
|
131
|
+
}
|
|
132
|
+
requireInteractive(question, `Pass ${flag}=<${allowed.join("|")}>, or --yes to take the recommended option.`);
|
|
133
|
+
return ask();
|
|
134
|
+
}
|
|
135
|
+
async function resolveOwnerEmail(supplied, knownEmail, assumeYes) {
|
|
136
|
+
if (supplied) {
|
|
137
|
+
console.log(`Owner email: ${supplied}`);
|
|
138
|
+
return supplied;
|
|
139
|
+
}
|
|
140
|
+
if (assumeYes) {
|
|
141
|
+
throw new Error("--yes needs an owner email, which becomes the admin when you register. Pass --email <address>, or run `seamless login` so it can be taken from your portal session.");
|
|
142
|
+
}
|
|
143
|
+
requireInteractive("Your email (becomes the admin when you register)", "Pass --email <address>, or run `seamless login` so it can be taken from your portal session.");
|
|
144
|
+
return orCancel(await text({
|
|
145
|
+
message: "Your email (becomes the admin when you register)",
|
|
146
|
+
placeholder: knownEmail ?? "you@example.com",
|
|
147
|
+
initialValue: knownEmail ?? "",
|
|
148
|
+
validate: (value) => (value ?? "").includes("@") ? undefined : "Enter a valid email address",
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
124
151
|
//# sourceMappingURL=projectSetup.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"projectSetup.js","sourceRoot":"","sources":["../../src/prompts/projectSetup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"projectSetup.js","sourceRoot":"","sources":["../../src/prompts/projectSetup.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { beforeEach } from "vitest";
|
|
2
|
+
// Prompts refuse to run without a TTY on stdin (see core/tty.ts), and vitest has
|
|
3
|
+
// none, so every test that exercises a prompt would otherwise fail on the guard
|
|
4
|
+
// rather than on what it is testing. Tests that want the no-terminal behavior
|
|
5
|
+
// set isTTY back to false in their own beforeEach, which runs after this one.
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
process.stdin.isTTY = true;
|
|
8
|
+
});
|
|
9
|
+
//# sourceMappingURL=testSetup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testSetup.js","sourceRoot":"","sources":["../src/testSetup.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAEpC,iFAAiF;AACjF,gFAAgF;AAChF,8EAA8E;AAC9E,8EAA8E;AAC9E,UAAU,CAAC,GAAG,EAAE;IACd,OAAO,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC;AAC7B,CAAC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Minimal adopter backend for the conformance harness — real @seamless-auth/express with a capture transport.",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@seamless-auth/express": "^0.
|
|
8
|
+
"@seamless-auth/express": "^0.12.1",
|
|
9
9
|
"cookie-parser": "^1.4.6",
|
|
10
10
|
"cors": "^2.8.5",
|
|
11
11
|
"express": "^5.1.0"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM node:24-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-fastify-*.tgz; \
|
|
11
|
+
fi
|
|
12
|
+
COPY server.mjs ./
|
|
13
|
+
EXPOSE 3001
|
|
14
|
+
CMD ["node", "server.mjs"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "seamless-verify-adapter-fastify",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Minimal adopter backend for the conformance harness — real @seamless-auth/fastify with a capture transport.",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@fastify/cors": "^11.0.1",
|
|
9
|
+
"@seamless-auth/fastify": "^0.3.1",
|
|
10
|
+
"fastify": "^5.2.0"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import cors from "@fastify/cors";
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import seamlessAuth from "@seamless-auth/fastify";
|
|
4
|
+
|
|
5
|
+
// The Fastify twin of adapter-app/server.mjs. Same routes, same env contract,
|
|
6
|
+
// same capture transport: the point of this service is that a spec cannot tell
|
|
7
|
+
// which adapter answered it, so any difference in behaviour is a real one.
|
|
8
|
+
//
|
|
9
|
+
// The adapter strips OTP/magic-link secrets before responding to the browser, so
|
|
10
|
+
// the conformance harness can't read codes from responses. These handlers receive
|
|
11
|
+
// the raw delivery payloads and stash them for the harness to read via /__captured.
|
|
12
|
+
const captured = new Map();
|
|
13
|
+
const ok = (channel) => ({ accepted: true, provider: "capture", channel });
|
|
14
|
+
|
|
15
|
+
const handlers = {
|
|
16
|
+
async sendOtpEmail({ to, token }) {
|
|
17
|
+
captured.set(to, { token: String(token) });
|
|
18
|
+
return ok("email");
|
|
19
|
+
},
|
|
20
|
+
async sendOtpSms({ to, token }) {
|
|
21
|
+
captured.set(to, { token: String(token) });
|
|
22
|
+
return ok("sms");
|
|
23
|
+
},
|
|
24
|
+
async sendMagicLinkEmail({ to, token, magicLinkUrl }) {
|
|
25
|
+
captured.set(to, { token, magicLinkUrl });
|
|
26
|
+
return ok("email");
|
|
27
|
+
},
|
|
28
|
+
async sendBootstrapInviteEmail({ to, token, inviteUrl }) {
|
|
29
|
+
captured.set(to, { token, inviteUrl });
|
|
30
|
+
return ok("email");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const app = Fastify();
|
|
35
|
+
|
|
36
|
+
// The React app (browser) calls the adapter cross-origin with credentials, so
|
|
37
|
+
// CORS must echo its origin and allow cookies. WEB_ORIGIN is the React app host.
|
|
38
|
+
await app.register(cors, {
|
|
39
|
+
origin: process.env.WEB_ORIGIN ?? "http://localhost:5173",
|
|
40
|
+
credentials: true,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get("/", async () => ({ ok: true }));
|
|
44
|
+
app.get("/__captured/:email", async (req) => captured.get(req.params.email) ?? null);
|
|
45
|
+
|
|
46
|
+
// Registered under a prefix, which is how the plugin scopes its cookie and origin
|
|
47
|
+
// hooks. @fastify/cookie comes with the plugin, so it is not registered here.
|
|
48
|
+
// There is no `issuer` option on this adapter (the Express one takes one); the
|
|
49
|
+
// audience is what both check the API's tokens against.
|
|
50
|
+
await app.register(seamlessAuth, {
|
|
51
|
+
prefix: "/auth",
|
|
52
|
+
authServerUrl: process.env.AUTH_SERVER_URL,
|
|
53
|
+
cookieSecret: process.env.COOKIE_SIGNING_KEY,
|
|
54
|
+
serviceSecret: process.env.API_SERVICE_TOKEN,
|
|
55
|
+
audience: process.env.AUTH_SERVER_URL,
|
|
56
|
+
jwksKid: process.env.JWKS_KID,
|
|
57
|
+
messaging: { handlers, defaults: { appName: "Seamless Verify" } },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const port = Number(process.env.PORT ?? 3001);
|
|
61
|
+
// 0.0.0.0, not the Fastify default of localhost: the port is published out of the
|
|
62
|
+
// container, and a listener bound to loopback inside it is unreachable from the host.
|
|
63
|
+
await app.listen({ port, host: "0.0.0.0" });
|
|
64
|
+
console.log(`verify fastify adapter listening on :${port}`);
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Build cache for CI, layered over docker-compose.verify.yml via
|
|
2
|
+
# SEAMLESS_VERIFY_COMPOSE_OVERRIDE. Kept out of the base file because `type=gha` needs the
|
|
3
|
+
# Actions cache credentials and is an error anywhere else, so a developer running
|
|
4
|
+
# `seamless verify` locally must not pick it up.
|
|
5
|
+
#
|
|
6
|
+
# Every image here is rebuilt from scratch on every CI run today, and the Dockerfiles are
|
|
7
|
+
# already ordered for this: each installs dependencies from a lockfile before copying
|
|
8
|
+
# source, so the expensive layer survives a source change and only a lockfile change
|
|
9
|
+
# invalidates it.
|
|
10
|
+
#
|
|
11
|
+
# Scopes are per service. One shared scope would have each build overwrite the last, and
|
|
12
|
+
# they would take turns missing.
|
|
13
|
+
services:
|
|
14
|
+
auth-api:
|
|
15
|
+
build:
|
|
16
|
+
cache_from:
|
|
17
|
+
- type=gha,scope=verify-auth-api
|
|
18
|
+
cache_to:
|
|
19
|
+
- type=gha,mode=max,scope=verify-auth-api
|
|
20
|
+
|
|
21
|
+
adapter:
|
|
22
|
+
build:
|
|
23
|
+
cache_from:
|
|
24
|
+
- type=gha,scope=verify-adapter
|
|
25
|
+
cache_to:
|
|
26
|
+
- type=gha,mode=max,scope=verify-adapter
|
|
27
|
+
|
|
28
|
+
adapter-fastify:
|
|
29
|
+
build:
|
|
30
|
+
cache_from:
|
|
31
|
+
- type=gha,scope=verify-adapter-fastify
|
|
32
|
+
cache_to:
|
|
33
|
+
- type=gha,mode=max,scope=verify-adapter-fastify
|
|
34
|
+
|
|
35
|
+
# The react service is built once per web template against different source, so its
|
|
36
|
+
# scope carries the template id. Sharing one scope across templates would mean each
|
|
37
|
+
# template evicting the previous one's cache on every run.
|
|
38
|
+
react:
|
|
39
|
+
build:
|
|
40
|
+
cache_from:
|
|
41
|
+
- type=gha,scope=verify-react-${SEAMLESS_VERIFY_TEMPLATE_ID:-default}
|
|
42
|
+
cache_to:
|
|
43
|
+
- type=gha,mode=max,scope=verify-react-${SEAMLESS_VERIFY_TEMPLATE_ID:-default}
|
|
@@ -9,13 +9,16 @@
|
|
|
9
9
|
# SEAMLESS_ADAPTER_DIR absolute path to the express adapter source
|
|
10
10
|
# API_SERVICE_TOKEN shared secret between API and adapter
|
|
11
11
|
# JWKS_KID key id the adapter expects (must match the API's active kid)
|
|
12
|
-
#
|
|
12
|
+
# OWNER_EMAIL tenant owner; this email gets the admin role at signup
|
|
13
13
|
|
|
14
14
|
name: seamless-verify
|
|
15
15
|
|
|
16
16
|
services:
|
|
17
17
|
postgres:
|
|
18
|
-
|
|
18
|
+
# Matches POSTGRES_IMAGE in src/core/images.ts, so the harness runs the same
|
|
19
|
+
# major a fresh scaffold gets. Every run starts with `down -v`, so the volume
|
|
20
|
+
# is always new and a major bump needs no migration here.
|
|
21
|
+
image: postgres:18
|
|
19
22
|
environment:
|
|
20
23
|
POSTGRES_USER: seamless
|
|
21
24
|
POSTGRES_PASSWORD: seamless
|
|
@@ -72,8 +75,9 @@ services:
|
|
|
72
75
|
DB_PASSWORD: seamless
|
|
73
76
|
DB_NAME: seamless_auth
|
|
74
77
|
API_SERVICE_TOKEN: ${API_SERVICE_TOKEN}
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
# The first admin comes from the OWNER_EMAIL grant at signup, so the
|
|
79
|
+
# harness can assert admin promotion without a separate invite flow.
|
|
80
|
+
OWNER_EMAIL: ${OWNER_EMAIL}
|
|
77
81
|
# OAuth: a single "mock" provider backed by the harness's in-process OIDC.
|
|
78
82
|
# authorizationUrl is browser/harness-visible (localhost); token/userInfo are
|
|
79
83
|
# called server-side from the container, so they go via host.docker.internal.
|
|
@@ -116,6 +120,32 @@ services:
|
|
|
116
120
|
timeout: 3s
|
|
117
121
|
retries: 20
|
|
118
122
|
|
|
123
|
+
# The same adopter backend on Fastify, so the cookie path is conformance-tested
|
|
124
|
+
# for both adapters rather than only the one the harness happens to be written
|
|
125
|
+
# against. Identical env contract and capture transport; only the framework and
|
|
126
|
+
# the published port differ, which is what makes a failure here attributable.
|
|
127
|
+
adapter-fastify:
|
|
128
|
+
build:
|
|
129
|
+
context: ./adapter-fastify-app
|
|
130
|
+
ports:
|
|
131
|
+
- '3001:3001'
|
|
132
|
+
environment:
|
|
133
|
+
PORT: '3001'
|
|
134
|
+
AUTH_SERVER_URL: http://auth-api:5312
|
|
135
|
+
APP_ORIGIN: http://localhost:3001
|
|
136
|
+
WEB_ORIGIN: http://localhost:5173
|
|
137
|
+
API_SERVICE_TOKEN: ${API_SERVICE_TOKEN}
|
|
138
|
+
COOKIE_SIGNING_KEY: ${API_SERVICE_TOKEN}
|
|
139
|
+
JWKS_KID: ${JWKS_KID}
|
|
140
|
+
depends_on:
|
|
141
|
+
auth-api:
|
|
142
|
+
condition: service_healthy
|
|
143
|
+
healthcheck:
|
|
144
|
+
test: ['CMD', 'curl', '-fsS', 'http://localhost:3001/']
|
|
145
|
+
interval: 3s
|
|
146
|
+
timeout: 3s
|
|
147
|
+
retries: 20
|
|
148
|
+
|
|
119
149
|
# React starter app (built SPA on nginx), pointed at the adapter so the React
|
|
120
150
|
# SDK's `${apiHost}/auth/*` calls hit the adapter's mount. Browser-visible URL,
|
|
121
151
|
# 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
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { OWNER_EMAIL } from '../lib/env';
|
|
2
|
+
import { expect, test } from '../lib/fixtures';
|
|
3
|
+
import { registerEmail, requestEmailOtp, verifyEmailOtp } from '../lib/flows';
|
|
4
|
+
|
|
5
|
+
test.describe('owner admin (api)', () => {
|
|
6
|
+
// The owner address is fixed in the stack's OWNER_EMAIL before boot, so this
|
|
7
|
+
// spec registers that exact email rather than the actor fixture's random one.
|
|
8
|
+
test('registering the owner email grants the admin role at signup', async ({ actor }) => {
|
|
9
|
+
const ephemeral = await registerEmail(actor.ctx, OWNER_EMAIL);
|
|
10
|
+
const code = await requestEmailOtp(actor.ctx, ephemeral);
|
|
11
|
+
|
|
12
|
+
const res = await verifyEmailOtp(actor.ctx, ephemeral, code);
|
|
13
|
+
expect(res.ok(), `verify-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy();
|
|
14
|
+
|
|
15
|
+
const body = await res.json();
|
|
16
|
+
expect(body.token, 'session access token').toBeTruthy();
|
|
17
|
+
|
|
18
|
+
const roles = (body.roles ?? []) as string[];
|
|
19
|
+
expect(
|
|
20
|
+
roles.includes('admin'),
|
|
21
|
+
`owner signup grants the admin role (got ${JSON.stringify(roles)})`,
|
|
22
|
+
).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -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
|
}
|