@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,704 @@
1
+ import "./chunk-7P6ASYW6.mjs";
2
+
3
+ // src/pkce.ts
4
+ function base64url(bytes) {
5
+ let binary = "";
6
+ for (const byte of bytes) binary += String.fromCharCode(byte);
7
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+ function randomBytes(length) {
10
+ const bytes = new Uint8Array(length);
11
+ crypto.getRandomValues(bytes);
12
+ return bytes;
13
+ }
14
+ function createVerifier() {
15
+ return base64url(randomBytes(32));
16
+ }
17
+ function createStateValue() {
18
+ return base64url(randomBytes(16));
19
+ }
20
+ async function createChallenge(verifier) {
21
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
22
+ return base64url(new Uint8Array(digest));
23
+ }
24
+ async function createPkcePair() {
25
+ const verifier = createVerifier();
26
+ return { verifier, challenge: await createChallenge(verifier) };
27
+ }
28
+ function timingSafeEqual(a, b) {
29
+ if (a.length !== b.length) return false;
30
+ let diff = 0;
31
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
32
+ return diff === 0;
33
+ }
34
+ function expiryFrom(expiresIn, now = Date.now()) {
35
+ const seconds = typeof expiresIn === "number" ? expiresIn : Number(expiresIn);
36
+ return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
37
+ }
38
+ function claimsOf(token) {
39
+ const parts = token.split(".");
40
+ if (parts.length < 2) return null;
41
+ try {
42
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
43
+ return JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, "=")));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ function expiryFromJwt(token) {
49
+ const parts = token.split(".");
50
+ if (parts.length < 2) return null;
51
+ try {
52
+ const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
53
+ const json = JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, "=")));
54
+ return typeof json.exp === "number" ? json.exp * 1e3 : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ // src/types.ts
61
+ var OAuthError = class extends Error {
62
+ constructor(message, code, description) {
63
+ super(message);
64
+ this.code = code;
65
+ this.description = description;
66
+ this.name = "OAuthError";
67
+ }
68
+ };
69
+
70
+ // src/client-auth.ts
71
+ var CANONICAL = {
72
+ basic: "client_secret_basic",
73
+ body: "client_secret_post",
74
+ client_secret_basic: "client_secret_basic",
75
+ client_secret_post: "client_secret_post",
76
+ private_key_jwt: "private_key_jwt",
77
+ none: "none"
78
+ };
79
+ function resolveMethod(auth) {
80
+ if (auth.method) return CANONICAL[auth.method] ?? "client_secret_basic";
81
+ if (auth.privateKey) return "private_key_jwt";
82
+ return auth.clientSecret ? "client_secret_basic" : "none";
83
+ }
84
+ async function loadJose() {
85
+ try {
86
+ return await import("./browser-FDUOEUM2.mjs");
87
+ } catch {
88
+ throw new OAuthError(
89
+ "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.",
90
+ "jose_missing"
91
+ );
92
+ }
93
+ }
94
+ async function signAssertion(auth, endpoint) {
95
+ const jose = await loadJose();
96
+ const alg = auth.assertionAlgorithm ?? "RS256";
97
+ const key = await jose.importJWK(auth.privateKey, alg);
98
+ const kid = auth.keyId ?? auth.privateKey.kid;
99
+ 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);
100
+ }
101
+ async function applyClientAuth(auth, endpoint, headers, form) {
102
+ switch (resolveMethod(auth)) {
103
+ case "client_secret_basic": {
104
+ const pair = `${encodeURIComponent(auth.clientId)}:${encodeURIComponent(auth.clientSecret)}`;
105
+ headers.authorization = `Basic ${btoa(pair)}`;
106
+ break;
107
+ }
108
+ case "client_secret_post":
109
+ form.set("client_id", auth.clientId);
110
+ form.set("client_secret", auth.clientSecret);
111
+ break;
112
+ case "private_key_jwt":
113
+ form.set("client_id", auth.clientId);
114
+ form.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
115
+ form.set("client_assertion", await signAssertion(auth, endpoint));
116
+ break;
117
+ case "none":
118
+ form.set("client_id", auth.clientId);
119
+ break;
120
+ }
121
+ }
122
+
123
+ // src/dpop.ts
124
+ async function loadJose2() {
125
+ try {
126
+ return await import("./browser-FDUOEUM2.mjs");
127
+ } catch {
128
+ throw new OAuthError(
129
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
130
+ "jose_missing"
131
+ );
132
+ }
133
+ }
134
+ async function createDpopKey(alg = "ES256") {
135
+ const jose = await loadJose2();
136
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
137
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
138
+ return {
139
+ privateKey,
140
+ publicJwk,
141
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
142
+ alg
143
+ };
144
+ }
145
+ async function proofFor(key, options) {
146
+ const jose = await loadJose2();
147
+ const htu = (() => {
148
+ try {
149
+ const url = new URL(options.url);
150
+ url.search = "";
151
+ url.hash = "";
152
+ return url.toString();
153
+ } catch {
154
+ return options.url;
155
+ }
156
+ })();
157
+ const claims = {
158
+ htm: options.method.toUpperCase(),
159
+ htu,
160
+ jti: crypto.randomUUID()
161
+ };
162
+ if (options.accessToken) {
163
+ const digest = await crypto.subtle.digest(
164
+ "SHA-256",
165
+ new TextEncoder().encode(options.accessToken)
166
+ );
167
+ let binary = "";
168
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
169
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
170
+ }
171
+ if (options.nonce) claims.nonce = options.nonce;
172
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
173
+ }
174
+ function nonceFrom(response) {
175
+ return response.headers.get("dpop-nonce");
176
+ }
177
+
178
+ // src/dpop-store.ts
179
+ var DB = "fluixi-oauth2-dpop";
180
+ var STORE = "keys";
181
+ function available() {
182
+ return typeof indexedDB !== "undefined";
183
+ }
184
+ function open() {
185
+ return new Promise((resolve, reject) => {
186
+ const request = indexedDB.open(DB, 1);
187
+ request.onupgradeneeded = () => {
188
+ const db = request.result;
189
+ if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
190
+ };
191
+ request.onsuccess = () => resolve(request.result);
192
+ request.onerror = () => reject(request.error ?? new Error("indexedDB.open failed"));
193
+ });
194
+ }
195
+ function act(mode, run) {
196
+ return open().then(
197
+ (db) => new Promise((resolve, reject) => {
198
+ const tx = db.transaction(STORE, mode);
199
+ const request = run(tx.objectStore(STORE));
200
+ request.onsuccess = () => resolve(request.result);
201
+ request.onerror = () => reject(request.error ?? new Error("DPoP key request failed"));
202
+ tx.oncomplete = () => db.close();
203
+ })
204
+ );
205
+ }
206
+ function usable(value) {
207
+ const stored = value;
208
+ 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
209
+ // written by something other than this, and binding to it would fail at the provider.
210
+ typeof stored.privateKey?.type === "string";
211
+ }
212
+ async function keyFor(clientKey, alg = "ES256", onEphemeral) {
213
+ if (!available()) {
214
+ onEphemeral?.("This environment has no IndexedDB, so the key lasts only as long as this document.");
215
+ return createDpopKey(alg);
216
+ }
217
+ try {
218
+ const found = await act("readonly", (store2) => store2.get(clientKey));
219
+ if (usable(found) && found.alg === alg) {
220
+ return {
221
+ privateKey: found.privateKey,
222
+ publicJwk: found.publicJwk,
223
+ thumbprint: found.thumbprint,
224
+ alg: found.alg
225
+ };
226
+ }
227
+ } catch (cause) {
228
+ onEphemeral?.(
229
+ `The stored DPoP key could not be read (${cause instanceof Error ? cause.message : String(cause)}), so a new one was made.`
230
+ );
231
+ }
232
+ const key = await createDpopKey(alg);
233
+ try {
234
+ await act(
235
+ "readwrite",
236
+ (store2) => store2.put(
237
+ {
238
+ privateKey: key.privateKey,
239
+ publicJwk: key.publicJwk,
240
+ thumbprint: key.thumbprint,
241
+ alg: key.alg
242
+ },
243
+ clientKey
244
+ )
245
+ );
246
+ } catch (cause) {
247
+ onEphemeral?.(
248
+ `The DPoP key could not be stored (${cause instanceof Error ? cause.message : String(cause)}), so a refresh after the next navigation will be refused.`
249
+ );
250
+ }
251
+ return key;
252
+ }
253
+ async function forgetKey(clientKey) {
254
+ if (!available()) return;
255
+ try {
256
+ await act("readwrite", (store2) => store2.delete(clientKey));
257
+ } catch {
258
+ }
259
+ }
260
+
261
+ // src/discovery.ts
262
+ var cache = /* @__PURE__ */ new Map();
263
+ function documentUrl(issuer) {
264
+ return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
265
+ }
266
+ async function discover(issuer, fetcher = fetch) {
267
+ const cached = cache.get(issuer);
268
+ if (cached) return cached;
269
+ const pending = (async () => {
270
+ let response;
271
+ try {
272
+ response = await fetcher(documentUrl(issuer));
273
+ } catch (cause) {
274
+ throw new OAuthError(
275
+ `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)})`,
276
+ "discovery_unreachable"
277
+ );
278
+ }
279
+ if (!response.ok) {
280
+ throw new OAuthError(
281
+ `Discovery failed for ${issuer}: ${response.status}`,
282
+ "discovery_failed"
283
+ );
284
+ }
285
+ const doc = await response.json();
286
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
287
+ throw new OAuthError(
288
+ `${issuer} published a discovery document with no authorization or token endpoint.`,
289
+ "discovery_incomplete"
290
+ );
291
+ }
292
+ return {
293
+ authorization: doc.authorization_endpoint,
294
+ token: doc.token_endpoint,
295
+ userinfo: doc.userinfo_endpoint,
296
+ endSession: doc.end_session_endpoint,
297
+ jwks: doc.jwks_uri,
298
+ introspection: doc.introspection_endpoint,
299
+ revocation: doc.revocation_endpoint,
300
+ issuer: doc.issuer ?? issuer
301
+ };
302
+ })();
303
+ cache.set(issuer, pending);
304
+ pending.catch(() => cache.delete(issuer));
305
+ return pending;
306
+ }
307
+
308
+ // src/return-to.ts
309
+ function safeReturnTo(candidate, options = {}) {
310
+ const fallback = options.fallback ?? "/";
311
+ if (!candidate || typeof candidate !== "string") return fallback;
312
+ const value = candidate.trim();
313
+ if (!value) return fallback;
314
+ if (/[\u0000-\u001f\u007f]/.test(value)) return fallback;
315
+ if (/^[/\\]{2}/.test(value)) return fallback;
316
+ if (value.startsWith("/")) return value;
317
+ const here = options.origin ?? (typeof location === "undefined" ? void 0 : location.origin);
318
+ const allowed = [here, ...options.allowedOrigins ?? []].filter(Boolean);
319
+ if (!allowed.length) return fallback;
320
+ let url;
321
+ try {
322
+ url = new URL(value);
323
+ } catch {
324
+ return fallback;
325
+ }
326
+ if (!allowed.includes(url.origin)) return fallback;
327
+ if (url.protocol !== "http:" && url.protocol !== "https:") return fallback;
328
+ return url.toString();
329
+ }
330
+
331
+ // src/browser.ts
332
+ var keysFor = (id) => ({
333
+ verifier: `fx.oauth2.${id}.verifier`,
334
+ state: `fx.oauth2.${id}.state`,
335
+ returnTo: `fx.oauth2.${id}.return`,
336
+ refresh: `fx.oauth2.${id}.refresh`,
337
+ /** Names the record in IndexedDB, not a `Storage` entry: a key is never a string here. */
338
+ dpop: `fx.oauth2.${id}.dpop`
339
+ });
340
+ var DEFAULT_SCOPES = ["openid", "profile", "email"];
341
+ function store(kind) {
342
+ if (typeof window === "undefined") return null;
343
+ try {
344
+ return kind === "local" ? window.localStorage : window.sessionStorage;
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+ function createBrowserClient(config, fetcher = fetch) {
350
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
351
+ const KEY = keysFor(config.storageKey ?? config.clientId);
352
+ const flow = () => store(config.storage ?? "session");
353
+ const durable = () => store(config.storage ?? "session");
354
+ let access = null;
355
+ let dpopKey = null;
356
+ let dpopPending = null;
357
+ let dpopNonce = null;
358
+ const keyForDpop = async () => {
359
+ if (!config.dpop) return null;
360
+ if (dpopKey) return dpopKey;
361
+ dpopPending ??= keyFor(KEY.dpop, config.dpopAlgorithm, (reason) => {
362
+ console.warn(`[fluixi/oauth2] ${reason}`);
363
+ }).then((key) => {
364
+ dpopKey = key;
365
+ return key;
366
+ });
367
+ return dpopPending;
368
+ };
369
+ let endpoints = config.endpoints ?? null;
370
+ async function resolveEndpoints() {
371
+ if (endpoints) return endpoints;
372
+ if (!config.issuer) {
373
+ throw new OAuthError(
374
+ "Give the client an `issuer` to discover from, or `endpoints` directly.",
375
+ "missing_issuer"
376
+ );
377
+ }
378
+ endpoints = await discover(config.issuer, fetcher);
379
+ return endpoints;
380
+ }
381
+ async function exchange(body) {
382
+ const { token } = await resolveEndpoints();
383
+ const key = await keyForDpop();
384
+ const headers = {
385
+ "content-type": "application/x-www-form-urlencoded"
386
+ };
387
+ const form = new URLSearchParams(body);
388
+ form.delete("client_id");
389
+ await applyClientAuth(
390
+ { clientId: config.clientId, method: config.tokenAuthMethod ?? "none", clientSecret: "" },
391
+ token,
392
+ headers,
393
+ form
394
+ );
395
+ const send = async () => {
396
+ if (key) {
397
+ headers.dpop = await proofFor(key, {
398
+ method: "POST",
399
+ url: token,
400
+ nonce: dpopNonce ?? void 0
401
+ });
402
+ }
403
+ return fetcher(token, { method: "POST", headers, body: form.toString() });
404
+ };
405
+ let response = await send();
406
+ if (key && !response.ok) {
407
+ const offered = nonceFrom(response);
408
+ if (offered && offered !== dpopNonce) {
409
+ dpopNonce = offered;
410
+ response = await send();
411
+ }
412
+ }
413
+ if (key) dpopNonce = nonceFrom(response) ?? dpopNonce;
414
+ const payload = await response.json().catch(() => ({}));
415
+ if (!response.ok) {
416
+ const code = String(payload.error ?? "token_request_failed");
417
+ const description = typeof payload.error_description === "string" ? payload.error_description : void 0;
418
+ 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." : "";
419
+ throw new OAuthError(
420
+ `Token request failed: ${[code, description].filter(Boolean).join(": ")}${hint}`,
421
+ code,
422
+ description
423
+ );
424
+ }
425
+ const accessToken = String(payload.access_token ?? "");
426
+ if (!accessToken) {
427
+ throw new OAuthError("The token response carried no access_token.", "no_access_token");
428
+ }
429
+ const tokens = {
430
+ accessToken,
431
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
432
+ idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
433
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
434
+ scope: typeof payload.scope === "string" ? payload.scope : void 0,
435
+ // `expires_in` is what the server said; the JWT's own `exp` is the fallback for a
436
+ // server that omits it.
437
+ expiresAt: expiryFrom(payload.expires_in) ?? expiryFromJwt(accessToken) ?? void 0
438
+ };
439
+ access = tokens;
440
+ if (tokens.refreshToken) durable()?.setItem(KEY.refresh, tokens.refreshToken);
441
+ return tokens;
442
+ }
443
+ return {
444
+ async login(options = {}) {
445
+ const { authorization } = await resolveEndpoints();
446
+ const { verifier, challenge } = await createPkcePair();
447
+ const state = createStateValue();
448
+ const storage = flow();
449
+ storage?.setItem(KEY.verifier, verifier);
450
+ storage?.setItem(KEY.state, state);
451
+ storage?.setItem(
452
+ KEY.returnTo,
453
+ safeReturnTo(options.returnTo ?? `${location.pathname}${location.search}`, {
454
+ fallback: "/",
455
+ allowedOrigins: config.allowedReturnOrigins
456
+ })
457
+ );
458
+ const url = new URL(authorization);
459
+ url.searchParams.set("response_type", "code");
460
+ url.searchParams.set("client_id", config.clientId);
461
+ url.searchParams.set("redirect_uri", config.redirectUri);
462
+ url.searchParams.set("scope", scopes.join(" "));
463
+ url.searchParams.set("state", state);
464
+ url.searchParams.set("code_challenge", challenge);
465
+ url.searchParams.set("code_challenge_method", "S256");
466
+ for (const [key, value] of Object.entries({
467
+ ...config.extraParams,
468
+ ...options.params
469
+ })) {
470
+ url.searchParams.set(key, value);
471
+ }
472
+ location.assign(url.toString());
473
+ },
474
+ async handleCallback(params) {
475
+ if (params.error) {
476
+ throw new OAuthError(
477
+ params.errorDescription ?? params.error,
478
+ params.error,
479
+ params.errorDescription
480
+ );
481
+ }
482
+ if (!params.code) {
483
+ throw new OAuthError("The callback carried no authorization code.", "no_code");
484
+ }
485
+ const storage = flow();
486
+ const expected = storage?.getItem(KEY.state) ?? null;
487
+ const verifier = storage?.getItem(KEY.verifier) ?? null;
488
+ storage?.removeItem(KEY.state);
489
+ storage?.removeItem(KEY.verifier);
490
+ if (!expected || !params.state || !timingSafeEqual(expected, params.state)) {
491
+ throw new OAuthError(
492
+ "The callback state did not match the one this client sent.",
493
+ "state_mismatch"
494
+ );
495
+ }
496
+ if (!verifier) {
497
+ throw new OAuthError(
498
+ "No PKCE verifier for this callback. The login was started elsewhere, or storage was cleared.",
499
+ "missing_verifier"
500
+ );
501
+ }
502
+ return exchange({
503
+ grant_type: "authorization_code",
504
+ code: params.code,
505
+ redirect_uri: config.redirectUri,
506
+ client_id: config.clientId,
507
+ code_verifier: verifier
508
+ });
509
+ },
510
+ async refresh() {
511
+ const token = access?.refreshToken ?? durable()?.getItem(KEY.refresh);
512
+ if (!token) {
513
+ throw new OAuthError("No refresh token, so the session cannot be extended.", "no_refresh_token");
514
+ }
515
+ try {
516
+ return await exchange({
517
+ grant_type: "refresh_token",
518
+ refresh_token: token,
519
+ client_id: config.clientId
520
+ });
521
+ } catch (error) {
522
+ if (error instanceof OAuthError && error.code === "invalid_grant") {
523
+ durable()?.removeItem(KEY.refresh);
524
+ access = null;
525
+ }
526
+ throw error;
527
+ }
528
+ },
529
+ async fetchUser(accessToken) {
530
+ const custom = config.userInfo;
531
+ const bearer = accessToken ?? access?.accessToken;
532
+ if (!bearer) throw new OAuthError("No access token to call userinfo with.", "no_access_token");
533
+ let endpoint = custom?.url;
534
+ if (!endpoint) {
535
+ const { userinfo } = await resolveEndpoints();
536
+ if (!userinfo) {
537
+ throw new OAuthError(
538
+ "The provider published no userinfo endpoint. Give `userInfo.url`, or the service its own `fetchUser`.",
539
+ "no_userinfo"
540
+ );
541
+ }
542
+ endpoint = userinfo;
543
+ }
544
+ const key = await keyForDpop();
545
+ const headers = {
546
+ // A bound token is presented with the DPoP scheme, not Bearer: a service checking
547
+ // binding refuses `Bearer` outright, since that is the word for an unbound token.
548
+ authorization: `${key ? "DPoP" : "Bearer"} ${bearer}`,
549
+ accept: "application/json",
550
+ ...custom?.headers
551
+ };
552
+ if (key) {
553
+ headers.dpop = await proofFor(key, {
554
+ method: custom?.method ?? "GET",
555
+ url: endpoint,
556
+ // `ath` binds the proof to this token, so one captured beside it cannot be
557
+ // reused with another.
558
+ accessToken: bearer,
559
+ nonce: dpopNonce ?? void 0
560
+ });
561
+ }
562
+ const response = await fetcher(endpoint, {
563
+ method: custom?.method ?? "GET",
564
+ headers,
565
+ ...custom?.credentials ? { credentials: custom.credentials } : {}
566
+ });
567
+ if (!response.ok) {
568
+ const reason = response.headers.get("www-authenticate")?.match(/error="([^"]+)"/)?.[1];
569
+ throw new OAuthError(
570
+ `The user endpoint refused the token: ${reason ?? response.status}`,
571
+ reason ?? "userinfo_failed"
572
+ );
573
+ }
574
+ const type = response.headers.get("content-type") ?? "";
575
+ if (type.includes("application/jwt")) {
576
+ throw new OAuthError(
577
+ "This provider returns a signed userinfo response, which needs its own verification. Give the service its own `fetchUser`.",
578
+ "userinfo_signed"
579
+ );
580
+ }
581
+ const raw = await response.json();
582
+ if (!custom?.url) {
583
+ const expected = access?.idToken ? claimsOf(access.idToken)?.sub : void 0;
584
+ if (expected !== void 0 && raw.sub !== expected) {
585
+ throw new OAuthError(
586
+ "The userinfo subject does not match the id token. The response was discarded.",
587
+ "sub_mismatch"
588
+ );
589
+ }
590
+ }
591
+ return custom?.map ? await custom.map(raw, bearer) : raw;
592
+ },
593
+ /**
594
+ * Retire a token at the provider, per RFC 7009.
595
+ *
596
+ * Defaults to the refresh token, which is the one worth retiring: an access token
597
+ * expires on its own in minutes, a refresh token does not. A provider with no
598
+ * revocation endpoint resolves without doing anything, because there is nothing this
599
+ * can do about that and failing a logout over it helps nobody.
600
+ */
601
+ async revoke(token, hint = "refresh_token") {
602
+ const target = token ?? (hint === "refresh_token" ? access?.refreshToken ?? durable()?.getItem(KEY.refresh) ?? void 0 : access?.accessToken);
603
+ if (!target) {
604
+ throw new OAuthError(
605
+ `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.`,
606
+ "nothing_to_revoke"
607
+ );
608
+ }
609
+ const { revocation } = await resolveEndpoints();
610
+ if (!revocation) {
611
+ throw new OAuthError(
612
+ "This provider publishes no revocation endpoint, so a token cannot be retired before it expires.",
613
+ "no_revocation_endpoint"
614
+ );
615
+ }
616
+ const headers = {
617
+ "content-type": "application/x-www-form-urlencoded"
618
+ };
619
+ const form = new URLSearchParams({ token: target, token_type_hint: hint });
620
+ await applyClientAuth(
621
+ { clientId: config.clientId, method: config.tokenAuthMethod ?? "none", clientSecret: "" },
622
+ revocation,
623
+ headers,
624
+ form
625
+ );
626
+ const response = await fetcher(revocation, {
627
+ method: "POST",
628
+ headers,
629
+ body: form.toString()
630
+ });
631
+ if (!response.ok) {
632
+ const payload = await response.json().catch(() => ({}));
633
+ throw new OAuthError(
634
+ `Revocation was refused: ${[payload.error, payload.error_description].filter(Boolean).join(": ") || response.status}`,
635
+ String(payload.error ?? "revocation_failed")
636
+ );
637
+ }
638
+ if (hint === "refresh_token") {
639
+ durable()?.removeItem(KEY.refresh);
640
+ if (access) access = { ...access, refreshToken: void 0 };
641
+ }
642
+ },
643
+ async logout(options = {}) {
644
+ const idToken = access?.idToken;
645
+ await this.revoke?.().catch(() => void 0);
646
+ access = null;
647
+ durable()?.removeItem(KEY.refresh);
648
+ dpopKey = null;
649
+ dpopPending = null;
650
+ await forgetKey(KEY.dpop);
651
+ const { endSession } = await resolveEndpoints().catch(() => ({ endSession: void 0 }));
652
+ const target = options.returnTo ? safeReturnTo(options.returnTo, {
653
+ fallback: config.postLogoutRedirectUri ?? "/",
654
+ allowedOrigins: config.allowedReturnOrigins
655
+ }) : config.postLogoutRedirectUri;
656
+ if (!endSession) {
657
+ if (target && typeof location !== "undefined") location.assign(target);
658
+ return;
659
+ }
660
+ const url = new URL(endSession);
661
+ if (target) url.searchParams.set("post_logout_redirect_uri", target);
662
+ if (idToken) url.searchParams.set("id_token_hint", idToken);
663
+ url.searchParams.set("client_id", config.clientId);
664
+ location.assign(url.toString());
665
+ },
666
+ async restore() {
667
+ if (access) return access;
668
+ if (!durable()?.getItem(KEY.refresh)) return null;
669
+ try {
670
+ return await this.refresh();
671
+ } catch {
672
+ return null;
673
+ }
674
+ },
675
+ /**
676
+ * A proof for a request a caller will send with this client's token.
677
+ *
678
+ * Null when the client does not bind its tokens, so a caller can send `Bearer`
679
+ * unchanged. Only this client holds the key, so nothing else can produce one.
680
+ */
681
+ async dpopProof(method, url) {
682
+ const key = await keyForDpop();
683
+ if (!key) return null;
684
+ return proofFor(key, {
685
+ method,
686
+ url,
687
+ accessToken: access?.accessToken,
688
+ nonce: dpopNonce ?? void 0
689
+ });
690
+ },
691
+ consumeReturnTo() {
692
+ const storage = flow();
693
+ const value = storage?.getItem(KEY.returnTo) ?? null;
694
+ storage?.removeItem(KEY.returnTo);
695
+ return value === null ? null : safeReturnTo(value, {
696
+ fallback: "/",
697
+ allowedOrigins: config.allowedReturnOrigins
698
+ });
699
+ }
700
+ };
701
+ }
702
+ export {
703
+ createBrowserClient
704
+ };