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.
- package/dist/api/jobs/index.d.ts +20 -20
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/keys/index.d.ts +6 -6
- package/dist/api/users/index.d.ts +43 -9
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +24 -3
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/cli/core/index.d.ts +46 -40
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +51 -101
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/i18n/index.d.ts +12 -5
- package/dist/cli/i18n/index.d.ts.map +1 -1
- package/dist/cli/i18n/index.js +45 -11
- package/dist/cli/i18n/index.js.map +1 -1
- package/dist/cli/platform-lib/index.d.ts +32 -6
- package/dist/cli/platform-lib/index.d.ts.map +1 -1
- package/dist/cli/platform-lib/index.js +82 -19
- package/dist/cli/platform-lib/index.js.map +1 -1
- package/dist/command/index.d.ts +1 -1
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +23 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/react/form/index.d.ts +0 -1
- package/dist/react/form/index.d.ts.map +1 -1
- package/dist/react/form/index.js +16 -15
- package/dist/react/form/index.js.map +1 -1
- package/dist/react/i18n/index.d.ts +43 -0
- package/dist/react/i18n/index.d.ts.map +1 -1
- package/dist/react/i18n/index.js +114 -10
- package/dist/react/i18n/index.js.map +1 -1
- package/dist/react/router/index.browser.js +128 -5
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +108 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +184 -6
- package/dist/react/router/index.js.map +1 -1
- package/dist/react/sitemap/index.browser.js +35 -0
- package/dist/react/sitemap/index.browser.js.map +1 -0
- package/dist/react/sitemap/index.d.ts +92 -0
- package/dist/react/sitemap/index.d.ts.map +1 -0
- package/dist/react/sitemap/index.js +131 -0
- package/dist/react/sitemap/index.js.map +1 -0
- package/dist/server/auth/index.d.ts +105 -1
- package/dist/server/auth/index.d.ts.map +1 -1
- package/dist/server/auth/index.js +1604 -7
- package/dist/server/auth/index.js.map +1 -1
- package/dist/server/cookies/index.d.ts +15 -0
- package/dist/server/cookies/index.d.ts.map +1 -1
- package/dist/server/cookies/index.js +22 -3
- package/dist/server/cookies/index.js.map +1 -1
- package/dist/server/core/index.d.ts +18 -0
- package/dist/server/core/index.d.ts.map +1 -1
- package/dist/server/core/index.js +25 -0
- package/dist/server/core/index.js.map +1 -1
- package/package.json +16 -3
- package/src/api/users/controllers/RealmController.ts +1 -0
- package/src/api/users/primitives/$realm.ts +26 -0
- package/src/api/users/providers/RealmProvider.ts +15 -0
- package/src/api/users/schemas/realmConfigSchema.ts +14 -0
- package/src/cli/core/atoms/buildOptions.ts +0 -12
- package/src/cli/core/commands/build.ts +0 -10
- package/src/cli/core/index.ts +0 -3
- package/src/cli/core/tasks/BuildCloudflareTask.ts +37 -17
- package/src/cli/core/tasks/BuildPrerenderTask.ts +44 -7
- package/src/cli/i18n/__tests__/I18nCheckService.spec.ts +48 -0
- package/src/cli/i18n/services/I18nCheckService.ts +65 -11
- package/src/cli/platform-lib/adapters/CloudflareAdapter.ts +128 -36
- package/src/mcp/__tests__/McpServerProvider.spec.ts +71 -0
- package/src/mcp/providers/McpServerProvider.ts +55 -0
- package/src/react/form/__tests__/FormModel-submit-loading.spec.ts +71 -0
- package/src/react/form/__tests__/form-submitting-reactive.browser.spec.tsx +96 -0
- package/src/react/form/services/FormModel.ts +57 -39
- package/src/react/i18n/__tests__/I18nProvider.spec.ts +89 -0
- package/src/react/i18n/__tests__/locale-routing.spec.ts +107 -0
- package/src/react/i18n/providers/I18nProvider.ts +171 -12
- package/src/react/router/__tests__/RouterLocaleProvider.spec.ts +127 -0
- package/src/react/router/index.browser.ts +4 -0
- package/src/react/router/index.shared.ts +1 -0
- package/src/react/router/index.ts +9 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +15 -1
- package/src/react/router/providers/ReactPageProvider.ts +12 -1
- package/src/react/router/providers/ReactServerProvider.ts +92 -1
- package/src/react/router/providers/RootComponentsProvider.ts +13 -0
- package/src/react/router/providers/RouterLocaleProvider.ts +125 -0
- package/src/react/router/providers/__tests__/RootComponentsProvider.spec.ts +15 -0
- package/src/react/router/providers/__tests__/rootComponents.ssr.browser.spec.tsx +67 -0
- package/src/react/sitemap/__tests__/$sitemap.spec.ts +131 -0
- package/src/react/sitemap/index.browser.ts +21 -0
- package/src/react/sitemap/index.ts +25 -0
- package/src/react/sitemap/primitives/$sitemap.browser.ts +26 -0
- package/src/react/sitemap/primitives/$sitemap.ts +196 -0
- package/src/server/auth/__tests__/appleClientSecret.spec.ts +34 -0
- package/src/server/auth/__tests__/authFederationClient.spec.ts +40 -0
- package/src/server/auth/__tests__/federationAssertion.spec.ts +146 -0
- package/src/server/auth/__tests__/federationRedirectReplay.spec.ts +44 -0
- package/src/server/auth/helpers/appleClientSecret.ts +24 -0
- package/src/server/auth/helpers/federationAssertion.ts +74 -0
- package/src/server/auth/helpers/jtiReplayGuard.ts +41 -0
- package/src/server/auth/helpers/safeRedirectPath.ts +19 -0
- package/src/server/auth/index.ts +4 -0
- package/src/server/auth/primitives/$authFederationBroker.ts +273 -0
- package/src/server/auth/primitives/$authFederationClient.ts +89 -0
- package/src/server/auth/providers/ServerAuthProvider.ts +18 -4
- package/src/server/cookies/__tests__/ServerCookiesProvider.spec.ts +70 -0
- package/src/server/cookies/providers/ServerCookiesProvider.ts +23 -3
- package/src/server/core/interfaces/ServerRequest.ts +8 -0
- package/src/server/core/primitives/$route.ts +27 -0
- package/src/cli/core/tasks/BuildSitemapTask.ts +0 -130
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { AlephaError, t } from "alepha";
|
|
2
|
+
import { SecurityError } from "alepha/security";
|
|
3
|
+
import { $route, BadRequestError } from "alepha/server";
|
|
4
|
+
import { $cookie } from "alepha/server/cookies";
|
|
5
|
+
import {
|
|
6
|
+
authorizationCodeGrant,
|
|
7
|
+
buildAuthorizationUrl,
|
|
8
|
+
type Configuration,
|
|
9
|
+
calculatePKCECodeChallenge,
|
|
10
|
+
discovery,
|
|
11
|
+
randomPKCECodeVerifier,
|
|
12
|
+
randomState,
|
|
13
|
+
} from "openid-client";
|
|
14
|
+
import { signAppleClientSecret } from "../helpers/appleClientSecret.ts";
|
|
15
|
+
import {
|
|
16
|
+
type FederationProfile,
|
|
17
|
+
signFederationAssertion,
|
|
18
|
+
} from "../helpers/federationAssertion.ts";
|
|
19
|
+
import { safeRedirectPath } from "../helpers/safeRedirectPath.ts";
|
|
20
|
+
|
|
21
|
+
export interface FederationBrokerProviders {
|
|
22
|
+
google?: { clientId: string; clientSecret: string };
|
|
23
|
+
apple?: {
|
|
24
|
+
serviceId: string;
|
|
25
|
+
teamId: string;
|
|
26
|
+
keyId: string;
|
|
27
|
+
privateKeyPem: string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FederationBrokerOptions {
|
|
32
|
+
/** Broker public origin, e.g. https://alepha.club — becomes the assertion `iss`. */
|
|
33
|
+
issuer: string;
|
|
34
|
+
/** EdDSA PKCS#8 PEM — signs assertions. */
|
|
35
|
+
signingKeyPem: string;
|
|
36
|
+
providers: FederationBrokerProviders;
|
|
37
|
+
/** Validate the requested tenant and return its exact origin (or null to reject). */
|
|
38
|
+
resolveTenant: (tenant: string) => Promise<string | null>;
|
|
39
|
+
assertionTtlSeconds?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ISSUERS = {
|
|
43
|
+
google: "https://accounts.google.com",
|
|
44
|
+
apple: "https://appleid.apple.com",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export const $authFederationBroker = (options: FederationBrokerOptions) => {
|
|
48
|
+
const callbackPath = "/auth/federated/callback";
|
|
49
|
+
|
|
50
|
+
if (!options.signingKeyPem) {
|
|
51
|
+
throw new AlephaError("$authFederationBroker requires signingKeyPem");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Per-flow cookie: carries the tenant + PKCE/state across the redirect.
|
|
55
|
+
const flow = $cookie({
|
|
56
|
+
name: "federationFlow",
|
|
57
|
+
ttl: [15, "minutes"],
|
|
58
|
+
httpOnly: true,
|
|
59
|
+
encrypt: true,
|
|
60
|
+
schema: t.object({
|
|
61
|
+
provider: t.text(),
|
|
62
|
+
tenantOrigin: t.text({ size: "long" }),
|
|
63
|
+
redirectPath: t.text({ size: "long" }),
|
|
64
|
+
codeVerifier: t.optional(t.text({ size: "long" })),
|
|
65
|
+
state: t.optional(t.text()),
|
|
66
|
+
nonce: t.optional(t.text()),
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const callbackUri = `${options.issuer}${callbackPath}`;
|
|
71
|
+
|
|
72
|
+
// Build an openid-client Configuration for a provider. Apple's client_secret
|
|
73
|
+
// is signed fresh on every call (~5min) — no static secret, no rotation.
|
|
74
|
+
const getConfig = async (
|
|
75
|
+
provider: "google" | "apple",
|
|
76
|
+
): Promise<Configuration> => {
|
|
77
|
+
if (provider === "google") {
|
|
78
|
+
const g = options.providers.google;
|
|
79
|
+
if (!g) {
|
|
80
|
+
throw new SecurityError("google federation not configured");
|
|
81
|
+
}
|
|
82
|
+
return discovery(new URL(ISSUERS.google), g.clientId, g.clientSecret);
|
|
83
|
+
}
|
|
84
|
+
const a = options.providers.apple;
|
|
85
|
+
if (!a) {
|
|
86
|
+
throw new SecurityError("apple federation not configured");
|
|
87
|
+
}
|
|
88
|
+
const clientSecret = await signAppleClientSecret({
|
|
89
|
+
privateKeyPem: a.privateKeyPem,
|
|
90
|
+
teamId: a.teamId,
|
|
91
|
+
serviceId: a.serviceId,
|
|
92
|
+
keyId: a.keyId,
|
|
93
|
+
});
|
|
94
|
+
return discovery(new URL(ISSUERS.apple), a.serviceId, clientSecret);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const scopeFor = (provider: string) =>
|
|
98
|
+
provider === "apple" ? "name email" : "openid email profile";
|
|
99
|
+
|
|
100
|
+
const start = $route({
|
|
101
|
+
path: "/auth/federated/start",
|
|
102
|
+
schema: {
|
|
103
|
+
query: t.object({
|
|
104
|
+
provider: t.text(),
|
|
105
|
+
tenant: t.text(),
|
|
106
|
+
redirect: t.optional(t.text({ size: "long" })),
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
handler: async ({ query, reply, cookies }) => {
|
|
110
|
+
if (query.provider !== "google" && query.provider !== "apple") {
|
|
111
|
+
throw new BadRequestError(`Unsupported provider '${query.provider}'`);
|
|
112
|
+
}
|
|
113
|
+
const tenantOrigin = await options.resolveTenant(query.tenant);
|
|
114
|
+
if (!tenantOrigin) {
|
|
115
|
+
throw new BadRequestError("Unknown or inactive tenant");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const config = await getConfig(query.provider);
|
|
119
|
+
const codeVerifier = randomPKCECodeVerifier();
|
|
120
|
+
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
|
121
|
+
const parameters: Record<string, string> = {
|
|
122
|
+
redirect_uri: callbackUri,
|
|
123
|
+
scope: scopeFor(query.provider),
|
|
124
|
+
code_challenge: codeChallenge,
|
|
125
|
+
code_challenge_method: "S256",
|
|
126
|
+
};
|
|
127
|
+
// Apple needs response_mode=form_post when requesting name/email scopes.
|
|
128
|
+
if (query.provider === "apple") {
|
|
129
|
+
parameters.response_mode = "form_post";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const usePkce = config.serverMetadata().supportsPKCE();
|
|
133
|
+
let state: string | undefined;
|
|
134
|
+
let nonce: string | undefined;
|
|
135
|
+
if (!usePkce) {
|
|
136
|
+
state = randomState();
|
|
137
|
+
nonce = randomState();
|
|
138
|
+
parameters.state = state;
|
|
139
|
+
parameters.nonce = nonce;
|
|
140
|
+
delete parameters.code_challenge;
|
|
141
|
+
delete parameters.code_challenge_method;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
flow.set(
|
|
145
|
+
{
|
|
146
|
+
provider: query.provider,
|
|
147
|
+
tenantOrigin,
|
|
148
|
+
redirectPath: safeRedirectPath(query.redirect),
|
|
149
|
+
codeVerifier: usePkce ? codeVerifier : undefined,
|
|
150
|
+
state,
|
|
151
|
+
nonce,
|
|
152
|
+
},
|
|
153
|
+
{ cookies },
|
|
154
|
+
);
|
|
155
|
+
reply.redirect(buildAuthorizationUrl(config, parameters).toString(), 302);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const handle = async (
|
|
160
|
+
urlOrReq: URL | Request,
|
|
161
|
+
cookies: any,
|
|
162
|
+
reply: any,
|
|
163
|
+
rawProfile?: Record<string, unknown>,
|
|
164
|
+
) => {
|
|
165
|
+
const ctx = flow.get({ cookies });
|
|
166
|
+
if (!ctx) {
|
|
167
|
+
throw new BadRequestError("Missing federation flow");
|
|
168
|
+
}
|
|
169
|
+
flow.del({ cookies });
|
|
170
|
+
|
|
171
|
+
const provider = ctx.provider as "google" | "apple";
|
|
172
|
+
|
|
173
|
+
let profile: FederationProfile;
|
|
174
|
+
try {
|
|
175
|
+
const config = await getConfig(provider);
|
|
176
|
+
const tokens = await authorizationCodeGrant(config, urlOrReq, {
|
|
177
|
+
pkceCodeVerifier: ctx.codeVerifier,
|
|
178
|
+
expectedState: ctx.state,
|
|
179
|
+
expectedNonce: ctx.nonce,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Verified claims come from the id_token; merge Apple's one-time form_post name.
|
|
183
|
+
const claims = (tokens.claims?.() ?? {}) as Record<string, unknown>;
|
|
184
|
+
const merged = { ...rawProfile, ...claims } as Record<string, unknown>;
|
|
185
|
+
profile = {
|
|
186
|
+
provider,
|
|
187
|
+
sub: String(merged.sub),
|
|
188
|
+
email: merged.email as string | undefined,
|
|
189
|
+
email_verified:
|
|
190
|
+
typeof merged.email_verified === "string"
|
|
191
|
+
? merged.email_verified === "true"
|
|
192
|
+
: (merged.email_verified as boolean | undefined),
|
|
193
|
+
name: merged.name as string | undefined,
|
|
194
|
+
given_name: merged.given_name as string | undefined,
|
|
195
|
+
family_name: merged.family_name as string | undefined,
|
|
196
|
+
picture: merged.picture as string | undefined,
|
|
197
|
+
is_private_email:
|
|
198
|
+
typeof merged.is_private_email === "string"
|
|
199
|
+
? merged.is_private_email === "true"
|
|
200
|
+
: (merged.is_private_email as boolean | undefined),
|
|
201
|
+
};
|
|
202
|
+
} catch {
|
|
203
|
+
// Upstream auth failed or the user denied consent — bounce back to the
|
|
204
|
+
// tenant with an error rather than surfacing a raw 500.
|
|
205
|
+
const fail = new URL(`${ctx.tenantOrigin}${ctx.redirectPath}`);
|
|
206
|
+
fail.searchParams.set("error", "federation_failed");
|
|
207
|
+
reply.redirect(fail.toString(), 302);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const assertion = await signFederationAssertion(profile, {
|
|
212
|
+
privateKeyPem: options.signingKeyPem,
|
|
213
|
+
issuer: options.issuer,
|
|
214
|
+
audience: ctx.tenantOrigin,
|
|
215
|
+
ttlSeconds: options.assertionTtlSeconds,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const dest = new URL(`${ctx.tenantOrigin}/auth/federated/callback`);
|
|
219
|
+
dest.searchParams.set("token", assertion);
|
|
220
|
+
dest.searchParams.set("redirect", ctx.redirectPath);
|
|
221
|
+
reply.redirect(dest.toString(), 302);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const callback = $route({
|
|
225
|
+
path: callbackPath,
|
|
226
|
+
handler: async ({ url, reply, cookies }) => handle(url, cookies, reply),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Apple posts the result (form_post). Extract its one-time `user` (name/email)
|
|
230
|
+
// before openid-client consumes the body.
|
|
231
|
+
const callbackPost = $route({
|
|
232
|
+
path: callbackPath,
|
|
233
|
+
method: "POST",
|
|
234
|
+
handler: async ({ reply, cookies, raw }) => {
|
|
235
|
+
let rawProfile: Record<string, unknown> | undefined;
|
|
236
|
+
let req: Request | URL = raw?.web?.req as Request;
|
|
237
|
+
if (raw?.web?.req) {
|
|
238
|
+
const cloned = raw.web.req.clone();
|
|
239
|
+
req = raw.web.req;
|
|
240
|
+
try {
|
|
241
|
+
const form = await cloned.formData();
|
|
242
|
+
const userField = form.get("user");
|
|
243
|
+
if (typeof userField === "string") {
|
|
244
|
+
const parsed = JSON.parse(userField) as {
|
|
245
|
+
name?: { firstName?: string; lastName?: string };
|
|
246
|
+
email?: string;
|
|
247
|
+
};
|
|
248
|
+
rawProfile = {};
|
|
249
|
+
if (parsed.name?.firstName) {
|
|
250
|
+
rawProfile.given_name = parsed.name.firstName;
|
|
251
|
+
}
|
|
252
|
+
if (parsed.name?.lastName) {
|
|
253
|
+
rawProfile.family_name = parsed.name.lastName;
|
|
254
|
+
}
|
|
255
|
+
if (parsed.name?.firstName || parsed.name?.lastName) {
|
|
256
|
+
rawProfile.name = [parsed.name?.firstName, parsed.name?.lastName]
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.join(" ");
|
|
259
|
+
}
|
|
260
|
+
if (parsed.email) {
|
|
261
|
+
rawProfile.email = parsed.email;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// ignore — name is optional on repeat logins
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await handle(req, cookies, reply, rawProfile);
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return { start, callback, callbackPost };
|
|
273
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { $context, t } from "alepha";
|
|
2
|
+
import type { IssuerPrimitive } from "alepha/security";
|
|
3
|
+
import { $route, BadRequestError } from "alepha/server";
|
|
4
|
+
import {
|
|
5
|
+
type VerifyAssertionOptions,
|
|
6
|
+
verifyFederationAssertion,
|
|
7
|
+
} from "../helpers/federationAssertion.ts";
|
|
8
|
+
import { JtiReplayGuard } from "../helpers/jtiReplayGuard.ts";
|
|
9
|
+
import { safeRedirectPath } from "../helpers/safeRedirectPath.ts";
|
|
10
|
+
import { ServerAuthProvider } from "../providers/ServerAuthProvider.ts";
|
|
11
|
+
import type { LinkAccountOptions, WithLinkFn } from "./$auth.ts";
|
|
12
|
+
|
|
13
|
+
export async function assertionToProfile(
|
|
14
|
+
token: string,
|
|
15
|
+
opts: VerifyAssertionOptions,
|
|
16
|
+
): Promise<{ provider: string; jti: string; link: LinkAccountOptions }> {
|
|
17
|
+
const { profile, jti } = await verifyFederationAssertion(token, opts);
|
|
18
|
+
return {
|
|
19
|
+
provider: profile.provider,
|
|
20
|
+
jti,
|
|
21
|
+
link: {
|
|
22
|
+
access_token: "", // federated: no upstream token retained
|
|
23
|
+
user: {
|
|
24
|
+
sub: profile.sub,
|
|
25
|
+
email: profile.email,
|
|
26
|
+
email_verified: profile.email_verified,
|
|
27
|
+
name: profile.name,
|
|
28
|
+
given_name: profile.given_name,
|
|
29
|
+
family_name: profile.family_name,
|
|
30
|
+
picture: profile.picture,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FederationClientOptions {
|
|
37
|
+
realm: IssuerPrimitive & WithLinkFn;
|
|
38
|
+
brokerUrl: string; // assertion issuer
|
|
39
|
+
publicKeyPem: string;
|
|
40
|
+
selfOrigin?: string; // optional override; otherwise derived from the request host
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const $authFederationClient = (options: FederationClientOptions) => {
|
|
44
|
+
const { alepha } = $context();
|
|
45
|
+
const replay = new JtiReplayGuard(); // single-use, bounded (assertions ~60s)
|
|
46
|
+
|
|
47
|
+
const callback = $route({
|
|
48
|
+
path: "/auth/federated/callback",
|
|
49
|
+
schema: {
|
|
50
|
+
query: t.object({
|
|
51
|
+
token: t.text({ size: "rich" }),
|
|
52
|
+
redirect: t.optional(t.text({ size: "long" })),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
handler: async ({ query, url, reply, cookies }) => {
|
|
56
|
+
const serverAuth = alepha.inject(ServerAuthProvider);
|
|
57
|
+
const audience = options.selfOrigin ?? `${url.protocol}//${url.host}`;
|
|
58
|
+
try {
|
|
59
|
+
const { provider, jti, link } = await assertionToProfile(query.token, {
|
|
60
|
+
publicKeyPem: options.publicKeyPem,
|
|
61
|
+
issuer: options.brokerUrl,
|
|
62
|
+
audience,
|
|
63
|
+
});
|
|
64
|
+
if (!replay.check(jti)) {
|
|
65
|
+
throw new BadRequestError("Assertion already used");
|
|
66
|
+
}
|
|
67
|
+
if (!options.realm.link) {
|
|
68
|
+
throw new BadRequestError("Realm has no link function");
|
|
69
|
+
}
|
|
70
|
+
const user = await options.realm.link(provider)(link);
|
|
71
|
+
await serverAuth.establishSession(
|
|
72
|
+
user,
|
|
73
|
+
options.realm,
|
|
74
|
+
provider,
|
|
75
|
+
cookies,
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
// Invalid / expired / replayed / tampered assertion (or a link
|
|
79
|
+
// refusal, e.g. unverified email) is an expected failure mode — bounce
|
|
80
|
+
// to login with an error rather than surfacing a raw 500.
|
|
81
|
+
reply.redirect("/auth/login?error=federation_failed", 302);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
reply.redirect(safeRedirectPath(query.redirect), 302);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { callback };
|
|
89
|
+
};
|
|
@@ -3,6 +3,7 @@ import { DateTimeProvider } from "alepha/datetime";
|
|
|
3
3
|
import { $logger } from "alepha/logger";
|
|
4
4
|
import {
|
|
5
5
|
InvalidCredentialsError,
|
|
6
|
+
type IssuerPrimitive,
|
|
6
7
|
SecurityError,
|
|
7
8
|
type UserAccount,
|
|
8
9
|
} from "alepha/security";
|
|
@@ -547,18 +548,31 @@ export class ServerAuthProvider {
|
|
|
547
548
|
return;
|
|
548
549
|
}
|
|
549
550
|
|
|
550
|
-
|
|
551
|
+
await this.establishSession(user, issuer, provider.name, cookies);
|
|
552
|
+
|
|
553
|
+
reply.redirect(redirectUri, 302);
|
|
554
|
+
}
|
|
551
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Establish a local session for an already-resolved user: mint realm tokens
|
|
558
|
+
* and write the `tokens` cookie. Used by the OAuth callback and by federated
|
|
559
|
+
* (broker) login. `issuer` is the realm issuer (provider.issuer / realm).
|
|
560
|
+
*/
|
|
561
|
+
public async establishSession(
|
|
562
|
+
user: UserAccount,
|
|
563
|
+
issuer: IssuerPrimitive,
|
|
564
|
+
providerName: string,
|
|
565
|
+
cookies: Cookies,
|
|
566
|
+
): Promise<void> {
|
|
567
|
+
const tokens = await issuer.createToken(user);
|
|
552
568
|
this.setTokens(
|
|
553
569
|
{
|
|
554
570
|
...tokens,
|
|
555
571
|
issued_at: this.dateTimeProvider.now().unix(),
|
|
556
|
-
provider:
|
|
572
|
+
provider: providerName,
|
|
557
573
|
},
|
|
558
574
|
cookies,
|
|
559
575
|
);
|
|
560
|
-
|
|
561
|
-
reply.redirect(redirectUri, 302);
|
|
562
576
|
}
|
|
563
577
|
|
|
564
578
|
/**
|
|
@@ -232,6 +232,76 @@ describe("ServerCookiesProvider", () => {
|
|
|
232
232
|
// Secure flag is not added in tests unless protocol is https, which is handled by the provider
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
+
describe("APP_NAME namespacing", () => {
|
|
236
|
+
class TokenApp {
|
|
237
|
+
tokens = $cookie({
|
|
238
|
+
name: "tokens",
|
|
239
|
+
schema: t.object({ value: t.text() }),
|
|
240
|
+
});
|
|
241
|
+
set = $action({
|
|
242
|
+
handler: () => {
|
|
243
|
+
this.tokens.set({ value: "from-app" });
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
read = $action({
|
|
247
|
+
schema: {
|
|
248
|
+
response: t.object({ value: t.optional(t.text()) }),
|
|
249
|
+
},
|
|
250
|
+
handler: ({ cookies }) => {
|
|
251
|
+
return { value: this.tokens.get({ cookies })?.value };
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const makeApp = (appName: string) =>
|
|
257
|
+
Alepha.create({
|
|
258
|
+
env: { COOKIE_SECRET: TEST_COOKIE_SECRET, APP_NAME: appName },
|
|
259
|
+
})
|
|
260
|
+
.with(AlephaServer)
|
|
261
|
+
.with(AlephaServerCookies)
|
|
262
|
+
.with(TokenApp);
|
|
263
|
+
|
|
264
|
+
test("prefixes the cookie name with the lowercased APP_NAME", async () => {
|
|
265
|
+
const appA = makeApp("AppA");
|
|
266
|
+
await appA.start();
|
|
267
|
+
|
|
268
|
+
const response = await appA.inject(TokenApp).set.fetch();
|
|
269
|
+
const setCookieHeader = response.headers.get("set-cookie");
|
|
270
|
+
|
|
271
|
+
// Cookie is written under the namespaced name, not the bare "tokens".
|
|
272
|
+
expect(setCookieHeader).toContain("appa.tokens=");
|
|
273
|
+
expect(setCookieHeader).not.toMatch(/(^|; )tokens=/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("two apps sharing a cookie jar do not collide", async () => {
|
|
277
|
+
const appA = makeApp("AppA");
|
|
278
|
+
const appB = makeApp("AppB");
|
|
279
|
+
await appA.start();
|
|
280
|
+
await appB.start();
|
|
281
|
+
|
|
282
|
+
// App A writes its cookie.
|
|
283
|
+
const responseA = await appA.inject(TokenApp).set.fetch();
|
|
284
|
+
const cookieA = responseA.headers
|
|
285
|
+
.get("set-cookie")!
|
|
286
|
+
.match(/appa\.tokens=([^;]*)/)![1];
|
|
287
|
+
|
|
288
|
+
// App A reads its own cookie back from the shared jar — works.
|
|
289
|
+
const incoming = `appa.tokens=${cookieA}`;
|
|
290
|
+
const readA = await appA
|
|
291
|
+
.inject(TokenApp)
|
|
292
|
+
.read.fetch({}, { request: { headers: { cookie: incoming } } });
|
|
293
|
+
expect(readA.data.value).toBe("from-app");
|
|
294
|
+
|
|
295
|
+
// App B sees the SAME jar (same host:localhost), but reads under
|
|
296
|
+
// "appb.tokens" — so App A's cookie is invisible to it. No collision,
|
|
297
|
+
// no failed-decrypt-then-delete logout.
|
|
298
|
+
const readB = await appB
|
|
299
|
+
.inject(TokenApp)
|
|
300
|
+
.read.fetch({}, { request: { headers: { cookie: incoming } } });
|
|
301
|
+
expect(readB.data.value).toBeUndefined();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
235
305
|
// test("should throw if secret is missing for secure cookies", async () => {
|
|
236
306
|
// class AppWithMissingSecret {
|
|
237
307
|
// badCookie = $cookie({
|
|
@@ -88,13 +88,33 @@ export class ServerCookiesProvider {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Namespaces a cookie name with the app's `APP_NAME` (lowercased) so that
|
|
93
|
+
* two Alepha apps sharing a cookie jar do not collide.
|
|
94
|
+
*
|
|
95
|
+
* Cookies are scoped by host + path only — never by port — so two apps on
|
|
96
|
+
* `localhost:3000` and `localhost:4000` would otherwise both read/write a
|
|
97
|
+
* cookie literally named `tokens` and silently clobber each other (and,
|
|
98
|
+
* because each encrypts with its own `APP_SECRET`, the foreign cookie fails
|
|
99
|
+
* to decrypt and gets deleted, logging the user out).
|
|
100
|
+
*
|
|
101
|
+
* When `APP_NAME` is unset the name is returned unchanged, so existing
|
|
102
|
+
* single-app deployments keep their current cookie names. Same convention as
|
|
103
|
+
* the `APP_NAME` prefix used by R2 storage and server-timing.
|
|
104
|
+
*/
|
|
105
|
+
protected prefixName(name: string): string {
|
|
106
|
+
const app = this.alepha.env.APP_NAME;
|
|
107
|
+
if (!app) return name;
|
|
108
|
+
return `${String(app).toLowerCase()}.${name}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
public getCookie<T extends TSchema>(
|
|
92
112
|
name: string,
|
|
93
113
|
options: CookiePrimitiveOptions<T>,
|
|
94
114
|
contextCookies?: Cookies,
|
|
95
115
|
): Static<T> | undefined {
|
|
96
116
|
const cookies = this.getCookiesFromContext(contextCookies);
|
|
97
|
-
let rawValue = cookies.req[name];
|
|
117
|
+
let rawValue = cookies.req[this.prefixName(name)];
|
|
98
118
|
|
|
99
119
|
if (!rawValue) return undefined;
|
|
100
120
|
|
|
@@ -174,7 +194,7 @@ export class ServerCookiesProvider {
|
|
|
174
194
|
cookie.maxAge = this.dateTimeProvider.duration(options.ttl).as("seconds");
|
|
175
195
|
}
|
|
176
196
|
|
|
177
|
-
cookies.res[name] = cookie;
|
|
197
|
+
cookies.res[this.prefixName(name)] = cookie;
|
|
178
198
|
}
|
|
179
199
|
|
|
180
200
|
public deleteCookie<T extends TSchema>(
|
|
@@ -182,7 +202,7 @@ export class ServerCookiesProvider {
|
|
|
182
202
|
contextCookies?: Cookies,
|
|
183
203
|
): void {
|
|
184
204
|
const cookies = this.getCookiesFromContext(contextCookies);
|
|
185
|
-
cookies.res[name] = null;
|
|
205
|
+
cookies.res[this.prefixName(name)] = null;
|
|
186
206
|
}
|
|
187
207
|
|
|
188
208
|
// --- Crypto & Parsing ---
|
|
@@ -188,6 +188,14 @@ export interface ServerRoute<
|
|
|
188
188
|
* @see ServerLoggerProvider
|
|
189
189
|
*/
|
|
190
190
|
silent?: boolean;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Mark this `GET` route as prerenderable. When `true`, the build invokes the
|
|
194
|
+
* handler in-process and writes its body verbatim to `dist/public/{path}`,
|
|
195
|
+
* so the route is served as a static file (e.g. `sitemap.xml`, `robots.txt`).
|
|
196
|
+
* The route still serves live at request time for runtimes that have a server.
|
|
197
|
+
*/
|
|
198
|
+
static?: boolean;
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
/**
|
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
PipelinePrimitive,
|
|
6
6
|
type PipelinePrimitiveOptions,
|
|
7
7
|
} from "alepha";
|
|
8
|
+
import { ServerReply } from "../helpers/ServerReply.ts";
|
|
8
9
|
import type {
|
|
9
10
|
RequestConfigSchema,
|
|
10
11
|
ServerHandler,
|
|
12
|
+
ServerRequest,
|
|
11
13
|
ServerRoute,
|
|
12
14
|
} from "../interfaces/ServerRequest.ts";
|
|
13
15
|
import { ServerRouterProvider } from "../providers/ServerRouterProvider.ts";
|
|
@@ -46,6 +48,31 @@ export class RoutePrimitive<
|
|
|
46
48
|
handler: this.handler,
|
|
47
49
|
} as ServerRoute<TConfig>);
|
|
48
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Invoke this route's handler in-process (no HTTP server) and return its path
|
|
54
|
+
* and body. Used by the build to snapshot `static` routes to files.
|
|
55
|
+
*
|
|
56
|
+
* Only meaningful for self-contained `GET` handlers that return a string or
|
|
57
|
+
* Buffer (e.g. `sitemap.xml`, `robots.txt`).
|
|
58
|
+
*/
|
|
59
|
+
public async prerender(): Promise<{ path: string; body: string | Buffer }> {
|
|
60
|
+
const reply = new ServerReply();
|
|
61
|
+
const request = {
|
|
62
|
+
method: this.options.method ?? "GET",
|
|
63
|
+
url: new URL(`http://localhost${this.options.path}`),
|
|
64
|
+
params: {},
|
|
65
|
+
query: {},
|
|
66
|
+
headers: {},
|
|
67
|
+
body: undefined,
|
|
68
|
+
metadata: {},
|
|
69
|
+
reply,
|
|
70
|
+
} as unknown as ServerRequest<TConfig>;
|
|
71
|
+
|
|
72
|
+
const result = await this.handler.run(request);
|
|
73
|
+
const body = (result ?? reply.body) as string | Buffer;
|
|
74
|
+
return { path: this.options.path, body };
|
|
75
|
+
}
|
|
49
76
|
}
|
|
50
77
|
|
|
51
78
|
$route[KIND] = RoutePrimitive;
|