@fluixi/oauth2 1.0.0-alpha.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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api-guard.cjs +185 -0
  3. package/dist/api-guard.d.ts +94 -0
  4. package/dist/api-guard.d.ts.map +1 -0
  5. package/dist/api-guard.js +127 -0
  6. package/dist/api-guard.mjs +141 -0
  7. package/dist/browser-FDUOEUM2.mjs +3544 -0
  8. package/dist/browser-ONMHX3Z3.mjs +3545 -0
  9. package/dist/browser.cjs +735 -0
  10. package/dist/browser.d.ts +3 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +448 -0
  13. package/dist/browser.mjs +704 -0
  14. package/dist/callback.cjs +125 -0
  15. package/dist/callback.d.ts +113 -0
  16. package/dist/callback.d.ts.map +1 -0
  17. package/dist/callback.js +183 -0
  18. package/dist/callback.mjs +106 -0
  19. package/dist/chunk-25WE6UWI.mjs +10 -0
  20. package/dist/chunk-4NSOGPLM.mjs +15 -0
  21. package/dist/chunk-7P6ASYW6.mjs +9 -0
  22. package/dist/chunk-OGSFNTKA.mjs +13 -0
  23. package/dist/client-auth.cjs +115 -0
  24. package/dist/client-auth.d.ts +43 -0
  25. package/dist/client-auth.d.ts.map +1 -0
  26. package/dist/client-auth.js +112 -0
  27. package/dist/client-auth.mjs +84 -0
  28. package/dist/discovery.cjs +86 -0
  29. package/dist/discovery.d.ts +15 -0
  30. package/dist/discovery.d.ts.map +1 -0
  31. package/dist/discovery.js +51 -0
  32. package/dist/discovery.mjs +63 -0
  33. package/dist/dpop-LVPWWADA.mjs +80 -0
  34. package/dist/dpop-NS7NKOWM.mjs +79 -0
  35. package/dist/dpop-store.cjs +152 -0
  36. package/dist/dpop-store.d.ts +37 -0
  37. package/dist/dpop-store.d.ts.map +1 -0
  38. package/dist/dpop-store.js +119 -0
  39. package/dist/dpop-store.mjs +121 -0
  40. package/dist/dpop.cjs +117 -0
  41. package/dist/dpop.d.ts +72 -0
  42. package/dist/dpop.d.ts.map +1 -0
  43. package/dist/dpop.js +114 -0
  44. package/dist/dpop.mjs +86 -0
  45. package/dist/env-config.cjs +366 -0
  46. package/dist/env-config.d.ts +103 -0
  47. package/dist/env-config.d.ts.map +1 -0
  48. package/dist/env-config.js +184 -0
  49. package/dist/env-config.mjs +343 -0
  50. package/dist/env-credentials.cjs +82 -0
  51. package/dist/env-credentials.d.ts +68 -0
  52. package/dist/env-credentials.d.ts.map +1 -0
  53. package/dist/env-credentials.js +76 -0
  54. package/dist/env-credentials.mjs +51 -0
  55. package/dist/index.cjs +940 -0
  56. package/dist/index.d.ts +104 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +101 -0
  59. package/dist/index.mjs +909 -0
  60. package/dist/pkce.cjs +87 -0
  61. package/dist/pkce.d.ts +58 -0
  62. package/dist/pkce.d.ts.map +1 -0
  63. package/dist/pkce.js +112 -0
  64. package/dist/pkce.mjs +66 -0
  65. package/dist/presets.cjs +240 -0
  66. package/dist/presets.d.ts +140 -0
  67. package/dist/presets.d.ts.map +1 -0
  68. package/dist/presets.js +182 -0
  69. package/dist/presets.mjs +217 -0
  70. package/dist/return-to.cjs +46 -0
  71. package/dist/return-to.d.ts +49 -0
  72. package/dist/return-to.d.ts.map +1 -0
  73. package/dist/return-to.js +71 -0
  74. package/dist/return-to.mjs +25 -0
  75. package/dist/roles.cjs +56 -0
  76. package/dist/roles.d.ts +35 -0
  77. package/dist/roles.d.ts.map +1 -0
  78. package/dist/roles.js +37 -0
  79. package/dist/roles.mjs +35 -0
  80. package/dist/server-client.cjs +134 -0
  81. package/dist/server-client.d.ts +12 -0
  82. package/dist/server-client.d.ts.map +1 -0
  83. package/dist/server-client.js +107 -0
  84. package/dist/server-client.mjs +111 -0
  85. package/dist/server.cjs +470 -0
  86. package/dist/server.d.ts +89 -0
  87. package/dist/server.d.ts.map +1 -0
  88. package/dist/server.js +360 -0
  89. package/dist/server.mjs +441 -0
  90. package/dist/service-client.cjs +244 -0
  91. package/dist/service-client.d.ts +89 -0
  92. package/dist/service-client.d.ts.map +1 -0
  93. package/dist/service-client.js +112 -0
  94. package/dist/service-client.mjs +213 -0
  95. package/dist/service.cjs +903 -0
  96. package/dist/service.d.ts +96 -0
  97. package/dist/service.d.ts.map +1 -0
  98. package/dist/service.js +129 -0
  99. package/dist/service.mjs +874 -0
  100. package/dist/tokens.cjs +29 -0
  101. package/dist/tokens.d.ts +13 -0
  102. package/dist/tokens.d.ts.map +1 -0
  103. package/dist/tokens.js +21 -0
  104. package/dist/tokens.mjs +8 -0
  105. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  106. package/dist/types.cjs +33 -0
  107. package/dist/types.d.ts +239 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +23 -0
  110. package/dist/types.mjs +12 -0
  111. package/dist/verify.cjs +724 -0
  112. package/dist/verify.d.ts +205 -0
  113. package/dist/verify.d.ts.map +1 -0
  114. package/dist/verify.js +371 -0
  115. package/dist/verify.mjs +590 -0
  116. package/package.json +121 -0
@@ -0,0 +1,470 @@
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.1 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
13
+ var __copyProps = (to, from, except, desc) => {
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (let key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(to, key) && key !== except)
17
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
18
+ }
19
+ return to;
20
+ };
21
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
22
+ // If the importer is in node compatibility mode or this is not an ESM
23
+ // file that has been converted to a CommonJS file using a Babel-
24
+ // compatible transform (i.e. "__esModule" has not been set), then set
25
+ // "default" to the CommonJS "module.exports" for node compatibility.
26
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
27
+ mod
28
+ ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/server.ts
32
+ var server_exports = {};
33
+ __export(server_exports, {
34
+ oauthRoutes: () => oauthRoutes,
35
+ sessionLocals: () => sessionLocals
36
+ });
37
+ module.exports = __toCommonJS(server_exports);
38
+ var import_server = require("@fluixi/session/server");
39
+
40
+ // src/return-to.ts
41
+ function safeReturnTo(candidate, options = {}) {
42
+ const fallback = options.fallback ?? "/";
43
+ if (!candidate || typeof candidate !== "string") return fallback;
44
+ const value = candidate.trim();
45
+ if (!value) return fallback;
46
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
47
+ if (/^[/\\]{2}/.test(value)) return fallback;
48
+ if (value.startsWith("/")) return value;
49
+ const here = options.origin ?? (typeof location === "undefined" ? void 0 : location.origin);
50
+ const allowed = [here, ...options.allowedOrigins ?? []].filter(Boolean);
51
+ if (!allowed.length) return fallback;
52
+ let url;
53
+ try {
54
+ url = new URL(value);
55
+ } catch {
56
+ return fallback;
57
+ }
58
+ if (!allowed.includes(url.origin)) return fallback;
59
+ if (url.protocol !== "http:" && url.protocol !== "https:") return fallback;
60
+ return url.toString();
61
+ }
62
+
63
+ // src/types.ts
64
+ var OAuthError = class extends Error {
65
+ constructor(message, code, description) {
66
+ super(message);
67
+ this.code = code;
68
+ this.description = description;
69
+ this.name = "OAuthError";
70
+ }
71
+ };
72
+
73
+ // src/client-auth.ts
74
+ var CANONICAL = {
75
+ basic: "client_secret_basic",
76
+ body: "client_secret_post",
77
+ client_secret_basic: "client_secret_basic",
78
+ client_secret_post: "client_secret_post",
79
+ private_key_jwt: "private_key_jwt",
80
+ none: "none"
81
+ };
82
+ function resolveMethod(auth) {
83
+ if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
84
+ if (auth.privateKey) return "private_key_jwt";
85
+ return auth.clientSecret ? "client_secret_basic" : "none";
86
+ }
87
+ async function loadJose() {
88
+ try {
89
+ return await import("jose");
90
+ } catch {
91
+ throw new OAuthError(
92
+ "private_key_jwt needs `jose`. Install it: npm i jose. It is an optional peer, so a client using a secret does not carry it.",
93
+ "jose_missing"
94
+ );
95
+ }
96
+ }
97
+ async function signAssertion(auth, endpoint) {
98
+ const jose = await loadJose();
99
+ const alg = auth.assertionAlgorithm ?? "RS256";
100
+ const key = await jose.importJWK(auth.privateKey, alg);
101
+ const kid = auth.keyId ?? auth.privateKey.kid;
102
+ return new jose.SignJWT({}).setProtectedHeader(kid ? { alg, kid } : { alg }).setIssuer(auth.clientId).setSubject(auth.clientId).setAudience(endpoint).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime("1m").sign(key);
103
+ }
104
+ async function applyClientAuth(auth, endpoint, headers, form) {
105
+ switch (resolveMethod(auth)) {
106
+ case "client_secret_basic": {
107
+ const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
108
+ headers.authorization = `Basic ${btoa(pair)}`;
109
+ break;
110
+ }
111
+ case "client_secret_post":
112
+ form.set("client_id", auth.clientId);
113
+ form.set("client_secret", auth.clientSecret);
114
+ break;
115
+ case "private_key_jwt":
116
+ form.set("client_id", auth.clientId);
117
+ form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
118
+ form.set("client_assertion", await signAssertion(auth, endpoint));
119
+ break;
120
+ case "none":
121
+ form.set("client_id", auth.clientId);
122
+ break;
123
+ }
124
+ }
125
+
126
+ // src/discovery.ts
127
+ var cache = /* @__PURE__ */ new Map();
128
+ function documentUrl(issuer) {
129
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
130
+ }
131
+ async function discover(issuer, fetcher = fetch) {
132
+ const cached = cache.get(issuer);
133
+ if (cached) return cached;
134
+ const pending = (async () => {
135
+ let response;
136
+ try {
137
+ response = await fetcher(documentUrl(issuer));
138
+ } catch (cause) {
139
+ throw new OAuthError(
140
+ `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${cause instanceof Error ? cause.message : String(cause)})`,
141
+ "discovery_unreachable"
142
+ );
143
+ }
144
+ if (!response.ok) {
145
+ throw new OAuthError(
146
+ `Discovery failed for ${issuer}: ${response.status}`,
147
+ "discovery_failed"
148
+ );
149
+ }
150
+ const doc = await response.json();
151
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
152
+ throw new OAuthError(
153
+ `${issuer} published a discovery document with no authorization or token endpoint.`,
154
+ "discovery_incomplete"
155
+ );
156
+ }
157
+ return {
158
+ authorization: doc.authorization_endpoint,
159
+ token: doc.token_endpoint,
160
+ userinfo: doc.userinfo_endpoint,
161
+ endSession: doc.end_session_endpoint,
162
+ jwks: doc.jwks_uri,
163
+ introspection: doc.introspection_endpoint,
164
+ revocation: doc.revocation_endpoint,
165
+ issuer: doc.issuer ?? issuer
166
+ };
167
+ })();
168
+ cache.set(issuer, pending);
169
+ pending.catch(() => cache.delete(issuer));
170
+ return pending;
171
+ }
172
+
173
+ // src/pkce.ts
174
+ function base64url(bytes) {
175
+ let binary = "";
176
+ for (const byte of bytes) binary += String.fromCharCode(byte);
177
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
178
+ }
179
+ function randomBytes(length) {
180
+ const bytes = new Uint8Array(length);
181
+ crypto.getRandomValues(bytes);
182
+ return bytes;
183
+ }
184
+ function createVerifier() {
185
+ return base64url(randomBytes(32));
186
+ }
187
+ function createStateValue() {
188
+ return base64url(randomBytes(16));
189
+ }
190
+ async function createChallenge(verifier) {
191
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
192
+ return base64url(new Uint8Array(digest));
193
+ }
194
+ async function createPkcePair() {
195
+ const verifier = createVerifier();
196
+ return { verifier, challenge: await createChallenge(verifier) };
197
+ }
198
+ function timingSafeEqual(a, b) {
199
+ if (a.length !== b.length) return false;
200
+ let diff = 0;
201
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
202
+ return diff === 0;
203
+ }
204
+ function expiryFrom(expiresIn, now = Date.now()) {
205
+ const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
206
+ return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
207
+ }
208
+
209
+ // src/server.ts
210
+ function oauthRoutes(config) {
211
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
212
+ const scopes = config.scopes ?? ["openid", "profile", "email"];
213
+ const cookie = (0, import_server.createSessionCookie)({
214
+ secret: config.cookieSecret,
215
+ name: config.cookieName,
216
+ secure: config.secureCookie
217
+ });
218
+ let endpoints = config.endpoints ?? null;
219
+ const resolve = async () => {
220
+ if (endpoints) return endpoints;
221
+ if (!config.issuer) {
222
+ throw new OAuthError("Give oauthRoutes an issuer or endpoints.", "missing_issuer");
223
+ }
224
+ endpoints = await discover(config.issuer);
225
+ return endpoints;
226
+ };
227
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
228
+ status,
229
+ headers: { "content-type": "application/json", ...headers }
230
+ });
231
+ const clientAuth = {
232
+ clientId: config.clientId,
233
+ // `client_secret_post` by default here, which is what this half has always sent, so
234
+ // an existing deployment keeps working.
235
+ method: config.tokenAuthMethod ?? (config.clientSecret ? "body" : "none"),
236
+ clientSecret: config.clientSecret,
237
+ privateKey: config.privateKey,
238
+ keyId: config.keyId,
239
+ assertionAlgorithm: config.assertionAlgorithm
240
+ };
241
+ async function tokenRequest(body) {
242
+ const { token } = await resolve();
243
+ const headers = {
244
+ "content-type": "application/x-www-form-urlencoded"
245
+ };
246
+ const form = new URLSearchParams(body);
247
+ await applyClientAuth(clientAuth, token, headers, form);
248
+ const response = await fetch(token, { method: "POST", headers, body: form.toString() });
249
+ const payload = await response.json().catch(() => ({}));
250
+ if (!response.ok) {
251
+ throw new OAuthError(
252
+ String(payload.error ?? "token_request_failed"),
253
+ String(payload.error ?? "token_request_failed")
254
+ );
255
+ }
256
+ return payload;
257
+ }
258
+ return async function middleware(request, next) {
259
+ const url = new URL(request.url);
260
+ if (!url.pathname.startsWith(`${base}/`)) return next();
261
+ const route = url.pathname.slice(base.length);
262
+ const session = async () => {
263
+ return cookie.read(request);
264
+ };
265
+ if (route === "/login") {
266
+ const { authorization } = await resolve();
267
+ const { verifier, challenge } = await createPkcePair();
268
+ const state = createStateValue();
269
+ const target = new URL(authorization);
270
+ target.searchParams.set("response_type", "code");
271
+ target.searchParams.set("client_id", config.clientId);
272
+ target.searchParams.set("redirect_uri", config.redirectUri);
273
+ target.searchParams.set("scope", scopes.join(" "));
274
+ target.searchParams.set("state", state);
275
+ target.searchParams.set("code_challenge", challenge);
276
+ target.searchParams.set("code_challenge_method", "S256");
277
+ const pendingCookie = await cookie.write(
278
+ {
279
+ accessToken: "",
280
+ pending: {
281
+ verifier,
282
+ state,
283
+ // Checked here rather than only at the callback, so a refused destination
284
+ // never reaches the cookie and cannot be read back out of it later.
285
+ returnTo: safeReturnTo(url.searchParams.get("returnTo"), {
286
+ fallback: "",
287
+ origin: url.origin,
288
+ allowedOrigins: config.allowedReturnOrigins
289
+ }) || void 0
290
+ }
291
+ },
292
+ // Ten minutes: long enough to sign in, short enough that an abandoned attempt
293
+ // does not sit in the browser.
294
+ { maxAge: 600 }
295
+ );
296
+ return new Response(null, {
297
+ status: 302,
298
+ headers: {
299
+ location: target.toString(),
300
+ "set-cookie": pendingCookie
301
+ }
302
+ });
303
+ }
304
+ if (route === "/callback") {
305
+ const current = await session();
306
+ const pending = current?.pending;
307
+ const error = url.searchParams.get("error");
308
+ if (error) return json({ error }, 400);
309
+ const code = url.searchParams.get("code");
310
+ const state = url.searchParams.get("state");
311
+ if (!code) return json({ error: "no_code" }, 400);
312
+ if (!pending || !state || !timingSafeEqual(pending.state, state)) {
313
+ return json({ error: "state_mismatch" }, 400);
314
+ }
315
+ const payload = await tokenRequest({
316
+ grant_type: "authorization_code",
317
+ code,
318
+ redirect_uri: config.redirectUri,
319
+ code_verifier: pending.verifier
320
+ });
321
+ const signedIn = await cookie.write({
322
+ accessToken: String(payload.access_token ?? ""),
323
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
324
+ idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
325
+ expiresAt: expiryFrom(payload.expires_in)
326
+ });
327
+ return new Response(null, {
328
+ status: 302,
329
+ headers: {
330
+ // Checked again on the way out. The cookie cannot be forged, but a value that
331
+ // was acceptable when the flow started should still be one when it finishes,
332
+ // and this is the line that actually performs the redirect.
333
+ location: safeReturnTo(pending.returnTo ?? config.defaultReturnTo, {
334
+ fallback: "/",
335
+ origin: url.origin,
336
+ allowedOrigins: config.allowedReturnOrigins
337
+ }),
338
+ "set-cookie": signedIn
339
+ }
340
+ });
341
+ }
342
+ if (route === "/refresh") {
343
+ const current = await session();
344
+ if (!current?.refreshToken) return json({ error: "no_session" }, 401);
345
+ const payload = await tokenRequest({
346
+ grant_type: "refresh_token",
347
+ refresh_token: current.refreshToken
348
+ });
349
+ const refreshed = await cookie.write({
350
+ accessToken: String(payload.access_token ?? ""),
351
+ // A provider that rotates refresh tokens sends a new one; one that does not
352
+ // expects the old one to keep working.
353
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
354
+ idToken: current.idToken,
355
+ expiresAt: expiryFrom(payload.expires_in)
356
+ });
357
+ return json({ ok: true }, 200, { "set-cookie": refreshed });
358
+ }
359
+ if (route === "/me") {
360
+ const current = await session();
361
+ if (!current?.accessToken) return json({ error: "no_session" }, 401);
362
+ const { userinfo } = await resolve();
363
+ if (!userinfo) return json({ error: "no_userinfo" }, 501);
364
+ const response = await fetch(userinfo, {
365
+ headers: { authorization: `Bearer ${current.accessToken}` }
366
+ });
367
+ if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
368
+ const claims = await response.json();
369
+ return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
370
+ }
371
+ if (route === "/revoke") {
372
+ const current = await session();
373
+ const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
374
+ const target = hint === "access_token" ? current?.accessToken : current?.refreshToken;
375
+ if (!target) return json({ error: "no_session" }, 401);
376
+ const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
377
+ if (!revocation) return json({ error: "no_revocation_endpoint" }, 501);
378
+ const headers = {
379
+ "content-type": "application/x-www-form-urlencoded"
380
+ };
381
+ const form = new URLSearchParams({ token: target, token_type_hint: hint });
382
+ await applyClientAuth(clientAuth, revocation, headers, form);
383
+ await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
384
+ () => void 0
385
+ );
386
+ return json({ ok: true, revoked: hint });
387
+ }
388
+ if (route === "/logout") {
389
+ const current = await session();
390
+ const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
391
+ const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
392
+ if (revocation && current?.refreshToken) {
393
+ const headers = {
394
+ "content-type": "application/x-www-form-urlencoded"
395
+ };
396
+ const form = new URLSearchParams({
397
+ token: current.refreshToken,
398
+ token_type_hint: "refresh_token"
399
+ });
400
+ await applyClientAuth(clientAuth, revocation, headers, form);
401
+ await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
402
+ () => void 0
403
+ );
404
+ }
405
+ const cleared = cookie.clear();
406
+ if (endSession && current?.idToken) {
407
+ const target = new URL(endSession);
408
+ target.searchParams.set("id_token_hint", current.idToken);
409
+ if (config.defaultReturnTo) {
410
+ target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
411
+ }
412
+ return new Response(null, {
413
+ status: 302,
414
+ headers: { location: target.toString(), "set-cookie": cleared }
415
+ });
416
+ }
417
+ return json({ ok: true }, 200, { "set-cookie": cleared });
418
+ }
419
+ return next();
420
+ };
421
+ }
422
+ function sessionLocals(config) {
423
+ const cookie = (0, import_server.createSessionCookie)({
424
+ secret: config.cookieSecret,
425
+ name: config.cookieName,
426
+ secure: config.secureCookie
427
+ });
428
+ const ttl = config.userCacheMs ?? 3e4;
429
+ const cache2 = /* @__PURE__ */ new Map();
430
+ const remember = (token, user) => {
431
+ if (ttl <= 0) return;
432
+ if (cache2.size > 500) {
433
+ const now = Date.now();
434
+ for (const [key, entry] of cache2) if (entry.until <= now) cache2.delete(key);
435
+ if (cache2.size > 500) cache2.delete(cache2.keys().next().value);
436
+ }
437
+ cache2.set(token, { user, until: Date.now() + ttl });
438
+ };
439
+ return async function middleware(request, next) {
440
+ const { getRequestLocals } = await import("@fluixi/server");
441
+ const locals = getRequestLocals(request);
442
+ const session = await cookie.read(request);
443
+ if (!session?.accessToken) {
444
+ locals.auth = { user: null, session: null };
445
+ return next();
446
+ }
447
+ const cached = cache2.get(session.accessToken);
448
+ if (cached && cached.until > Date.now()) {
449
+ locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
450
+ return next();
451
+ }
452
+ let user = null;
453
+ try {
454
+ const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
455
+ if (endpoints?.userinfo) {
456
+ const response = await fetch(endpoints.userinfo, {
457
+ headers: { authorization: `Bearer ${session.accessToken}` }
458
+ });
459
+ if (response.ok) {
460
+ const claims = await response.json();
461
+ user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
462
+ remember(session.accessToken, user);
463
+ }
464
+ }
465
+ } catch {
466
+ }
467
+ locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
468
+ return next();
469
+ };
470
+ }
@@ -0,0 +1,89 @@
1
+ import { type ClientAuthMethodInput } from './client-auth.js';
2
+ import { type Endpoints } from './types.js';
3
+ export interface OAuthRoutesConfig {
4
+ issuer?: string;
5
+ endpoints?: Endpoints;
6
+ clientId: string;
7
+ /** A server-held client can keep one, which is what makes it confidential. */
8
+ clientSecret?: string;
9
+ redirectUri: string;
10
+ scopes?: string[];
11
+ /** Where the routes are mounted. Must agree with the browser client. Default `/auth`. */
12
+ basePath?: string;
13
+ /** At least 32 bytes. The cookie is encrypted with a key derived from it. */
14
+ cookieSecret: string;
15
+ cookieName?: string;
16
+ /** Default: true in production. Set false only for plain-http local work. */
17
+ secureCookie?: boolean;
18
+ /** Where to send the browser after a login with no recorded destination. */
19
+ defaultReturnTo?: string;
20
+ /**
21
+ * Origins a login may return to besides this server's own.
22
+ *
23
+ * A destination arrives on the query string, so it is attacker controlled. Anything
24
+ * that is not a path on this origin is refused unless it is named here. Adding an
25
+ * origin here makes it a valid landing place for an authenticated visitor, so add only
26
+ * ones you control.
27
+ */
28
+ allowedReturnOrigins?: string[];
29
+ /**
30
+ * How this server authenticates to the token endpoint.
31
+ *
32
+ * `private_key_jwt` needs `privateKey` and sends no secret at all, which some providers
33
+ * require: Okta's org authorization server refuses anything else.
34
+ */
35
+ tokenAuthMethod?: ClientAuthMethodInput;
36
+ /** The signing key for `private_key_jwt`, as a JWK. */
37
+ privateKey?: Record<string, unknown>;
38
+ /** Names which key, for a provider holding more than one. */
39
+ keyId?: string;
40
+ /** Default `RS256`. */
41
+ assertionAlgorithm?: string;
42
+ /**
43
+ * How long `sessionLocals` may reuse a userinfo response, in milliseconds.
44
+ *
45
+ * Without this every server render calls the provider, which is a round trip per page
46
+ * and makes the provider being up a condition of rendering at all.
47
+ *
48
+ * The cost is freshness: a session disabled at the provider keeps rendering as signed
49
+ * in until the entry expires. Short by default for that reason, and worth leaving
50
+ * short. `0` disables it and asks every time.
51
+ */
52
+ userCacheMs?: number;
53
+ /** Map the provider's userinfo onto the app's own shape. */
54
+ mapUser?: (claims: Record<string, unknown>, accessToken: string) => unknown;
55
+ }
56
+ /**
57
+ * The middleware.
58
+ *
59
+ * Handles `<basePath>/login`, `/callback`, `/refresh`, `/me` and `/logout`, and passes
60
+ * anything else along untouched.
61
+ */
62
+ export declare function oauthRoutes(config: OAuthRoutesConfig): (request: Request, next: () => Promise<Response>) => Promise<Response>;
63
+ /**
64
+ * Put the signed-in user on the request, so a server render knows who is looking.
65
+ *
66
+ * This is the piece that makes `tokens: 'server'` able to render authenticated pages. The
67
+ * cookie is the credential and the server can read it, so the user is resolved before
68
+ * the render and written where `@fluixi/auth`'s hooks already look: request locals,
69
+ * under the key they read. `useUser()` then answers during SSR instead of returning null
70
+ * and filling in after hydration.
71
+ *
72
+ * Dependency injection does not solve this and is not meant to. DI decides which
73
+ * instance a component gets; locals carry what the middleware learned about this
74
+ * request. They are different questions, and the user is the second one.
75
+ *
76
+ * There is no browser-mode equivalent, and that is not an omission. In that mode the
77
+ * token lives in the browser and is never sent on a document request, so the server has
78
+ * no credential to resolve and cannot know who is signed in. An app built that way
79
+ * renders the signed-out shell and fills it in on the client.
80
+ *
81
+ * ```ts
82
+ * export default defineMiddleware([auth, sessionLocals(config), ...]);
83
+ * ```
84
+ *
85
+ * Mount it after `oauthRoutes`, so a request to `/auth/login` is answered rather than
86
+ * having its session resolved first.
87
+ */
88
+ export declare function sessionLocals(config: OAuthRoutesConfig): (request: Request, next: () => Promise<Response>) => Promise<Response>;
89
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAgCA,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAExD,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;IACxC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;CAC7E;AAYD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,IAwDjD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CAwMrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,iBAAiB,IA8BnD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CA0CrB"}