@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,3 @@
1
+ import type { AuthClient, BrowserConfig } from './types.js';
2
+ export declare function createBrowserClient(config: BrowserConfig, fetcher?: typeof fetch): AuthClient;
3
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EACV,UAAU,EACV,aAAa,EAKd,MAAM,YAAY,CAAC;AA+BpB,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,aAAa,EACrB,OAAO,GAAE,OAAO,KAAa,GAC5B,UAAU,CAyeZ"}
@@ -0,0 +1,448 @@
1
+ /**
2
+ * A public client that holds its own tokens.
3
+ *
4
+ * The browser drives the whole flow: it builds the authorization URL, navigates, comes
5
+ * back to the redirect URI with a code, and exchanges it. There is no client secret,
6
+ * because a secret in a browser is not one, and PKCE is what replaces it.
7
+ *
8
+ * What this mode accepts: a refresh token in storage is reachable by any script on the
9
+ * page. That is the cost of not running a server, and it is why `tokens: 'server'` exists.
10
+ * The access token is kept in memory alone, so the window where it is readable is the
11
+ * life of the page rather than the life of the session.
12
+ */
13
+ import { claimsOf, createPkcePair, createStateValue, expiryFrom, expiryFromJwt, timingSafeEqual, } from './pkce.js';
14
+ import { applyClientAuth } from './client-auth.js';
15
+ import { nonceFrom, proofFor, } from './dpop.js';
16
+ import { forgetKey, keyFor } from './dpop-store.js';
17
+ import { discover } from './discovery.js';
18
+ import { safeReturnTo } from './return-to.js';
19
+ import { OAuthError } from './types.js';
20
+ /**
21
+ * What survives the redirect, and nothing that does not have to.
22
+ *
23
+ * Namespaced per client, because an app can offer more than one provider. Shared keys
24
+ * meant a second client overwrote the first's verifier and state, so whichever login the
25
+ * visitor did not finish broke the one they did.
26
+ */
27
+ const keysFor = (id) => ({
28
+ verifier: `fx.oauth2.${id}.verifier`,
29
+ state: `fx.oauth2.${id}.state`,
30
+ returnTo: `fx.oauth2.${id}.return`,
31
+ refresh: `fx.oauth2.${id}.refresh`,
32
+ /** Names the record in IndexedDB, not a `Storage` entry: a key is never a string here. */
33
+ dpop: `fx.oauth2.${id}.dpop`,
34
+ });
35
+ const DEFAULT_SCOPES = ['openid', 'profile', 'email'];
36
+ /** Absent during a server render, where there is no storage and no flow to run. */
37
+ function store(kind) {
38
+ if (typeof window === 'undefined')
39
+ return null;
40
+ try {
41
+ return kind === 'local' ? window.localStorage : window.sessionStorage;
42
+ }
43
+ catch {
44
+ // Storage throws rather than returning null when the browser has disabled it.
45
+ return null;
46
+ }
47
+ }
48
+ export function createBrowserClient(config, fetcher = fetch) {
49
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
50
+ // The client id by default, which is already distinct per provider. `storageKey`
51
+ // overrides it for an app running two clients against the same provider.
52
+ const KEY = keysFor(config.storageKey ?? config.clientId);
53
+ // The flow's temporary values follow the configured storage, so a `session` choice
54
+ // really does end when the tab does.
55
+ const flow = () => store(config.storage ?? 'session');
56
+ // The refresh token outlives a page load by definition, so it goes where the app said.
57
+ const durable = () => store(config.storage ?? 'session');
58
+ /** In memory only. A reload restores from the refresh token instead. */
59
+ let access = null;
60
+ /**
61
+ * The key this client binds its tokens to.
62
+ *
63
+ * Persisted, because a refresh token is bound to the key that obtained it and browser mode
64
+ * reaches a new document on every sign in. See `dpop-store.ts` for why IndexedDB and not
65
+ * `localStorage`.
66
+ */
67
+ let dpopKey = null;
68
+ /** Resolved once, so concurrent callers share one key rather than racing to make two. */
69
+ let dpopPending = null;
70
+ /** The nonce a provider last demanded, reused until it asks for another. */
71
+ let dpopNonce = null;
72
+ const keyForDpop = async () => {
73
+ if (!config.dpop)
74
+ return null;
75
+ if (dpopKey)
76
+ return dpopKey;
77
+ // Two requests in flight at startup would otherwise each make a key and store it, and
78
+ // the second would replace the first after tokens were already bound to it.
79
+ dpopPending ??= keyFor(KEY.dpop, config.dpopAlgorithm, (reason) => {
80
+ console.warn(`[fluixi/oauth2] ${reason}`);
81
+ }).then((key) => {
82
+ dpopKey = key;
83
+ return key;
84
+ });
85
+ return dpopPending;
86
+ };
87
+ let endpoints = config.endpoints ?? null;
88
+ async function resolveEndpoints() {
89
+ if (endpoints)
90
+ return endpoints;
91
+ if (!config.issuer) {
92
+ throw new OAuthError('Give the client an `issuer` to discover from, or `endpoints` directly.', 'missing_issuer');
93
+ }
94
+ endpoints = await discover(config.issuer, fetcher);
95
+ return endpoints;
96
+ }
97
+ /** The token endpoint speaks form encoding, not JSON. */
98
+ async function exchange(body) {
99
+ const { token } = await resolveEndpoints();
100
+ const key = await keyForDpop();
101
+ const headers = {
102
+ 'content-type': 'application/x-www-form-urlencoded',
103
+ };
104
+ const form = new URLSearchParams(body);
105
+ form.delete('client_id');
106
+ // A public client: `none` names itself and proves nothing, which is what PKCE covers.
107
+ // `basic` sends an empty password, which some providers insist on even so.
108
+ await applyClientAuth({ clientId: config.clientId, method: config.tokenAuthMethod ?? 'none', clientSecret: '' }, token, headers, form);
109
+ const send = async () => {
110
+ if (key) {
111
+ // A fresh proof per attempt: `jti` and `iat` make one single use, so a retry
112
+ // cannot reuse the last.
113
+ headers.dpop = await proofFor(key, {
114
+ method: 'POST',
115
+ url: token,
116
+ nonce: dpopNonce ?? undefined,
117
+ });
118
+ }
119
+ return fetcher(token, { method: 'POST', headers, body: form.toString() });
120
+ };
121
+ let response = await send();
122
+ // A provider may refuse the first proof and supply a nonce to include in the next.
123
+ // Once, deliberately: a second refusal is a real one, and retrying forever on a
124
+ // provider that always asks would be a loop.
125
+ if (key && !response.ok) {
126
+ const offered = nonceFrom(response);
127
+ if (offered && offered !== dpopNonce) {
128
+ dpopNonce = offered;
129
+ response = await send();
130
+ }
131
+ }
132
+ if (key)
133
+ dpopNonce = nonceFrom(response) ?? dpopNonce;
134
+ const payload = (await response.json().catch(() => ({})));
135
+ if (!response.ok) {
136
+ const code = String(payload.error ?? 'token_request_failed');
137
+ const description = typeof payload.error_description === 'string' ? payload.error_description : undefined;
138
+ // A provider asking for proof of possession says so in a word that means nothing on
139
+ // its own, and the answer is a setting rather than a fix. Okta turns DPoP on by
140
+ // default for new applications, so this is met before anything else is wrong.
141
+ const hint = (code === 'invalid_dpop_proof' || code === 'use_dpop_nonce') && !config.dpop
142
+ ? ' This provider requires DPoP (RFC 9449), which binds a token to a key this client holds. Set `dpop: true` on the config.'
143
+ : '';
144
+ throw new OAuthError(`Token request failed: ${[code, description].filter(Boolean).join(': ')}${hint}`, code, description);
145
+ }
146
+ const accessToken = String(payload.access_token ?? '');
147
+ if (!accessToken) {
148
+ throw new OAuthError('The token response carried no access_token.', 'no_access_token');
149
+ }
150
+ const tokens = {
151
+ accessToken,
152
+ refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined,
153
+ idToken: typeof payload.id_token === 'string' ? payload.id_token : undefined,
154
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : undefined,
155
+ scope: typeof payload.scope === 'string' ? payload.scope : undefined,
156
+ // `expires_in` is what the server said; the JWT's own `exp` is the fallback for a
157
+ // server that omits it.
158
+ expiresAt: expiryFrom(payload.expires_in) ?? expiryFromJwt(accessToken) ?? undefined,
159
+ };
160
+ access = tokens;
161
+ if (tokens.refreshToken)
162
+ durable()?.setItem(KEY.refresh, tokens.refreshToken);
163
+ return tokens;
164
+ }
165
+ return {
166
+ async login(options = {}) {
167
+ const { authorization } = await resolveEndpoints();
168
+ const { verifier, challenge } = await createPkcePair();
169
+ const state = createStateValue();
170
+ const storage = flow();
171
+ storage?.setItem(KEY.verifier, verifier);
172
+ storage?.setItem(KEY.state, state);
173
+ // Where to land afterwards. Recorded here because the redirect discards everything
174
+ // this module is holding.
175
+ //
176
+ // Checked even though the caller supplied it: an app that passes a value from its
177
+ // own query string, which is the ordinary way a login remembers a destination, would
178
+ // otherwise carry an attacker's choice straight through.
179
+ storage?.setItem(KEY.returnTo, safeReturnTo(options.returnTo ?? `${location.pathname}${location.search}`, {
180
+ fallback: '/',
181
+ allowedOrigins: config.allowedReturnOrigins,
182
+ }));
183
+ const url = new URL(authorization);
184
+ url.searchParams.set('response_type', 'code');
185
+ url.searchParams.set('client_id', config.clientId);
186
+ url.searchParams.set('redirect_uri', config.redirectUri);
187
+ url.searchParams.set('scope', scopes.join(' '));
188
+ url.searchParams.set('state', state);
189
+ url.searchParams.set('code_challenge', challenge);
190
+ url.searchParams.set('code_challenge_method', 'S256');
191
+ for (const [key, value] of Object.entries({
192
+ ...config.extraParams,
193
+ ...options.params,
194
+ })) {
195
+ url.searchParams.set(key, value);
196
+ }
197
+ location.assign(url.toString());
198
+ },
199
+ async handleCallback(params) {
200
+ if (params.error) {
201
+ throw new OAuthError(params.errorDescription ?? params.error, params.error, params.errorDescription);
202
+ }
203
+ if (!params.code) {
204
+ throw new OAuthError('The callback carried no authorization code.', 'no_code');
205
+ }
206
+ const storage = flow();
207
+ const expected = storage?.getItem(KEY.state) ?? null;
208
+ const verifier = storage?.getItem(KEY.verifier) ?? null;
209
+ // Single-use. Cleared before the exchange so a replayed callback finds nothing,
210
+ // whether or not the exchange succeeds.
211
+ storage?.removeItem(KEY.state);
212
+ storage?.removeItem(KEY.verifier);
213
+ if (!expected || !params.state || !timingSafeEqual(expected, params.state)) {
214
+ throw new OAuthError('The callback state did not match the one this client sent.', 'state_mismatch');
215
+ }
216
+ if (!verifier) {
217
+ throw new OAuthError('No PKCE verifier for this callback. The login was started elsewhere, or storage was cleared.', 'missing_verifier');
218
+ }
219
+ return exchange({
220
+ grant_type: 'authorization_code',
221
+ code: params.code,
222
+ redirect_uri: config.redirectUri,
223
+ client_id: config.clientId,
224
+ code_verifier: verifier,
225
+ });
226
+ },
227
+ async refresh() {
228
+ const token = access?.refreshToken ?? durable()?.getItem(KEY.refresh);
229
+ if (!token) {
230
+ throw new OAuthError('No refresh token, so the session cannot be extended.', 'no_refresh_token');
231
+ }
232
+ try {
233
+ return await exchange({
234
+ grant_type: 'refresh_token',
235
+ refresh_token: token,
236
+ client_id: config.clientId,
237
+ });
238
+ }
239
+ catch (error) {
240
+ // Only `invalid_grant` says the token itself is finished, which is what RFC 6749
241
+ // §5.2 reserves it for: expired, revoked, or never issued here. Everything else is
242
+ // about this attempt. A wrong DPoP proof, a nonce the provider wants, a network that
243
+ // dropped: discarding on those throws away a working session and turns one bad
244
+ // request into a sign out. It did, and the token was gone before anything could
245
+ // revoke it.
246
+ if (error instanceof OAuthError && error.code === 'invalid_grant') {
247
+ durable()?.removeItem(KEY.refresh);
248
+ access = null;
249
+ }
250
+ throw error;
251
+ }
252
+ },
253
+ async fetchUser(accessToken) {
254
+ const custom = config.userInfo;
255
+ const bearer = accessToken ?? access?.accessToken;
256
+ if (!bearer)
257
+ throw new OAuthError('No access token to call userinfo with.', 'no_access_token');
258
+ // A configured URL wins over discovery. That is the app saying its own API is the
259
+ // authority on who this is, which it usually is: the provider does not know what
260
+ // someone is allowed to do here.
261
+ let endpoint = custom?.url;
262
+ if (!endpoint) {
263
+ const { userinfo } = await resolveEndpoints();
264
+ if (!userinfo) {
265
+ throw new OAuthError('The provider published no userinfo endpoint. Give `userInfo.url`, or the service its own `fetchUser`.', 'no_userinfo');
266
+ }
267
+ endpoint = userinfo;
268
+ }
269
+ const key = await keyForDpop();
270
+ const headers = {
271
+ // A bound token is presented with the DPoP scheme, not Bearer: a service checking
272
+ // binding refuses `Bearer` outright, since that is the word for an unbound token.
273
+ authorization: `${key ? 'DPoP' : 'Bearer'} ${bearer}`,
274
+ accept: 'application/json',
275
+ ...custom?.headers,
276
+ };
277
+ if (key) {
278
+ headers.dpop = await proofFor(key, {
279
+ method: custom?.method ?? 'GET',
280
+ url: endpoint,
281
+ // `ath` binds the proof to this token, so one captured beside it cannot be
282
+ // reused with another.
283
+ accessToken: bearer,
284
+ nonce: dpopNonce ?? undefined,
285
+ });
286
+ }
287
+ const response = await fetcher(endpoint, {
288
+ method: custom?.method ?? 'GET',
289
+ headers,
290
+ ...(custom?.credentials ? { credentials: custom.credentials } : {}),
291
+ });
292
+ if (!response.ok) {
293
+ // The reason is in WWW-Authenticate, not the body: `invalid_token` when the
294
+ // access token expired, which is worth telling apart from the endpoint being down.
295
+ const reason = response.headers
296
+ .get('www-authenticate')
297
+ ?.match(/error="([^"]+)"/)?.[1];
298
+ throw new OAuthError(`The user endpoint refused the token: ${reason ?? response.status}`, reason ?? 'userinfo_failed');
299
+ }
300
+ // A provider configured with `userinfo_signed_response_alg` answers with a signed
301
+ // JWT instead. Verifying it needs the provider's JWKS, which this client does not
302
+ // fetch, so say that rather than failing inside a JSON parse.
303
+ const type = response.headers.get('content-type') ?? '';
304
+ if (type.includes('application/jwt')) {
305
+ throw new OAuthError('This provider returns a signed userinfo response, which needs its own verification. Give the service its own `fetchUser`.', 'userinfo_signed');
306
+ }
307
+ const raw = (await response.json());
308
+ // Only for the provider's own userinfo. OIDC requires that a `sub` which does not
309
+ // match the id token be discarded, because a response obtained for one subject
310
+ // must not be read as another. An app's own endpoint answers in its own shape and
311
+ // has no such claim to check.
312
+ if (!custom?.url) {
313
+ const expected = access?.idToken ? claimsOf(access.idToken)?.sub : undefined;
314
+ if (expected !== undefined && raw.sub !== expected) {
315
+ throw new OAuthError('The userinfo subject does not match the id token. The response was discarded.', 'sub_mismatch');
316
+ }
317
+ }
318
+ return custom?.map ? (await custom.map(raw, bearer)) : raw;
319
+ },
320
+ /**
321
+ * Retire a token at the provider, per RFC 7009.
322
+ *
323
+ * Defaults to the refresh token, which is the one worth retiring: an access token
324
+ * expires on its own in minutes, a refresh token does not. A provider with no
325
+ * revocation endpoint resolves without doing anything, because there is nothing this
326
+ * can do about that and failing a logout over it helps nobody.
327
+ */
328
+ async revoke(token, hint = 'refresh_token') {
329
+ const target = token ??
330
+ (hint === 'refresh_token'
331
+ ? (access?.refreshToken ?? durable()?.getItem(KEY.refresh) ?? undefined)
332
+ : access?.accessToken);
333
+ // Each of these used to return quietly, which made a revocation that did nothing
334
+ // indistinguishable from one that worked. A caller cannot act on silence.
335
+ if (!target) {
336
+ // States the absence and stops. Naming a cause was worse than saying nothing: it
337
+ // read `offline_access` was missing from the scopes to someone whose introspection
338
+ // showed it present, and the real reason was a failed refresh having dropped the
339
+ // token. A message that guesses sends people to the wrong dashboard.
340
+ throw new OAuthError(`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.`, 'nothing_to_revoke');
341
+ }
342
+ const { revocation } = await resolveEndpoints();
343
+ if (!revocation) {
344
+ throw new OAuthError('This provider publishes no revocation endpoint, so a token cannot be retired before it expires.', 'no_revocation_endpoint');
345
+ }
346
+ const headers = {
347
+ 'content-type': 'application/x-www-form-urlencoded',
348
+ };
349
+ const form = new URLSearchParams({ token: target, token_type_hint: hint });
350
+ await applyClientAuth({ clientId: config.clientId, method: config.tokenAuthMethod ?? 'none', clientSecret: '' }, revocation, headers, form);
351
+ const response = await fetcher(revocation, {
352
+ method: 'POST',
353
+ headers,
354
+ body: form.toString(),
355
+ });
356
+ // RFC 7009 says a provider answers 200 whether or not it knew the token, so anything
357
+ // else is a refusal worth reporting rather than swallowing.
358
+ if (!response.ok) {
359
+ const payload = (await response.json().catch(() => ({})));
360
+ throw new OAuthError(`Revocation was refused: ${[payload.error, payload.error_description].filter(Boolean).join(': ') || response.status}`, String(payload.error ?? 'revocation_failed'));
361
+ }
362
+ // Gone as far as this client is concerned, whatever it decides to do next.
363
+ if (hint === 'refresh_token') {
364
+ durable()?.removeItem(KEY.refresh);
365
+ if (access)
366
+ access = { ...access, refreshToken: undefined };
367
+ }
368
+ },
369
+ async logout(options = {}) {
370
+ const idToken = access?.idToken;
371
+ // Retire the refresh token before dropping it, or a copy stays usable for its whole
372
+ // lifetime while this browser believes it signed out. A provider that cannot revoke
373
+ // must not stop someone signing out, so this failure is the one place it is ignored.
374
+ await this.revoke?.().catch(() => undefined);
375
+ access = null;
376
+ durable()?.removeItem(KEY.refresh);
377
+ // The key outlives the tokens otherwise, and the next sign in would prove to the
378
+ // provider that it is the same client as this one.
379
+ dpopKey = null;
380
+ dpopPending = null;
381
+ await forgetKey(KEY.dpop);
382
+ const { endSession } = await resolveEndpoints().catch(() => ({ endSession: undefined }));
383
+ // `postLogoutRedirectUri` is configuration and trusted; `options.returnTo` is not.
384
+ const target = options.returnTo
385
+ ? safeReturnTo(options.returnTo, {
386
+ fallback: config.postLogoutRedirectUri ?? '/',
387
+ allowedOrigins: config.allowedReturnOrigins,
388
+ })
389
+ : config.postLogoutRedirectUri;
390
+ // Without an end-session endpoint the provider's own cookie survives, and the next
391
+ // login is silent rather than a prompt. Clearing locally is all this can do.
392
+ if (!endSession) {
393
+ if (target && typeof location !== 'undefined')
394
+ location.assign(target);
395
+ return;
396
+ }
397
+ const url = new URL(endSession);
398
+ if (target)
399
+ url.searchParams.set('post_logout_redirect_uri', target);
400
+ if (idToken)
401
+ url.searchParams.set('id_token_hint', idToken);
402
+ url.searchParams.set('client_id', config.clientId);
403
+ location.assign(url.toString());
404
+ },
405
+ async restore() {
406
+ if (access)
407
+ return access;
408
+ // A refresh token is the only thing that survives a reload, so restoring means
409
+ // spending it for a fresh access token.
410
+ if (!durable()?.getItem(KEY.refresh))
411
+ return null;
412
+ try {
413
+ return await this.refresh();
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ },
419
+ /**
420
+ * A proof for a request a caller will send with this client's token.
421
+ *
422
+ * Null when the client does not bind its tokens, so a caller can send `Bearer`
423
+ * unchanged. Only this client holds the key, so nothing else can produce one.
424
+ */
425
+ async dpopProof(method, url) {
426
+ const key = await keyForDpop();
427
+ if (!key)
428
+ return null;
429
+ return proofFor(key, {
430
+ method,
431
+ url,
432
+ accessToken: access?.accessToken,
433
+ nonce: dpopNonce ?? undefined,
434
+ });
435
+ },
436
+ consumeReturnTo() {
437
+ const storage = flow();
438
+ const value = storage?.getItem(KEY.returnTo) ?? null;
439
+ storage?.removeItem(KEY.returnTo);
440
+ // Again on the way out: storage is readable and writable by any script on the page,
441
+ // so what went in is not proof of what comes back.
442
+ return value === null ? null : safeReturnTo(value, {
443
+ fallback: '/',
444
+ allowedOrigins: config.allowedReturnOrigins,
445
+ });
446
+ },
447
+ };
448
+ }