create-ailk 0.11.0 → 0.11.2
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/dist/content-payloads.js +2 -2
- package/dist/lib/scaffold-render-sweep.d.ts +16 -0
- package/dist/lib/scaffold-render-sweep.js +52 -3
- package/dist/module-architecture.js +11 -0
- package/package.json +1 -1
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +8 -8
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +8 -8
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +8 -8
- package/site-templates/shapes/organization-service/content/en/Service/first-engagement.mdx +8 -8
- package/site-templates/shapes/organization-service/content/en/Service/second-engagement.mdx +8 -8
- package/site-templates/shapes/organization-service/content/en/Service/third-engagement.mdx +8 -8
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +8 -8
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +8 -8
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +8 -8
- package/templates/.env.example +13 -7
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/package.json +1 -1
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +21 -16
- package/templates/apps/mcp/package.json +1 -1
- package/templates/apps/web/CLAUDE.md +1 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +1 -14
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/for/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +1 -14
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/layout.tsx +57 -56
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +1 -15
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +1 -14
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +4 -16
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +3 -15
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +0 -10
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +1 -14
- package/templates/apps/web/auth.ts +73 -13
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/versions.json +0 -1
|
@@ -37,7 +37,7 @@ import { authPreHandlerPlugin } from "../../auth.js";
|
|
|
37
37
|
function buildApp(): FastifyInstance {
|
|
38
38
|
const app = Fastify();
|
|
39
39
|
app.register(authPreHandlerPlugin, {
|
|
40
|
-
|
|
40
|
+
trustedOrigins: ["https://app.example.com"],
|
|
41
41
|
});
|
|
42
42
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
return app;
|
package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts
CHANGED
|
@@ -38,7 +38,7 @@ const BEARER = "tok_live_xyz";
|
|
|
38
38
|
|
|
39
39
|
function buildApp(): FastifyInstance {
|
|
40
40
|
const app = Fastify();
|
|
41
|
-
app.register(authPreHandlerPlugin, {
|
|
41
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
42
42
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
44
44
|
return app;
|
|
@@ -33,7 +33,7 @@ const BEARER = "tok_live_abc";
|
|
|
33
33
|
|
|
34
34
|
function buildApp(): FastifyInstance {
|
|
35
35
|
const app = Fastify();
|
|
36
|
-
app.register(authPreHandlerPlugin, {
|
|
36
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
37
37
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
38
38
|
return app;
|
|
39
39
|
}
|
|
@@ -35,7 +35,7 @@ const COOKIE_NAME = "better-auth.session_token";
|
|
|
35
35
|
|
|
36
36
|
function buildApp(): FastifyInstance {
|
|
37
37
|
const app = Fastify();
|
|
38
|
-
app.register(authPreHandlerPlugin, {
|
|
38
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
39
39
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
40
40
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
41
41
|
// HEAD is auto-registered by Fastify when GET is declared; no explicit handler needed.
|
|
@@ -72,7 +72,7 @@ describe("S13 adversarial — CSRF on cookie path", () => {
|
|
|
72
72
|
expect(JSON.parse(res.body)).toEqual({ error: "unauthorized" });
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
it("rejects POST when Origin scheme differs from the configured
|
|
75
|
+
it("rejects POST when Origin scheme differs from the configured trusted origin", async () => {
|
|
76
76
|
app = buildApp();
|
|
77
77
|
await app.ready();
|
|
78
78
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file csrf-trusted-origins.test.ts (#7395 AC-3)
|
|
3
|
+
*
|
|
4
|
+
* The cookie path's CSRF check against a multi-origin allowlist. A spoke site
|
|
5
|
+
* forwards the visitor's cookie and the browser's real `Origin` to the hub
|
|
6
|
+
* API, so a write is accepted iff that `Origin` is EXACTLY one of the
|
|
7
|
+
* configured trusted origins — never a prefix, a suffix or the first entry
|
|
8
|
+
* alone — and an empty list refuses every cookie write in every environment
|
|
9
|
+
* (the dev fail-open is gone, spec D4/D6.2). The non-rejecting resolver the
|
|
10
|
+
* checkout route uses applies the same check (#6866 D2).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import Fastify from "fastify";
|
|
14
|
+
import type { FastifyInstance } from "fastify";
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
17
|
+
jest.mock("../../../lib/prisma.js", () => ({
|
|
18
|
+
prisma: {
|
|
19
|
+
session: { findUnique: jest.fn() },
|
|
20
|
+
apiToken: {
|
|
21
|
+
findUnique: jest.fn(),
|
|
22
|
+
update: jest.fn().mockResolvedValue(undefined),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
28
|
+
import { authPreHandlerPlugin, resolveOptionalCookieUser } from "../../auth.js";
|
|
29
|
+
|
|
30
|
+
const HUB = "https://hub.example";
|
|
31
|
+
const SPOKE_A = "https://spoke-a.example";
|
|
32
|
+
const TRUSTED = [HUB, SPOKE_A];
|
|
33
|
+
const COOKIE = "better-auth.session_token=sess_tok_abc";
|
|
34
|
+
|
|
35
|
+
function buildApp(trustedOrigins: readonly string[]): FastifyInstance {
|
|
36
|
+
const app = Fastify();
|
|
37
|
+
app.register(authPreHandlerPlugin, { trustedOrigins });
|
|
38
|
+
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildResolverApp(trustedOrigins: readonly string[]): FastifyInstance {
|
|
43
|
+
const app = Fastify();
|
|
44
|
+
app.post("/resolve", async (req) =>
|
|
45
|
+
resolveOptionalCookieUser(req, { trustedOrigins }),
|
|
46
|
+
);
|
|
47
|
+
return app;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function post(app: FastifyInstance, url: string, origin?: string) {
|
|
51
|
+
return app.inject({
|
|
52
|
+
method: "POST",
|
|
53
|
+
url,
|
|
54
|
+
headers: { cookie: COOKIE, ...(origin !== undefined && { origin }) },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("#7395 — cookie-path CSRF against the trusted-origin allowlist", () => {
|
|
59
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
60
|
+
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
61
|
+
let app: FastifyInstance;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
66
|
+
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
67
|
+
id: "ses_1",
|
|
68
|
+
userId: "usr_42",
|
|
69
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
70
|
+
activeOrganizationId: null,
|
|
71
|
+
activeWorkspaceId: null,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(async () => {
|
|
76
|
+
await app.close();
|
|
77
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
|
78
|
+
else process.env.NODE_ENV = originalNodeEnv;
|
|
79
|
+
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
80
|
+
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it.each([HUB, SPOKE_A])(
|
|
84
|
+
"accepts a cookie POST from the listed origin %s",
|
|
85
|
+
async (origin) => {
|
|
86
|
+
app = buildApp(TRUSTED);
|
|
87
|
+
await app.ready();
|
|
88
|
+
|
|
89
|
+
const res = await post(app, "/echo", origin);
|
|
90
|
+
|
|
91
|
+
expect(res.statusCode).toBe(200);
|
|
92
|
+
expect(res.json()).toEqual({ userId: "usr_42" });
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
["an unlisted spoke", "https://spoke-b.example"],
|
|
98
|
+
["a listed origin as a host prefix", "https://spoke-a.example.evil.test"],
|
|
99
|
+
["an opaque origin", "null"],
|
|
100
|
+
["a case variant of a listed origin", "HTTPS://HUB.EXAMPLE"],
|
|
101
|
+
["a listed origin with a trailing slash", "https://hub.example/"],
|
|
102
|
+
["a listed origin with its default port", "https://hub.example:443"],
|
|
103
|
+
["a missing Origin", undefined],
|
|
104
|
+
])("refuses a cookie POST from %s with 401", async (_label, origin) => {
|
|
105
|
+
app = buildApp(TRUSTED);
|
|
106
|
+
await app.ready();
|
|
107
|
+
|
|
108
|
+
const res = await post(app, "/echo", origin);
|
|
109
|
+
|
|
110
|
+
expect(res.statusCode).toBe(401);
|
|
111
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses every cookie POST with an empty list, even in development", async () => {
|
|
115
|
+
process.env.NODE_ENV = "development";
|
|
116
|
+
app = buildApp([]);
|
|
117
|
+
await app.ready();
|
|
118
|
+
|
|
119
|
+
const res = await post(app, "/echo", HUB);
|
|
120
|
+
|
|
121
|
+
expect(res.statusCode).toBe(401);
|
|
122
|
+
expect(res.json()).toEqual({ error: "unauthorized" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("resolveOptionalCookieUser returns the user for a listed spoke", async () => {
|
|
126
|
+
app = buildResolverApp(TRUSTED);
|
|
127
|
+
await app.ready();
|
|
128
|
+
|
|
129
|
+
const res = await post(app, "/resolve", SPOKE_A);
|
|
130
|
+
|
|
131
|
+
expect(res.json()).toEqual({ kind: "user", userId: "usr_42" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("resolveOptionalCookieUser returns none/cross_origin for an unlisted spoke", async () => {
|
|
135
|
+
app = buildResolverApp(TRUSTED);
|
|
136
|
+
await app.ready();
|
|
137
|
+
|
|
138
|
+
const res = await post(app, "/resolve", "https://spoke-b.example");
|
|
139
|
+
|
|
140
|
+
expect(res.json()).toEqual({ kind: "none", reason: "cross_origin" });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -40,7 +40,7 @@ const COOKIE_NAME = "better-auth.session_token";
|
|
|
40
40
|
function buildApp(): FastifyInstance {
|
|
41
41
|
const app = Fastify();
|
|
42
42
|
app.register(authPreHandlerPlugin, {
|
|
43
|
-
|
|
43
|
+
trustedOrigins: ["https://app.example.com"],
|
|
44
44
|
});
|
|
45
45
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
46
46
|
return app;
|
|
@@ -46,7 +46,7 @@ let CURRENT_TIME = 1_000_000_000_000;
|
|
|
46
46
|
function buildApp(): FastifyInstance {
|
|
47
47
|
const app = Fastify();
|
|
48
48
|
app.register(authPreHandlerPlugin, {
|
|
49
|
-
|
|
49
|
+
trustedOrigins: ["https://app.example.com"],
|
|
50
50
|
});
|
|
51
51
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
52
52
|
return app;
|
|
@@ -37,7 +37,7 @@ const BEARER_HASH = createHash("sha256").update(BEARER).digest("hex");
|
|
|
37
37
|
|
|
38
38
|
function buildApp(): FastifyInstance {
|
|
39
39
|
const app = Fastify();
|
|
40
|
-
app.register(authPreHandlerPlugin, {
|
|
40
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
41
41
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
42
42
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
43
43
|
return app;
|
|
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
|
|
|
27
27
|
function buildApp(): FastifyInstance {
|
|
28
28
|
const app = Fastify();
|
|
29
29
|
app.register(authPreHandlerPlugin, {
|
|
30
|
-
|
|
30
|
+
trustedOrigins: ["https://app.example.com"],
|
|
31
31
|
});
|
|
32
32
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
33
33
|
return app;
|
|
@@ -33,7 +33,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
33
33
|
|
|
34
34
|
function buildApp(): FastifyInstance {
|
|
35
35
|
const app = Fastify();
|
|
36
|
-
app.register(authPreHandlerPlugin, {
|
|
36
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
37
37
|
app.get("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
38
38
|
app.post("/echo", async (req) => ({ userId: req.userId ?? null }));
|
|
39
39
|
return app;
|
|
@@ -31,7 +31,7 @@ const echoHandler = jest.fn(async () => ({ ok: true }));
|
|
|
31
31
|
function buildApp(): FastifyInstance {
|
|
32
32
|
const app = Fastify();
|
|
33
33
|
app.register(authPreHandlerPlugin, {
|
|
34
|
-
|
|
34
|
+
trustedOrigins: ["https://app.example.com"],
|
|
35
35
|
});
|
|
36
36
|
app.get("/echo", echoHandler);
|
|
37
37
|
app.post("/echo", echoHandler);
|
|
@@ -27,7 +27,7 @@ import { authPreHandlerPlugin } from "../auth.js";
|
|
|
27
27
|
function buildApp(): FastifyInstance {
|
|
28
28
|
const app = Fastify();
|
|
29
29
|
app.register(authPreHandlerPlugin, {
|
|
30
|
-
|
|
30
|
+
trustedOrigins: ["https://app.example.com"],
|
|
31
31
|
});
|
|
32
32
|
app.get("/echo", async () => ({ ok: true }));
|
|
33
33
|
return app;
|
|
@@ -28,7 +28,7 @@ const COOKIE = "better-auth.session_token=sess_tok_abc.signature";
|
|
|
28
28
|
function buildApp(): FastifyInstance {
|
|
29
29
|
const app = Fastify();
|
|
30
30
|
app.post("/resolve", async (req) =>
|
|
31
|
-
resolveOptionalCookieUser(req, {
|
|
31
|
+
resolveOptionalCookieUser(req, { trustedOrigins: [FRONTEND] }),
|
|
32
32
|
);
|
|
33
33
|
return app;
|
|
34
34
|
}
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* Looked up directly against the `Session` table (the row Better Auth's
|
|
9
9
|
* database adapter creates on sign-in). Cookie wins when both modes are
|
|
10
10
|
* present (locked by brief decision OQ-D / spec §3 "Both present").
|
|
11
|
-
* Non-GET requests with a cookie are required to carry
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Non-GET requests with a cookie are required to carry an Origin header
|
|
12
|
+
* that is exactly one of the configured trusted origins (#7395) — the
|
|
13
|
+
* lightweight CSRF mechanism agreed in OQ-D. Bearer-authenticated callers
|
|
14
|
+
* skip the origin check (non-browser flow).
|
|
14
15
|
*
|
|
15
16
|
* 2. Bearer path — Authorization: Bearer <token>. The token is sha256-hashed
|
|
16
17
|
* and matched against `ApiToken.token` (stored as hash). Revoked / expired
|
|
@@ -120,27 +121,21 @@ function readBearer(request: FastifyRequest): string | undefined {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
|
-
*
|
|
124
|
+
* CSRF check for cookie-authenticated mutating requests (#7395).
|
|
124
125
|
* Returns true if the request is safe (GET/HEAD/OPTIONS) or the Origin header
|
|
125
|
-
*
|
|
126
|
+
* is exactly one of the trusted origins. False otherwise — including for any
|
|
127
|
+
* write when the list is empty, in every environment.
|
|
126
128
|
*/
|
|
127
129
|
function isOriginAllowed(
|
|
128
130
|
request: FastifyRequest,
|
|
129
|
-
|
|
131
|
+
trustedOrigins: readonly string[],
|
|
130
132
|
): boolean {
|
|
131
133
|
const method = request.method.toUpperCase();
|
|
132
134
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
133
135
|
return true;
|
|
134
|
-
if (!frontendOrigin) {
|
|
135
|
-
// No FRONTEND_URL configured: fail OPEN in dev/test (zero-config OSS
|
|
136
|
-
// boot), fail CLOSED in production (security review M1 — with the
|
|
137
|
-
// multi-tenant module's state-changing routes, an unset origin must not
|
|
138
|
-
// silently disable the CSRF check in a real deployment).
|
|
139
|
-
return process.env.NODE_ENV !== "production";
|
|
140
|
-
}
|
|
141
136
|
const origin = request.headers.origin;
|
|
142
137
|
if (!origin) return false;
|
|
143
|
-
return origin
|
|
138
|
+
return trustedOrigins.includes(origin);
|
|
144
139
|
}
|
|
145
140
|
|
|
146
141
|
type SessionRow = {
|
|
@@ -187,8 +182,11 @@ async function resolveBearerToken(
|
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
export type AuthPreHandlerOptions = {
|
|
190
|
-
/**
|
|
191
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The browser origins a cookie-authenticated write may come from — exact
|
|
187
|
+
* matches only. server.ts passes `config.TRUSTED_ORIGINS` (#7395).
|
|
188
|
+
*/
|
|
189
|
+
trustedOrigins: readonly string[];
|
|
192
190
|
};
|
|
193
191
|
|
|
194
192
|
/**
|
|
@@ -214,7 +212,7 @@ export type OptionalCookieUser =
|
|
|
214
212
|
* route's buyer stamp).
|
|
215
213
|
*
|
|
216
214
|
* Applies the cookie path's own rules — the same private cookie reader,
|
|
217
|
-
* Session lookup, expiry comparison and
|
|
215
|
+
* Session lookup, expiry comparison and trusted-origin check as
|
|
218
216
|
* `makeAuthPreHandler` — so the two paths cannot drift. Reads the COOKIE only:
|
|
219
217
|
* a bearer `ApiToken` never identifies a user here. Never writes a reply and
|
|
220
218
|
* never throws; every failure is a `none` with its reason. The cookie is
|
|
@@ -238,7 +236,7 @@ export async function resolveOptionalCookieUser(
|
|
|
238
236
|
if (session.expiresAt.getTime() <= getClock().now()) {
|
|
239
237
|
return { kind: "none", reason: "expired" };
|
|
240
238
|
}
|
|
241
|
-
if (!isOriginAllowed(request, opts.
|
|
239
|
+
if (!isOriginAllowed(request, opts.trustedOrigins)) {
|
|
242
240
|
return { kind: "none", reason: "cross_origin" };
|
|
243
241
|
}
|
|
244
242
|
// Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
|
|
@@ -251,8 +249,8 @@ export async function resolveOptionalCookieUser(
|
|
|
251
249
|
* Exported standalone so route-test suites can register it directly without
|
|
252
250
|
* spinning up the full server module.
|
|
253
251
|
*/
|
|
254
|
-
export function makeAuthPreHandler(opts: AuthPreHandlerOptions
|
|
255
|
-
const {
|
|
252
|
+
export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
|
|
253
|
+
const { trustedOrigins } = opts;
|
|
256
254
|
return async function authPreHandler(
|
|
257
255
|
request: FastifyRequest,
|
|
258
256
|
reply: FastifyReply,
|
|
@@ -272,7 +270,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
|
|
|
272
270
|
() => undefined,
|
|
273
271
|
);
|
|
274
272
|
if (session && session.expiresAt.getTime() > now) {
|
|
275
|
-
if (!isOriginAllowed(request,
|
|
273
|
+
if (!isOriginAllowed(request, trustedOrigins)) {
|
|
276
274
|
reply.code(401).send(UNAUTHORIZED);
|
|
277
275
|
return;
|
|
278
276
|
}
|
|
@@ -42,7 +42,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
42
42
|
|
|
43
43
|
async function buildApp(): Promise<FastifyInstance> {
|
|
44
44
|
const app = Fastify();
|
|
45
|
-
app.register(authPreHandlerPlugin, {
|
|
45
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
46
46
|
app.register(tokenRoutes);
|
|
47
47
|
await app.ready();
|
|
48
48
|
return app;
|
|
@@ -37,7 +37,7 @@ const FRONTEND = "https://app.example.com";
|
|
|
37
37
|
|
|
38
38
|
async function buildApp(): Promise<FastifyInstance> {
|
|
39
39
|
const app = Fastify();
|
|
40
|
-
app.register(authPreHandlerPlugin, {
|
|
40
|
+
app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
|
|
41
41
|
app.register(userRoutes);
|
|
42
42
|
await app.ready();
|
|
43
43
|
return app;
|
|
@@ -91,7 +91,10 @@ import { checkoutRoutes } from "../index";
|
|
|
91
91
|
// #6877 — pin the suite's site origin to the fixtures' own origin, so every
|
|
92
92
|
// anonymous case below sends same-origin redirects. Runs before any
|
|
93
93
|
// `describe` is collected, so the #6866 block's SAME_ORIGIN reads it too.
|
|
94
|
+
// #7395 — the redirect lock and the buyer's cookie check read the trusted
|
|
95
|
+
// allowlist, not FRONTEND_URL, so the suite pins both.
|
|
94
96
|
config.FRONTEND_URL = "https://example.com";
|
|
97
|
+
config.TRUSTED_ORIGINS = ["https://example.com"];
|
|
95
98
|
|
|
96
99
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
97
100
|
|
|
@@ -1298,6 +1301,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1298
1301
|
beforeEach(async () => {
|
|
1299
1302
|
jest.clearAllMocks();
|
|
1300
1303
|
config.FRONTEND_URL = SITE_ORIGIN;
|
|
1304
|
+
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1301
1305
|
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
1302
1306
|
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1303
1307
|
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
@@ -1328,6 +1332,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1328
1332
|
delete process.env["STRIPE_SECRET_KEY"];
|
|
1329
1333
|
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1330
1334
|
config.FRONTEND_URL = SITE_ORIGIN;
|
|
1335
|
+
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1331
1336
|
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
1332
1337
|
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
1333
1338
|
});
|
|
@@ -1453,45 +1458,24 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1453
1458
|
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1454
1459
|
});
|
|
1455
1460
|
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
},
|
|
1475
|
-
],
|
|
1476
|
-
])(
|
|
1477
|
-
"#6877 R8: %s answers 500 config-error with no Stripe call",
|
|
1478
|
-
async (_label, arrange) => {
|
|
1479
|
-
const envFrontendUrl = process.env.FRONTEND_URL;
|
|
1480
|
-
const nodeEnv = config.NODE_ENV;
|
|
1481
|
-
try {
|
|
1482
|
-
arrange();
|
|
1483
|
-
const res = await post(VALID_BODY);
|
|
1484
|
-
|
|
1485
|
-
expect(res.statusCode).toBe(500);
|
|
1486
|
-
expect(res.json()).toEqual({ error: "config-error" });
|
|
1487
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1488
|
-
} finally {
|
|
1489
|
-
config.FRONTEND_URL = SITE_ORIGIN;
|
|
1490
|
-
config.NODE_ENV = nodeEnv;
|
|
1491
|
-
process.env.FRONTEND_URL = envFrontendUrl;
|
|
1492
|
-
}
|
|
1493
|
-
},
|
|
1494
|
-
);
|
|
1461
|
+
// #7395 D4 — what an unusable or unset FRONTEND_URL did here before
|
|
1462
|
+
// (#6877 R8) is now an empty trusted list: config resolves both to `[]`
|
|
1463
|
+
// (config/__tests__/trusted-origins.test.ts), and the route answers the
|
|
1464
|
+
// same 500.
|
|
1465
|
+
it("#6877 R8: an empty trusted-origin list answers 500 config-error with no Stripe call", async () => {
|
|
1466
|
+
config.TRUSTED_ORIGINS = [];
|
|
1467
|
+
|
|
1468
|
+
const res = await post(VALID_BODY);
|
|
1469
|
+
|
|
1470
|
+
expect(res.statusCode).toBe(500);
|
|
1471
|
+
expect(res.json()).toEqual({ error: "config-error" });
|
|
1472
|
+
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1473
|
+
expect(
|
|
1474
|
+
logLines.some(
|
|
1475
|
+
(l) => l["msg"] === "[checkout] no trusted browser origin is configured",
|
|
1476
|
+
),
|
|
1477
|
+
).toBe(true);
|
|
1478
|
+
});
|
|
1495
1479
|
|
|
1496
1480
|
it("#6877 R9: a refused request never reads the session cookie's row", async () => {
|
|
1497
1481
|
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
@@ -1514,3 +1498,148 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1514
1498
|
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1515
1499
|
});
|
|
1516
1500
|
});
|
|
1501
|
+
|
|
1502
|
+
// ── Trusted-origin allowlist (#7395) ──────────────────────────────────────────
|
|
1503
|
+
|
|
1504
|
+
describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () => {
|
|
1505
|
+
const SITE_ORIGIN = "https://example.com";
|
|
1506
|
+
const SPOKE_A = "https://spoke-a.example";
|
|
1507
|
+
const SESSION_TOKEN = "sess_tok_7395";
|
|
1508
|
+
const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
|
|
1509
|
+
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
1510
|
+
|
|
1511
|
+
let app: FastifyInstance;
|
|
1512
|
+
|
|
1513
|
+
function post(
|
|
1514
|
+
payload: Record<string, unknown>,
|
|
1515
|
+
headers: Record<string, string> = {},
|
|
1516
|
+
) {
|
|
1517
|
+
return app.inject({
|
|
1518
|
+
method: "POST",
|
|
1519
|
+
url: "/v1/checkout/sessions",
|
|
1520
|
+
headers,
|
|
1521
|
+
payload,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
beforeEach(async () => {
|
|
1526
|
+
jest.clearAllMocks();
|
|
1527
|
+
config.TRUSTED_ORIGINS = [SITE_ORIGIN, SPOKE_A];
|
|
1528
|
+
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
1529
|
+
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1530
|
+
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
1531
|
+
mockCreateCheckoutSession.mockResolvedValue({
|
|
1532
|
+
sessionId: "cs_test_allow7395",
|
|
1533
|
+
url: "https://checkout.stripe.com/c/pay/cs_test_allow7395",
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
app = buildApp();
|
|
1537
|
+
await app.ready();
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
afterEach(async () => {
|
|
1541
|
+
await app.close();
|
|
1542
|
+
delete process.env["STRIPE_SECRET_KEY"];
|
|
1543
|
+
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1544
|
+
(prisma.session.findUnique as jest.Mock).mockReset();
|
|
1545
|
+
(prisma.user.findUnique as jest.Mock).mockReset();
|
|
1546
|
+
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
1547
|
+
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
it("accepts an anonymous checkout with both redirects on a listed spoke", async () => {
|
|
1551
|
+
const successUrl = `${SPOKE_A}/purchase/success`;
|
|
1552
|
+
const cancelUrl = `${SPOKE_A}/purchase/cancel`;
|
|
1553
|
+
|
|
1554
|
+
const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
|
|
1555
|
+
|
|
1556
|
+
expect(res.statusCode).toBe(201);
|
|
1557
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1558
|
+
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1559
|
+
);
|
|
1560
|
+
});
|
|
1561
|
+
|
|
1562
|
+
it("accepts redirects split across two listed origins", async () => {
|
|
1563
|
+
const successUrl = `${SPOKE_A}/purchase/success`;
|
|
1564
|
+
const cancelUrl = `${SITE_ORIGIN}/pricing`;
|
|
1565
|
+
|
|
1566
|
+
const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
|
|
1567
|
+
|
|
1568
|
+
expect(res.statusCode).toBe(201);
|
|
1569
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1570
|
+
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1571
|
+
);
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
it.each([
|
|
1575
|
+
["successUrl", "https://spoke-b.example/x"],
|
|
1576
|
+
["successUrl", "https://spoke-a.example.evil.test/x"],
|
|
1577
|
+
["cancelUrl", "https://spoke-b.example/x"],
|
|
1578
|
+
["cancelUrl", "https://spoke-a.example.evil.test/x"],
|
|
1579
|
+
])(
|
|
1580
|
+
"refuses a %s on the unlisted %j with 400 naming the field and no Stripe call",
|
|
1581
|
+
async (field, url) => {
|
|
1582
|
+
const res = await post({ ...VALID_BODY, [field]: url });
|
|
1583
|
+
|
|
1584
|
+
expect(res.statusCode).toBe(400);
|
|
1585
|
+
const body = res.json();
|
|
1586
|
+
expect(body.error).toBe("validation-error");
|
|
1587
|
+
expect(body.details).toHaveLength(1);
|
|
1588
|
+
expect(body.details[0].path).toEqual([field]);
|
|
1589
|
+
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1590
|
+
},
|
|
1591
|
+
);
|
|
1592
|
+
|
|
1593
|
+
it("answers 500 config-error with no Stripe call when the list is empty", async () => {
|
|
1594
|
+
config.TRUSTED_ORIGINS = [];
|
|
1595
|
+
|
|
1596
|
+
const res = await post({
|
|
1597
|
+
...VALID_BODY,
|
|
1598
|
+
successUrl: `${SPOKE_A}/purchase/success`,
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
expect(res.statusCode).toBe(500);
|
|
1602
|
+
expect(res.json()).toEqual({ error: "config-error" });
|
|
1603
|
+
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it("signs the buyer in for a valid cookie forwarded with a listed spoke's Origin", async () => {
|
|
1607
|
+
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
1608
|
+
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
1609
|
+
id: "ses_1",
|
|
1610
|
+
userId: "usr_buyer",
|
|
1611
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
1612
|
+
activeOrganizationId: null,
|
|
1613
|
+
activeWorkspaceId: null,
|
|
1614
|
+
});
|
|
1615
|
+
(prisma.user.findUnique as jest.Mock).mockImplementation(
|
|
1616
|
+
async (args: { select: Record<string, boolean> }) => {
|
|
1617
|
+
const row: Record<string, unknown> = {
|
|
1618
|
+
id: "usr_buyer",
|
|
1619
|
+
email: "buyer@example.com",
|
|
1620
|
+
emailVerified: true,
|
|
1621
|
+
billingProvider: null,
|
|
1622
|
+
billingCustomerId: null,
|
|
1623
|
+
};
|
|
1624
|
+
return Object.fromEntries(
|
|
1625
|
+
Object.keys(args.select).map((k) => [k, row[k]]),
|
|
1626
|
+
);
|
|
1627
|
+
},
|
|
1628
|
+
);
|
|
1629
|
+
|
|
1630
|
+
const res = await post(
|
|
1631
|
+
{
|
|
1632
|
+
productCode: "ailk-pro",
|
|
1633
|
+
successUrl: `${SPOKE_A}/purchase/success`,
|
|
1634
|
+
cancelUrl: `${SPOKE_A}/purchase/cancel`,
|
|
1635
|
+
},
|
|
1636
|
+
{ cookie: COOKIE, origin: SPOKE_A },
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
expect(res.statusCode).toBe(201);
|
|
1640
|
+
expect(mockCreateCheckoutSession.mock.calls[0][0].buyer).toEqual({
|
|
1641
|
+
userId: "usr_buyer",
|
|
1642
|
+
email: "buyer@example.com",
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
});
|