@fluixi/oauth2 1.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/dist/api-guard.cjs +185 -0
  3. package/dist/api-guard.d.ts +94 -0
  4. package/dist/api-guard.d.ts.map +1 -0
  5. package/dist/api-guard.js +127 -0
  6. package/dist/api-guard.mjs +141 -0
  7. package/dist/browser-FDUOEUM2.mjs +3544 -0
  8. package/dist/browser-ONMHX3Z3.mjs +3545 -0
  9. package/dist/browser.cjs +735 -0
  10. package/dist/browser.d.ts +3 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +448 -0
  13. package/dist/browser.mjs +704 -0
  14. package/dist/callback.cjs +125 -0
  15. package/dist/callback.d.ts +113 -0
  16. package/dist/callback.d.ts.map +1 -0
  17. package/dist/callback.js +183 -0
  18. package/dist/callback.mjs +106 -0
  19. package/dist/chunk-25WE6UWI.mjs +10 -0
  20. package/dist/chunk-4NSOGPLM.mjs +15 -0
  21. package/dist/chunk-7P6ASYW6.mjs +9 -0
  22. package/dist/chunk-OGSFNTKA.mjs +13 -0
  23. package/dist/client-auth.cjs +115 -0
  24. package/dist/client-auth.d.ts +43 -0
  25. package/dist/client-auth.d.ts.map +1 -0
  26. package/dist/client-auth.js +112 -0
  27. package/dist/client-auth.mjs +84 -0
  28. package/dist/discovery.cjs +86 -0
  29. package/dist/discovery.d.ts +15 -0
  30. package/dist/discovery.d.ts.map +1 -0
  31. package/dist/discovery.js +51 -0
  32. package/dist/discovery.mjs +63 -0
  33. package/dist/dpop-LVPWWADA.mjs +80 -0
  34. package/dist/dpop-NS7NKOWM.mjs +79 -0
  35. package/dist/dpop-store.cjs +152 -0
  36. package/dist/dpop-store.d.ts +37 -0
  37. package/dist/dpop-store.d.ts.map +1 -0
  38. package/dist/dpop-store.js +119 -0
  39. package/dist/dpop-store.mjs +121 -0
  40. package/dist/dpop.cjs +117 -0
  41. package/dist/dpop.d.ts +72 -0
  42. package/dist/dpop.d.ts.map +1 -0
  43. package/dist/dpop.js +114 -0
  44. package/dist/dpop.mjs +86 -0
  45. package/dist/env-config.cjs +366 -0
  46. package/dist/env-config.d.ts +103 -0
  47. package/dist/env-config.d.ts.map +1 -0
  48. package/dist/env-config.js +184 -0
  49. package/dist/env-config.mjs +343 -0
  50. package/dist/env-credentials.cjs +82 -0
  51. package/dist/env-credentials.d.ts +68 -0
  52. package/dist/env-credentials.d.ts.map +1 -0
  53. package/dist/env-credentials.js +76 -0
  54. package/dist/env-credentials.mjs +51 -0
  55. package/dist/index.cjs +940 -0
  56. package/dist/index.d.ts +104 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +101 -0
  59. package/dist/index.mjs +909 -0
  60. package/dist/pkce.cjs +87 -0
  61. package/dist/pkce.d.ts +58 -0
  62. package/dist/pkce.d.ts.map +1 -0
  63. package/dist/pkce.js +112 -0
  64. package/dist/pkce.mjs +66 -0
  65. package/dist/presets.cjs +240 -0
  66. package/dist/presets.d.ts +140 -0
  67. package/dist/presets.d.ts.map +1 -0
  68. package/dist/presets.js +182 -0
  69. package/dist/presets.mjs +217 -0
  70. package/dist/return-to.cjs +46 -0
  71. package/dist/return-to.d.ts +49 -0
  72. package/dist/return-to.d.ts.map +1 -0
  73. package/dist/return-to.js +71 -0
  74. package/dist/return-to.mjs +25 -0
  75. package/dist/roles.cjs +56 -0
  76. package/dist/roles.d.ts +35 -0
  77. package/dist/roles.d.ts.map +1 -0
  78. package/dist/roles.js +37 -0
  79. package/dist/roles.mjs +35 -0
  80. package/dist/server-client.cjs +134 -0
  81. package/dist/server-client.d.ts +12 -0
  82. package/dist/server-client.d.ts.map +1 -0
  83. package/dist/server-client.js +107 -0
  84. package/dist/server-client.mjs +111 -0
  85. package/dist/server.cjs +470 -0
  86. package/dist/server.d.ts +89 -0
  87. package/dist/server.d.ts.map +1 -0
  88. package/dist/server.js +360 -0
  89. package/dist/server.mjs +441 -0
  90. package/dist/service-client.cjs +244 -0
  91. package/dist/service-client.d.ts +89 -0
  92. package/dist/service-client.d.ts.map +1 -0
  93. package/dist/service-client.js +112 -0
  94. package/dist/service-client.mjs +213 -0
  95. package/dist/service.cjs +903 -0
  96. package/dist/service.d.ts +96 -0
  97. package/dist/service.d.ts.map +1 -0
  98. package/dist/service.js +129 -0
  99. package/dist/service.mjs +874 -0
  100. package/dist/tokens.cjs +29 -0
  101. package/dist/tokens.d.ts +13 -0
  102. package/dist/tokens.d.ts.map +1 -0
  103. package/dist/tokens.js +21 -0
  104. package/dist/tokens.mjs +8 -0
  105. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  106. package/dist/types.cjs +33 -0
  107. package/dist/types.d.ts +239 -0
  108. package/dist/types.d.ts.map +1 -0
  109. package/dist/types.js +23 -0
  110. package/dist/types.mjs +12 -0
  111. package/dist/verify.cjs +724 -0
  112. package/dist/verify.d.ts +205 -0
  113. package/dist/verify.d.ts.map +1 -0
  114. package/dist/verify.js +371 -0
  115. package/dist/verify.mjs +590 -0
  116. package/package.json +121 -0
@@ -0,0 +1,903 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/service.ts
31
+ var service_exports = {};
32
+ __export(service_exports, {
33
+ createAuthService: () => createAuthService,
34
+ paramsFromUrl: () => paramsFromUrl
35
+ });
36
+ module.exports = __toCommonJS(service_exports);
37
+ var import_session = require("@fluixi/session");
38
+ var import_client = require("@fluixi/auth/client");
39
+
40
+ // src/pkce.ts
41
+ function base64url(bytes) {
42
+ let binary = "";
43
+ for (const byte of bytes) binary += String.fromCharCode(byte);
44
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
45
+ }
46
+ function randomBytes(length) {
47
+ const bytes = new Uint8Array(length);
48
+ crypto.getRandomValues(bytes);
49
+ return bytes;
50
+ }
51
+ function createVerifier() {
52
+ return base64url(randomBytes(32));
53
+ }
54
+ function createStateValue() {
55
+ return base64url(randomBytes(16));
56
+ }
57
+ async function createChallenge(verifier) {
58
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
59
+ return base64url(new Uint8Array(digest));
60
+ }
61
+ async function createPkcePair() {
62
+ const verifier = createVerifier();
63
+ return { verifier, challenge: await createChallenge(verifier) };
64
+ }
65
+ function timingSafeEqual(a, b) {
66
+ if (a.length !== b.length) return false;
67
+ let diff = 0;
68
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
69
+ return diff === 0;
70
+ }
71
+ function expiryFrom(expiresIn, now = Date.now()) {
72
+ const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
73
+ return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
74
+ }
75
+ function claimsOf(token) {
76
+ const parts = token.split(".");
77
+ if (parts.length < 2) return null;
78
+ try {
79
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
80
+ return JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, "=")));
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+ function expiryFromJwt(token) {
86
+ const parts = token.split(".");
87
+ if (parts.length < 2) return null;
88
+ try {
89
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
90
+ const json = JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, "=")));
91
+ return typeof json.exp === "number" ? json.exp * 1e3 : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // src/return-to.ts
98
+ function safeReturnTo(candidate, options = {}) {
99
+ const fallback = options.fallback ?? "/";
100
+ if (!candidate || typeof candidate !== "string") return fallback;
101
+ const value = candidate.trim();
102
+ if (!value) return fallback;
103
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
104
+ if (/^[/\\]{2}/.test(value)) return fallback;
105
+ if (value.startsWith("/")) return value;
106
+ const here = options.origin ?? (typeof location === "undefined" ? void 0 : location.origin);
107
+ const allowed = [here, ...options.allowedOrigins ?? []].filter(Boolean);
108
+ if (!allowed.length) return fallback;
109
+ let url;
110
+ try {
111
+ url = new URL(value);
112
+ } catch {
113
+ return fallback;
114
+ }
115
+ if (!allowed.includes(url.origin)) return fallback;
116
+ if (url.protocol !== "http:" && url.protocol !== "https:") return fallback;
117
+ return url.toString();
118
+ }
119
+
120
+ // src/types.ts
121
+ var OAuthError = class extends Error {
122
+ constructor(message, code, description) {
123
+ super(message);
124
+ this.code = code;
125
+ this.description = description;
126
+ this.name = "OAuthError";
127
+ }
128
+ };
129
+
130
+ // src/client-auth.ts
131
+ var CANONICAL = {
132
+ basic: "client_secret_basic",
133
+ body: "client_secret_post",
134
+ client_secret_basic: "client_secret_basic",
135
+ client_secret_post: "client_secret_post",
136
+ private_key_jwt: "private_key_jwt",
137
+ none: "none"
138
+ };
139
+ function resolveMethod(auth) {
140
+ if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
141
+ if (auth.privateKey) return "private_key_jwt";
142
+ return auth.clientSecret ? "client_secret_basic" : "none";
143
+ }
144
+ async function loadJose() {
145
+ try {
146
+ return await import("jose");
147
+ } catch {
148
+ throw new OAuthError(
149
+ "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.",
150
+ "jose_missing"
151
+ );
152
+ }
153
+ }
154
+ async function signAssertion(auth, endpoint) {
155
+ const jose = await loadJose();
156
+ const alg = auth.assertionAlgorithm ?? "RS256";
157
+ const key = await jose.importJWK(auth.privateKey, alg);
158
+ const kid = auth.keyId ?? auth.privateKey.kid;
159
+ return new jose.SignJWT({}).setProtectedHeader(kid ? { alg, kid } : { alg }).setIssuer(auth.clientId).setSubject(auth.clientId).setAudience(endpoint).setJti(crypto.randomUUID()).setIssuedAt().setExpirationTime("1m").sign(key);
160
+ }
161
+ async function applyClientAuth(auth, endpoint, headers, form) {
162
+ switch (resolveMethod(auth)) {
163
+ case "client_secret_basic": {
164
+ const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
165
+ headers.authorization = `Basic ${btoa(pair)}`;
166
+ break;
167
+ }
168
+ case "client_secret_post":
169
+ form.set("client_id", auth.clientId);
170
+ form.set("client_secret", auth.clientSecret);
171
+ break;
172
+ case "private_key_jwt":
173
+ form.set("client_id", auth.clientId);
174
+ form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
175
+ form.set("client_assertion", await signAssertion(auth, endpoint));
176
+ break;
177
+ case "none":
178
+ form.set("client_id", auth.clientId);
179
+ break;
180
+ }
181
+ }
182
+
183
+ // src/dpop.ts
184
+ async function loadJose2() {
185
+ try {
186
+ return await import("jose");
187
+ } catch {
188
+ throw new OAuthError(
189
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
190
+ "jose_missing"
191
+ );
192
+ }
193
+ }
194
+ async function createDpopKey(alg = "ES256") {
195
+ const jose = await loadJose2();
196
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
197
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
198
+ return {
199
+ privateKey,
200
+ publicJwk,
201
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
202
+ alg
203
+ };
204
+ }
205
+ async function proofFor(key, options) {
206
+ const jose = await loadJose2();
207
+ const htu = (() => {
208
+ try {
209
+ const url = new URL(options.url);
210
+ url.search = "";
211
+ url.hash = "";
212
+ return url.toString();
213
+ } catch {
214
+ return options.url;
215
+ }
216
+ })();
217
+ const claims = {
218
+ htm: options.method.toUpperCase(),
219
+ htu,
220
+ jti: crypto.randomUUID()
221
+ };
222
+ if (options.accessToken) {
223
+ const digest = await crypto.subtle.digest(
224
+ "SHA-256",
225
+ new TextEncoder().encode(options.accessToken)
226
+ );
227
+ let binary = "";
228
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
229
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
230
+ }
231
+ if (options.nonce) claims.nonce = options.nonce;
232
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
233
+ }
234
+ function nonceFrom(response) {
235
+ return response.headers.get("dpop-nonce");
236
+ }
237
+
238
+ // src/dpop-store.ts
239
+ var DB = "fluixi-oauth2-dpop";
240
+ var STORE = "keys";
241
+ function available() {
242
+ return typeof indexedDB !== "undefined";
243
+ }
244
+ function open() {
245
+ return new Promise((resolve, reject) => {
246
+ const request = indexedDB.open(DB, 1);
247
+ request.onupgradeneeded = () => {
248
+ const db = request.result;
249
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
250
+ };
251
+ request.onsuccess = () => resolve(request.result);
252
+ request.onerror = () => reject(request.error ?? new Error("indexedDB.open failed"));
253
+ });
254
+ }
255
+ function act(mode, run) {
256
+ return open().then(
257
+ (db) => new Promise((resolve, reject) => {
258
+ const tx = db.transaction(STORE, mode);
259
+ const request = run(tx.objectStore(STORE));
260
+ request.onsuccess = () => resolve(request.result);
261
+ request.onerror = () => reject(request.error ?? new Error("DPoP key request failed"));
262
+ tx.oncomplete = () => db.close();
263
+ })
264
+ );
265
+ }
266
+ function usable(value) {
267
+ const stored = value;
268
+ return !!stored && typeof stored.thumbprint === "string" && typeof stored.alg === "string" && !!stored.publicJwk && // A CryptoKey survives the round trip as a CryptoKey. Anything else means the record was
269
+ // written by something other than this, and binding to it would fail at the provider.
270
+ typeof stored.privateKey?.type === "string";
271
+ }
272
+ async function keyFor(clientKey, alg = "ES256", onEphemeral) {
273
+ if (!available()) {
274
+ onEphemeral?.("This environment has no IndexedDB, so the key lasts only as long as this document.");
275
+ return createDpopKey(alg);
276
+ }
277
+ try {
278
+ const found = await act("readonly", (store2) => store2.get(clientKey));
279
+ if (usable(found) && found.alg === alg) {
280
+ return {
281
+ privateKey: found.privateKey,
282
+ publicJwk: found.publicJwk,
283
+ thumbprint: found.thumbprint,
284
+ alg: found.alg
285
+ };
286
+ }
287
+ } catch (cause) {
288
+ onEphemeral?.(
289
+ `The stored DPoP key could not be read (${cause instanceof Error ? cause.message : String(cause)}), so a new one was made.`
290
+ );
291
+ }
292
+ const key = await createDpopKey(alg);
293
+ try {
294
+ await act(
295
+ "readwrite",
296
+ (store2) => store2.put(
297
+ {
298
+ privateKey: key.privateKey,
299
+ publicJwk: key.publicJwk,
300
+ thumbprint: key.thumbprint,
301
+ alg: key.alg
302
+ },
303
+ clientKey
304
+ )
305
+ );
306
+ } catch (cause) {
307
+ onEphemeral?.(
308
+ `The DPoP key could not be stored (${cause instanceof Error ? cause.message : String(cause)}), so a refresh after the next navigation will be refused.`
309
+ );
310
+ }
311
+ return key;
312
+ }
313
+ async function forgetKey(clientKey) {
314
+ if (!available()) return;
315
+ try {
316
+ await act("readwrite", (store2) => store2.delete(clientKey));
317
+ } catch {
318
+ }
319
+ }
320
+
321
+ // src/discovery.ts
322
+ var cache = /* @__PURE__ */ new Map();
323
+ function documentUrl(issuer) {
324
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
325
+ }
326
+ async function discover(issuer, fetcher = fetch) {
327
+ const cached = cache.get(issuer);
328
+ if (cached) return cached;
329
+ const pending = (async () => {
330
+ let response;
331
+ try {
332
+ response = await fetcher(documentUrl(issuer));
333
+ } catch (cause) {
334
+ throw new OAuthError(
335
+ `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)})`,
336
+ "discovery_unreachable"
337
+ );
338
+ }
339
+ if (!response.ok) {
340
+ throw new OAuthError(
341
+ `Discovery failed for ${issuer}: ${response.status}`,
342
+ "discovery_failed"
343
+ );
344
+ }
345
+ const doc = await response.json();
346
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
347
+ throw new OAuthError(
348
+ `${issuer} published a discovery document with no authorization or token endpoint.`,
349
+ "discovery_incomplete"
350
+ );
351
+ }
352
+ return {
353
+ authorization: doc.authorization_endpoint,
354
+ token: doc.token_endpoint,
355
+ userinfo: doc.userinfo_endpoint,
356
+ endSession: doc.end_session_endpoint,
357
+ jwks: doc.jwks_uri,
358
+ introspection: doc.introspection_endpoint,
359
+ revocation: doc.revocation_endpoint,
360
+ issuer: doc.issuer ?? issuer
361
+ };
362
+ })();
363
+ cache.set(issuer, pending);
364
+ pending.catch(() => cache.delete(issuer));
365
+ return pending;
366
+ }
367
+
368
+ // src/browser.ts
369
+ var keysFor = (id) => ({
370
+ verifier: `fx.oauth2.${id}.verifier`,
371
+ state: `fx.oauth2.${id}.state`,
372
+ returnTo: `fx.oauth2.${id}.return`,
373
+ refresh: `fx.oauth2.${id}.refresh`,
374
+ /** Names the record in IndexedDB, not a `Storage` entry: a key is never a string here. */
375
+ dpop: `fx.oauth2.${id}.dpop`
376
+ });
377
+ var DEFAULT_SCOPES = ["openid", "profile", "email"];
378
+ function store(kind) {
379
+ if (typeof window === "undefined") return null;
380
+ try {
381
+ return kind === "local" ? window.localStorage : window.sessionStorage;
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+ function createBrowserClient(config, fetcher = fetch) {
387
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
388
+ const KEY = keysFor(config.storageKey ?? config.clientId);
389
+ const flow = () => store(config.storage ?? "session");
390
+ const durable = () => store(config.storage ?? "session");
391
+ let access = null;
392
+ let dpopKey = null;
393
+ let dpopPending = null;
394
+ let dpopNonce = null;
395
+ const keyForDpop = async () => {
396
+ if (!config.dpop) return null;
397
+ if (dpopKey) return dpopKey;
398
+ dpopPending ??= keyFor(KEY.dpop, config.dpopAlgorithm, (reason) => {
399
+ console.warn(`[fluixi/oauth2] ${reason}`);
400
+ }).then((key) => {
401
+ dpopKey = key;
402
+ return key;
403
+ });
404
+ return dpopPending;
405
+ };
406
+ let endpoints = config.endpoints ?? null;
407
+ async function resolveEndpoints() {
408
+ if (endpoints) return endpoints;
409
+ if (!config.issuer) {
410
+ throw new OAuthError(
411
+ "Give the client an `issuer` to discover from, or `endpoints` directly.",
412
+ "missing_issuer"
413
+ );
414
+ }
415
+ endpoints = await discover(config.issuer, fetcher);
416
+ return endpoints;
417
+ }
418
+ async function exchange(body) {
419
+ const { token } = await resolveEndpoints();
420
+ const key = await keyForDpop();
421
+ const headers = {
422
+ "content-type": "application/x-www-form-urlencoded"
423
+ };
424
+ const form = new URLSearchParams(body);
425
+ form.delete("client_id");
426
+ await applyClientAuth(
427
+ { clientId: config.clientId, method: config.tokenAuthMethod ?? "none", clientSecret: "" },
428
+ token,
429
+ headers,
430
+ form
431
+ );
432
+ const send = async () => {
433
+ if (key) {
434
+ headers.dpop = await proofFor(key, {
435
+ method: "POST",
436
+ url: token,
437
+ nonce: dpopNonce ?? void 0
438
+ });
439
+ }
440
+ return fetcher(token, { method: "POST", headers, body: form.toString() });
441
+ };
442
+ let response = await send();
443
+ if (key && !response.ok) {
444
+ const offered = nonceFrom(response);
445
+ if (offered && offered !== dpopNonce) {
446
+ dpopNonce = offered;
447
+ response = await send();
448
+ }
449
+ }
450
+ if (key) dpopNonce = nonceFrom(response) ?? dpopNonce;
451
+ const payload = await response.json().catch(() => ({}));
452
+ if (!response.ok) {
453
+ const code = String(payload.error ?? "token_request_failed");
454
+ const description = typeof payload.error_description === "string" ? payload.error_description : void 0;
455
+ const hint = (code === "invalid_dpop_proof" || code === "use_dpop_nonce") && !config.dpop ? " This provider requires DPoP (RFC 9449), which binds a token to a key this client holds. Set `dpop: true` on the config." : "";
456
+ throw new OAuthError(
457
+ `Token request failed: ${[code, description].filter(Boolean).join(": ")}${hint}`,
458
+ code,
459
+ description
460
+ );
461
+ }
462
+ const accessToken = String(payload.access_token ?? "");
463
+ if (!accessToken) {
464
+ throw new OAuthError("The token response carried no access_token.", "no_access_token");
465
+ }
466
+ const tokens = {
467
+ accessToken,
468
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
469
+ idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
470
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
471
+ scope: typeof payload.scope === "string" ? payload.scope : void 0,
472
+ // `expires_in` is what the server said; the JWT's own `exp` is the fallback for a
473
+ // server that omits it.
474
+ expiresAt: expiryFrom(payload.expires_in) ?? expiryFromJwt(accessToken) ?? void 0
475
+ };
476
+ access = tokens;
477
+ if (tokens.refreshToken) durable()?.setItem(KEY.refresh, tokens.refreshToken);
478
+ return tokens;
479
+ }
480
+ return {
481
+ async login(options = {}) {
482
+ const { authorization } = await resolveEndpoints();
483
+ const { verifier, challenge } = await createPkcePair();
484
+ const state = createStateValue();
485
+ const storage = flow();
486
+ storage?.setItem(KEY.verifier, verifier);
487
+ storage?.setItem(KEY.state, state);
488
+ storage?.setItem(
489
+ KEY.returnTo,
490
+ safeReturnTo(options.returnTo ?? `${location.pathname}${location.search}`, {
491
+ fallback: "/",
492
+ allowedOrigins: config.allowedReturnOrigins
493
+ })
494
+ );
495
+ const url = new URL(authorization);
496
+ url.searchParams.set("response_type", "code");
497
+ url.searchParams.set("client_id", config.clientId);
498
+ url.searchParams.set("redirect_uri", config.redirectUri);
499
+ url.searchParams.set("scope", scopes.join(" "));
500
+ url.searchParams.set("state", state);
501
+ url.searchParams.set("code_challenge", challenge);
502
+ url.searchParams.set("code_challenge_method", "S256");
503
+ for (const [key, value] of Object.entries({
504
+ ...config.extraParams,
505
+ ...options.params
506
+ })) {
507
+ url.searchParams.set(key, value);
508
+ }
509
+ location.assign(url.toString());
510
+ },
511
+ async handleCallback(params) {
512
+ if (params.error) {
513
+ throw new OAuthError(
514
+ params.errorDescription ?? params.error,
515
+ params.error,
516
+ params.errorDescription
517
+ );
518
+ }
519
+ if (!params.code) {
520
+ throw new OAuthError("The callback carried no authorization code.", "no_code");
521
+ }
522
+ const storage = flow();
523
+ const expected = storage?.getItem(KEY.state) ?? null;
524
+ const verifier = storage?.getItem(KEY.verifier) ?? null;
525
+ storage?.removeItem(KEY.state);
526
+ storage?.removeItem(KEY.verifier);
527
+ if (!expected || !params.state || !timingSafeEqual(expected, params.state)) {
528
+ throw new OAuthError(
529
+ "The callback state did not match the one this client sent.",
530
+ "state_mismatch"
531
+ );
532
+ }
533
+ if (!verifier) {
534
+ throw new OAuthError(
535
+ "No PKCE verifier for this callback. The login was started elsewhere, or storage was cleared.",
536
+ "missing_verifier"
537
+ );
538
+ }
539
+ return exchange({
540
+ grant_type: "authorization_code",
541
+ code: params.code,
542
+ redirect_uri: config.redirectUri,
543
+ client_id: config.clientId,
544
+ code_verifier: verifier
545
+ });
546
+ },
547
+ async refresh() {
548
+ const token = access?.refreshToken ?? durable()?.getItem(KEY.refresh);
549
+ if (!token) {
550
+ throw new OAuthError("No refresh token, so the session cannot be extended.", "no_refresh_token");
551
+ }
552
+ try {
553
+ return await exchange({
554
+ grant_type: "refresh_token",
555
+ refresh_token: token,
556
+ client_id: config.clientId
557
+ });
558
+ } catch (error) {
559
+ if (error instanceof OAuthError && error.code === "invalid_grant") {
560
+ durable()?.removeItem(KEY.refresh);
561
+ access = null;
562
+ }
563
+ throw error;
564
+ }
565
+ },
566
+ async fetchUser(accessToken) {
567
+ const custom = config.userInfo;
568
+ const bearer = accessToken ?? access?.accessToken;
569
+ if (!bearer) throw new OAuthError("No access token to call userinfo with.", "no_access_token");
570
+ let endpoint = custom?.url;
571
+ if (!endpoint) {
572
+ const { userinfo } = await resolveEndpoints();
573
+ if (!userinfo) {
574
+ throw new OAuthError(
575
+ "The provider published no userinfo endpoint. Give `userInfo.url`, or the service its own `fetchUser`.",
576
+ "no_userinfo"
577
+ );
578
+ }
579
+ endpoint = userinfo;
580
+ }
581
+ const key = await keyForDpop();
582
+ const headers = {
583
+ // A bound token is presented with the DPoP scheme, not Bearer: a service checking
584
+ // binding refuses `Bearer` outright, since that is the word for an unbound token.
585
+ authorization: `${key ? "DPoP" : "Bearer"} ${bearer}`,
586
+ accept: "application/json",
587
+ ...custom?.headers
588
+ };
589
+ if (key) {
590
+ headers.dpop = await proofFor(key, {
591
+ method: custom?.method ?? "GET",
592
+ url: endpoint,
593
+ // `ath` binds the proof to this token, so one captured beside it cannot be
594
+ // reused with another.
595
+ accessToken: bearer,
596
+ nonce: dpopNonce ?? void 0
597
+ });
598
+ }
599
+ const response = await fetcher(endpoint, {
600
+ method: custom?.method ?? "GET",
601
+ headers,
602
+ ...custom?.credentials ? { credentials: custom.credentials } : {}
603
+ });
604
+ if (!response.ok) {
605
+ const reason = response.headers.get("www-authenticate")?.match(/error="([^"]+)"/)?.[1];
606
+ throw new OAuthError(
607
+ `The user endpoint refused the token: ${reason ?? response.status}`,
608
+ reason ?? "userinfo_failed"
609
+ );
610
+ }
611
+ const type = response.headers.get("content-type") ?? "";
612
+ if (type.includes("application/jwt")) {
613
+ throw new OAuthError(
614
+ "This provider returns a signed userinfo response, which needs its own verification. Give the service its own `fetchUser`.",
615
+ "userinfo_signed"
616
+ );
617
+ }
618
+ const raw = await response.json();
619
+ if (!custom?.url) {
620
+ const expected = access?.idToken ? claimsOf(access.idToken)?.sub : void 0;
621
+ if (expected !== void 0 && raw.sub !== expected) {
622
+ throw new OAuthError(
623
+ "The userinfo subject does not match the id token. The response was discarded.",
624
+ "sub_mismatch"
625
+ );
626
+ }
627
+ }
628
+ return custom?.map ? await custom.map(raw, bearer) : raw;
629
+ },
630
+ /**
631
+ * Retire a token at the provider, per RFC 7009.
632
+ *
633
+ * Defaults to the refresh token, which is the one worth retiring: an access token
634
+ * expires on its own in minutes, a refresh token does not. A provider with no
635
+ * revocation endpoint resolves without doing anything, because there is nothing this
636
+ * can do about that and failing a logout over it helps nobody.
637
+ */
638
+ async revoke(token, hint = "refresh_token") {
639
+ const target = token ?? (hint === "refresh_token" ? access?.refreshToken ?? durable()?.getItem(KEY.refresh) ?? void 0 : access?.accessToken);
640
+ if (!target) {
641
+ throw new OAuthError(
642
+ `No ${hint.replace("_", " ")} held by this client, so there is nothing to revoke. One is stored when the provider issues it, and cleared when the provider rejects it.`,
643
+ "nothing_to_revoke"
644
+ );
645
+ }
646
+ const { revocation } = await resolveEndpoints();
647
+ if (!revocation) {
648
+ throw new OAuthError(
649
+ "This provider publishes no revocation endpoint, so a token cannot be retired before it expires.",
650
+ "no_revocation_endpoint"
651
+ );
652
+ }
653
+ const headers = {
654
+ "content-type": "application/x-www-form-urlencoded"
655
+ };
656
+ const form = new URLSearchParams({ token: target, token_type_hint: hint });
657
+ await applyClientAuth(
658
+ { clientId: config.clientId, method: config.tokenAuthMethod ?? "none", clientSecret: "" },
659
+ revocation,
660
+ headers,
661
+ form
662
+ );
663
+ const response = await fetcher(revocation, {
664
+ method: "POST",
665
+ headers,
666
+ body: form.toString()
667
+ });
668
+ if (!response.ok) {
669
+ const payload = await response.json().catch(() => ({}));
670
+ throw new OAuthError(
671
+ `Revocation was refused: ${[payload.error, payload.error_description].filter(Boolean).join(": ") || response.status}`,
672
+ String(payload.error ?? "revocation_failed")
673
+ );
674
+ }
675
+ if (hint === "refresh_token") {
676
+ durable()?.removeItem(KEY.refresh);
677
+ if (access) access = { ...access, refreshToken: void 0 };
678
+ }
679
+ },
680
+ async logout(options = {}) {
681
+ const idToken = access?.idToken;
682
+ await this.revoke?.().catch(() => void 0);
683
+ access = null;
684
+ durable()?.removeItem(KEY.refresh);
685
+ dpopKey = null;
686
+ dpopPending = null;
687
+ await forgetKey(KEY.dpop);
688
+ const { endSession } = await resolveEndpoints().catch(() => ({ endSession: void 0 }));
689
+ const target = options.returnTo ? safeReturnTo(options.returnTo, {
690
+ fallback: config.postLogoutRedirectUri ?? "/",
691
+ allowedOrigins: config.allowedReturnOrigins
692
+ }) : config.postLogoutRedirectUri;
693
+ if (!endSession) {
694
+ if (target && typeof location !== "undefined") location.assign(target);
695
+ return;
696
+ }
697
+ const url = new URL(endSession);
698
+ if (target) url.searchParams.set("post_logout_redirect_uri", target);
699
+ if (idToken) url.searchParams.set("id_token_hint", idToken);
700
+ url.searchParams.set("client_id", config.clientId);
701
+ location.assign(url.toString());
702
+ },
703
+ async restore() {
704
+ if (access) return access;
705
+ if (!durable()?.getItem(KEY.refresh)) return null;
706
+ try {
707
+ return await this.refresh();
708
+ } catch {
709
+ return null;
710
+ }
711
+ },
712
+ /**
713
+ * A proof for a request a caller will send with this client's token.
714
+ *
715
+ * Null when the client does not bind its tokens, so a caller can send `Bearer`
716
+ * unchanged. Only this client holds the key, so nothing else can produce one.
717
+ */
718
+ async dpopProof(method, url) {
719
+ const key = await keyForDpop();
720
+ if (!key) return null;
721
+ return proofFor(key, {
722
+ method,
723
+ url,
724
+ accessToken: access?.accessToken,
725
+ nonce: dpopNonce ?? void 0
726
+ });
727
+ },
728
+ consumeReturnTo() {
729
+ const storage = flow();
730
+ const value = storage?.getItem(KEY.returnTo) ?? null;
731
+ storage?.removeItem(KEY.returnTo);
732
+ return value === null ? null : safeReturnTo(value, {
733
+ fallback: "/",
734
+ allowedOrigins: config.allowedReturnOrigins
735
+ });
736
+ }
737
+ };
738
+ }
739
+
740
+ // src/server-client.ts
741
+ var COOKIE_SESSION = "cookie";
742
+ function createServerClient(config, fetcher = fetch) {
743
+ const base = (config.basePath ?? "/auth").replace(/\/$/, "");
744
+ const url = (path) => `${base}${path}`;
745
+ const call = (path, init = {}) => fetcher(url(path), { credentials: "same-origin", ...init });
746
+ return {
747
+ async login(options = {}) {
748
+ const target = new URL(url("/login"), location.origin);
749
+ if (options.returnTo) target.searchParams.set("returnTo", options.returnTo);
750
+ for (const [key, value] of Object.entries(options.params ?? {})) {
751
+ target.searchParams.set(key, value);
752
+ }
753
+ location.assign(target.toString());
754
+ },
755
+ async handleCallback(params) {
756
+ const response = await call("/callback", {
757
+ method: "POST",
758
+ headers: { "content-type": "application/json" },
759
+ body: JSON.stringify(params)
760
+ });
761
+ if (!response.ok) {
762
+ const body = await response.json().catch(() => ({}));
763
+ throw new OAuthError(
764
+ String(body.error ?? `Callback failed: ${response.status}`),
765
+ String(body.error ?? "callback_failed")
766
+ );
767
+ }
768
+ return { accessToken: COOKIE_SESSION };
769
+ },
770
+ async refresh() {
771
+ const response = await call("/refresh", { method: "POST" });
772
+ if (!response.ok) {
773
+ throw new OAuthError("The server could not extend the session.", "refresh_failed");
774
+ }
775
+ return { accessToken: COOKIE_SESSION };
776
+ },
777
+ async fetchUser() {
778
+ const custom = config.userInfo;
779
+ const response = custom?.url ? await fetcher(custom.url, {
780
+ method: custom.method ?? "GET",
781
+ credentials: custom.credentials ?? "same-origin",
782
+ headers: { accept: "application/json", ...custom.headers }
783
+ }) : await call("/me");
784
+ if (!response.ok) {
785
+ throw new OAuthError(`The server refused the user request: ${response.status}`, "me_failed");
786
+ }
787
+ const raw = await response.json();
788
+ return custom?.map ? await custom.map(raw, COOKIE_SESSION) : raw;
789
+ },
790
+ /**
791
+ * Retire the session's tokens, which only the server can do here.
792
+ *
793
+ * The browser holds a cookie and no token, so it has nothing to send to a revocation
794
+ * endpoint. This asks the server's own route, which opens the cookie and revokes what
795
+ * is inside.
796
+ */
797
+ async revoke(_token, hint = "refresh_token") {
798
+ await call(`/revoke?hint=${hint}`, { method: "POST" }).catch(() => void 0);
799
+ },
800
+ async logout(options = {}) {
801
+ await call("/logout", { method: "POST" }).catch(() => void 0);
802
+ const target = options.returnTo ? safeReturnTo(options.returnTo, {
803
+ fallback: config.postLogoutRedirectUri ?? "/",
804
+ allowedOrigins: config.allowedReturnOrigins
805
+ }) : config.postLogoutRedirectUri ?? "/";
806
+ if (typeof location !== "undefined") location.assign(target);
807
+ },
808
+ async restore() {
809
+ const response = await call("/me");
810
+ return response.ok ? { accessToken: COOKIE_SESSION } : null;
811
+ }
812
+ };
813
+ }
814
+
815
+ // src/service.ts
816
+ function paramsFromUrl(url) {
817
+ const href = url ?? (typeof location === "undefined" ? null : location.href);
818
+ if (!href) return {};
819
+ const { searchParams } = new URL(String(href));
820
+ return {
821
+ code: searchParams.get("code") ?? void 0,
822
+ state: searchParams.get("state") ?? void 0,
823
+ error: searchParams.get("error") ?? void 0,
824
+ errorDescription: searchParams.get("error_description") ?? void 0
825
+ };
826
+ }
827
+ function clientFor(options) {
828
+ if (options.client) return options.client;
829
+ return options.config.tokens === "server" ? createServerClient(options.config) : createBrowserClient(options.config);
830
+ }
831
+ function createAuthService(options) {
832
+ const client = clientFor(options);
833
+ let latest = null;
834
+ const session = (0, import_session.createSession)({
835
+ /** The credential is the callback, so this is the code exchange. */
836
+ async login(params) {
837
+ latest = await client.handleCallback(params);
838
+ return { token: latest.accessToken, expiresAt: latest.expiresAt };
839
+ },
840
+ async fetchUser(token) {
841
+ if (options.fetchUser) return options.fetchUser(token);
842
+ return client.fetchUser(token === COOKIE_SESSION ? void 0 : token);
843
+ },
844
+ async refresh() {
845
+ latest = await client.refresh();
846
+ return { token: latest.accessToken, expiresAt: latest.expiresAt };
847
+ },
848
+ async logout() {
849
+ latest = null;
850
+ await client.logout();
851
+ },
852
+ getExpiry(token) {
853
+ if (token === COOKIE_SESSION) return null;
854
+ return latest?.expiresAt ?? expiryFromJwt(token);
855
+ },
856
+ access: options.access,
857
+ storageKeys: options.storageKeys,
858
+ refreshSkewMs: options.refreshSkewMs,
859
+ onLogin: options.onLogin,
860
+ onLogout: options.onLogout,
861
+ onExpired: options.onExpired,
862
+ onError: options.onError
863
+ });
864
+ const hooks = (0, import_client.createAuthHooks)({
865
+ session,
866
+ navigate: options.navigate,
867
+ loginPath: options.loginPath ?? "/login",
868
+ homePath: options.homePath ?? "/",
869
+ forbiddenPath: options.forbiddenPath ?? "/403"
870
+ });
871
+ return Object.assign(session, {
872
+ client,
873
+ protectedRoute: hooks.protectedRoute,
874
+ Can: hooks.Can,
875
+ Protect: hooks.Protect,
876
+ useAuth: hooks.useAuth,
877
+ useUser: hooks.useUser,
878
+ useCan: hooks.useCan,
879
+ signIn(loginOptions = {}) {
880
+ return client.login(loginOptions);
881
+ },
882
+ /**
883
+ * Finish a login and say where to go.
884
+ *
885
+ * The destination is checked before it is handed back, so an app that navigates to it
886
+ * without looking cannot be turned into an open redirect by a poisoned login link.
887
+ */
888
+ async complete(params) {
889
+ const callback = params ?? paramsFromUrl();
890
+ if (!callback.code && !callback.error) {
891
+ throw new OAuthError(
892
+ "complete() found no callback parameters. It runs in the browser, on the redirect URI, where the code and the PKCE verifier both are.",
893
+ "no_callback_params"
894
+ );
895
+ }
896
+ await session.login(callback);
897
+ return safeReturnTo(client.consumeReturnTo?.(), {
898
+ fallback: "/",
899
+ allowedOrigins: "allowedReturnOrigins" in options.config ? options.config.allowedReturnOrigins : void 0
900
+ });
901
+ }
902
+ });
903
+ }