@fluixi/oauth2 1.0.0-alpha.10 → 1.0.0-alpha.100

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 (55) hide show
  1. package/LICENSE +1 -1
  2. package/dist/backchannel.cjs +462 -0
  3. package/dist/backchannel.d.ts +39 -0
  4. package/dist/backchannel.d.ts.map +1 -0
  5. package/dist/backchannel.js +97 -0
  6. package/dist/backchannel.mjs +307 -0
  7. package/dist/{browser-36FHB6KG.mjs → browser-TV25RBLT.mjs} +2 -2
  8. package/dist/browser.cjs +6 -1
  9. package/dist/browser.mjs +6 -1
  10. package/dist/callback.cjs +2 -2
  11. package/dist/callback.js +1 -1
  12. package/dist/callback.mjs +3 -3
  13. package/dist/{chunk-F52RUVDM.mjs → chunk-2B3I72DV.mjs} +1 -1
  14. package/dist/chunk-KOP5Z4LB.mjs +108 -0
  15. package/dist/{chunk-OGS6MQ3D.mjs → chunk-KTGVSZE6.mjs} +1 -1
  16. package/dist/discovery.cjs +6 -1
  17. package/dist/discovery.d.ts.map +1 -1
  18. package/dist/discovery.js +6 -1
  19. package/dist/discovery.mjs +6 -1
  20. package/dist/dpop-O3HWR7AH.mjs +22 -0
  21. package/dist/{dpop-GOWCCCFR.mjs → dpop-SGWG3K2I.mjs} +4 -4
  22. package/dist/index.cjs +108 -8
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -0
  26. package/dist/index.mjs +112 -12
  27. package/dist/module.cjs +1 -1
  28. package/dist/module.mjs +1 -1
  29. package/dist/server.cjs +705 -143
  30. package/dist/server.d.ts +71 -4
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/server.js +455 -64
  33. package/dist/server.mjs +588 -145
  34. package/dist/service-client.cjs +7 -2
  35. package/dist/service-client.mjs +9 -4
  36. package/dist/service.cjs +104 -5
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +38 -6
  39. package/dist/service.mjs +105 -6
  40. package/dist/session-monitor.cjs +98 -0
  41. package/dist/session-monitor.d.ts +32 -0
  42. package/dist/session-monitor.d.ts.map +1 -0
  43. package/dist/session-monitor.js +104 -0
  44. package/dist/session-monitor.mjs +77 -0
  45. package/dist/tokens.cjs +1 -1
  46. package/dist/tokens.d.ts +2 -2
  47. package/dist/tokens.d.ts.map +1 -1
  48. package/dist/tokens.js +1 -1
  49. package/dist/tokens.mjs +1 -1
  50. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  51. package/dist/types.d.ts +8 -0
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/verify.cjs +7 -2
  54. package/dist/verify.mjs +12 -7
  55. package/package.json +22 -16
package/dist/server.mjs CHANGED
@@ -1,5 +1,13 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.10 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-OGS6MQ3D.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.100 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import {
3
+ OAuthError,
4
+ createDpopKey,
5
+ exportDpopKey,
6
+ importDpopKey,
7
+ nonceFrom,
8
+ proofFor
9
+ } from "./chunk-KOP5Z4LB.mjs";
10
+ import "./chunk-KTGVSZE6.mjs";
3
11
 
4
12
  // src/server.ts
5
13
  import { createSessionCookie } from "@fluixi/session/server";
@@ -27,16 +35,6 @@ function safeReturnTo(candidate, options = {}) {
27
35
  return url.toString();
28
36
  }
29
37
 
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
38
  // src/client-auth.ts
41
39
  var CANONICAL = {
42
40
  basic: "client_secret_basic",
@@ -53,7 +51,7 @@ function resolveMethod(auth) {
53
51
  }
54
52
  async function loadJose() {
55
53
  try {
56
- return await import("./browser-36FHB6KG.mjs");
54
+ return await import("./browser-TV25RBLT.mjs");
57
55
  } catch {
58
56
  throw new OAuthError(
59
57
  "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.",
@@ -103,8 +101,12 @@ async function discover(issuer, fetcher = fetch) {
103
101
  try {
104
102
  response = await fetcher(documentUrl(issuer));
105
103
  } catch (cause) {
104
+ const why = cause instanceof Error ? cause.message : String(cause);
106
105
  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)})`,
106
+ typeof window === "undefined" ? (
107
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
108
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
109
+ ) : `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. (${why})`,
108
110
  "discovery_unreachable"
109
111
  );
110
112
  }
@@ -129,6 +131,7 @@ async function discover(issuer, fetcher = fetch) {
129
131
  jwks: doc.jwks_uri,
130
132
  introspection: doc.introspection_endpoint,
131
133
  revocation: doc.revocation_endpoint,
134
+ checkSession: doc.check_session_iframe,
132
135
  issuer: doc.issuer ?? issuer
133
136
  };
134
137
  })();
@@ -137,6 +140,251 @@ async function discover(issuer, fetcher = fetch) {
137
140
  return pending;
138
141
  }
139
142
 
143
+ // src/verify.ts
144
+ var TokenError = class extends OAuthError {
145
+ constructor(message, code = "invalid_token") {
146
+ super(message, code);
147
+ this.name = "TokenError";
148
+ }
149
+ };
150
+ function bearerOf(request) {
151
+ const header = request.headers.get("authorization");
152
+ if (!header) return null;
153
+ const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
154
+ return match ? match[1].trim() : null;
155
+ }
156
+ async function loadJose2() {
157
+ try {
158
+ return await import("./browser-TV25RBLT.mjs");
159
+ } catch {
160
+ throw new OAuthError(
161
+ "Token verification needs `jose`. Install it: npm i jose. It is an optional peer so an app that does not verify does not carry it.",
162
+ "jose_missing"
163
+ );
164
+ }
165
+ }
166
+ function createTokenVerifier(options) {
167
+ const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
168
+ if (!issuers.length) {
169
+ throw new Error("[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.");
170
+ }
171
+ const discoveryIssuer = issuers[0];
172
+ if (options.audience === void 0) {
173
+ throw new Error(
174
+ "[fluixi/oauth2] createTokenVerifier needs an audience, or `audience: false` to say this service really does accept a token minted for any client of the issuer."
175
+ );
176
+ }
177
+ if (options.audience === false && options.authorizedParty === void 0) {
178
+ console.warn(
179
+ "[fluixi/oauth2] This verifier checks neither `aud` nor `azp`, so a token minted for any client of the issuer is accepted. Set `authorizedParty` if that is not what you meant."
180
+ );
181
+ }
182
+ const algorithms = options.algorithms ?? ["RS256"];
183
+ if (algorithms.some((alg) => !alg || alg.toLowerCase() === "none")) {
184
+ throw new Error("[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.");
185
+ }
186
+ let keys = null;
187
+ async function keySet() {
188
+ if (keys) return keys;
189
+ const jose = await loadJose2();
190
+ if (options.jwks) {
191
+ keys = jose.createLocalJWKSet(options.jwks);
192
+ return keys;
193
+ }
194
+ let uri = options.jwksUri;
195
+ if (!uri) {
196
+ const endpoints = await discover(discoveryIssuer);
197
+ uri = endpoints.jwks;
198
+ }
199
+ if (!uri) {
200
+ throw new OAuthError(
201
+ `${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`,
202
+ "no_jwks"
203
+ );
204
+ }
205
+ keys = jose.createRemoteJWKSet(new URL(uri));
206
+ return keys;
207
+ }
208
+ const checkProof = async (claims, token, request) => {
209
+ const jose = await loadJose2();
210
+ const { boundKeyOf, thumbprintOf } = await import("./dpop-O3HWR7AH.mjs");
211
+ const bound = boundKeyOf(claims);
212
+ if (!bound) {
213
+ throw new TokenError(
214
+ "This token is not bound to a key, and this service requires binding.",
215
+ "token_not_bound"
216
+ );
217
+ }
218
+ const proof = request.headers.get("dpop");
219
+ if (!proof) throw new TokenError("The request carried no DPoP proof.", "missing_dpop_proof");
220
+ let payload;
221
+ let header;
222
+ try {
223
+ const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: "dpop+jwt" });
224
+ payload = result.payload;
225
+ header = result.protectedHeader;
226
+ } catch (cause) {
227
+ throw new TokenError(
228
+ `The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`,
229
+ "invalid_dpop_proof"
230
+ );
231
+ }
232
+ const presented = await thumbprintOf(header.jwk);
233
+ if (presented !== bound) {
234
+ throw new TokenError(
235
+ "The DPoP proof was signed with a different key from the one this token is bound to.",
236
+ "dpop_key_mismatch"
237
+ );
238
+ }
239
+ const url = new URL(request.url);
240
+ url.search = "";
241
+ url.hash = "";
242
+ if (payload.htm !== request.method.toUpperCase()) {
243
+ throw new TokenError("The DPoP proof was made for a different method.", "invalid_dpop_proof");
244
+ }
245
+ if (payload.htu !== url.toString()) {
246
+ throw new TokenError("The DPoP proof was made for a different URL.", "invalid_dpop_proof");
247
+ }
248
+ if (typeof payload.ath === "string") {
249
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
250
+ let binary = "";
251
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
252
+ const expected = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
253
+ if (payload.ath !== expected) {
254
+ throw new TokenError("The DPoP proof was made for a different token.", "invalid_dpop_proof");
255
+ }
256
+ }
257
+ const age = Math.abs(Date.now() / 1e3 - payload.iat);
258
+ if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
259
+ throw new TokenError("The DPoP proof is too old to accept.", "invalid_dpop_proof");
260
+ }
261
+ };
262
+ const verify = async (token, request) => {
263
+ if (!token) throw new TokenError("No token to verify.", "no_token");
264
+ const jose = await loadJose2();
265
+ let payload;
266
+ try {
267
+ ({ payload } = await jose.jwtVerify(token, await keySet(), {
268
+ // jose takes a list and accepts any of them, which is what a provider naming
269
+ // itself two ways needs.
270
+ issuer: issuers,
271
+ ...options.audience === false ? {} : { audience: options.audience },
272
+ algorithms,
273
+ clockTolerance: options.clockToleranceSec ?? 5
274
+ }));
275
+ } catch (cause) {
276
+ throw new TokenError(
277
+ `The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`
278
+ );
279
+ }
280
+ if (options.authorizedParty !== void 0) {
281
+ const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
282
+ const issuedTo = payload.azp ?? payload.client_id;
283
+ if (typeof issuedTo !== "string" || !allowed.includes(issuedTo)) {
284
+ throw new TokenError("The token was issued to a different client.", "invalid_azp");
285
+ }
286
+ }
287
+ if (options.dpop) {
288
+ if (!request) {
289
+ throw new Error(
290
+ "[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token."
291
+ );
292
+ }
293
+ await checkProof(payload, token, request);
294
+ }
295
+ if (options.require) {
296
+ const verdict = options.require(payload);
297
+ if (verdict !== true) {
298
+ throw new TokenError(
299
+ typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
300
+ "insufficient_claims"
301
+ );
302
+ }
303
+ }
304
+ return payload;
305
+ };
306
+ const verifier = verify;
307
+ verifier.bearerOf = bearerOf;
308
+ verifier.fromRequest = async (request) => {
309
+ const token = verifier.bearerOf(request);
310
+ if (!token) {
311
+ throw new TokenError("The request carried no bearer token.", "no_token");
312
+ }
313
+ return verify(token, request);
314
+ };
315
+ return verifier;
316
+ }
317
+
318
+ // src/backchannel.ts
319
+ var LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
320
+ function memoryLogouts(ttlMs = 14 * 24 * 36e5) {
321
+ const sids = /* @__PURE__ */ new Map();
322
+ const subs = /* @__PURE__ */ new Map();
323
+ const prune = (now) => {
324
+ for (const map of [sids, subs]) for (const [key, at] of map) if (now - at > ttlMs) map.delete(key);
325
+ };
326
+ return {
327
+ add(entry) {
328
+ prune(Date.now());
329
+ if (entry.sid) sids.set(entry.sid, entry.at);
330
+ else if (entry.sub) subs.set(entry.sub, Math.max(entry.at, subs.get(entry.sub) ?? 0));
331
+ },
332
+ ended(session) {
333
+ if (session.sid && sids.has(session.sid)) return true;
334
+ const all = session.sub ? subs.get(session.sub) : void 0;
335
+ return all !== void 0 && session.since <= all;
336
+ }
337
+ };
338
+ }
339
+ function idTokenClaims(idToken) {
340
+ if (!idToken) return {};
341
+ try {
342
+ const part = idToken.split(".")[1] ?? "";
343
+ const json = atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "="));
344
+ const claims = JSON.parse(json);
345
+ return {
346
+ sid: typeof claims.sid === "string" ? claims.sid : void 0,
347
+ sub: typeof claims.sub === "string" ? claims.sub : void 0,
348
+ iat: typeof claims.iat === "number" ? claims.iat : void 0
349
+ };
350
+ } catch {
351
+ return {};
352
+ }
353
+ }
354
+ function headerOf(token) {
355
+ try {
356
+ const part = token.split(".")[0] ?? "";
357
+ return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=")));
358
+ } catch {
359
+ return {};
360
+ }
361
+ }
362
+ function logoutTokenVerifier(options) {
363
+ const verify = createTokenVerifier({
364
+ issuer: options.issuer,
365
+ audience: options.clientId,
366
+ jwksUri: options.jwksUri,
367
+ jwks: options.jwks,
368
+ require: (claims) => {
369
+ const events = claims.events;
370
+ if (!events || typeof events !== "object" || !(LOGOUT_EVENT in events)) return "no logout event";
371
+ if ("nonce" in claims) return "a logout token has no nonce";
372
+ if (typeof claims.sid !== "string" && typeof claims.sub !== "string") return "names no session and no subject";
373
+ return true;
374
+ }
375
+ });
376
+ return async (token) => {
377
+ const typ = headerOf(token).typ;
378
+ if (typ !== void 0 && typ !== "logout+jwt") throw new Error("not a logout token");
379
+ const claims = await verify(token);
380
+ return {
381
+ sid: typeof claims.sid === "string" ? claims.sid : void 0,
382
+ sub: typeof claims.sub === "string" ? claims.sub : void 0,
383
+ at: (typeof claims.iat === "number" ? claims.iat : Math.floor(Date.now() / 1e3)) * 1e3
384
+ };
385
+ };
386
+ }
387
+
140
388
  // src/pkce.ts
141
389
  function base64url(bytes) {
142
390
  let binary = "";
@@ -173,81 +421,60 @@ function expiryFrom(expiresIn, now = Date.now()) {
173
421
  return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
174
422
  }
175
423
 
176
- // src/dpop.ts
177
- async function loadJose2() {
178
- try {
179
- return await import("./browser-36FHB6KG.mjs");
180
- } catch {
181
- throw new OAuthError(
182
- "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
183
- "jose_missing"
184
- );
185
- }
186
- }
187
- async function createDpopKey(alg = "ES256", options = {}) {
188
- const jose = await loadJose2();
189
- const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
190
- extractable: options.extractable ?? false
191
- });
192
- const publicJwk = { ...await jose.exportJWK(publicKey) };
193
- return {
194
- privateKey,
195
- publicJwk,
196
- thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
197
- alg
198
- };
424
+ // src/server.ts
425
+ import { defineModule } from "@fluixi/start/app";
426
+ var RETRY_MARK = ".retry";
427
+ var PROMPTS = /* @__PURE__ */ new Set(["none", "login", "consent", "select_account"]);
428
+ var SILENT_REFUSALS = /* @__PURE__ */ new Set([
429
+ "login_required",
430
+ "consent_required",
431
+ "interaction_required",
432
+ "account_selection_required"
433
+ ]);
434
+ function checkedCookie(config, sessionState) {
435
+ const name = `${config.cookieName ?? "fx_session"}_checked`;
436
+ const secure = config.secureCookie ?? true;
437
+ const maxAge = Math.max(0, Math.floor(config.silentRecheck ?? 60));
438
+ const value = sessionState ?? "1";
439
+ return { name, header: `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}` };
199
440
  }
200
- async function exportDpopKey(key) {
201
- const jose = await loadJose2();
202
- return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
441
+ function sessionStateFrom(url) {
442
+ const value = url.searchParams.get("session_state");
443
+ return value && /^[\w-]{1,128}\.[\w-]{1,64}$/.test(value) ? value : void 0;
203
444
  }
204
- var PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
205
- async function importDpopKey(stored) {
206
- const jose = await loadJose2();
207
- const privateKey = await jose.importJWK(stored.jwk, stored.alg);
208
- const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
209
- return {
210
- privateKey,
211
- publicJwk,
212
- thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
213
- alg: stored.alg
214
- };
445
+ function isPageRequest(request) {
446
+ if (request.method !== "GET") return false;
447
+ const dest = request.headers.get("sec-fetch-dest");
448
+ if (dest) return dest === "document";
449
+ return (request.headers.get("accept") ?? "").includes("text/html");
215
450
  }
216
- async function proofFor(key, options) {
217
- const jose = await loadJose2();
218
- const htu = (() => {
219
- try {
220
- const url = new URL(options.url);
221
- url.search = "";
222
- url.hash = "";
223
- return url.toString();
224
- } catch {
225
- return options.url;
226
- }
227
- })();
228
- const claims = {
229
- htm: options.method.toUpperCase(),
230
- htu,
231
- jti: crypto.randomUUID()
232
- };
233
- if (options.accessToken) {
234
- const digest = await crypto.subtle.digest(
235
- "SHA-256",
236
- new TextEncoder().encode(options.accessToken)
237
- );
238
- let binary = "";
239
- for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
240
- claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
451
+ function cookieValue(request, name) {
452
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
453
+ const [key, ...rest] = part.trim().split("=");
454
+ if (key === name) return rest.join("=");
241
455
  }
242
- if (options.nonce) claims.nonce = options.nonce;
243
- return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
456
+ return void 0;
244
457
  }
245
- function nonceFrom(response) {
246
- return response.headers.get("dpop-nonce");
458
+ function hasCookie(request, name) {
459
+ return cookieValue(request, name) !== void 0;
460
+ }
461
+ function under(pathname, path) {
462
+ return pathname === path || pathname.startsWith(path.endsWith("/") ? path : `${path}/`);
463
+ }
464
+ function signInRedirect(request, config) {
465
+ if (!isPageRequest(request)) return null;
466
+ const url = new URL(request.url);
467
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
468
+ const guarded = (config.protectedPaths ?? []).some((path) => under(url.pathname, path));
469
+ const mode = guarded ? "required" : config.signIn ?? "manual";
470
+ if (mode === "manual") return null;
471
+ if ([base, ...config.publicPaths ?? []].some((path) => under(url.pathname, path))) return null;
472
+ if (url.searchParams.has("auth_error")) return null;
473
+ if (mode === "silent" && hasCookie(request, checkedCookie(config).name)) return null;
474
+ const login = new URLSearchParams({ returnTo: url.pathname + url.search });
475
+ if (mode === "silent") login.set("prompt", "none");
476
+ return new Response(null, { status: 302, headers: { location: `${base}/login?${login}` } });
247
477
  }
248
-
249
- // src/server.ts
250
- import { defineModule } from "@fluixi/start/app";
251
478
  var dpopNonces = /* @__PURE__ */ new Map();
252
479
  async function prove(headers, key, method, url, accessToken) {
253
480
  headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
@@ -260,27 +487,52 @@ function learnNonce(url, response) {
260
487
  dpopNonces.set(origin, offered);
261
488
  return fresh && !response.ok;
262
489
  }
263
- async function withToken(url, session) {
490
+ async function sendAs(session, input, init = {}) {
491
+ const url = String(input);
492
+ const method = (init.method ?? "GET").toUpperCase();
264
493
  const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
265
494
  const send = async () => {
266
- const headers = {
495
+ const credentials = {
267
496
  authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
268
497
  };
269
- if (key) await prove(headers, key, "GET", url, session.accessToken);
270
- return fetch(url, { headers });
498
+ if (key) await prove(credentials, key, method, url, session.accessToken);
499
+ const headers = new Headers(init.headers);
500
+ for (const [name, value] of Object.entries(credentials)) headers.set(name, value);
501
+ return fetch(url, { ...init, method, headers });
271
502
  };
272
503
  let response = await send();
273
504
  if (key && learnNonce(url, response)) response = await send();
274
505
  return response;
275
506
  }
276
- function oauthRoutes(config) {
277
- const base = (config.basePath ?? "/auth").replace(/\/$/, "");
278
- const scopes = config.scopes ?? ["openid", "profile", "email"];
279
- const cookie = createSessionCookie({
280
- secret: config.cookieSecret,
281
- name: config.cookieName,
282
- secure: config.secureCookie
283
- });
507
+ var withToken = (url, session) => sendAs(session, url);
508
+ var ProviderUnavailable = class extends OAuthError {
509
+ constructor(retryAfterMs) {
510
+ super("The authorization server is unavailable.", "temporarily_unavailable");
511
+ this.retryAfterMs = retryAfterMs;
512
+ }
513
+ };
514
+ var AnswerLost = class extends OAuthError {
515
+ constructor(what) {
516
+ super(`The token request's answer was lost (${what}).`, "answer_lost");
517
+ }
518
+ };
519
+ var NOT_SENT = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
520
+ var RETRY_DEFAULT_MS = 5e3;
521
+ var RETRY_MAX_MS = 3e5;
522
+ function retryAfter(response) {
523
+ const header = response.headers.get("retry-after");
524
+ if (!header) return RETRY_DEFAULT_MS;
525
+ const seconds = Number(header);
526
+ const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
527
+ return Number.isFinite(ms) ? Math.min(Math.max(ms, 1e3), RETRY_MAX_MS) : RETRY_DEFAULT_MS;
528
+ }
529
+ function unanswered(error) {
530
+ const code = error?.cause?.code;
531
+ if (code && NOT_SENT.has(code)) return new ProviderUnavailable(RETRY_DEFAULT_MS);
532
+ return new AnswerLost(code ?? (error instanceof Error ? error.name : "no answer"));
533
+ }
534
+ function tokenClient(config) {
535
+ const timeoutMs = config.tokenTimeoutMs ?? 1e4;
284
536
  let endpoints = config.endpoints ?? null;
285
537
  const resolve = async () => {
286
538
  if (endpoints) return endpoints;
@@ -290,10 +542,6 @@ function oauthRoutes(config) {
290
542
  endpoints = await discover(config.issuer);
291
543
  return endpoints;
292
544
  };
293
- const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
294
- status,
295
- headers: { "content-type": "application/json", ...headers }
296
- });
297
545
  const clientAuth = {
298
546
  clientId: config.clientId,
299
547
  // `client_secret_post` by default here, which is what this half has always sent, so
@@ -313,30 +561,142 @@ function oauthRoutes(config) {
313
561
  const form = new URLSearchParams(body);
314
562
  await applyClientAuth(clientAuth, token, headers, form);
315
563
  if (key) await prove(headers, key, "POST", token);
316
- return fetch(token, { method: "POST", headers, body: form.toString() });
564
+ return fetch(token, { method: "POST", headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
317
565
  };
318
- let response = await attempt();
319
- if (key && learnNonce(token, response)) response = await attempt();
320
- const payload = await response.json().catch(() => ({}));
566
+ let response;
567
+ let payload = null;
568
+ try {
569
+ response = await attempt();
570
+ if (key && learnNonce(token, response)) response = await attempt();
571
+ if (response.status === 429 || response.status >= 500) throw new ProviderUnavailable(retryAfter(response));
572
+ payload = await response.json().catch(() => null);
573
+ } catch (error) {
574
+ throw error instanceof OAuthError ? error : unanswered(error);
575
+ }
321
576
  if (!response.ok) {
322
577
  throw new OAuthError(
323
- String(payload.error ?? "token_request_failed"),
324
- String(payload.error ?? "token_request_failed")
578
+ String(payload?.error ?? "token_request_failed"),
579
+ String(payload?.error ?? "token_request_failed")
325
580
  );
326
581
  }
582
+ if (typeof payload?.access_token !== "string" || !payload.access_token) {
583
+ throw new AnswerLost("a success without an access token");
584
+ }
327
585
  return payload;
328
586
  }
587
+ return { resolve, tokenRequest, clientAuth };
588
+ }
589
+ var renewals = /* @__PURE__ */ new Map();
590
+ var RENEWAL_REUSE_MS = 3e4;
591
+ var SPENT_MS = 5 * 6e4;
592
+ function renew(current, tokenRequest) {
593
+ const refreshToken = current.refreshToken;
594
+ const now = Date.now();
595
+ for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
596
+ const known = renewals.get(refreshToken);
597
+ if (known) return known.result;
598
+ const result = (async () => {
599
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
600
+ const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
601
+ return {
602
+ accessToken: String(payload.access_token ?? ""),
603
+ // A provider that rotates refresh tokens sends a new one; one that does not
604
+ // expects the old one to keep working.
605
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
606
+ idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
607
+ expiresAt: expiryFrom(payload.expires_in),
608
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
609
+ dpop: current.dpop,
610
+ sessionState: current.sessionState
611
+ };
612
+ })().catch((error) => {
613
+ if (error instanceof ProviderUnavailable) {
614
+ renewals.set(refreshToken, { until: Date.now() + error.retryAfterMs, result });
615
+ throw error;
616
+ }
617
+ if (error instanceof OAuthError) {
618
+ renewals.set(refreshToken, { until: Date.now() + SPENT_MS, result });
619
+ return null;
620
+ }
621
+ renewals.delete(refreshToken);
622
+ throw error;
623
+ });
624
+ renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
625
+ return result;
626
+ }
627
+ var EXPIRY_SKEW_MS = 3e4;
628
+ var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
629
+ var defaultLogouts = /* @__PURE__ */ new WeakMap();
630
+ function logoutsOf(config) {
631
+ if (config.logouts) return config.logouts;
632
+ let store = defaultLogouts.get(config);
633
+ if (!store) defaultLogouts.set(config, store = memoryLogouts());
634
+ return store;
635
+ }
636
+ async function endedAtProvider(config, session) {
637
+ const id = idTokenClaims(session.idToken);
638
+ if (!id.sid && !id.sub) return false;
639
+ return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
640
+ }
641
+ function oauthRoutes(config) {
642
+ const { resolve, tokenRequest, clientAuth } = tokenClient(config);
643
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
644
+ status,
645
+ headers: { "content-type": "application/json", ...headers }
646
+ });
647
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
648
+ const scopes = config.scopes ?? ["openid", "profile", "email"];
649
+ const cookie = createSessionCookie({
650
+ secret: config.cookieSecret,
651
+ name: config.cookieName,
652
+ secure: config.secureCookie
653
+ });
329
654
  return async function middleware(request, next) {
330
655
  const url = new URL(request.url);
331
656
  if (!url.pathname.startsWith(`${base}/`)) return next();
332
657
  const route = url.pathname.slice(base.length);
333
658
  const session = async () => {
334
- return cookie.read(request);
659
+ const current = await cookie.read(request);
660
+ return current && await endedAtProvider(config, current) ? null : current;
335
661
  };
662
+ if (route === "/backchannel-logout") {
663
+ const headers = { "cache-control": "no-store" };
664
+ if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
665
+ try {
666
+ const form = await request.formData();
667
+ const token = form.get("logout_token");
668
+ if (typeof token !== "string" || !token) throw new Error("no logout_token");
669
+ const endpoints = await resolve();
670
+ const issuer = endpoints.issuer ?? config.issuer;
671
+ if (!issuer) throw new Error("no issuer");
672
+ if (!endpoints.jwks) throw new Error("no jwks_uri");
673
+ const jwks = await (await fetch(endpoints.jwks)).json();
674
+ const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
675
+ await logoutsOf(config).add(entry);
676
+ return new Response(null, { status: 200, headers });
677
+ } catch {
678
+ return json({ error: "invalid_request" }, 400, headers);
679
+ }
680
+ }
336
681
  if (route === "/login") {
337
- const { authorization } = await resolve();
682
+ let authorization;
683
+ try {
684
+ ({ authorization } = await resolve());
685
+ } catch (error) {
686
+ if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
687
+ const back = safeReturnTo(url.searchParams.get("returnTo"), {
688
+ fallback: config.defaultReturnTo ?? "/",
689
+ origin: url.origin,
690
+ allowedOrigins: config.allowedReturnOrigins
691
+ });
692
+ const target2 = new URL(back, url.origin);
693
+ target2.searchParams.set("auth_error", "provider_unreachable");
694
+ const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
695
+ return new Response(null, { status: 302, headers: { location: location2 } });
696
+ }
338
697
  const { verifier, challenge } = await createPkcePair();
339
- const state = createStateValue();
698
+ const retry = url.searchParams.get("retry") === "1";
699
+ const state = createStateValue() + (retry ? RETRY_MARK : "");
340
700
  const target = new URL(authorization);
341
701
  target.searchParams.set("response_type", "code");
342
702
  target.searchParams.set("client_id", config.clientId);
@@ -345,6 +705,8 @@ function oauthRoutes(config) {
345
705
  target.searchParams.set("state", state);
346
706
  target.searchParams.set("code_challenge", challenge);
347
707
  target.searchParams.set("code_challenge_method", "S256");
708
+ const prompt = url.searchParams.get("prompt");
709
+ if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
348
710
  const pendingCookie = await cookie.write(
349
711
  {
350
712
  accessToken: "",
@@ -357,7 +719,8 @@ function oauthRoutes(config) {
357
719
  fallback: "",
358
720
  origin: url.origin,
359
721
  allowedOrigins: config.allowedReturnOrigins
360
- }) || void 0
722
+ }) || void 0,
723
+ silent: prompt === "none" || void 0
361
724
  }
362
725
  },
363
726
  // Ten minutes: long enough to sign in, short enough that an abandoned attempt
@@ -376,12 +739,43 @@ function oauthRoutes(config) {
376
739
  const current = await session();
377
740
  const pending = current?.pending;
378
741
  const error = url.searchParams.get("error");
379
- if (error) return json({ error }, 400);
742
+ if (error) {
743
+ const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
744
+ let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
745
+ fallback: "/",
746
+ origin: url.origin,
747
+ allowedOrigins: config.allowedReturnOrigins
748
+ });
749
+ const headers = new Headers();
750
+ headers.append("set-cookie", cookie.clear());
751
+ if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
752
+ headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
753
+ } else {
754
+ const target = new URL(location2, url.origin);
755
+ target.searchParams.set("auth_error", error);
756
+ location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
757
+ }
758
+ headers.set("location", location2);
759
+ return new Response(null, { status: 302, headers });
760
+ }
380
761
  const code = url.searchParams.get("code");
381
762
  const state = url.searchParams.get("state");
382
763
  if (!code) return json({ error: "no_code" }, 400);
383
764
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
384
- return json({ error: "state_mismatch" }, 400);
765
+ const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
766
+ if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
767
+ if (!state?.endsWith(RETRY_MARK)) {
768
+ return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
769
+ }
770
+ const target = new URL(home, url.origin);
771
+ target.searchParams.set("auth_error", "state_mismatch");
772
+ return new Response(null, {
773
+ status: 302,
774
+ headers: {
775
+ location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
776
+ "set-cookie": cookie.clear()
777
+ }
778
+ });
385
779
  }
386
780
  const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
387
781
  const payload = await tokenRequest(
@@ -399,7 +793,8 @@ function oauthRoutes(config) {
399
793
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
400
794
  expiresAt: expiryFrom(payload.expires_in),
401
795
  tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
402
- dpop: key ? await exportDpopKey(key) : void 0
796
+ dpop: key ? await exportDpopKey(key) : void 0,
797
+ sessionState: sessionStateFrom(url)
403
798
  });
404
799
  return new Response(null, {
405
800
  status: 302,
@@ -419,22 +814,9 @@ function oauthRoutes(config) {
419
814
  if (route === "/refresh") {
420
815
  const current = await session();
421
816
  if (!current?.refreshToken) return json({ error: "no_session" }, 401);
422
- const key = current.dpop ? await importDpopKey(current.dpop) : null;
423
- const payload = await tokenRequest(
424
- { grant_type: "refresh_token", refresh_token: current.refreshToken },
425
- key
426
- );
427
- const refreshed = await cookie.write({
428
- accessToken: String(payload.access_token ?? ""),
429
- // A provider that rotates refresh tokens sends a new one; one that does not
430
- // expects the old one to keep working.
431
- refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
432
- idToken: current.idToken,
433
- expiresAt: expiryFrom(payload.expires_in),
434
- tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
435
- dpop: current.dpop
436
- });
437
- return json({ ok: true }, 200, { "set-cookie": refreshed });
817
+ const renewed = await renew(current, tokenRequest);
818
+ if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
819
+ return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
438
820
  }
439
821
  if (route === "/me") {
440
822
  const current = await session();
@@ -446,6 +828,13 @@ function oauthRoutes(config) {
446
828
  const claims = await response.json();
447
829
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
448
830
  }
831
+ if (route === "/session-check") {
832
+ const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
833
+ const current = await session();
834
+ const checked = cookieValue(request, checkedCookie(config).name);
835
+ const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
836
+ return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
837
+ }
449
838
  if (route === "/revoke") {
450
839
  const current = await session();
451
840
  const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
@@ -468,31 +857,33 @@ function oauthRoutes(config) {
468
857
  const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
469
858
  const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
470
859
  if (revocation && current?.refreshToken) {
471
- const headers = {
860
+ const headers2 = {
472
861
  "content-type": "application/x-www-form-urlencoded"
473
862
  };
474
863
  const form = new URLSearchParams({
475
864
  token: current.refreshToken,
476
865
  token_type_hint: "refresh_token"
477
866
  });
478
- await applyClientAuth(clientAuth, revocation, headers, form);
479
- await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
867
+ await applyClientAuth(clientAuth, revocation, headers2, form);
868
+ await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
480
869
  () => void 0
481
870
  );
482
871
  }
483
872
  const cleared = cookie.clear();
873
+ const headers = new Headers();
874
+ headers.append("set-cookie", cleared);
875
+ headers.append("set-cookie", checkedCookie(config).header);
484
876
  if (endSession && current?.idToken) {
485
877
  const target = new URL(endSession);
486
878
  target.searchParams.set("id_token_hint", current.idToken);
487
879
  if (config.defaultReturnTo) {
488
880
  target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
489
881
  }
490
- return new Response(null, {
491
- status: 302,
492
- headers: { location: target.toString(), "set-cookie": cleared }
493
- });
882
+ headers.set("location", target.toString());
883
+ return new Response(null, { status: 302, headers });
494
884
  }
495
- return json({ ok: true }, 200, { "set-cookie": cleared });
885
+ headers.set("content-type", "application/json");
886
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
496
887
  }
497
888
  return next();
498
889
  };
@@ -503,6 +894,7 @@ function sessionLocals(config) {
503
894
  name: config.cookieName,
504
895
  secure: config.secureCookie
505
896
  });
897
+ const { tokenRequest } = tokenClient(config);
506
898
  const ttl = config.userCacheMs ?? 3e4;
507
899
  const cache2 = /* @__PURE__ */ new Map();
508
900
  const remember = (token, user) => {
@@ -517,21 +909,61 @@ function sessionLocals(config) {
517
909
  return async function middleware(request, next) {
518
910
  const { getRequestLocals } = await import("@fluixi/server");
519
911
  const locals = getRequestLocals(request);
520
- const session = await cookie.read(request);
521
- if (!session?.accessToken) {
912
+ let session = await cookie.read(request);
913
+ let setCookie;
914
+ const respond = async (response) => {
915
+ if (!setCookie) return response;
916
+ const copy = new Response(response.body, response);
917
+ copy.headers.append("set-cookie", setCookie);
918
+ return copy;
919
+ };
920
+ const renewOrDrop = async (current2) => {
921
+ const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
922
+ setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
923
+ return renewed;
924
+ };
925
+ let paused = false;
926
+ const keep = (current2) => (error) => {
927
+ if (error instanceof ProviderUnavailable) paused = true;
928
+ return current2;
929
+ };
930
+ if (session?.accessToken && expired(session)) {
931
+ session = await renewOrDrop(session).catch(keep(session));
932
+ }
933
+ const signedOut = async () => {
934
+ const redirect = signInRedirect(request, config);
935
+ if (redirect) return respond(redirect);
522
936
  locals.auth = { user: null, session: null };
523
- return next();
937
+ return respond(await next());
938
+ };
939
+ if (!session?.accessToken) return signedOut();
940
+ if (await endedAtProvider(config, session)) {
941
+ setCookie = cookie.clear();
942
+ session = null;
943
+ return signedOut();
524
944
  }
525
- const cached = cache2.get(session.accessToken);
945
+ const live = session;
946
+ const fetchAs = (input, init) => sendAs(live, input, init);
947
+ const cached = cache2.get(live.accessToken);
526
948
  if (cached && cached.until > Date.now()) {
527
- locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
528
- return next();
949
+ locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
950
+ return respond(await next());
529
951
  }
530
952
  let user = null;
953
+ let unreachable = false;
531
954
  try {
532
955
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
533
956
  if (endpoints?.userinfo) {
534
- const response = await withToken(endpoints.userinfo, session);
957
+ let response = await withToken(endpoints.userinfo, session);
958
+ if (response.status === 401 && !setCookie && !paused) {
959
+ session = await renewOrDrop(session).catch(keep(session));
960
+ if (!session) return signedOut();
961
+ response = await withToken(endpoints.userinfo, session);
962
+ }
963
+ if (response.status === 401) {
964
+ if (!paused) setCookie = cookie.clear();
965
+ return signedOut();
966
+ }
535
967
  if (response.ok) {
536
968
  const claims = await response.json();
537
969
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -539,16 +971,27 @@ function sessionLocals(config) {
539
971
  }
540
972
  }
541
973
  } catch {
974
+ unreachable = true;
542
975
  }
543
- locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
544
- return next();
976
+ if (!session) return signedOut();
977
+ const current = session;
978
+ const fetchCurrent = (input, init) => sendAs(current, input, init);
979
+ locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
980
+ return respond(await next());
545
981
  };
546
982
  }
983
+ async function requestAuth(request) {
984
+ const { getLocals, getRequestLocals } = await import("@fluixi/server");
985
+ const locals = request ? getRequestLocals(request) : getLocals();
986
+ return locals.auth ?? { user: null, session: null };
987
+ }
547
988
  function authServerModule(config) {
548
989
  return defineModule({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
549
990
  }
550
991
  export {
551
992
  authServerModule,
993
+ memoryLogouts,
552
994
  oauthRoutes,
995
+ requestAuth,
553
996
  sessionLocals
554
997
  };