@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,441 @@
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.1 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import "./chunk-25WE6UWI.mjs";
3
+
4
+ // src/server.ts
5
+ import { createSessionCookie } from "@fluixi/session/server";
6
+
7
+ // src/return-to.ts
8
+ function safeReturnTo(candidate, options = {}) {
9
+ const fallback = options.fallback ?? "/";
10
+ if (!candidate || typeof candidate !== "string") return fallback;
11
+ const value = candidate.trim();
12
+ if (!value) return fallback;
13
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
14
+ if (/^[/\\]{2}/.test(value)) return fallback;
15
+ if (value.startsWith("/")) return value;
16
+ const here = options.origin ?? (typeof location === "undefined" ? void 0 : location.origin);
17
+ const allowed = [here, ...options.allowedOrigins ?? []].filter(Boolean);
18
+ if (!allowed.length) return fallback;
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ if (!allowed.includes(url.origin)) return fallback;
26
+ if (url.protocol !== "http:" && url.protocol !== "https:") return fallback;
27
+ return url.toString();
28
+ }
29
+
30
+ // src/types.ts
31
+ var OAuthError = class extends Error {
32
+ constructor(message, code, description) {
33
+ super(message);
34
+ this.code = code;
35
+ this.description = description;
36
+ this.name = "OAuthError";
37
+ }
38
+ };
39
+
40
+ // src/client-auth.ts
41
+ var CANONICAL = {
42
+ basic: "client_secret_basic",
43
+ body: "client_secret_post",
44
+ client_secret_basic: "client_secret_basic",
45
+ client_secret_post: "client_secret_post",
46
+ private_key_jwt: "private_key_jwt",
47
+ none: "none"
48
+ };
49
+ function resolveMethod(auth) {
50
+ if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
51
+ if (auth.privateKey) return "private_key_jwt";
52
+ return auth.clientSecret ? "client_secret_basic" : "none";
53
+ }
54
+ async function loadJose() {
55
+ try {
56
+ return await import("./browser-ONMHX3Z3.mjs");
57
+ } catch {
58
+ throw new OAuthError(
59
+ "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.",
60
+ "jose_missing"
61
+ );
62
+ }
63
+ }
64
+ async function signAssertion(auth, endpoint) {
65
+ const jose = await loadJose();
66
+ const alg = auth.assertionAlgorithm ?? "RS256";
67
+ const key = await jose.importJWK(auth.privateKey, alg);
68
+ const kid = auth.keyId ?? auth.privateKey.kid;
69
+ 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);
70
+ }
71
+ async function applyClientAuth(auth, endpoint, headers, form) {
72
+ switch (resolveMethod(auth)) {
73
+ case "client_secret_basic": {
74
+ const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
75
+ headers.authorization = `Basic ${btoa(pair)}`;
76
+ break;
77
+ }
78
+ case "client_secret_post":
79
+ form.set("client_id", auth.clientId);
80
+ form.set("client_secret", auth.clientSecret);
81
+ break;
82
+ case "private_key_jwt":
83
+ form.set("client_id", auth.clientId);
84
+ form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
85
+ form.set("client_assertion", await signAssertion(auth, endpoint));
86
+ break;
87
+ case "none":
88
+ form.set("client_id", auth.clientId);
89
+ break;
90
+ }
91
+ }
92
+
93
+ // src/discovery.ts
94
+ var cache = /* @__PURE__ */ new Map();
95
+ function documentUrl(issuer) {
96
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
97
+ }
98
+ async function discover(issuer, fetcher = fetch) {
99
+ const cached = cache.get(issuer);
100
+ if (cached) return cached;
101
+ const pending = (async () => {
102
+ let response;
103
+ try {
104
+ response = await fetcher(documentUrl(issuer));
105
+ } catch (cause) {
106
+ throw new OAuthError(
107
+ `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)})`,
108
+ "discovery_unreachable"
109
+ );
110
+ }
111
+ if (!response.ok) {
112
+ throw new OAuthError(
113
+ `Discovery failed for ${issuer}: ${response.status}`,
114
+ "discovery_failed"
115
+ );
116
+ }
117
+ const doc = await response.json();
118
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
119
+ throw new OAuthError(
120
+ `${issuer} published a discovery document with no authorization or token endpoint.`,
121
+ "discovery_incomplete"
122
+ );
123
+ }
124
+ return {
125
+ authorization: doc.authorization_endpoint,
126
+ token: doc.token_endpoint,
127
+ userinfo: doc.userinfo_endpoint,
128
+ endSession: doc.end_session_endpoint,
129
+ jwks: doc.jwks_uri,
130
+ introspection: doc.introspection_endpoint,
131
+ revocation: doc.revocation_endpoint,
132
+ issuer: doc.issuer ?? issuer
133
+ };
134
+ })();
135
+ cache.set(issuer, pending);
136
+ pending.catch(() => cache.delete(issuer));
137
+ return pending;
138
+ }
139
+
140
+ // src/pkce.ts
141
+ function base64url(bytes) {
142
+ let binary = "";
143
+ for (const byte of bytes) binary += String.fromCharCode(byte);
144
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
145
+ }
146
+ function randomBytes(length) {
147
+ const bytes = new Uint8Array(length);
148
+ crypto.getRandomValues(bytes);
149
+ return bytes;
150
+ }
151
+ function createVerifier() {
152
+ return base64url(randomBytes(32));
153
+ }
154
+ function createStateValue() {
155
+ return base64url(randomBytes(16));
156
+ }
157
+ async function createChallenge(verifier) {
158
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
159
+ return base64url(new Uint8Array(digest));
160
+ }
161
+ async function createPkcePair() {
162
+ const verifier = createVerifier();
163
+ return { verifier, challenge: await createChallenge(verifier) };
164
+ }
165
+ function timingSafeEqual(a, b) {
166
+ if (a.length !== b.length) return false;
167
+ let diff = 0;
168
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
169
+ return diff === 0;
170
+ }
171
+ function expiryFrom(expiresIn, now = Date.now()) {
172
+ const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
173
+ return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
174
+ }
175
+
176
+ // src/server.ts
177
+ function oauthRoutes(config) {
178
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
179
+ const scopes = config.scopes ?? ["openid", "profile", "email"];
180
+ const cookie = createSessionCookie({
181
+ secret: config.cookieSecret,
182
+ name: config.cookieName,
183
+ secure: config.secureCookie
184
+ });
185
+ let endpoints = config.endpoints ?? null;
186
+ const resolve = async () => {
187
+ if (endpoints) return endpoints;
188
+ if (!config.issuer) {
189
+ throw new OAuthError("Give oauthRoutes an issuer or endpoints.", "missing_issuer");
190
+ }
191
+ endpoints = await discover(config.issuer);
192
+ return endpoints;
193
+ };
194
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
195
+ status,
196
+ headers: { "content-type": "application/json", ...headers }
197
+ });
198
+ const clientAuth = {
199
+ clientId: config.clientId,
200
+ // `client_secret_post` by default here, which is what this half has always sent, so
201
+ // an existing deployment keeps working.
202
+ method: config.tokenAuthMethod ?? (config.clientSecret ? "body" : "none"),
203
+ clientSecret: config.clientSecret,
204
+ privateKey: config.privateKey,
205
+ keyId: config.keyId,
206
+ assertionAlgorithm: config.assertionAlgorithm
207
+ };
208
+ async function tokenRequest(body) {
209
+ const { token } = await resolve();
210
+ const headers = {
211
+ "content-type": "application/x-www-form-urlencoded"
212
+ };
213
+ const form = new URLSearchParams(body);
214
+ await applyClientAuth(clientAuth, token, headers, form);
215
+ const response = await fetch(token, { method: "POST", headers, body: form.toString() });
216
+ const payload = await response.json().catch(() => ({}));
217
+ if (!response.ok) {
218
+ throw new OAuthError(
219
+ String(payload.error ?? "token_request_failed"),
220
+ String(payload.error ?? "token_request_failed")
221
+ );
222
+ }
223
+ return payload;
224
+ }
225
+ return async function middleware(request, next) {
226
+ const url = new URL(request.url);
227
+ if (!url.pathname.startsWith(`${base}/`)) return next();
228
+ const route = url.pathname.slice(base.length);
229
+ const session = async () => {
230
+ return cookie.read(request);
231
+ };
232
+ if (route === "/login") {
233
+ const { authorization } = await resolve();
234
+ const { verifier, challenge } = await createPkcePair();
235
+ const state = createStateValue();
236
+ const target = new URL(authorization);
237
+ target.searchParams.set("response_type", "code");
238
+ target.searchParams.set("client_id", config.clientId);
239
+ target.searchParams.set("redirect_uri", config.redirectUri);
240
+ target.searchParams.set("scope", scopes.join(" "));
241
+ target.searchParams.set("state", state);
242
+ target.searchParams.set("code_challenge", challenge);
243
+ target.searchParams.set("code_challenge_method", "S256");
244
+ const pendingCookie = await cookie.write(
245
+ {
246
+ accessToken: "",
247
+ pending: {
248
+ verifier,
249
+ state,
250
+ // Checked here rather than only at the callback, so a refused destination
251
+ // never reaches the cookie and cannot be read back out of it later.
252
+ returnTo: safeReturnTo(url.searchParams.get("returnTo"), {
253
+ fallback: "",
254
+ origin: url.origin,
255
+ allowedOrigins: config.allowedReturnOrigins
256
+ }) || void 0
257
+ }
258
+ },
259
+ // Ten minutes: long enough to sign in, short enough that an abandoned attempt
260
+ // does not sit in the browser.
261
+ { maxAge: 600 }
262
+ );
263
+ return new Response(null, {
264
+ status: 302,
265
+ headers: {
266
+ location: target.toString(),
267
+ "set-cookie": pendingCookie
268
+ }
269
+ });
270
+ }
271
+ if (route === "/callback") {
272
+ const current = await session();
273
+ const pending = current?.pending;
274
+ const error = url.searchParams.get("error");
275
+ if (error) return json({ error }, 400);
276
+ const code = url.searchParams.get("code");
277
+ const state = url.searchParams.get("state");
278
+ if (!code) return json({ error: "no_code" }, 400);
279
+ if (!pending || !state || !timingSafeEqual(pending.state, state)) {
280
+ return json({ error: "state_mismatch" }, 400);
281
+ }
282
+ const payload = await tokenRequest({
283
+ grant_type: "authorization_code",
284
+ code,
285
+ redirect_uri: config.redirectUri,
286
+ code_verifier: pending.verifier
287
+ });
288
+ const signedIn = await cookie.write({
289
+ accessToken: String(payload.access_token ?? ""),
290
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
291
+ idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
292
+ expiresAt: expiryFrom(payload.expires_in)
293
+ });
294
+ return new Response(null, {
295
+ status: 302,
296
+ headers: {
297
+ // Checked again on the way out. The cookie cannot be forged, but a value that
298
+ // was acceptable when the flow started should still be one when it finishes,
299
+ // and this is the line that actually performs the redirect.
300
+ location: safeReturnTo(pending.returnTo ?? config.defaultReturnTo, {
301
+ fallback: "/",
302
+ origin: url.origin,
303
+ allowedOrigins: config.allowedReturnOrigins
304
+ }),
305
+ "set-cookie": signedIn
306
+ }
307
+ });
308
+ }
309
+ if (route === "/refresh") {
310
+ const current = await session();
311
+ if (!current?.refreshToken) return json({ error: "no_session" }, 401);
312
+ const payload = await tokenRequest({
313
+ grant_type: "refresh_token",
314
+ refresh_token: current.refreshToken
315
+ });
316
+ const refreshed = await cookie.write({
317
+ accessToken: String(payload.access_token ?? ""),
318
+ // A provider that rotates refresh tokens sends a new one; one that does not
319
+ // expects the old one to keep working.
320
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
321
+ idToken: current.idToken,
322
+ expiresAt: expiryFrom(payload.expires_in)
323
+ });
324
+ return json({ ok: true }, 200, { "set-cookie": refreshed });
325
+ }
326
+ if (route === "/me") {
327
+ const current = await session();
328
+ if (!current?.accessToken) return json({ error: "no_session" }, 401);
329
+ const { userinfo } = await resolve();
330
+ if (!userinfo) return json({ error: "no_userinfo" }, 501);
331
+ const response = await fetch(userinfo, {
332
+ headers: { authorization: `Bearer ${current.accessToken}` }
333
+ });
334
+ if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
335
+ const claims = await response.json();
336
+ return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
337
+ }
338
+ if (route === "/revoke") {
339
+ const current = await session();
340
+ const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
341
+ const target = hint === "access_token" ? current?.accessToken : current?.refreshToken;
342
+ if (!target) return json({ error: "no_session" }, 401);
343
+ const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
344
+ if (!revocation) return json({ error: "no_revocation_endpoint" }, 501);
345
+ const headers = {
346
+ "content-type": "application/x-www-form-urlencoded"
347
+ };
348
+ const form = new URLSearchParams({ token: target, token_type_hint: hint });
349
+ await applyClientAuth(clientAuth, revocation, headers, form);
350
+ await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
351
+ () => void 0
352
+ );
353
+ return json({ ok: true, revoked: hint });
354
+ }
355
+ if (route === "/logout") {
356
+ const current = await session();
357
+ const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
358
+ const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
359
+ if (revocation && current?.refreshToken) {
360
+ const headers = {
361
+ "content-type": "application/x-www-form-urlencoded"
362
+ };
363
+ const form = new URLSearchParams({
364
+ token: current.refreshToken,
365
+ token_type_hint: "refresh_token"
366
+ });
367
+ await applyClientAuth(clientAuth, revocation, headers, form);
368
+ await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
369
+ () => void 0
370
+ );
371
+ }
372
+ const cleared = cookie.clear();
373
+ if (endSession && current?.idToken) {
374
+ const target = new URL(endSession);
375
+ target.searchParams.set("id_token_hint", current.idToken);
376
+ if (config.defaultReturnTo) {
377
+ target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
378
+ }
379
+ return new Response(null, {
380
+ status: 302,
381
+ headers: { location: target.toString(), "set-cookie": cleared }
382
+ });
383
+ }
384
+ return json({ ok: true }, 200, { "set-cookie": cleared });
385
+ }
386
+ return next();
387
+ };
388
+ }
389
+ function sessionLocals(config) {
390
+ const cookie = createSessionCookie({
391
+ secret: config.cookieSecret,
392
+ name: config.cookieName,
393
+ secure: config.secureCookie
394
+ });
395
+ const ttl = config.userCacheMs ?? 3e4;
396
+ const cache2 = /* @__PURE__ */ new Map();
397
+ const remember = (token, user) => {
398
+ if (ttl <= 0) return;
399
+ if (cache2.size > 500) {
400
+ const now = Date.now();
401
+ for (const [key, entry] of cache2) if (entry.until <= now) cache2.delete(key);
402
+ if (cache2.size > 500) cache2.delete(cache2.keys().next().value);
403
+ }
404
+ cache2.set(token, { user, until: Date.now() + ttl });
405
+ };
406
+ return async function middleware(request, next) {
407
+ const { getRequestLocals } = await import("@fluixi/server");
408
+ const locals = getRequestLocals(request);
409
+ const session = await cookie.read(request);
410
+ if (!session?.accessToken) {
411
+ locals.auth = { user: null, session: null };
412
+ return next();
413
+ }
414
+ const cached = cache2.get(session.accessToken);
415
+ if (cached && cached.until > Date.now()) {
416
+ locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
417
+ return next();
418
+ }
419
+ let user = null;
420
+ try {
421
+ const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
422
+ if (endpoints?.userinfo) {
423
+ const response = await fetch(endpoints.userinfo, {
424
+ headers: { authorization: `Bearer ${session.accessToken}` }
425
+ });
426
+ if (response.ok) {
427
+ const claims = await response.json();
428
+ user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
429
+ remember(session.accessToken, user);
430
+ }
431
+ }
432
+ } catch {
433
+ }
434
+ locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
435
+ return next();
436
+ };
437
+ }
438
+ export {
439
+ oauthRoutes,
440
+ sessionLocals
441
+ };
@@ -0,0 +1,244 @@
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/service-client.ts
32
+ var service_client_exports = {};
33
+ __export(service_client_exports, {
34
+ createServiceClient: () => createServiceClient
35
+ });
36
+ module.exports = __toCommonJS(service_client_exports);
37
+
38
+ // src/types.ts
39
+ var OAuthError = class extends Error {
40
+ constructor(message, code, description) {
41
+ super(message);
42
+ this.code = code;
43
+ this.description = description;
44
+ this.name = "OAuthError";
45
+ }
46
+ };
47
+
48
+ // src/client-auth.ts
49
+ var CANONICAL = {
50
+ basic: "client_secret_basic",
51
+ body: "client_secret_post",
52
+ client_secret_basic: "client_secret_basic",
53
+ client_secret_post: "client_secret_post",
54
+ private_key_jwt: "private_key_jwt",
55
+ none: "none"
56
+ };
57
+ function resolveMethod(auth) {
58
+ if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
59
+ if (auth.privateKey) return "private_key_jwt";
60
+ return auth.clientSecret ? "client_secret_basic" : "none";
61
+ }
62
+ function assertUsable(auth, who) {
63
+ if (!auth.clientId) throw new Error(`[fluixi/oauth2] ${who} needs a clientId.`);
64
+ const method = resolveMethod(auth);
65
+ if (method === "private_key_jwt" && !auth.privateKey) {
66
+ throw new Error(
67
+ `[fluixi/oauth2] ${who}: private_key_jwt needs \`privateKey\`, the JWK whose public half the provider holds.`
68
+ );
69
+ }
70
+ if ((method === "client_secret_basic" || method === "client_secret_post") && !auth.clientSecret) {
71
+ throw new Error(
72
+ `[fluixi/oauth2] ${who}: ${method} needs a \`clientSecret\`. A public client should use \`method: 'none'\`, which PKCE is what protects.`
73
+ );
74
+ }
75
+ return method;
76
+ }
77
+ async function loadJose() {
78
+ try {
79
+ return await import("jose");
80
+ } catch {
81
+ throw new OAuthError(
82
+ "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.",
83
+ "jose_missing"
84
+ );
85
+ }
86
+ }
87
+ async function signAssertion(auth, endpoint) {
88
+ const jose = await loadJose();
89
+ const alg = auth.assertionAlgorithm ?? "RS256";
90
+ const key = await jose.importJWK(auth.privateKey, alg);
91
+ const kid = auth.keyId ?? auth.privateKey.kid;
92
+ 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);
93
+ }
94
+ async function applyClientAuth(auth, endpoint, headers, form) {
95
+ switch (resolveMethod(auth)) {
96
+ case "client_secret_basic": {
97
+ const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
98
+ headers.authorization = `Basic ${btoa(pair)}`;
99
+ break;
100
+ }
101
+ case "client_secret_post":
102
+ form.set("client_id", auth.clientId);
103
+ form.set("client_secret", auth.clientSecret);
104
+ break;
105
+ case "private_key_jwt":
106
+ form.set("client_id", auth.clientId);
107
+ form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
108
+ form.set("client_assertion", await signAssertion(auth, endpoint));
109
+ break;
110
+ case "none":
111
+ form.set("client_id", auth.clientId);
112
+ break;
113
+ }
114
+ }
115
+
116
+ // src/discovery.ts
117
+ var cache = /* @__PURE__ */ new Map();
118
+ function documentUrl(issuer) {
119
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
120
+ }
121
+ async function discover(issuer, fetcher = fetch) {
122
+ const cached = cache.get(issuer);
123
+ if (cached) return cached;
124
+ const pending = (async () => {
125
+ let response;
126
+ try {
127
+ response = await fetcher(documentUrl(issuer));
128
+ } catch (cause) {
129
+ throw new OAuthError(
130
+ `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)})`,
131
+ "discovery_unreachable"
132
+ );
133
+ }
134
+ if (!response.ok) {
135
+ throw new OAuthError(
136
+ `Discovery failed for ${issuer}: ${response.status}`,
137
+ "discovery_failed"
138
+ );
139
+ }
140
+ const doc = await response.json();
141
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
142
+ throw new OAuthError(
143
+ `${issuer} published a discovery document with no authorization or token endpoint.`,
144
+ "discovery_incomplete"
145
+ );
146
+ }
147
+ return {
148
+ authorization: doc.authorization_endpoint,
149
+ token: doc.token_endpoint,
150
+ userinfo: doc.userinfo_endpoint,
151
+ endSession: doc.end_session_endpoint,
152
+ jwks: doc.jwks_uri,
153
+ introspection: doc.introspection_endpoint,
154
+ revocation: doc.revocation_endpoint,
155
+ issuer: doc.issuer ?? issuer
156
+ };
157
+ })();
158
+ cache.set(issuer, pending);
159
+ pending.catch(() => cache.delete(issuer));
160
+ return pending;
161
+ }
162
+
163
+ // src/pkce.ts
164
+ function expiryFrom(expiresIn, now = Date.now()) {
165
+ const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
166
+ return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
167
+ }
168
+
169
+ // src/service-client.ts
170
+ function createServiceClient(options) {
171
+ const auth = {
172
+ clientId: options.clientId,
173
+ method: options.clientAuthMethod,
174
+ clientSecret: options.clientSecret,
175
+ privateKey: options.privateKey,
176
+ keyId: options.keyId,
177
+ assertionAlgorithm: options.assertionAlgorithm
178
+ };
179
+ if (resolveMethod(auth) === "none") {
180
+ throw new Error(
181
+ "[fluixi/oauth2] createServiceClient needs credentials: a clientSecret, or private_key_jwt with a key. This grant is the credential, so it belongs on a server and never in a browser."
182
+ );
183
+ }
184
+ assertUsable(auth, "createServiceClient");
185
+ if (!options.issuer && !options.tokenEndpoint) {
186
+ throw new Error("[fluixi/oauth2] createServiceClient needs an issuer or a tokenEndpoint.");
187
+ }
188
+ const skew = options.refreshSkewMs ?? 3e4;
189
+ let cached = null;
190
+ let pending = null;
191
+ let endpoint = options.tokenEndpoint ?? null;
192
+ async function resolveEndpoint() {
193
+ if (endpoint) return endpoint;
194
+ const endpoints = await discover(options.issuer);
195
+ endpoint = endpoints.token;
196
+ return endpoint;
197
+ }
198
+ const stillGood = () => cached !== null && (cached.expiresAt === void 0 || cached.expiresAt - skew > Date.now());
199
+ async function request() {
200
+ const url = await resolveEndpoint();
201
+ const headers = {
202
+ "content-type": "application/x-www-form-urlencoded",
203
+ accept: "application/json"
204
+ };
205
+ const form = new URLSearchParams({ grant_type: "client_credentials", ...options.extraParams });
206
+ if (options.scopes?.length) form.set("scope", options.scopes.join(" "));
207
+ if (options.audience) form.set("audience", options.audience);
208
+ await applyClientAuth(auth, url, headers, form);
209
+ const response = await fetch(url, { method: "POST", headers, body: form.toString() });
210
+ const payload = await response.json().catch(() => ({}));
211
+ if (!response.ok) {
212
+ const reason = [payload.error, payload.error_description].filter(Boolean).join(": ");
213
+ throw new OAuthError(
214
+ `The token endpoint refused this service: ${reason || response.status}`,
215
+ String(payload.error ?? "client_credentials_failed"),
216
+ typeof payload.error_description === "string" ? payload.error_description : void 0
217
+ );
218
+ }
219
+ const accessToken = String(payload.access_token ?? "");
220
+ if (!accessToken) {
221
+ throw new OAuthError("The token response carried no access_token.", "no_access_token");
222
+ }
223
+ cached = {
224
+ accessToken,
225
+ expiresAt: expiryFrom(payload.expires_in),
226
+ scope: typeof payload.scope === "string" ? payload.scope : void 0
227
+ };
228
+ return cached;
229
+ }
230
+ async function current() {
231
+ if (stillGood()) return cached;
232
+ pending ??= request().finally(() => {
233
+ pending = null;
234
+ });
235
+ return pending;
236
+ }
237
+ return {
238
+ current,
239
+ token: async () => (await current()).accessToken,
240
+ reset: () => {
241
+ cached = null;
242
+ }
243
+ };
244
+ }