alepha 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/api/jobs/index.d.ts +20 -20
  2. package/dist/api/jobs/index.d.ts.map +1 -1
  3. package/dist/api/keys/index.d.ts +6 -6
  4. package/dist/api/users/index.d.ts +43 -9
  5. package/dist/api/users/index.d.ts.map +1 -1
  6. package/dist/api/users/index.js +24 -3
  7. package/dist/api/users/index.js.map +1 -1
  8. package/dist/api/verifications/index.d.ts +13 -13
  9. package/dist/cli/core/index.d.ts +46 -40
  10. package/dist/cli/core/index.d.ts.map +1 -1
  11. package/dist/cli/core/index.js +51 -101
  12. package/dist/cli/core/index.js.map +1 -1
  13. package/dist/cli/i18n/index.d.ts +12 -5
  14. package/dist/cli/i18n/index.d.ts.map +1 -1
  15. package/dist/cli/i18n/index.js +45 -11
  16. package/dist/cli/i18n/index.js.map +1 -1
  17. package/dist/cli/platform-lib/index.d.ts +32 -6
  18. package/dist/cli/platform-lib/index.d.ts.map +1 -1
  19. package/dist/cli/platform-lib/index.js +82 -19
  20. package/dist/cli/platform-lib/index.js.map +1 -1
  21. package/dist/command/index.d.ts +1 -1
  22. package/dist/mcp/index.d.ts +9 -0
  23. package/dist/mcp/index.d.ts.map +1 -1
  24. package/dist/mcp/index.js +23 -0
  25. package/dist/mcp/index.js.map +1 -1
  26. package/dist/react/form/index.d.ts +0 -1
  27. package/dist/react/form/index.d.ts.map +1 -1
  28. package/dist/react/form/index.js +16 -15
  29. package/dist/react/form/index.js.map +1 -1
  30. package/dist/react/i18n/index.d.ts +43 -0
  31. package/dist/react/i18n/index.d.ts.map +1 -1
  32. package/dist/react/i18n/index.js +114 -10
  33. package/dist/react/i18n/index.js.map +1 -1
  34. package/dist/react/router/index.browser.js +128 -5
  35. package/dist/react/router/index.browser.js.map +1 -1
  36. package/dist/react/router/index.d.ts +108 -1
  37. package/dist/react/router/index.d.ts.map +1 -1
  38. package/dist/react/router/index.js +184 -6
  39. package/dist/react/router/index.js.map +1 -1
  40. package/dist/react/sitemap/index.browser.js +35 -0
  41. package/dist/react/sitemap/index.browser.js.map +1 -0
  42. package/dist/react/sitemap/index.d.ts +92 -0
  43. package/dist/react/sitemap/index.d.ts.map +1 -0
  44. package/dist/react/sitemap/index.js +131 -0
  45. package/dist/react/sitemap/index.js.map +1 -0
  46. package/dist/server/auth/index.d.ts +105 -1
  47. package/dist/server/auth/index.d.ts.map +1 -1
  48. package/dist/server/auth/index.js +1604 -7
  49. package/dist/server/auth/index.js.map +1 -1
  50. package/dist/server/cookies/index.d.ts +15 -0
  51. package/dist/server/cookies/index.d.ts.map +1 -1
  52. package/dist/server/cookies/index.js +22 -3
  53. package/dist/server/cookies/index.js.map +1 -1
  54. package/dist/server/core/index.d.ts +18 -0
  55. package/dist/server/core/index.d.ts.map +1 -1
  56. package/dist/server/core/index.js +25 -0
  57. package/dist/server/core/index.js.map +1 -1
  58. package/package.json +16 -3
  59. package/src/api/users/controllers/RealmController.ts +1 -0
  60. package/src/api/users/primitives/$realm.ts +26 -0
  61. package/src/api/users/providers/RealmProvider.ts +15 -0
  62. package/src/api/users/schemas/realmConfigSchema.ts +14 -0
  63. package/src/cli/core/atoms/buildOptions.ts +0 -12
  64. package/src/cli/core/commands/build.ts +0 -10
  65. package/src/cli/core/index.ts +0 -3
  66. package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
  67. package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
  68. package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
  69. package/src/cli/i18n/services/I18nCheckService.ts +65 -11
  70. package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
  71. package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
  72. package/src/mcp/providers/McpServerProvider.ts +55 -0
  73. package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
  74. package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
  75. package/src/react/form/services/FormModel.ts +57 -39
  76. package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
  77. package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
  78. package/src/react/i18n/providers/I18nProvider.ts +171 -12
  79. package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
  80. package/src/react/router/index.browser.ts +4 -0
  81. package/src/react/router/index.shared.ts +1 -0
  82. package/src/react/router/index.ts +9 -0
  83. package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
  84. package/src/react/router/providers/ReactPageProvider.ts +12 -1
  85. package/src/react/router/providers/ReactServerProvider.ts +92 -1
  86. package/src/react/router/providers/RootComponentsProvider.ts +13 -0
  87. package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
  88. package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
  89. package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
  90. package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
  91. package/src/react/sitemap/index.browser.ts +21 -0
  92. package/src/react/sitemap/index.ts +25 -0
  93. package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
  94. package/src/react/sitemap/primitives/$sitemap.ts +196 -0
  95. package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
  96. package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
  97. package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
  98. package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
  99. package/src/server/auth/helpers/appleClientSecret.ts +24 -0
  100. package/src/server/auth/helpers/federationAssertion.ts +74 -0
  101. package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
  102. package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
  103. package/src/server/auth/index.ts +4 -0
  104. package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
  105. package/src/server/auth/primitives/$authFederationClient.ts +89 -0
  106. package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
  107. package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
  108. package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
  109. package/src/server/core/interfaces/ServerRequest.ts +8 -0
  110. package/src/server/core/primitives/$route.ts +27 -0
  111. package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
@@ -0,0 +1,196 @@
1
+ import { $inject, createPrimitive, KIND, Primitive } from "alepha";
2
+ import { DateTimeProvider } from "alepha/datetime";
3
+ import { type ServerRequest, ServerRouterProvider } from "alepha/server";
4
+
5
+ /**
6
+ * Expose a `sitemap.xml` generated from the application's `$page` primitives.
7
+ *
8
+ * Registers a `GET /sitemap.xml` route that reads every registered page at
9
+ * request time and emits a standard XML sitemap. Marked `static` by default, so
10
+ * the build prerenders it to `dist/public/sitemap.xml` for static deployments —
11
+ * while SSR runtimes also serve it live.
12
+ *
13
+ * The hostname comes from `options.hostname`, falling back to `PUBLIC_URL`, then
14
+ * to `""` (relative URLs).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { $sitemap } from "alepha/react/sitemap";
19
+ *
20
+ * class AppRouter {
21
+ * sitemap = $sitemap();
22
+ * }
23
+ * ```
24
+ */
25
+ export const $sitemap = (
26
+ options: SitemapPrimitiveOptions = {},
27
+ ): SitemapPrimitive => {
28
+ return createPrimitive(SitemapPrimitive, options);
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------------------------------------------------
32
+
33
+ export interface SitemapPrimitiveOptions {
34
+ /**
35
+ * Absolute base URL used to build `<loc>` entries (e.g. "https://alepha.dev").
36
+ *
37
+ * Defaults to `PUBLIC_URL`, then to `""` (relative URLs).
38
+ */
39
+ hostname?: string;
40
+
41
+ /**
42
+ * Route path the sitemap is served at.
43
+ *
44
+ * @default "/sitemap.xml"
45
+ */
46
+ path?: string;
47
+
48
+ /**
49
+ * Prerender the sitemap to a static file at build time.
50
+ *
51
+ * @default true
52
+ */
53
+ static?: boolean;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------------------------------------------------
57
+
58
+ export class SitemapPrimitive extends Primitive<SitemapPrimitiveOptions> {
59
+ protected readonly router = $inject(ServerRouterProvider);
60
+ protected readonly dateTime = $inject(DateTimeProvider);
61
+
62
+ protected onInit() {
63
+ this.router.createRoute({
64
+ method: "GET",
65
+ path: this.options.path ?? "/sitemap.xml",
66
+ static: this.options.static ?? true,
67
+ silent: true,
68
+ handler: (request: ServerRequest) => {
69
+ request.reply.setHeader("content-type", "application/xml");
70
+ return this.buildSitemap();
71
+ },
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Render the sitemap to its path and body. Used by the build to snapshot the
77
+ * sitemap to a static file.
78
+ */
79
+ public prerender(): { path: string; body: string } {
80
+ return {
81
+ path: this.options.path ?? "/sitemap.xml",
82
+ body: this.buildSitemap(),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Build the sitemap XML from the application's page primitives.
88
+ */
89
+ protected buildSitemap(): string {
90
+ const hostname =
91
+ this.options.hostname ?? String(this.alepha.env.PUBLIC_URL ?? "");
92
+ const pages = this.getSitemapPages();
93
+ return this.generateSitemapFromPages(pages, hostname);
94
+ }
95
+
96
+ /**
97
+ * Select the pages that should appear in the sitemap.
98
+ *
99
+ * Excludes layout pages (with `children`), wildcard paths, and `/404`.
100
+ * Parameterized pages are included only when they declare `static.entries`.
101
+ */
102
+ protected getSitemapPages(): any[] {
103
+ const pages = this.alepha.primitives("page") as any[];
104
+ return pages.filter((page) => {
105
+ const options = page.options;
106
+ const path: string = options.path ?? "";
107
+ if (options.children) {
108
+ return false;
109
+ }
110
+ if (path.includes("*")) {
111
+ return false;
112
+ }
113
+ if (path === "/404") {
114
+ return false;
115
+ }
116
+ if (!options.schema?.params) {
117
+ return true;
118
+ }
119
+ if (
120
+ options.static &&
121
+ typeof options.static === "object" &&
122
+ options.static.entries
123
+ ) {
124
+ return true;
125
+ }
126
+ return false;
127
+ });
128
+ }
129
+
130
+ protected generateSitemapFromPages(pages: any[], baseUrl: string): string {
131
+ const urls: string[] = [];
132
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
133
+
134
+ for (const page of pages) {
135
+ const options = page.options;
136
+
137
+ if (!options.schema?.params) {
138
+ const path = options.path || "";
139
+ const url = `${normalizedBaseUrl}${path === "" ? "/" : path}`;
140
+ urls.push(url);
141
+ } else if (
142
+ options.static &&
143
+ typeof options.static === "object" &&
144
+ options.static.entries
145
+ ) {
146
+ for (const entry of options.static.entries) {
147
+ const path = this.buildPathFromParams(
148
+ options.path || "",
149
+ entry.params || {},
150
+ );
151
+ const url = `${normalizedBaseUrl}${path}`;
152
+ urls.push(url);
153
+ }
154
+ }
155
+ }
156
+
157
+ return this.buildSitemapXml(urls);
158
+ }
159
+
160
+ protected buildPathFromParams(
161
+ pathPattern: string,
162
+ params: Record<string, any>,
163
+ ): string {
164
+ let path = pathPattern;
165
+ for (const [key, value] of Object.entries(params)) {
166
+ path = path.replace(`:${key}`, String(value));
167
+ }
168
+ return path || "/";
169
+ }
170
+
171
+ protected buildSitemapXml(urls: string[]): string {
172
+ const lastMod = this.dateTime.now().format("YYYY-MM-DD");
173
+ const urlEntries = urls
174
+ .map(
175
+ (url) =>
176
+ ` <url>\n <loc>${this.escapeXml(url)}</loc>\n <lastmod>${lastMod}</lastmod>\n </url>`,
177
+ )
178
+ .join("\n");
179
+
180
+ return `<?xml version="1.0" encoding="UTF-8"?>
181
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
182
+ ${urlEntries}
183
+ </urlset>`;
184
+ }
185
+
186
+ protected escapeXml(str: string): string {
187
+ return str
188
+ .replace(/&/g, "&amp;")
189
+ .replace(/</g, "&lt;")
190
+ .replace(/>/g, "&gt;")
191
+ .replace(/"/g, "&quot;")
192
+ .replace(/'/g, "&#39;");
193
+ }
194
+ }
195
+
196
+ $sitemap[KIND] = SitemapPrimitive;
@@ -0,0 +1,34 @@
1
+ import {
2
+ decodeJwt,
3
+ decodeProtectedHeader,
4
+ exportPKCS8,
5
+ generateKeyPair,
6
+ } from "jose";
7
+ import { describe, expect, it } from "vitest";
8
+ import { signAppleClientSecret } from "../helpers/appleClientSecret.ts";
9
+
10
+ describe("apple client secret", () => {
11
+ it("signs an ES256 JWT with the Apple-required claims + kid header", async () => {
12
+ const { privateKey } = await generateKeyPair("ES256", {
13
+ crv: "P-256",
14
+ extractable: true,
15
+ });
16
+ const pem = await exportPKCS8(privateKey);
17
+ const secret = await signAppleClientSecret({
18
+ privateKeyPem: pem,
19
+ teamId: "TEAM123456",
20
+ serviceId: "club.alepha.signin",
21
+ keyId: "KEY1234567",
22
+ });
23
+ const claims = decodeJwt(secret);
24
+ expect(claims.iss).toBe("TEAM123456");
25
+ expect(claims.sub).toBe("club.alepha.signin");
26
+ expect(claims.aud).toBe("https://appleid.apple.com");
27
+ expect(typeof claims.exp).toBe("number");
28
+
29
+ // Apple requires ES256 + the Key ID in the protected header.
30
+ const header = decodeProtectedHeader(secret);
31
+ expect(header.alg).toBe("ES256");
32
+ expect(header.kid).toBe("KEY1234567");
33
+ });
34
+ });
@@ -0,0 +1,40 @@
1
+ import { exportPKCS8, exportSPKI, generateKeyPair } from "jose";
2
+ import { describe, expect, it } from "vitest";
3
+ import { signFederationAssertion } from "../helpers/federationAssertion.ts";
4
+ import { assertionToProfile } from "../primitives/$authFederationClient.ts";
5
+
6
+ describe("federation client mapping", () => {
7
+ it("maps a verified assertion to a LinkAccountOptions profile", async () => {
8
+ const { privateKey, publicKey } = await generateKeyPair("EdDSA", {
9
+ crv: "Ed25519",
10
+ extractable: true,
11
+ });
12
+ const priv = await exportPKCS8(privateKey);
13
+ const pub = await exportSPKI(publicKey);
14
+ const token = await signFederationAssertion(
15
+ {
16
+ provider: "google",
17
+ sub: "g-1",
18
+ email: "x@y.z",
19
+ email_verified: true,
20
+ name: "X Y",
21
+ },
22
+ {
23
+ privateKeyPem: priv,
24
+ issuer: "https://alepha.club",
25
+ audience: "https://b14.alepha.club",
26
+ },
27
+ );
28
+ const { provider, link } = await assertionToProfile(token, {
29
+ publicKeyPem: pub,
30
+ issuer: "https://alepha.club",
31
+ audience: "https://b14.alepha.club",
32
+ });
33
+ expect(provider).toBe("google");
34
+ expect(link.user).toMatchObject({
35
+ sub: "g-1",
36
+ email: "x@y.z",
37
+ email_verified: true,
38
+ });
39
+ });
40
+ });
@@ -0,0 +1,146 @@
1
+ import {
2
+ exportPKCS8,
3
+ exportSPKI,
4
+ generateKeyPair,
5
+ importPKCS8,
6
+ SignJWT,
7
+ } from "jose";
8
+ import { describe, expect, it } from "vitest";
9
+ import {
10
+ type FederationProfile,
11
+ signFederationAssertion,
12
+ verifyFederationAssertion,
13
+ } from "../helpers/federationAssertion.ts";
14
+
15
+ const profile: FederationProfile = {
16
+ provider: "google",
17
+ sub: "google-123",
18
+ email: "p@example.com",
19
+ email_verified: true,
20
+ name: "Pat Player",
21
+ };
22
+
23
+ const keys = async () => {
24
+ const { privateKey, publicKey } = await generateKeyPair("EdDSA", {
25
+ crv: "Ed25519",
26
+ extractable: true,
27
+ });
28
+ return {
29
+ priv: await exportPKCS8(privateKey),
30
+ pub: await exportSPKI(publicKey),
31
+ };
32
+ };
33
+
34
+ describe("federation assertion", () => {
35
+ it("round-trips a signed assertion for the right audience", async () => {
36
+ const { priv, pub } = await keys();
37
+ const token = await signFederationAssertion(profile, {
38
+ privateKeyPem: priv,
39
+ issuer: "https://alepha.club",
40
+ audience: "https://b14.alepha.club",
41
+ });
42
+ const out = await verifyFederationAssertion(token, {
43
+ publicKeyPem: pub,
44
+ issuer: "https://alepha.club",
45
+ audience: "https://b14.alepha.club",
46
+ });
47
+ expect(out.profile).toMatchObject({
48
+ provider: "google",
49
+ sub: "google-123",
50
+ email: "p@example.com",
51
+ });
52
+ expect(out.jti).toBeTruthy();
53
+ });
54
+
55
+ it("rejects a wrong audience (tenant mismatch)", async () => {
56
+ const { priv, pub } = await keys();
57
+ const token = await signFederationAssertion(profile, {
58
+ privateKeyPem: priv,
59
+ issuer: "https://alepha.club",
60
+ audience: "https://b14.alepha.club",
61
+ });
62
+ await expect(
63
+ verifyFederationAssertion(token, {
64
+ publicKeyPem: pub,
65
+ issuer: "https://alepha.club",
66
+ audience: "https://other.alepha.club",
67
+ }),
68
+ ).rejects.toThrow();
69
+ });
70
+
71
+ it("rejects an expired assertion", async () => {
72
+ const { priv, pub } = await keys();
73
+ const token = await signFederationAssertion(profile, {
74
+ privateKeyPem: priv,
75
+ issuer: "https://alepha.club",
76
+ audience: "https://b14.alepha.club",
77
+ ttlSeconds: -1,
78
+ });
79
+ await expect(
80
+ verifyFederationAssertion(token, {
81
+ publicKeyPem: pub,
82
+ issuer: "https://alepha.club",
83
+ audience: "https://b14.alepha.club",
84
+ }),
85
+ ).rejects.toThrow();
86
+ });
87
+
88
+ it("rejects a tampered signature (verified by a different key)", async () => {
89
+ const { priv } = await keys();
90
+ const { pub: otherPub } = await keys();
91
+ const token = await signFederationAssertion(profile, {
92
+ privateKeyPem: priv,
93
+ issuer: "https://alepha.club",
94
+ audience: "https://b14.alepha.club",
95
+ });
96
+ await expect(
97
+ verifyFederationAssertion(token, {
98
+ publicKeyPem: otherPub,
99
+ issuer: "https://alepha.club",
100
+ audience: "https://b14.alepha.club",
101
+ }),
102
+ ).rejects.toThrow();
103
+ });
104
+
105
+ it("rejects an algorithm-confusion token (non-EdDSA alg)", async () => {
106
+ // Sign with HS256 using the SPKI bytes as a symmetric secret — the classic
107
+ // alg-confusion attack. The EdDSA pin must reject it.
108
+ const { pub } = await keys();
109
+ const forged = await new SignJWT({
110
+ profile,
111
+ })
112
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
113
+ .setIssuer("https://alepha.club")
114
+ .setAudience("https://b14.alepha.club")
115
+ .setJti("forged")
116
+ .setIssuedAt()
117
+ .setExpirationTime("60s")
118
+ .sign(new TextEncoder().encode(pub));
119
+ await expect(
120
+ verifyFederationAssertion(forged, {
121
+ publicKeyPem: pub,
122
+ issuer: "https://alepha.club",
123
+ audience: "https://b14.alepha.club",
124
+ }),
125
+ ).rejects.toThrow();
126
+ });
127
+
128
+ it("rejects an assertion missing jti", async () => {
129
+ const { priv, pub } = await keys();
130
+ const key = await importPKCS8(priv, "EdDSA");
131
+ const noJti = await new SignJWT({ profile })
132
+ .setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
133
+ .setIssuer("https://alepha.club")
134
+ .setAudience("https://b14.alepha.club")
135
+ .setIssuedAt()
136
+ .setExpirationTime("60s")
137
+ .sign(key);
138
+ await expect(
139
+ verifyFederationAssertion(noJti, {
140
+ publicKeyPem: pub,
141
+ issuer: "https://alepha.club",
142
+ audience: "https://b14.alepha.club",
143
+ }),
144
+ ).rejects.toThrow();
145
+ });
146
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JtiReplayGuard } from "../helpers/jtiReplayGuard.ts";
3
+ import { safeRedirectPath } from "../helpers/safeRedirectPath.ts";
4
+
5
+ describe("safeRedirectPath", () => {
6
+ it("keeps a simple absolute path", () => {
7
+ expect(safeRedirectPath("/me")).toBe("/me");
8
+ expect(safeRedirectPath("/admin/pages?x=1")).toBe("/admin/pages?x=1");
9
+ });
10
+
11
+ it("rejects protocol-relative, absolute, backslash, and empty → fallback", () => {
12
+ expect(safeRedirectPath("//evil.com")).toBe("/");
13
+ expect(safeRedirectPath("https://evil.com")).toBe("/");
14
+ expect(safeRedirectPath("/\\evil.com")).toBe("/");
15
+ expect(safeRedirectPath("evil")).toBe("/");
16
+ expect(safeRedirectPath(undefined)).toBe("/");
17
+ expect(safeRedirectPath(undefined, "/home")).toBe("/home");
18
+ });
19
+ });
20
+
21
+ describe("JtiReplayGuard", () => {
22
+ it("accepts a jti once, rejects the replay", () => {
23
+ const g = new JtiReplayGuard();
24
+ expect(g.check("a")).toBe(true);
25
+ expect(g.check("a")).toBe(false);
26
+ expect(g.check("b")).toBe(true);
27
+ });
28
+
29
+ it("accepts the same jti again once its TTL has elapsed", () => {
30
+ const g = new JtiReplayGuard(100); // 100ms TTL
31
+ expect(g.check("a", 1_000)).toBe(true);
32
+ expect(g.check("a", 1_050)).toBe(false); // still within TTL
33
+ expect(g.check("a", 2_000)).toBe(true); // expired → fresh again
34
+ });
35
+
36
+ it("stays bounded under churn (hard cap evicts oldest)", () => {
37
+ const g = new JtiReplayGuard(60_000, 5) as unknown as {
38
+ check: (j: string, n?: number) => boolean;
39
+ seen: Map<string, number>;
40
+ };
41
+ for (let i = 0; i < 50; i++) g.check(`j${i}`, 1_000);
42
+ expect(g.seen.size).toBeLessThanOrEqual(5);
43
+ });
44
+ });
@@ -0,0 +1,24 @@
1
+ import { importPKCS8, SignJWT } from "jose";
2
+
3
+ export interface AppleClientSecretOptions {
4
+ privateKeyPem: string; // ES256 .p8 contents (PKCS#8 PEM)
5
+ teamId: string; // Apple Team ID -> iss
6
+ serviceId: string; // Apple Service ID -> sub (the OAuth client_id)
7
+ keyId: string; // Apple Key ID -> header kid
8
+ ttlSeconds?: number; // default 300 (5 min)
9
+ }
10
+
11
+ /** Signs Apple's short-lived ES256 client_secret JWT on demand (no rotation job). */
12
+ export async function signAppleClientSecret(
13
+ opts: AppleClientSecretOptions,
14
+ ): Promise<string> {
15
+ const key = await importPKCS8(opts.privateKeyPem, "ES256");
16
+ return new SignJWT({})
17
+ .setProtectedHeader({ alg: "ES256", kid: opts.keyId, typ: "JWT" })
18
+ .setIssuer(opts.teamId)
19
+ .setSubject(opts.serviceId)
20
+ .setAudience("https://appleid.apple.com")
21
+ .setIssuedAt()
22
+ .setExpirationTime(`${opts.ttlSeconds ?? 300}s`)
23
+ .sign(key);
24
+ }
@@ -0,0 +1,74 @@
1
+ import { importPKCS8, importSPKI, jwtVerify, SignJWT } from "jose";
2
+
3
+ const ALG = "EdDSA";
4
+
5
+ export interface FederationProfile {
6
+ provider: "google" | "apple";
7
+ sub: string;
8
+ email?: string;
9
+ email_verified?: boolean;
10
+ name?: string;
11
+ given_name?: string;
12
+ family_name?: string;
13
+ picture?: string;
14
+ is_private_email?: boolean;
15
+ }
16
+
17
+ export interface SignAssertionOptions {
18
+ privateKeyPem: string; // EdDSA PKCS#8 PEM
19
+ issuer: string;
20
+ audience: string; // exact tenant origin
21
+ ttlSeconds?: number; // default 60
22
+ jti?: string; // default random
23
+ }
24
+
25
+ export interface VerifyAssertionOptions {
26
+ publicKeyPem: string; // EdDSA SPKI PEM
27
+ issuer: string;
28
+ audience: string;
29
+ }
30
+
31
+ export interface VerifiedAssertion {
32
+ profile: FederationProfile;
33
+ jti: string;
34
+ }
35
+
36
+ export async function signFederationAssertion(
37
+ profile: FederationProfile,
38
+ opts: SignAssertionOptions,
39
+ ): Promise<string> {
40
+ const key = await importPKCS8(opts.privateKeyPem, ALG);
41
+ const jti = opts.jti ?? crypto.randomUUID();
42
+ const ttl = opts.ttlSeconds ?? 60;
43
+ return new SignJWT({ profile })
44
+ .setProtectedHeader({ alg: ALG, typ: "JWT" })
45
+ .setIssuer(opts.issuer)
46
+ .setAudience(opts.audience)
47
+ .setJti(jti)
48
+ .setIssuedAt()
49
+ .setExpirationTime(`${ttl}s`)
50
+ .sign(key);
51
+ }
52
+
53
+ export async function verifyFederationAssertion(
54
+ token: string,
55
+ opts: VerifyAssertionOptions,
56
+ ): Promise<VerifiedAssertion> {
57
+ const key = await importSPKI(opts.publicKeyPem, ALG);
58
+ const { payload } = await jwtVerify(token, key, {
59
+ issuer: opts.issuer,
60
+ audience: opts.audience,
61
+ algorithms: [ALG],
62
+ // Defense-in-depth: reject a (signature-valid) token that omits any of
63
+ // these, so it can't slip an unbounded lifetime or an unreplayable id.
64
+ requiredClaims: ["exp", "iat", "jti"],
65
+ });
66
+ const profile = payload.profile as FederationProfile | undefined;
67
+ if (!profile?.sub || !profile.provider) {
68
+ throw new Error("Federation assertion missing profile.sub/provider");
69
+ }
70
+ if (!payload.jti) {
71
+ throw new Error("Federation assertion missing jti");
72
+ }
73
+ return { profile, jti: String(payload.jti) };
74
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Single-use guard for short-lived assertion `jti`s. Bounded + self-pruning so
3
+ * it can't grow without limit in a long-lived process. Best-effort per-instance
4
+ * (assertions are also `aud`-bound + ~60s TTL, so a cross-isolate replay window
5
+ * is tiny); use a shared store if you need a hard cross-instance guarantee.
6
+ */
7
+ export class JtiReplayGuard {
8
+ protected readonly seen = new Map<string, number>(); // jti -> expiry epoch ms
9
+
10
+ constructor(
11
+ protected readonly ttlMs = 120_000,
12
+ protected readonly maxEntries = 10_000,
13
+ ) {}
14
+
15
+ /** Records `jti` and returns true if fresh; false if already used (replay). */
16
+ check(jti: string, now: number = Date.now()): boolean {
17
+ this.prune(now);
18
+ if (this.seen.has(jti)) {
19
+ return false;
20
+ }
21
+ this.seen.set(jti, now + this.ttlMs);
22
+ return true;
23
+ }
24
+
25
+ protected prune(now: number): void {
26
+ for (const [k, exp] of this.seen) {
27
+ if (exp <= now) {
28
+ this.seen.delete(k);
29
+ }
30
+ }
31
+ // Hard cap: if still over budget after dropping expired, evict oldest-first
32
+ // (Map preserves insertion order).
33
+ while (this.seen.size >= this.maxEntries) {
34
+ const oldest = this.seen.keys().next().value;
35
+ if (oldest === undefined) {
36
+ break;
37
+ }
38
+ this.seen.delete(oldest);
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Returns a safe in-app redirect target: a single absolute path on the current
3
+ * origin. Rejects protocol-relative (`//host`), absolute URLs, and backslash
4
+ * tricks so a crafted `redirect` query can't become a post-auth open redirect.
5
+ */
6
+ export function safeRedirectPath(
7
+ redirect: string | undefined,
8
+ fallback = "/",
9
+ ): string {
10
+ if (
11
+ typeof redirect === "string" &&
12
+ redirect.startsWith("/") &&
13
+ !redirect.startsWith("//") &&
14
+ !redirect.includes("\\")
15
+ ) {
16
+ return redirect;
17
+ }
18
+ return fallback;
19
+ }
@@ -5,11 +5,15 @@ import { ServerAuthProvider } from "./providers/ServerAuthProvider.ts";
5
5
 
6
6
  // ---------------------------------------------------------------------------------------------------------------------
7
7
 
8
+ export * from "./helpers/appleClientSecret.ts";
9
+ export * from "./helpers/federationAssertion.ts";
8
10
  export * from "./index.shared.ts";
9
11
  export * from "./primitives/$auth.ts";
10
12
  export * from "./primitives/$authApple.ts";
11
13
  export * from "./primitives/$authCredentials.ts";
12
14
  export * from "./primitives/$authFacebook.ts";
15
+ export * from "./primitives/$authFederationBroker.ts";
16
+ export * from "./primitives/$authFederationClient.ts";
13
17
  export * from "./primitives/$authFranceConnect.ts";
14
18
  export * from "./primitives/$authGithub.ts";
15
19
  export * from "./primitives/$authGoogle.ts";