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

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 (69) hide show
  1. package/LICENSE +1 -1
  2. package/dist/api-guard.cjs +1 -1
  3. package/dist/api-guard.mjs +1 -1
  4. package/dist/backchannel.cjs +462 -0
  5. package/dist/backchannel.d.ts +39 -0
  6. package/dist/backchannel.d.ts.map +1 -0
  7. package/dist/backchannel.js +97 -0
  8. package/dist/backchannel.mjs +307 -0
  9. package/dist/{browser-36FHB6KG.mjs → browser-3AN2ABKG.mjs} +2 -2
  10. package/dist/browser.cjs +6 -1
  11. package/dist/browser.mjs +6 -1
  12. package/dist/callback.cjs +2 -2
  13. package/dist/callback.d.ts +2 -2
  14. package/dist/callback.js +3 -3
  15. package/dist/callback.mjs +3 -3
  16. package/dist/{chunk-OGS6MQ3D.mjs → chunk-AYQ2UUAK.mjs} +1 -1
  17. package/dist/chunk-LJV5WIJI.mjs +108 -0
  18. package/dist/{chunk-F52RUVDM.mjs → chunk-TJDXJSUS.mjs} +1 -1
  19. package/dist/discovery.cjs +6 -1
  20. package/dist/discovery.d.ts.map +1 -1
  21. package/dist/discovery.js +6 -1
  22. package/dist/discovery.mjs +6 -1
  23. package/dist/{dpop-GOWCCCFR.mjs → dpop-SSEBKRGF.mjs} +4 -4
  24. package/dist/dpop-SYK6ZHW4.mjs +22 -0
  25. package/dist/env-config.cjs +1 -1
  26. package/dist/env-config.mjs +1 -1
  27. package/dist/index.cjs +108 -8
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.mjs +112 -12
  32. package/dist/module.cjs +1 -1
  33. package/dist/module.mjs +1 -1
  34. package/dist/presets.cjs +1 -1
  35. package/dist/presets.mjs +1 -1
  36. package/dist/roles.cjs +1 -1
  37. package/dist/roles.d.ts +1 -1
  38. package/dist/roles.js +1 -1
  39. package/dist/roles.mjs +1 -1
  40. package/dist/server.cjs +705 -143
  41. package/dist/server.d.ts +71 -4
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +456 -65
  44. package/dist/server.mjs +588 -145
  45. package/dist/service-client.cjs +7 -2
  46. package/dist/service-client.mjs +9 -4
  47. package/dist/service.cjs +104 -5
  48. package/dist/service.d.ts +1 -1
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/service.js +38 -6
  51. package/dist/service.mjs +105 -6
  52. package/dist/session-monitor.cjs +98 -0
  53. package/dist/session-monitor.d.ts +32 -0
  54. package/dist/session-monitor.d.ts.map +1 -0
  55. package/dist/session-monitor.js +104 -0
  56. package/dist/session-monitor.mjs +77 -0
  57. package/dist/tokens.cjs +1 -1
  58. package/dist/tokens.d.ts +2 -2
  59. package/dist/tokens.d.ts.map +1 -1
  60. package/dist/tokens.js +1 -1
  61. package/dist/tokens.mjs +1 -1
  62. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  63. package/dist/types.d.ts +8 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/verify.cjs +8 -3
  66. package/dist/verify.d.ts +2 -2
  67. package/dist/verify.js +2 -2
  68. package/dist/verify.mjs +13 -8
  69. package/package.json +22 -16
package/dist/server.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.10 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.101 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
@@ -6,6 +6,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __esm = (fn, res) => function __init() {
10
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
11
+ };
9
12
  var __export = (target, all) => {
10
13
  for (var name in all)
11
14
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -28,11 +31,132 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
31
  ));
29
32
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
33
 
34
+ // src/types.ts
35
+ var OAuthError;
36
+ var init_types = __esm({
37
+ "src/types.ts"() {
38
+ "use strict";
39
+ 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
+ });
49
+
50
+ // src/dpop.ts
51
+ var dpop_exports = {};
52
+ __export(dpop_exports, {
53
+ boundKeyOf: () => boundKeyOf,
54
+ createDpopKey: () => createDpopKey,
55
+ exportDpopKey: () => exportDpopKey,
56
+ importDpopKey: () => importDpopKey,
57
+ nonceFrom: () => nonceFrom,
58
+ proofFor: () => proofFor,
59
+ thumbprintOf: () => thumbprintOf,
60
+ wantsNonce: () => wantsNonce
61
+ });
62
+ async function loadJose2() {
63
+ try {
64
+ return await import("jose");
65
+ } catch {
66
+ throw new OAuthError(
67
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
68
+ "jose_missing"
69
+ );
70
+ }
71
+ }
72
+ async function createDpopKey(alg = "ES256", options = {}) {
73
+ const jose = await loadJose2();
74
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
75
+ extractable: options.extractable ?? false
76
+ });
77
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
78
+ return {
79
+ privateKey,
80
+ publicJwk,
81
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
82
+ alg
83
+ };
84
+ }
85
+ async function exportDpopKey(key) {
86
+ const jose = await loadJose2();
87
+ return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
88
+ }
89
+ async function importDpopKey(stored) {
90
+ const jose = await loadJose2();
91
+ const privateKey = await jose.importJWK(stored.jwk, stored.alg);
92
+ const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
93
+ return {
94
+ privateKey,
95
+ publicJwk,
96
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
97
+ alg: stored.alg
98
+ };
99
+ }
100
+ async function thumbprintOf(jwk) {
101
+ const jose = await loadJose2();
102
+ return jose.calculateJwkThumbprint(jwk, "sha256");
103
+ }
104
+ async function proofFor(key, options) {
105
+ const jose = await loadJose2();
106
+ const htu = (() => {
107
+ try {
108
+ const url = new URL(options.url);
109
+ url.search = "";
110
+ url.hash = "";
111
+ return url.toString();
112
+ } catch {
113
+ return options.url;
114
+ }
115
+ })();
116
+ const claims = {
117
+ htm: options.method.toUpperCase(),
118
+ htu,
119
+ jti: crypto.randomUUID()
120
+ };
121
+ if (options.accessToken) {
122
+ const digest = await crypto.subtle.digest(
123
+ "SHA-256",
124
+ new TextEncoder().encode(options.accessToken)
125
+ );
126
+ let binary = "";
127
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
128
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
129
+ }
130
+ if (options.nonce) claims.nonce = options.nonce;
131
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
132
+ }
133
+ function nonceFrom(response) {
134
+ return response.headers.get("dpop-nonce");
135
+ }
136
+ function wantsNonce(error, response) {
137
+ const code = typeof error === "string" ? error : error?.error;
138
+ return code === "use_dpop_nonce" || !!response && !!nonceFrom(response);
139
+ }
140
+ function boundKeyOf(claims) {
141
+ const cnf = claims.cnf;
142
+ return typeof cnf?.jkt === "string" ? cnf.jkt : null;
143
+ }
144
+ var PRIVATE_MEMBERS;
145
+ var init_dpop = __esm({
146
+ "src/dpop.ts"() {
147
+ "use strict";
148
+ init_types();
149
+ PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
150
+ }
151
+ });
152
+
31
153
  // src/server.ts
32
154
  var server_exports = {};
33
155
  __export(server_exports, {
34
156
  authServerModule: () => authServerModule,
157
+ memoryLogouts: () => memoryLogouts,
35
158
  oauthRoutes: () => oauthRoutes,
159
+ requestAuth: () => requestAuth,
36
160
  sessionLocals: () => sessionLocals
37
161
  });
38
162
  module.exports = __toCommonJS(server_exports);
@@ -61,17 +185,8 @@ function safeReturnTo(candidate, options = {}) {
61
185
  return url.toString();
62
186
  }
63
187
 
64
- // src/types.ts
65
- var OAuthError = class extends Error {
66
- constructor(message, code, description) {
67
- super(message);
68
- this.code = code;
69
- this.description = description;
70
- this.name = "OAuthError";
71
- }
72
- };
73
-
74
188
  // src/client-auth.ts
189
+ init_types();
75
190
  var CANONICAL = {
76
191
  basic: "client_secret_basic",
77
192
  body: "client_secret_post",
@@ -125,6 +240,7 @@ async function applyClientAuth(auth, endpoint, headers, form) {
125
240
  }
126
241
 
127
242
  // src/discovery.ts
243
+ init_types();
128
244
  var cache = /* @__PURE__ */ new Map();
129
245
  function documentUrl(issuer) {
130
246
  return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
@@ -137,8 +253,12 @@ async function discover(issuer, fetcher = fetch) {
137
253
  try {
138
254
  response = await fetcher(documentUrl(issuer));
139
255
  } catch (cause) {
256
+ const why = cause instanceof Error ? cause.message : String(cause);
140
257
  throw new OAuthError(
141
- `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)})`,
258
+ typeof window === "undefined" ? (
259
+ // On a server nothing blocks the read: the provider is down, or the address is wrong.
260
+ `Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
261
+ ) : `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})`,
142
262
  "discovery_unreachable"
143
263
  );
144
264
  }
@@ -163,6 +283,7 @@ async function discover(issuer, fetcher = fetch) {
163
283
  jwks: doc.jwks_uri,
164
284
  introspection: doc.introspection_endpoint,
165
285
  revocation: doc.revocation_endpoint,
286
+ checkSession: doc.check_session_iframe,
166
287
  issuer: doc.issuer ?? issuer
167
288
  };
168
289
  })();
@@ -171,6 +292,252 @@ async function discover(issuer, fetcher = fetch) {
171
292
  return pending;
172
293
  }
173
294
 
295
+ // src/verify.ts
296
+ init_types();
297
+ var TokenError = class extends OAuthError {
298
+ constructor(message, code = "invalid_token") {
299
+ super(message, code);
300
+ this.name = "TokenError";
301
+ }
302
+ };
303
+ function bearerOf(request) {
304
+ const header = request.headers.get("authorization");
305
+ if (!header) return null;
306
+ const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
307
+ return match ? match[1].trim() : null;
308
+ }
309
+ async function loadJose3() {
310
+ try {
311
+ return await import("jose");
312
+ } catch {
313
+ throw new OAuthError(
314
+ "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.",
315
+ "jose_missing"
316
+ );
317
+ }
318
+ }
319
+ function createTokenVerifier(options) {
320
+ const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
321
+ if (!issuers.length) {
322
+ throw new Error("[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.");
323
+ }
324
+ const discoveryIssuer = issuers[0];
325
+ if (options.audience === void 0) {
326
+ throw new Error(
327
+ "[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."
328
+ );
329
+ }
330
+ if (options.audience === false && options.authorizedParty === void 0) {
331
+ console.warn(
332
+ "[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."
333
+ );
334
+ }
335
+ const algorithms = options.algorithms ?? ["RS256"];
336
+ if (algorithms.some((alg) => !alg || alg.toLowerCase() === "none")) {
337
+ throw new Error("[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.");
338
+ }
339
+ let keys = null;
340
+ async function keySet() {
341
+ if (keys) return keys;
342
+ const jose = await loadJose3();
343
+ if (options.jwks) {
344
+ keys = jose.createLocalJWKSet(options.jwks);
345
+ return keys;
346
+ }
347
+ let uri = options.jwksUri;
348
+ if (!uri) {
349
+ const endpoints = await discover(discoveryIssuer);
350
+ uri = endpoints.jwks;
351
+ }
352
+ if (!uri) {
353
+ throw new OAuthError(
354
+ `${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`,
355
+ "no_jwks"
356
+ );
357
+ }
358
+ keys = jose.createRemoteJWKSet(new URL(uri));
359
+ return keys;
360
+ }
361
+ const checkProof = async (claims, token, request) => {
362
+ const jose = await loadJose3();
363
+ const { boundKeyOf: boundKeyOf2, thumbprintOf: thumbprintOf2 } = await Promise.resolve().then(() => (init_dpop(), dpop_exports));
364
+ const bound = boundKeyOf2(claims);
365
+ if (!bound) {
366
+ throw new TokenError(
367
+ "This token is not bound to a key, and this service requires binding.",
368
+ "token_not_bound"
369
+ );
370
+ }
371
+ const proof = request.headers.get("dpop");
372
+ if (!proof) throw new TokenError("The request carried no DPoP proof.", "missing_dpop_proof");
373
+ let payload;
374
+ let header;
375
+ try {
376
+ const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: "dpop+jwt" });
377
+ payload = result.payload;
378
+ header = result.protectedHeader;
379
+ } catch (cause) {
380
+ throw new TokenError(
381
+ `The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`,
382
+ "invalid_dpop_proof"
383
+ );
384
+ }
385
+ const presented = await thumbprintOf2(header.jwk);
386
+ if (presented !== bound) {
387
+ throw new TokenError(
388
+ "The DPoP proof was signed with a different key from the one this token is bound to.",
389
+ "dpop_key_mismatch"
390
+ );
391
+ }
392
+ const url = new URL(request.url);
393
+ url.search = "";
394
+ url.hash = "";
395
+ if (payload.htm !== request.method.toUpperCase()) {
396
+ throw new TokenError("The DPoP proof was made for a different method.", "invalid_dpop_proof");
397
+ }
398
+ if (payload.htu !== url.toString()) {
399
+ throw new TokenError("The DPoP proof was made for a different URL.", "invalid_dpop_proof");
400
+ }
401
+ if (typeof payload.ath === "string") {
402
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
403
+ let binary = "";
404
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
405
+ const expected = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
406
+ if (payload.ath !== expected) {
407
+ throw new TokenError("The DPoP proof was made for a different token.", "invalid_dpop_proof");
408
+ }
409
+ }
410
+ const age = Math.abs(Date.now() / 1e3 - payload.iat);
411
+ if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
412
+ throw new TokenError("The DPoP proof is too old to accept.", "invalid_dpop_proof");
413
+ }
414
+ };
415
+ const verify = async (token, request) => {
416
+ if (!token) throw new TokenError("No token to verify.", "no_token");
417
+ const jose = await loadJose3();
418
+ let payload;
419
+ try {
420
+ ({ payload } = await jose.jwtVerify(token, await keySet(), {
421
+ // jose takes a list and accepts any of them, which is what a provider naming
422
+ // itself two ways needs.
423
+ issuer: issuers,
424
+ ...options.audience === false ? {} : { audience: options.audience },
425
+ algorithms,
426
+ clockTolerance: options.clockToleranceSec ?? 5
427
+ }));
428
+ } catch (cause) {
429
+ throw new TokenError(
430
+ `The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`
431
+ );
432
+ }
433
+ if (options.authorizedParty !== void 0) {
434
+ const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
435
+ const issuedTo = payload.azp ?? payload.client_id;
436
+ if (typeof issuedTo !== "string" || !allowed.includes(issuedTo)) {
437
+ throw new TokenError("The token was issued to a different client.", "invalid_azp");
438
+ }
439
+ }
440
+ if (options.dpop) {
441
+ if (!request) {
442
+ throw new Error(
443
+ "[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token."
444
+ );
445
+ }
446
+ await checkProof(payload, token, request);
447
+ }
448
+ if (options.require) {
449
+ const verdict = options.require(payload);
450
+ if (verdict !== true) {
451
+ throw new TokenError(
452
+ typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
453
+ "insufficient_claims"
454
+ );
455
+ }
456
+ }
457
+ return payload;
458
+ };
459
+ const verifier = verify;
460
+ verifier.bearerOf = bearerOf;
461
+ verifier.fromRequest = async (request) => {
462
+ const token = verifier.bearerOf(request);
463
+ if (!token) {
464
+ throw new TokenError("The request carried no bearer token.", "no_token");
465
+ }
466
+ return verify(token, request);
467
+ };
468
+ return verifier;
469
+ }
470
+
471
+ // src/backchannel.ts
472
+ var LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
473
+ function memoryLogouts(ttlMs = 14 * 24 * 36e5) {
474
+ const sids = /* @__PURE__ */ new Map();
475
+ const subs = /* @__PURE__ */ new Map();
476
+ const prune = (now) => {
477
+ for (const map of [sids, subs]) for (const [key, at] of map) if (now - at > ttlMs) map.delete(key);
478
+ };
479
+ return {
480
+ add(entry) {
481
+ prune(Date.now());
482
+ if (entry.sid) sids.set(entry.sid, entry.at);
483
+ else if (entry.sub) subs.set(entry.sub, Math.max(entry.at, subs.get(entry.sub) ?? 0));
484
+ },
485
+ ended(session) {
486
+ if (session.sid && sids.has(session.sid)) return true;
487
+ const all = session.sub ? subs.get(session.sub) : void 0;
488
+ return all !== void 0 && session.since <= all;
489
+ }
490
+ };
491
+ }
492
+ function idTokenClaims(idToken) {
493
+ if (!idToken) return {};
494
+ try {
495
+ const part = idToken.split(".")[1] ?? "";
496
+ const json = atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "="));
497
+ const claims = JSON.parse(json);
498
+ return {
499
+ sid: typeof claims.sid === "string" ? claims.sid : void 0,
500
+ sub: typeof claims.sub === "string" ? claims.sub : void 0,
501
+ iat: typeof claims.iat === "number" ? claims.iat : void 0
502
+ };
503
+ } catch {
504
+ return {};
505
+ }
506
+ }
507
+ function headerOf(token) {
508
+ try {
509
+ const part = token.split(".")[0] ?? "";
510
+ return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=")));
511
+ } catch {
512
+ return {};
513
+ }
514
+ }
515
+ function logoutTokenVerifier(options) {
516
+ const verify = createTokenVerifier({
517
+ issuer: options.issuer,
518
+ audience: options.clientId,
519
+ jwksUri: options.jwksUri,
520
+ jwks: options.jwks,
521
+ require: (claims) => {
522
+ const events = claims.events;
523
+ if (!events || typeof events !== "object" || !(LOGOUT_EVENT in events)) return "no logout event";
524
+ if ("nonce" in claims) return "a logout token has no nonce";
525
+ if (typeof claims.sid !== "string" && typeof claims.sub !== "string") return "names no session and no subject";
526
+ return true;
527
+ }
528
+ });
529
+ return async (token) => {
530
+ const typ = headerOf(token).typ;
531
+ if (typ !== void 0 && typ !== "logout+jwt") throw new Error("not a logout token");
532
+ const claims = await verify(token);
533
+ return {
534
+ sid: typeof claims.sid === "string" ? claims.sid : void 0,
535
+ sub: typeof claims.sub === "string" ? claims.sub : void 0,
536
+ at: (typeof claims.iat === "number" ? claims.iat : Math.floor(Date.now() / 1e3)) * 1e3
537
+ };
538
+ };
539
+ }
540
+
174
541
  // src/pkce.ts
175
542
  function base64url(bytes) {
176
543
  let binary = "";
@@ -207,81 +574,62 @@ function expiryFrom(expiresIn, now = Date.now()) {
207
574
  return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
208
575
  }
209
576
 
210
- // src/dpop.ts
211
- async function loadJose2() {
212
- try {
213
- return await import("jose");
214
- } catch {
215
- throw new OAuthError(
216
- "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
217
- "jose_missing"
218
- );
219
- }
577
+ // src/server.ts
578
+ init_types();
579
+ init_dpop();
580
+ var import_app = require("@fluixi/start/app");
581
+ var RETRY_MARK = ".retry";
582
+ var PROMPTS = /* @__PURE__ */ new Set(["none", "login", "consent", "select_account"]);
583
+ var SILENT_REFUSALS = /* @__PURE__ */ new Set([
584
+ "login_required",
585
+ "consent_required",
586
+ "interaction_required",
587
+ "account_selection_required"
588
+ ]);
589
+ function checkedCookie(config, sessionState) {
590
+ const name = `${config.cookieName ?? "fx_session"}_checked`;
591
+ const secure = config.secureCookie ?? true;
592
+ const maxAge = Math.max(0, Math.floor(config.silentRecheck ?? 60));
593
+ const value = sessionState ?? "1";
594
+ return { name, header: `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}` };
220
595
  }
221
- async function createDpopKey(alg = "ES256", options = {}) {
222
- const jose = await loadJose2();
223
- const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
224
- extractable: options.extractable ?? false
225
- });
226
- const publicJwk = { ...await jose.exportJWK(publicKey) };
227
- return {
228
- privateKey,
229
- publicJwk,
230
- thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
231
- alg
232
- };
596
+ function sessionStateFrom(url) {
597
+ const value = url.searchParams.get("session_state");
598
+ return value && /^[\w-]{1,128}\.[\w-]{1,64}$/.test(value) ? value : void 0;
233
599
  }
234
- async function exportDpopKey(key) {
235
- const jose = await loadJose2();
236
- return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
600
+ function isPageRequest(request) {
601
+ if (request.method !== "GET") return false;
602
+ const dest = request.headers.get("sec-fetch-dest");
603
+ if (dest) return dest === "document";
604
+ return (request.headers.get("accept") ?? "").includes("text/html");
237
605
  }
238
- var PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
239
- async function importDpopKey(stored) {
240
- const jose = await loadJose2();
241
- const privateKey = await jose.importJWK(stored.jwk, stored.alg);
242
- const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
243
- return {
244
- privateKey,
245
- publicJwk,
246
- thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
247
- alg: stored.alg
248
- };
249
- }
250
- async function proofFor(key, options) {
251
- const jose = await loadJose2();
252
- const htu = (() => {
253
- try {
254
- const url = new URL(options.url);
255
- url.search = "";
256
- url.hash = "";
257
- return url.toString();
258
- } catch {
259
- return options.url;
260
- }
261
- })();
262
- const claims = {
263
- htm: options.method.toUpperCase(),
264
- htu,
265
- jti: crypto.randomUUID()
266
- };
267
- if (options.accessToken) {
268
- const digest = await crypto.subtle.digest(
269
- "SHA-256",
270
- new TextEncoder().encode(options.accessToken)
271
- );
272
- let binary = "";
273
- for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
274
- claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
606
+ function cookieValue(request, name) {
607
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
608
+ const [key, ...rest] = part.trim().split("=");
609
+ if (key === name) return rest.join("=");
275
610
  }
276
- if (options.nonce) claims.nonce = options.nonce;
277
- return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
611
+ return void 0;
278
612
  }
279
- function nonceFrom(response) {
280
- return response.headers.get("dpop-nonce");
613
+ function hasCookie(request, name) {
614
+ return cookieValue(request, name) !== void 0;
615
+ }
616
+ function under(pathname, path) {
617
+ return pathname === path || pathname.startsWith(path.endsWith("/") ? path : `${path}/`);
618
+ }
619
+ function signInRedirect(request, config) {
620
+ if (!isPageRequest(request)) return null;
621
+ const url = new URL(request.url);
622
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
623
+ const guarded = (config.protectedPaths ?? []).some((path) => under(url.pathname, path));
624
+ const mode = guarded ? "required" : config.signIn ?? "manual";
625
+ if (mode === "manual") return null;
626
+ if ([base, ...config.publicPaths ?? []].some((path) => under(url.pathname, path))) return null;
627
+ if (url.searchParams.has("auth_error")) return null;
628
+ if (mode === "silent" && hasCookie(request, checkedCookie(config).name)) return null;
629
+ const login = new URLSearchParams({ returnTo: url.pathname + url.search });
630
+ if (mode === "silent") login.set("prompt", "none");
631
+ return new Response(null, { status: 302, headers: { location: `${base}/login?${login}` } });
281
632
  }
282
-
283
- // src/server.ts
284
- var import_app = require("@fluixi/start/app");
285
633
  var dpopNonces = /* @__PURE__ */ new Map();
286
634
  async function prove(headers, key, method, url, accessToken) {
287
635
  headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
@@ -294,27 +642,52 @@ function learnNonce(url, response) {
294
642
  dpopNonces.set(origin, offered);
295
643
  return fresh && !response.ok;
296
644
  }
297
- async function withToken(url, session) {
645
+ async function sendAs(session, input, init = {}) {
646
+ const url = String(input);
647
+ const method = (init.method ?? "GET").toUpperCase();
298
648
  const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
299
649
  const send = async () => {
300
- const headers = {
650
+ const credentials = {
301
651
  authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
302
652
  };
303
- if (key) await prove(headers, key, "GET", url, session.accessToken);
304
- return fetch(url, { headers });
653
+ if (key) await prove(credentials, key, method, url, session.accessToken);
654
+ const headers = new Headers(init.headers);
655
+ for (const [name, value] of Object.entries(credentials)) headers.set(name, value);
656
+ return fetch(url, { ...init, method, headers });
305
657
  };
306
658
  let response = await send();
307
659
  if (key && learnNonce(url, response)) response = await send();
308
660
  return response;
309
661
  }
310
- function oauthRoutes(config) {
311
- const base = (config.basePath ?? "/auth").replace(/\/$/, "");
312
- const scopes = config.scopes ?? ["openid", "profile", "email"];
313
- const cookie = (0, import_server.createSessionCookie)({
314
- secret: config.cookieSecret,
315
- name: config.cookieName,
316
- secure: config.secureCookie
317
- });
662
+ var withToken = (url, session) => sendAs(session, url);
663
+ var ProviderUnavailable = class extends OAuthError {
664
+ constructor(retryAfterMs) {
665
+ super("The authorization server is unavailable.", "temporarily_unavailable");
666
+ this.retryAfterMs = retryAfterMs;
667
+ }
668
+ };
669
+ var AnswerLost = class extends OAuthError {
670
+ constructor(what) {
671
+ super(`The token request's answer was lost (${what}).`, "answer_lost");
672
+ }
673
+ };
674
+ var NOT_SENT = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
675
+ var RETRY_DEFAULT_MS = 5e3;
676
+ var RETRY_MAX_MS = 3e5;
677
+ function retryAfter(response) {
678
+ const header = response.headers.get("retry-after");
679
+ if (!header) return RETRY_DEFAULT_MS;
680
+ const seconds = Number(header);
681
+ const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
682
+ return Number.isFinite(ms) ? Math.min(Math.max(ms, 1e3), RETRY_MAX_MS) : RETRY_DEFAULT_MS;
683
+ }
684
+ function unanswered(error) {
685
+ const code = error?.cause?.code;
686
+ if (code && NOT_SENT.has(code)) return new ProviderUnavailable(RETRY_DEFAULT_MS);
687
+ return new AnswerLost(code ?? (error instanceof Error ? error.name : "no answer"));
688
+ }
689
+ function tokenClient(config) {
690
+ const timeoutMs = config.tokenTimeoutMs ?? 1e4;
318
691
  let endpoints = config.endpoints ?? null;
319
692
  const resolve = async () => {
320
693
  if (endpoints) return endpoints;
@@ -324,10 +697,6 @@ function oauthRoutes(config) {
324
697
  endpoints = await discover(config.issuer);
325
698
  return endpoints;
326
699
  };
327
- const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
328
- status,
329
- headers: { "content-type": "application/json", ...headers }
330
- });
331
700
  const clientAuth = {
332
701
  clientId: config.clientId,
333
702
  // `client_secret_post` by default here, which is what this half has always sent, so
@@ -347,30 +716,142 @@ function oauthRoutes(config) {
347
716
  const form = new URLSearchParams(body);
348
717
  await applyClientAuth(clientAuth, token, headers, form);
349
718
  if (key) await prove(headers, key, "POST", token);
350
- return fetch(token, { method: "POST", headers, body: form.toString() });
719
+ return fetch(token, { method: "POST", headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
351
720
  };
352
- let response = await attempt();
353
- if (key && learnNonce(token, response)) response = await attempt();
354
- const payload = await response.json().catch(() => ({}));
721
+ let response;
722
+ let payload = null;
723
+ try {
724
+ response = await attempt();
725
+ if (key && learnNonce(token, response)) response = await attempt();
726
+ if (response.status === 429 || response.status >= 500) throw new ProviderUnavailable(retryAfter(response));
727
+ payload = await response.json().catch(() => null);
728
+ } catch (error) {
729
+ throw error instanceof OAuthError ? error : unanswered(error);
730
+ }
355
731
  if (!response.ok) {
356
732
  throw new OAuthError(
357
- String(payload.error ?? "token_request_failed"),
358
- String(payload.error ?? "token_request_failed")
733
+ String(payload?.error ?? "token_request_failed"),
734
+ String(payload?.error ?? "token_request_failed")
359
735
  );
360
736
  }
737
+ if (typeof payload?.access_token !== "string" || !payload.access_token) {
738
+ throw new AnswerLost("a success without an access token");
739
+ }
361
740
  return payload;
362
741
  }
742
+ return { resolve, tokenRequest, clientAuth };
743
+ }
744
+ var renewals = /* @__PURE__ */ new Map();
745
+ var RENEWAL_REUSE_MS = 3e4;
746
+ var SPENT_MS = 5 * 6e4;
747
+ function renew(current, tokenRequest) {
748
+ const refreshToken = current.refreshToken;
749
+ const now = Date.now();
750
+ for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
751
+ const known = renewals.get(refreshToken);
752
+ if (known) return known.result;
753
+ const result = (async () => {
754
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
755
+ const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
756
+ return {
757
+ accessToken: String(payload.access_token ?? ""),
758
+ // A provider that rotates refresh tokens sends a new one; one that does not
759
+ // expects the old one to keep working.
760
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
761
+ idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
762
+ expiresAt: expiryFrom(payload.expires_in),
763
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
764
+ dpop: current.dpop,
765
+ sessionState: current.sessionState
766
+ };
767
+ })().catch((error) => {
768
+ if (error instanceof ProviderUnavailable) {
769
+ renewals.set(refreshToken, { until: Date.now() + error.retryAfterMs, result });
770
+ throw error;
771
+ }
772
+ if (error instanceof OAuthError) {
773
+ renewals.set(refreshToken, { until: Date.now() + SPENT_MS, result });
774
+ return null;
775
+ }
776
+ renewals.delete(refreshToken);
777
+ throw error;
778
+ });
779
+ renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
780
+ return result;
781
+ }
782
+ var EXPIRY_SKEW_MS = 3e4;
783
+ var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
784
+ var defaultLogouts = /* @__PURE__ */ new WeakMap();
785
+ function logoutsOf(config) {
786
+ if (config.logouts) return config.logouts;
787
+ let store = defaultLogouts.get(config);
788
+ if (!store) defaultLogouts.set(config, store = memoryLogouts());
789
+ return store;
790
+ }
791
+ async function endedAtProvider(config, session) {
792
+ const id = idTokenClaims(session.idToken);
793
+ if (!id.sid && !id.sub) return false;
794
+ return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
795
+ }
796
+ function oauthRoutes(config) {
797
+ const { resolve, tokenRequest, clientAuth } = tokenClient(config);
798
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
799
+ status,
800
+ headers: { "content-type": "application/json", ...headers }
801
+ });
802
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
803
+ const scopes = config.scopes ?? ["openid", "profile", "email"];
804
+ const cookie = (0, import_server.createSessionCookie)({
805
+ secret: config.cookieSecret,
806
+ name: config.cookieName,
807
+ secure: config.secureCookie
808
+ });
363
809
  return async function middleware(request, next) {
364
810
  const url = new URL(request.url);
365
811
  if (!url.pathname.startsWith(`${base}/`)) return next();
366
812
  const route = url.pathname.slice(base.length);
367
813
  const session = async () => {
368
- return cookie.read(request);
814
+ const current = await cookie.read(request);
815
+ return current && await endedAtProvider(config, current) ? null : current;
369
816
  };
817
+ if (route === "/backchannel-logout") {
818
+ const headers = { "cache-control": "no-store" };
819
+ if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
820
+ try {
821
+ const form = await request.formData();
822
+ const token = form.get("logout_token");
823
+ if (typeof token !== "string" || !token) throw new Error("no logout_token");
824
+ const endpoints = await resolve();
825
+ const issuer = endpoints.issuer ?? config.issuer;
826
+ if (!issuer) throw new Error("no issuer");
827
+ if (!endpoints.jwks) throw new Error("no jwks_uri");
828
+ const jwks = await (await fetch(endpoints.jwks)).json();
829
+ const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
830
+ await logoutsOf(config).add(entry);
831
+ return new Response(null, { status: 200, headers });
832
+ } catch {
833
+ return json({ error: "invalid_request" }, 400, headers);
834
+ }
835
+ }
370
836
  if (route === "/login") {
371
- const { authorization } = await resolve();
837
+ let authorization;
838
+ try {
839
+ ({ authorization } = await resolve());
840
+ } catch (error) {
841
+ if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
842
+ const back = safeReturnTo(url.searchParams.get("returnTo"), {
843
+ fallback: config.defaultReturnTo ?? "/",
844
+ origin: url.origin,
845
+ allowedOrigins: config.allowedReturnOrigins
846
+ });
847
+ const target2 = new URL(back, url.origin);
848
+ target2.searchParams.set("auth_error", "provider_unreachable");
849
+ const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
850
+ return new Response(null, { status: 302, headers: { location: location2 } });
851
+ }
372
852
  const { verifier, challenge } = await createPkcePair();
373
- const state = createStateValue();
853
+ const retry = url.searchParams.get("retry") === "1";
854
+ const state = createStateValue() + (retry ? RETRY_MARK : "");
374
855
  const target = new URL(authorization);
375
856
  target.searchParams.set("response_type", "code");
376
857
  target.searchParams.set("client_id", config.clientId);
@@ -379,6 +860,8 @@ function oauthRoutes(config) {
379
860
  target.searchParams.set("state", state);
380
861
  target.searchParams.set("code_challenge", challenge);
381
862
  target.searchParams.set("code_challenge_method", "S256");
863
+ const prompt = url.searchParams.get("prompt");
864
+ if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
382
865
  const pendingCookie = await cookie.write(
383
866
  {
384
867
  accessToken: "",
@@ -391,7 +874,8 @@ function oauthRoutes(config) {
391
874
  fallback: "",
392
875
  origin: url.origin,
393
876
  allowedOrigins: config.allowedReturnOrigins
394
- }) || void 0
877
+ }) || void 0,
878
+ silent: prompt === "none" || void 0
395
879
  }
396
880
  },
397
881
  // Ten minutes: long enough to sign in, short enough that an abandoned attempt
@@ -410,12 +894,43 @@ function oauthRoutes(config) {
410
894
  const current = await session();
411
895
  const pending = current?.pending;
412
896
  const error = url.searchParams.get("error");
413
- if (error) return json({ error }, 400);
897
+ if (error) {
898
+ const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
899
+ let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
900
+ fallback: "/",
901
+ origin: url.origin,
902
+ allowedOrigins: config.allowedReturnOrigins
903
+ });
904
+ const headers = new Headers();
905
+ headers.append("set-cookie", cookie.clear());
906
+ if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
907
+ headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
908
+ } else {
909
+ const target = new URL(location2, url.origin);
910
+ target.searchParams.set("auth_error", error);
911
+ location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
912
+ }
913
+ headers.set("location", location2);
914
+ return new Response(null, { status: 302, headers });
915
+ }
414
916
  const code = url.searchParams.get("code");
415
917
  const state = url.searchParams.get("state");
416
918
  if (!code) return json({ error: "no_code" }, 400);
417
919
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
418
- return json({ error: "state_mismatch" }, 400);
920
+ const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
921
+ if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
922
+ if (!state?.endsWith(RETRY_MARK)) {
923
+ return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
924
+ }
925
+ const target = new URL(home, url.origin);
926
+ target.searchParams.set("auth_error", "state_mismatch");
927
+ return new Response(null, {
928
+ status: 302,
929
+ headers: {
930
+ location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
931
+ "set-cookie": cookie.clear()
932
+ }
933
+ });
419
934
  }
420
935
  const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
421
936
  const payload = await tokenRequest(
@@ -433,7 +948,8 @@ function oauthRoutes(config) {
433
948
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
434
949
  expiresAt: expiryFrom(payload.expires_in),
435
950
  tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
436
- dpop: key ? await exportDpopKey(key) : void 0
951
+ dpop: key ? await exportDpopKey(key) : void 0,
952
+ sessionState: sessionStateFrom(url)
437
953
  });
438
954
  return new Response(null, {
439
955
  status: 302,
@@ -453,22 +969,9 @@ function oauthRoutes(config) {
453
969
  if (route === "/refresh") {
454
970
  const current = await session();
455
971
  if (!current?.refreshToken) return json({ error: "no_session" }, 401);
456
- const key = current.dpop ? await importDpopKey(current.dpop) : null;
457
- const payload = await tokenRequest(
458
- { grant_type: "refresh_token", refresh_token: current.refreshToken },
459
- key
460
- );
461
- const refreshed = await cookie.write({
462
- accessToken: String(payload.access_token ?? ""),
463
- // A provider that rotates refresh tokens sends a new one; one that does not
464
- // expects the old one to keep working.
465
- refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
466
- idToken: current.idToken,
467
- expiresAt: expiryFrom(payload.expires_in),
468
- tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
469
- dpop: current.dpop
470
- });
471
- return json({ ok: true }, 200, { "set-cookie": refreshed });
972
+ const renewed = await renew(current, tokenRequest);
973
+ if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
974
+ return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
472
975
  }
473
976
  if (route === "/me") {
474
977
  const current = await session();
@@ -480,6 +983,13 @@ function oauthRoutes(config) {
480
983
  const claims = await response.json();
481
984
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
482
985
  }
986
+ if (route === "/session-check") {
987
+ const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
988
+ const current = await session();
989
+ const checked = cookieValue(request, checkedCookie(config).name);
990
+ const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
991
+ return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
992
+ }
483
993
  if (route === "/revoke") {
484
994
  const current = await session();
485
995
  const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
@@ -502,31 +1012,33 @@ function oauthRoutes(config) {
502
1012
  const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
503
1013
  const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
504
1014
  if (revocation && current?.refreshToken) {
505
- const headers = {
1015
+ const headers2 = {
506
1016
  "content-type": "application/x-www-form-urlencoded"
507
1017
  };
508
1018
  const form = new URLSearchParams({
509
1019
  token: current.refreshToken,
510
1020
  token_type_hint: "refresh_token"
511
1021
  });
512
- await applyClientAuth(clientAuth, revocation, headers, form);
513
- await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
1022
+ await applyClientAuth(clientAuth, revocation, headers2, form);
1023
+ await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
514
1024
  () => void 0
515
1025
  );
516
1026
  }
517
1027
  const cleared = cookie.clear();
1028
+ const headers = new Headers();
1029
+ headers.append("set-cookie", cleared);
1030
+ headers.append("set-cookie", checkedCookie(config).header);
518
1031
  if (endSession && current?.idToken) {
519
1032
  const target = new URL(endSession);
520
1033
  target.searchParams.set("id_token_hint", current.idToken);
521
1034
  if (config.defaultReturnTo) {
522
1035
  target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
523
1036
  }
524
- return new Response(null, {
525
- status: 302,
526
- headers: { location: target.toString(), "set-cookie": cleared }
527
- });
1037
+ headers.set("location", target.toString());
1038
+ return new Response(null, { status: 302, headers });
528
1039
  }
529
- return json({ ok: true }, 200, { "set-cookie": cleared });
1040
+ headers.set("content-type", "application/json");
1041
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
530
1042
  }
531
1043
  return next();
532
1044
  };
@@ -537,6 +1049,7 @@ function sessionLocals(config) {
537
1049
  name: config.cookieName,
538
1050
  secure: config.secureCookie
539
1051
  });
1052
+ const { tokenRequest } = tokenClient(config);
540
1053
  const ttl = config.userCacheMs ?? 3e4;
541
1054
  const cache2 = /* @__PURE__ */ new Map();
542
1055
  const remember = (token, user) => {
@@ -551,21 +1064,61 @@ function sessionLocals(config) {
551
1064
  return async function middleware(request, next) {
552
1065
  const { getRequestLocals } = await import("@fluixi/server");
553
1066
  const locals = getRequestLocals(request);
554
- const session = await cookie.read(request);
555
- if (!session?.accessToken) {
1067
+ let session = await cookie.read(request);
1068
+ let setCookie;
1069
+ const respond = async (response) => {
1070
+ if (!setCookie) return response;
1071
+ const copy = new Response(response.body, response);
1072
+ copy.headers.append("set-cookie", setCookie);
1073
+ return copy;
1074
+ };
1075
+ const renewOrDrop = async (current2) => {
1076
+ const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
1077
+ setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
1078
+ return renewed;
1079
+ };
1080
+ let paused = false;
1081
+ const keep = (current2) => (error) => {
1082
+ if (error instanceof ProviderUnavailable) paused = true;
1083
+ return current2;
1084
+ };
1085
+ if (session?.accessToken && expired(session)) {
1086
+ session = await renewOrDrop(session).catch(keep(session));
1087
+ }
1088
+ const signedOut = async () => {
1089
+ const redirect = signInRedirect(request, config);
1090
+ if (redirect) return respond(redirect);
556
1091
  locals.auth = { user: null, session: null };
557
- return next();
1092
+ return respond(await next());
1093
+ };
1094
+ if (!session?.accessToken) return signedOut();
1095
+ if (await endedAtProvider(config, session)) {
1096
+ setCookie = cookie.clear();
1097
+ session = null;
1098
+ return signedOut();
558
1099
  }
559
- const cached = cache2.get(session.accessToken);
1100
+ const live = session;
1101
+ const fetchAs = (input, init) => sendAs(live, input, init);
1102
+ const cached = cache2.get(live.accessToken);
560
1103
  if (cached && cached.until > Date.now()) {
561
- locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
562
- return next();
1104
+ locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
1105
+ return respond(await next());
563
1106
  }
564
1107
  let user = null;
1108
+ let unreachable = false;
565
1109
  try {
566
1110
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
567
1111
  if (endpoints?.userinfo) {
568
- const response = await withToken(endpoints.userinfo, session);
1112
+ let response = await withToken(endpoints.userinfo, session);
1113
+ if (response.status === 401 && !setCookie && !paused) {
1114
+ session = await renewOrDrop(session).catch(keep(session));
1115
+ if (!session) return signedOut();
1116
+ response = await withToken(endpoints.userinfo, session);
1117
+ }
1118
+ if (response.status === 401) {
1119
+ if (!paused) setCookie = cookie.clear();
1120
+ return signedOut();
1121
+ }
569
1122
  if (response.ok) {
570
1123
  const claims = await response.json();
571
1124
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -573,11 +1126,20 @@ function sessionLocals(config) {
573
1126
  }
574
1127
  }
575
1128
  } catch {
1129
+ unreachable = true;
576
1130
  }
577
- locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
578
- return next();
1131
+ if (!session) return signedOut();
1132
+ const current = session;
1133
+ const fetchCurrent = (input, init) => sendAs(current, input, init);
1134
+ locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
1135
+ return respond(await next());
579
1136
  };
580
1137
  }
1138
+ async function requestAuth(request) {
1139
+ const { getLocals, getRequestLocals } = await import("@fluixi/server");
1140
+ const locals = request ? getRequestLocals(request) : getLocals();
1141
+ return locals.auth ?? { user: null, session: null };
1142
+ }
581
1143
  function authServerModule(config) {
582
1144
  return (0, import_app.defineModule)({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
583
1145
  }