@fluixi/oauth2 1.0.0-alpha.11 → 1.0.0-alpha.13

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 (45) hide show
  1. package/dist/backchannel.cjs +462 -0
  2. package/dist/backchannel.d.ts +39 -0
  3. package/dist/backchannel.d.ts.map +1 -0
  4. package/dist/backchannel.js +97 -0
  5. package/dist/backchannel.mjs +307 -0
  6. package/dist/{browser-N432SMC3.mjs → browser-SQXLVG25.mjs} +2 -2
  7. package/dist/browser.cjs +6 -1
  8. package/dist/browser.mjs +6 -1
  9. package/dist/callback.mjs +1 -1
  10. package/dist/{chunk-ZVDC3JW7.mjs → chunk-CVUD2UAP.mjs} +1 -1
  11. package/dist/chunk-H5KMWPVT.mjs +108 -0
  12. package/dist/{chunk-4TPZVJSR.mjs → chunk-QZFYCRMT.mjs} +1 -1
  13. package/dist/discovery.cjs +6 -1
  14. package/dist/discovery.d.ts.map +1 -1
  15. package/dist/discovery.js +6 -1
  16. package/dist/discovery.mjs +6 -1
  17. package/dist/{dpop-DYUR2CH2.mjs → dpop-7YXQSM5R.mjs} +4 -4
  18. package/dist/dpop-SP7YCDPE.mjs +22 -0
  19. package/dist/index.cjs +107 -7
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.mjs +111 -11
  24. package/dist/server.cjs +649 -137
  25. package/dist/server.d.ts +64 -4
  26. package/dist/server.d.ts.map +1 -1
  27. package/dist/server.js +376 -61
  28. package/dist/server.mjs +532 -139
  29. package/dist/service-client.cjs +7 -2
  30. package/dist/service-client.mjs +9 -4
  31. package/dist/service.cjs +104 -5
  32. package/dist/service.d.ts.map +1 -1
  33. package/dist/service.js +38 -6
  34. package/dist/service.mjs +105 -6
  35. package/dist/session-monitor.cjs +98 -0
  36. package/dist/session-monitor.d.ts +32 -0
  37. package/dist/session-monitor.d.ts.map +1 -0
  38. package/dist/session-monitor.js +104 -0
  39. package/dist/session-monitor.mjs +77 -0
  40. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  41. package/dist/types.d.ts +8 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/verify.cjs +7 -2
  44. package/dist/verify.mjs +12 -7
  45. package/package.json +15 -15
package/dist/server.mjs CHANGED
@@ -1,5 +1,13 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.11 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-4TPZVJSR.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.13 | (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-H5KMWPVT.mjs";
10
+ import "./chunk-QZFYCRMT.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-N432SMC3.mjs");
54
+ return await import("./browser-SQXLVG25.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-SQXLVG25.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-SP7YCDPE.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-N432SMC3.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,25 @@ 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
+ function tokenClient(config) {
284
509
  let endpoints = config.endpoints ?? null;
285
510
  const resolve = async () => {
286
511
  if (endpoints) return endpoints;
@@ -290,10 +515,6 @@ function oauthRoutes(config) {
290
515
  endpoints = await discover(config.issuer);
291
516
  return endpoints;
292
517
  };
293
- const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
294
- status,
295
- headers: { "content-type": "application/json", ...headers }
296
- });
297
518
  const clientAuth = {
298
519
  clientId: config.clientId,
299
520
  // `client_secret_post` by default here, which is what this half has always sent, so
@@ -326,17 +547,111 @@ function oauthRoutes(config) {
326
547
  }
327
548
  return payload;
328
549
  }
550
+ return { resolve, tokenRequest, clientAuth };
551
+ }
552
+ var renewals = /* @__PURE__ */ new Map();
553
+ var RENEWAL_REUSE_MS = 3e4;
554
+ function renew(current, tokenRequest) {
555
+ const refreshToken = current.refreshToken;
556
+ const now = Date.now();
557
+ for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
558
+ const known = renewals.get(refreshToken);
559
+ if (known) return known.result;
560
+ const result = (async () => {
561
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
562
+ const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
563
+ return {
564
+ accessToken: String(payload.access_token ?? ""),
565
+ // A provider that rotates refresh tokens sends a new one; one that does not
566
+ // expects the old one to keep working.
567
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
568
+ idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
569
+ expiresAt: expiryFrom(payload.expires_in),
570
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
571
+ dpop: current.dpop,
572
+ sessionState: current.sessionState
573
+ };
574
+ })().catch((error) => {
575
+ if (error instanceof OAuthError) return null;
576
+ renewals.delete(refreshToken);
577
+ throw error;
578
+ });
579
+ renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
580
+ return result;
581
+ }
582
+ var EXPIRY_SKEW_MS = 3e4;
583
+ var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
584
+ var defaultLogouts = /* @__PURE__ */ new WeakMap();
585
+ function logoutsOf(config) {
586
+ if (config.logouts) return config.logouts;
587
+ let store = defaultLogouts.get(config);
588
+ if (!store) defaultLogouts.set(config, store = memoryLogouts());
589
+ return store;
590
+ }
591
+ async function endedAtProvider(config, session) {
592
+ const id = idTokenClaims(session.idToken);
593
+ if (!id.sid && !id.sub) return false;
594
+ return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
595
+ }
596
+ function oauthRoutes(config) {
597
+ const { resolve, tokenRequest, clientAuth } = tokenClient(config);
598
+ const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
599
+ status,
600
+ headers: { "content-type": "application/json", ...headers }
601
+ });
602
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
603
+ const scopes = config.scopes ?? ["openid", "profile", "email"];
604
+ const cookie = createSessionCookie({
605
+ secret: config.cookieSecret,
606
+ name: config.cookieName,
607
+ secure: config.secureCookie
608
+ });
329
609
  return async function middleware(request, next) {
330
610
  const url = new URL(request.url);
331
611
  if (!url.pathname.startsWith(`${base}/`)) return next();
332
612
  const route = url.pathname.slice(base.length);
333
613
  const session = async () => {
334
- return cookie.read(request);
614
+ const current = await cookie.read(request);
615
+ return current && await endedAtProvider(config, current) ? null : current;
335
616
  };
617
+ if (route === "/backchannel-logout") {
618
+ const headers = { "cache-control": "no-store" };
619
+ if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
620
+ try {
621
+ const form = await request.formData();
622
+ const token = form.get("logout_token");
623
+ if (typeof token !== "string" || !token) throw new Error("no logout_token");
624
+ const endpoints = await resolve();
625
+ const issuer = endpoints.issuer ?? config.issuer;
626
+ if (!issuer) throw new Error("no issuer");
627
+ if (!endpoints.jwks) throw new Error("no jwks_uri");
628
+ const jwks = await (await fetch(endpoints.jwks)).json();
629
+ const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
630
+ await logoutsOf(config).add(entry);
631
+ return new Response(null, { status: 200, headers });
632
+ } catch {
633
+ return json({ error: "invalid_request" }, 400, headers);
634
+ }
635
+ }
336
636
  if (route === "/login") {
337
- const { authorization } = await resolve();
637
+ let authorization;
638
+ try {
639
+ ({ authorization } = await resolve());
640
+ } catch (error) {
641
+ if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
642
+ const back = safeReturnTo(url.searchParams.get("returnTo"), {
643
+ fallback: config.defaultReturnTo ?? "/",
644
+ origin: url.origin,
645
+ allowedOrigins: config.allowedReturnOrigins
646
+ });
647
+ const target2 = new URL(back, url.origin);
648
+ target2.searchParams.set("auth_error", "provider_unreachable");
649
+ const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
650
+ return new Response(null, { status: 302, headers: { location: location2 } });
651
+ }
338
652
  const { verifier, challenge } = await createPkcePair();
339
- const state = createStateValue();
653
+ const retry = url.searchParams.get("retry") === "1";
654
+ const state = createStateValue() + (retry ? RETRY_MARK : "");
340
655
  const target = new URL(authorization);
341
656
  target.searchParams.set("response_type", "code");
342
657
  target.searchParams.set("client_id", config.clientId);
@@ -345,6 +660,8 @@ function oauthRoutes(config) {
345
660
  target.searchParams.set("state", state);
346
661
  target.searchParams.set("code_challenge", challenge);
347
662
  target.searchParams.set("code_challenge_method", "S256");
663
+ const prompt = url.searchParams.get("prompt");
664
+ if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
348
665
  const pendingCookie = await cookie.write(
349
666
  {
350
667
  accessToken: "",
@@ -357,7 +674,8 @@ function oauthRoutes(config) {
357
674
  fallback: "",
358
675
  origin: url.origin,
359
676
  allowedOrigins: config.allowedReturnOrigins
360
- }) || void 0
677
+ }) || void 0,
678
+ silent: prompt === "none" || void 0
361
679
  }
362
680
  },
363
681
  // Ten minutes: long enough to sign in, short enough that an abandoned attempt
@@ -376,12 +694,43 @@ function oauthRoutes(config) {
376
694
  const current = await session();
377
695
  const pending = current?.pending;
378
696
  const error = url.searchParams.get("error");
379
- if (error) return json({ error }, 400);
697
+ if (error) {
698
+ const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
699
+ let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
700
+ fallback: "/",
701
+ origin: url.origin,
702
+ allowedOrigins: config.allowedReturnOrigins
703
+ });
704
+ const headers = new Headers();
705
+ headers.append("set-cookie", cookie.clear());
706
+ if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
707
+ headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
708
+ } else {
709
+ const target = new URL(location2, url.origin);
710
+ target.searchParams.set("auth_error", error);
711
+ location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
712
+ }
713
+ headers.set("location", location2);
714
+ return new Response(null, { status: 302, headers });
715
+ }
380
716
  const code = url.searchParams.get("code");
381
717
  const state = url.searchParams.get("state");
382
718
  if (!code) return json({ error: "no_code" }, 400);
383
719
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
384
- return json({ error: "state_mismatch" }, 400);
720
+ const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
721
+ if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
722
+ if (!state?.endsWith(RETRY_MARK)) {
723
+ return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
724
+ }
725
+ const target = new URL(home, url.origin);
726
+ target.searchParams.set("auth_error", "state_mismatch");
727
+ return new Response(null, {
728
+ status: 302,
729
+ headers: {
730
+ location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
731
+ "set-cookie": cookie.clear()
732
+ }
733
+ });
385
734
  }
386
735
  const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
387
736
  const payload = await tokenRequest(
@@ -399,7 +748,8 @@ function oauthRoutes(config) {
399
748
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
400
749
  expiresAt: expiryFrom(payload.expires_in),
401
750
  tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
402
- dpop: key ? await exportDpopKey(key) : void 0
751
+ dpop: key ? await exportDpopKey(key) : void 0,
752
+ sessionState: sessionStateFrom(url)
403
753
  });
404
754
  return new Response(null, {
405
755
  status: 302,
@@ -419,22 +769,9 @@ function oauthRoutes(config) {
419
769
  if (route === "/refresh") {
420
770
  const current = await session();
421
771
  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 });
772
+ const renewed = await renew(current, tokenRequest);
773
+ if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
774
+ return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
438
775
  }
439
776
  if (route === "/me") {
440
777
  const current = await session();
@@ -446,6 +783,13 @@ function oauthRoutes(config) {
446
783
  const claims = await response.json();
447
784
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
448
785
  }
786
+ if (route === "/session-check") {
787
+ const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
788
+ const current = await session();
789
+ const checked = cookieValue(request, checkedCookie(config).name);
790
+ const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
791
+ return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
792
+ }
449
793
  if (route === "/revoke") {
450
794
  const current = await session();
451
795
  const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
@@ -468,31 +812,33 @@ function oauthRoutes(config) {
468
812
  const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
469
813
  const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
470
814
  if (revocation && current?.refreshToken) {
471
- const headers = {
815
+ const headers2 = {
472
816
  "content-type": "application/x-www-form-urlencoded"
473
817
  };
474
818
  const form = new URLSearchParams({
475
819
  token: current.refreshToken,
476
820
  token_type_hint: "refresh_token"
477
821
  });
478
- await applyClientAuth(clientAuth, revocation, headers, form);
479
- await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
822
+ await applyClientAuth(clientAuth, revocation, headers2, form);
823
+ await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
480
824
  () => void 0
481
825
  );
482
826
  }
483
827
  const cleared = cookie.clear();
828
+ const headers = new Headers();
829
+ headers.append("set-cookie", cleared);
830
+ headers.append("set-cookie", checkedCookie(config).header);
484
831
  if (endSession && current?.idToken) {
485
832
  const target = new URL(endSession);
486
833
  target.searchParams.set("id_token_hint", current.idToken);
487
834
  if (config.defaultReturnTo) {
488
835
  target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
489
836
  }
490
- return new Response(null, {
491
- status: 302,
492
- headers: { location: target.toString(), "set-cookie": cleared }
493
- });
837
+ headers.set("location", target.toString());
838
+ return new Response(null, { status: 302, headers });
494
839
  }
495
- return json({ ok: true }, 200, { "set-cookie": cleared });
840
+ headers.set("content-type", "application/json");
841
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
496
842
  }
497
843
  return next();
498
844
  };
@@ -503,6 +849,7 @@ function sessionLocals(config) {
503
849
  name: config.cookieName,
504
850
  secure: config.secureCookie
505
851
  });
852
+ const { tokenRequest } = tokenClient(config);
506
853
  const ttl = config.userCacheMs ?? 3e4;
507
854
  const cache2 = /* @__PURE__ */ new Map();
508
855
  const remember = (token, user) => {
@@ -517,21 +864,56 @@ function sessionLocals(config) {
517
864
  return async function middleware(request, next) {
518
865
  const { getRequestLocals } = await import("@fluixi/server");
519
866
  const locals = getRequestLocals(request);
520
- const session = await cookie.read(request);
521
- if (!session?.accessToken) {
867
+ let session = await cookie.read(request);
868
+ let setCookie;
869
+ const respond = async (response) => {
870
+ if (!setCookie) return response;
871
+ const copy = new Response(response.body, response);
872
+ copy.headers.append("set-cookie", setCookie);
873
+ return copy;
874
+ };
875
+ const renewOrDrop = async (current2) => {
876
+ const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
877
+ setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
878
+ return renewed;
879
+ };
880
+ if (session?.accessToken && expired(session)) {
881
+ session = await renewOrDrop(session).catch(() => session);
882
+ }
883
+ const signedOut = async () => {
884
+ const redirect = signInRedirect(request, config);
885
+ if (redirect) return respond(redirect);
522
886
  locals.auth = { user: null, session: null };
523
- return next();
887
+ return respond(await next());
888
+ };
889
+ if (!session?.accessToken) return signedOut();
890
+ if (await endedAtProvider(config, session)) {
891
+ setCookie = cookie.clear();
892
+ session = null;
893
+ return signedOut();
524
894
  }
525
- const cached = cache2.get(session.accessToken);
895
+ const live = session;
896
+ const fetchAs = (input, init) => sendAs(live, input, init);
897
+ const cached = cache2.get(live.accessToken);
526
898
  if (cached && cached.until > Date.now()) {
527
- locals.auth = { user: cached.user, session: { expiresAt: session.expiresAt } };
528
- return next();
899
+ locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
900
+ return respond(await next());
529
901
  }
530
902
  let user = null;
903
+ let unreachable = false;
531
904
  try {
532
905
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
533
906
  if (endpoints?.userinfo) {
534
- const response = await withToken(endpoints.userinfo, session);
907
+ let response = await withToken(endpoints.userinfo, session);
908
+ if (response.status === 401 && !setCookie) {
909
+ session = await renewOrDrop(session).catch(() => session);
910
+ if (!session) return signedOut();
911
+ response = await withToken(endpoints.userinfo, session);
912
+ }
913
+ if (response.status === 401) {
914
+ setCookie = cookie.clear();
915
+ return signedOut();
916
+ }
535
917
  if (response.ok) {
536
918
  const claims = await response.json();
537
919
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -539,16 +921,27 @@ function sessionLocals(config) {
539
921
  }
540
922
  }
541
923
  } catch {
924
+ unreachable = true;
542
925
  }
543
- locals.auth = { user, session: user ? { expiresAt: session.expiresAt } : null };
544
- return next();
926
+ if (!session) return signedOut();
927
+ const current = session;
928
+ const fetchCurrent = (input, init) => sendAs(current, input, init);
929
+ locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
930
+ return respond(await next());
545
931
  };
546
932
  }
933
+ async function requestAuth(request) {
934
+ const { getLocals, getRequestLocals } = await import("@fluixi/server");
935
+ const locals = request ? getRequestLocals(request) : getLocals();
936
+ return locals.auth ?? { user: null, session: null };
937
+ }
547
938
  function authServerModule(config) {
548
939
  return defineModule({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
549
940
  }
550
941
  export {
551
942
  authServerModule,
943
+ memoryLogouts,
552
944
  oauthRoutes,
945
+ requestAuth,
553
946
  sessionLocals
554
947
  };