@udibo/oauth2 0.4.1 → 0.5.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.
- package/README.md +3 -3
- package/SECURITY.md +0 -4
- package/esm/adapters/hono/_common.d.ts +10 -13
- package/esm/adapters/hono/_common.js +11 -20
- package/esm/adapters/hono/authorization-server.d.ts +16 -10
- package/esm/adapters/hono/authorization-server.js +13 -8
- package/esm/adapters/hono/bff/auth-request-store.d.ts +4 -0
- package/esm/adapters/hono/bff/auth-request-store.js +4 -0
- package/esm/adapters/hono/bff/bff.d.ts +29 -18
- package/esm/adapters/hono/bff/bff.js +15 -11
- package/esm/adapters/hono/bff/proxy.js +7 -55
- package/esm/adapters/hono/bff/session-store.d.ts +9 -9
- package/esm/adapters/hono/bff/session-store.js +3 -2
- package/esm/adapters/hono/bff/testing.d.ts +14 -3
- package/esm/adapters/hono/bff/testing.js +14 -3
- package/esm/adapters/hono/identity.d.ts +5 -3
- package/esm/adapters/hono/identity.js +1 -17
- package/esm/adapters/hono/resource-server.d.ts +3 -2
- package/esm/adapters/hono/resource-server.js +3 -2
- package/esm/client/_http.d.ts +4 -7
- package/esm/client/_http.js +4 -37
- package/esm/client/base.d.ts +3 -6
- package/esm/client/bff-client.d.ts +7 -6
- package/esm/client/bff-client.js +7 -6
- package/esm/client/browser-storage.d.ts +33 -34
- package/esm/client/browser-storage.js +47 -32
- package/esm/client/check.d.ts +10 -3
- package/esm/client/check.js +28 -8
- package/esm/client/direct-client.d.ts +49 -29
- package/esm/client/direct-client.js +59 -59
- package/esm/client/discovery-cache.d.ts +12 -5
- package/esm/client/discovery-cache.js +11 -4
- package/esm/client/events.d.ts +11 -3
- package/esm/client/events.js +1 -1
- package/esm/client/mod.d.ts +6 -4
- package/esm/client/mod.js +5 -3
- package/esm/client/storage.d.ts +53 -23
- package/esm/client/storage.js +43 -15
- package/esm/errors.d.ts +15 -12
- package/esm/errors.js +12 -8
- package/esm/identity/delivery.d.ts +7 -7
- package/esm/identity/delivery.js +1 -2
- package/esm/identity/errors.d.ts +10 -4
- package/esm/identity/errors.js +4 -1
- package/esm/identity/events.d.ts +21 -5
- package/esm/identity/events.js +4 -2
- package/esm/identity/external/_shared.d.ts +5 -7
- package/esm/identity/external/_shared.js +5 -7
- package/esm/identity/external/_token-exchange.d.ts +3 -6
- package/esm/identity/external/_token-exchange.js +2 -5
- package/esm/identity/external/apple-client-secret.d.ts +13 -8
- package/esm/identity/external/apple-client-secret.js +9 -6
- package/esm/identity/external/apple.d.ts +17 -10
- package/esm/identity/external/apple.js +12 -9
- package/esm/identity/external/discord.d.ts +5 -1
- package/esm/identity/external/errors.d.ts +6 -2
- package/esm/identity/external/errors.js +6 -2
- package/esm/identity/external/flow.d.ts +9 -6
- package/esm/identity/external/flow.js +6 -5
- package/esm/identity/external/github.d.ts +7 -3
- package/esm/identity/external/github.js +2 -2
- package/esm/identity/external/google.d.ts +5 -1
- package/esm/identity/external/oauth2.d.ts +13 -2
- package/esm/identity/external/oauth2.js +3 -3
- package/esm/identity/external/oidc.d.ts +21 -10
- package/esm/identity/external/oidc.js +11 -6
- package/esm/identity/external/provider.d.ts +4 -2
- package/esm/identity/identifier.d.ts +6 -5
- package/esm/identity/identifier.js +6 -5
- package/esm/identity/lockout.d.ts +4 -3
- package/esm/identity/lockout.js +4 -3
- package/esm/identity/mfa/mod.d.ts +2 -1
- package/esm/identity/mfa/mod.js +2 -1
- package/esm/identity/mfa/recovery-codes.d.ts +2 -1
- package/esm/identity/mfa/service.d.ts +29 -11
- package/esm/identity/mfa/service.js +35 -11
- package/esm/identity/mfa/totp.d.ts +4 -2
- package/esm/identity/mfa/totp.js +4 -2
- package/esm/identity/migration.d.ts +8 -7
- package/esm/identity/migration.js +9 -12
- package/esm/identity/mod.d.ts +2 -2
- package/esm/identity/mod.js +2 -2
- package/esm/identity/otp.d.ts +23 -16
- package/esm/identity/otp.js +13 -12
- package/esm/identity/password-policy.d.ts +3 -3
- package/esm/identity/password-policy.js +3 -3
- package/esm/identity/password.d.ts +23 -20
- package/esm/identity/password.js +15 -16
- package/esm/identity/rate-limit.d.ts +1 -7
- package/esm/identity/rate-limit.js +1 -2
- package/esm/identity/service.d.ts +76 -44
- package/esm/identity/service.js +86 -60
- package/esm/identity/session.d.ts +5 -4
- package/esm/identity/session.js +5 -4
- package/esm/identity/token-flow.d.ts +8 -8
- package/esm/identity/token-flow.js +5 -6
- package/esm/models/client.d.ts +7 -4
- package/esm/models/token.d.ts +2 -3
- package/esm/react/callback.d.ts +3 -3
- package/esm/react/callback.js +3 -3
- package/esm/react/components/class-names.d.ts +3 -6
- package/esm/react/components/class-names.js +3 -6
- package/esm/react/components/internal.d.ts +7 -17
- package/esm/react/components/internal.js +7 -17
- package/esm/react/components/mod.d.ts +7 -5
- package/esm/react/components/mod.js +7 -5
- package/esm/react/components/types.d.ts +8 -6
- package/esm/react/context.d.ts +3 -3
- package/esm/react/provider.d.ts +3 -2
- package/esm/react/provider.js +0 -13
- package/esm/react/require-auth.d.ts +0 -3
- package/esm/react/require-auth.js +0 -3
- package/esm/react/testing.d.ts +3 -2
- package/esm/react/testing.js +3 -3
- package/esm/react/use-oauth2.d.ts +1 -1
- package/esm/server/authorization-server.d.ts +81 -54
- package/esm/server/authorization-server.js +76 -100
- package/esm/server/grants/authorization-code.d.ts +7 -3
- package/esm/server/grants/authorization-code.js +6 -2
- package/esm/server/grants/device-authorization.d.ts +1 -1
- package/esm/server/grants/grant.d.ts +15 -8
- package/esm/server/grants/grant.js +26 -18
- package/esm/server/grants/password.d.ts +1 -1
- package/esm/server/grants/refresh-token.d.ts +6 -3
- package/esm/server/grants/refresh-token.js +4 -3
- package/esm/server/introspection-token-reader.d.ts +16 -7
- package/esm/server/introspection-token-reader.js +9 -3
- package/esm/server/jwks-token-reader.d.ts +3 -3
- package/esm/server/jwks-token-reader.js +3 -3
- package/esm/server/redirect-uri.d.ts +6 -9
- package/esm/server/redirect-uri.js +7 -34
- package/esm/server/resource-server.d.ts +5 -3
- package/esm/server/resource-server.js +5 -3
- package/esm/server/services/client.d.ts +5 -1
- package/esm/server/services/device-authorization.d.ts +6 -6
- package/esm/server/services/token.d.ts +15 -8
- package/esm/server/services/token.js +1 -1
- package/esm/server/signing-keys.d.ts +24 -9
- package/esm/server/signing-keys.js +20 -9
- package/esm/testing/contract/mod.d.ts +3 -3
- package/esm/testing/contract/mod.js +3 -3
- package/esm/testing/contract/tenant.d.ts +1 -1
- package/esm/testing/contract/tenant.js +2 -2
- package/esm/testing/mod.d.ts +6 -4
- package/esm/testing/mod.js +6 -4
- package/esm/testing/services.d.ts +1 -1
- package/esm/testing/services.js +1 -1
- package/esm/testing/tenant.d.ts +14 -5
- package/esm/utils/_buffer.d.ts +3 -2
- package/esm/utils/_buffer.js +3 -2
- package/esm/utils/_timing-safe.d.ts +8 -6
- package/esm/utils/_timing-safe.js +8 -6
- package/esm/utils/basic-auth.d.ts +5 -12
- package/esm/utils/basic-auth.js +5 -12
- package/esm/utils/crypto.d.ts +2 -1
- package/esm/utils/crypto.js +2 -1
- package/esm/utils/text.d.ts +2 -9
- package/esm/utils/text.js +2 -9
- package/esm/utils/url.d.ts +2 -2
- package/esm/utils/url.js +0 -2
- package/package.json +1 -1
package/esm/client/check.js
CHANGED
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
* @module
|
|
28
28
|
*/
|
|
29
29
|
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
30
|
+
import { receiveJson, sendGuarded } from "./_http.js";
|
|
31
|
+
const CHECK_ENDPOINT = "check endpoint";
|
|
30
32
|
/**
|
|
31
33
|
* Ask a check endpoint whether the caller holds the named permissions.
|
|
32
34
|
*
|
|
@@ -46,7 +48,11 @@ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
|
46
48
|
* tenant no longer resolves is a 404 and lands there too. Echoing an `org_id`
|
|
47
49
|
* off a signed claim is how you meet it, because the organization can be
|
|
48
50
|
* deleted after the signature, so catch it on that path rather than reading a
|
|
49
|
-
* throw as a bug in your own setup.
|
|
51
|
+
* throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
|
|
52
|
+
* JSON object or exceeds the response-size cap, also throws
|
|
53
|
+
* {@linkcode ServerError}, and a request whose response headers do not
|
|
54
|
+
* arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
|
|
55
|
+
* The body's shape is not validated beyond being a JSON object.
|
|
50
56
|
*
|
|
51
57
|
* @example
|
|
52
58
|
* ```ts
|
|
@@ -68,9 +74,19 @@ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
|
68
74
|
*/
|
|
69
75
|
export async function checkPermissions(options) {
|
|
70
76
|
const fetchImpl = options.fetch ?? fetch;
|
|
77
|
+
const reportUnreachable = async (input, init) => {
|
|
78
|
+
try {
|
|
79
|
+
return await fetchImpl(input, init);
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new TemporarilyUnavailableError("check endpoint unreachable", {
|
|
83
|
+
cause,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
71
87
|
let response;
|
|
72
88
|
try {
|
|
73
|
-
response = await
|
|
89
|
+
response = await sendGuarded(reportUnreachable, options.endpoint, {
|
|
74
90
|
method: "POST",
|
|
75
91
|
headers: {
|
|
76
92
|
authorization: `Bearer ${options.accessToken}`,
|
|
@@ -80,18 +96,22 @@ export async function checkPermissions(options) {
|
|
|
80
96
|
permissions: options.permissions,
|
|
81
97
|
...(options.resource ? { resource: options.resource } : {}),
|
|
82
98
|
}),
|
|
83
|
-
});
|
|
99
|
+
}, CHECK_ENDPOINT);
|
|
84
100
|
}
|
|
85
|
-
catch (
|
|
86
|
-
|
|
87
|
-
cause
|
|
88
|
-
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error instanceof Error &&
|
|
103
|
+
error.cause instanceof TemporarilyUnavailableError) {
|
|
104
|
+
throw error.cause;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
89
107
|
}
|
|
90
108
|
if (response.status >= 500) {
|
|
109
|
+
await response.body?.cancel();
|
|
91
110
|
throw new TemporarilyUnavailableError(`check endpoint answered ${response.status}`);
|
|
92
111
|
}
|
|
93
112
|
if (!response.ok) {
|
|
113
|
+
await response.body?.cancel();
|
|
94
114
|
throw new ServerError(`check request refused: ${response.status}`);
|
|
95
115
|
}
|
|
96
|
-
return await response
|
|
116
|
+
return await receiveJson(response, CHECK_ENDPOINT);
|
|
97
117
|
}
|
|
@@ -62,9 +62,9 @@ export interface DirectClientOptions extends BaseOptions {
|
|
|
62
62
|
/** Default scope string to request when none is passed explicitly. */
|
|
63
63
|
scope?: string;
|
|
64
64
|
/**
|
|
65
|
-
* Issuer URL. If provided, endpoints
|
|
66
|
-
*
|
|
67
|
-
* Mutually exclusive with
|
|
65
|
+
* Issuer URL. If provided, endpoints come from the issuer's discovery
|
|
66
|
+
* document, fetched on first use and re-fetched once it expires (see
|
|
67
|
+
* {@link DirectClient.discover}). Mutually exclusive with `endpoints`.
|
|
68
68
|
*/
|
|
69
69
|
issuer?: string;
|
|
70
70
|
/** Explicit endpoint URLs, bypassing discovery. */
|
|
@@ -99,8 +99,9 @@ export interface DirectClientOptions extends BaseOptions {
|
|
|
99
99
|
/**
|
|
100
100
|
* Max age of a pending auth-request (`state`) record before
|
|
101
101
|
* {@link DirectClient.exchangeAuthorizationCode} rejects it as stale, in ms.
|
|
102
|
-
* Bounds the callback replay window
|
|
103
|
-
*
|
|
102
|
+
* Bounds the callback replay window. Both default storages also expire
|
|
103
|
+
* records older than this; a storage you pass expires records by its own
|
|
104
|
+
* rules. Defaults to 10 minutes; set `Infinity` to disable the check.
|
|
104
105
|
*/
|
|
105
106
|
authRequestTtlMs?: number;
|
|
106
107
|
}
|
|
@@ -184,7 +185,7 @@ export interface ExchangeOptions {
|
|
|
184
185
|
export interface ExchangeResult {
|
|
185
186
|
/** The token bundle, not persisted to this client's storage. */
|
|
186
187
|
tokens: TokenBundle;
|
|
187
|
-
/** The
|
|
188
|
+
/** The refresh token the server issued with this response, if any. */
|
|
188
189
|
refreshToken?: string;
|
|
189
190
|
/** `returnTo` recorded at authorize time, for the code exchange. */
|
|
190
191
|
returnTo?: string;
|
|
@@ -268,8 +269,8 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
268
269
|
* this works against any compliant server. **Lazy + cached**: it only runs
|
|
269
270
|
* on the first endpoint use of an `issuer`-configured client (never at
|
|
270
271
|
* construction), so there is no startup cost and no per-request fetch.
|
|
271
|
-
* Concurrent calls share one in-flight request; a failure
|
|
272
|
-
*
|
|
272
|
+
* Concurrent calls share one in-flight request; a failure is not cached, so
|
|
273
|
+
* a later call retries.
|
|
273
274
|
*
|
|
274
275
|
* **Re-resolution contract.** The document this client holds is never
|
|
275
276
|
* permanent: every endpoint use re-resolves once the copy in hand is past
|
|
@@ -289,7 +290,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
289
290
|
* @returns The server's metadata document.
|
|
290
291
|
* @throws {Error} when the client was configured with explicit `endpoints`
|
|
291
292
|
* rather than an `issuer` — there is nothing to discover.
|
|
292
|
-
* @throws {
|
|
293
|
+
* @throws {OAuth2Error} the last attempt's error when both well-known paths
|
|
294
|
+
* fail: the server is unreachable, answers non-OK or with something other
|
|
295
|
+
* than a JSON object, or reports an `issuer` other than the configured one.
|
|
293
296
|
*
|
|
294
297
|
* @example
|
|
295
298
|
* ```ts
|
|
@@ -367,9 +370,10 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
367
370
|
* URL, exchanges the code at the token endpoint, persists the token bundle,
|
|
368
371
|
* and emits `authenticated`.
|
|
369
372
|
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
+
* A duplicate call with the same `code` while the first is still in flight
|
|
374
|
+
* (a React strict-mode double mount) returns the first call's promise rather
|
|
375
|
+
* than redeeming the single-use code twice. A call after it settles redeems
|
|
376
|
+
* again, and fails.
|
|
373
377
|
*
|
|
374
378
|
* @param input The callback URL, its query string, or the parsed params.
|
|
375
379
|
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
@@ -403,8 +407,12 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
403
407
|
* token state across this client's storage and the caller's external store,
|
|
404
408
|
* so refresh, revocation, and logout all stop working as expected.
|
|
405
409
|
*
|
|
406
|
-
* The verifier and `returnTo` are
|
|
407
|
-
* record is consumed
|
|
410
|
+
* The verifier and `returnTo` are claimed under `state` before the token
|
|
411
|
+
* call, so the record is consumed whether or not the exchange succeeds and
|
|
412
|
+
* two callbacks racing on one `state` reach the token endpoint at most once
|
|
413
|
+
* (atomically so for a storage that implements
|
|
414
|
+
* {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
|
|
415
|
+
* semantics.
|
|
408
416
|
*
|
|
409
417
|
* @param code The authorization code from the callback.
|
|
410
418
|
* @param state The `state` the callback echoed back.
|
|
@@ -523,7 +531,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
523
531
|
* minimum interval and deadline, and `signal` to cancel.
|
|
524
532
|
* @returns The persisted token bundle.
|
|
525
533
|
* @throws {AccessDeniedError} when `signal` aborts, or the user declines.
|
|
526
|
-
* @throws {InvalidGrantError} when
|
|
534
|
+
* @throws {InvalidGrantError} when `expiresAt` passes before approval. A
|
|
535
|
+
* server that reports the code expired first throws `ExpiredTokenError`
|
|
536
|
+
* instead.
|
|
527
537
|
* @throws {OAuth2Error} on any other terminal error from the server.
|
|
528
538
|
*/
|
|
529
539
|
pollDeviceToken(deviceCode: string, options?: PollDeviceTokenOptions): Promise<TokenBundle>;
|
|
@@ -561,18 +571,19 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
561
571
|
*
|
|
562
572
|
* @returns The claims the server returns.
|
|
563
573
|
* @throws {Error} when no userinfo endpoint is configured or discoverable.
|
|
564
|
-
* @throws {
|
|
565
|
-
*
|
|
574
|
+
* @throws {InvalidGrantError} when no usable token is stored and there is no
|
|
575
|
+
* refresh token to mint one.
|
|
576
|
+
* @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
|
|
566
577
|
*/
|
|
567
578
|
getUserInfo(): Promise<UserInfoClaims>;
|
|
568
579
|
/**
|
|
569
580
|
* Base64url-decodes an OIDC `id_token` payload and returns the claims.
|
|
570
581
|
*
|
|
571
|
-
* **
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
* with
|
|
582
|
+
* **Validates nothing** — not the signature, `iss`, `aud`, `exp`, or
|
|
583
|
+
* `nonce`. Use it only on an `id_token` this client received from its own
|
|
584
|
+
* token endpoint (which is what {@link getUser} does); the client does not
|
|
585
|
+
* require that endpoint to be `https`. For a token obtained any other way,
|
|
586
|
+
* verify it with a JWT library against the server's JWKS.
|
|
576
587
|
*
|
|
577
588
|
* @param idToken A compact-serialized JWS.
|
|
578
589
|
* @returns The decoded payload claims.
|
|
@@ -590,6 +601,11 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
590
601
|
* A `401` without that challenge is returned as-is — it means "not allowed",
|
|
591
602
|
* not "token stale", and re-minting would not help.
|
|
592
603
|
*
|
|
604
|
+
* The stored access token is sent as-is, even when it has expired; recovery
|
|
605
|
+
* depends on the resource server answering with that challenge. It is
|
|
606
|
+
* attached to whatever URL you pass, so send only requests meant for the
|
|
607
|
+
* APIs this token is for.
|
|
608
|
+
*
|
|
593
609
|
* @param input Request target, as for `fetch`. A `Request` keeps the headers
|
|
594
610
|
* and body it was built with; it is cloned up front so the retry has an
|
|
595
611
|
* unread body.
|
|
@@ -613,9 +629,10 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
613
629
|
* state, emits `logged_out`, and returns the OIDC end-session URL when the
|
|
614
630
|
* server advertises one.
|
|
615
631
|
*
|
|
616
|
-
* Best effort covers the
|
|
617
|
-
* skips the revocation
|
|
618
|
-
*
|
|
632
|
+
* Best effort covers the reads too: a refresh-token store that cannot be
|
|
633
|
+
* read skips the revocation, and a token store that cannot be read omits
|
|
634
|
+
* `id_token_hint`, rather than failing the sign-out, because a client that
|
|
635
|
+
* stays signed in locally because its store broke is the worse outcome.
|
|
619
636
|
*
|
|
620
637
|
* @param options `returnTo` becomes `post_logout_redirect_uri` on the
|
|
621
638
|
* end-session URL.
|
|
@@ -643,7 +660,8 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
643
660
|
getUser(): Promise<UserInfoClaims | null>;
|
|
644
661
|
/**
|
|
645
662
|
* The session as this client sees it, derived from stored tokens — no
|
|
646
|
-
* network call unless the claims come from a userinfo endpoint
|
|
663
|
+
* network call unless the claims come from a userinfo endpoint, which may
|
|
664
|
+
* refresh the access token first.
|
|
647
665
|
*
|
|
648
666
|
* `isAuthenticated` requires a usable access token: a stored bundle whose
|
|
649
667
|
* `accessToken` is non-empty and not past its expiry. A holder of a valid
|
|
@@ -699,9 +717,11 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
699
717
|
* For ordinary HTTP calls prefer {@link fetch}, which attaches the token and
|
|
700
718
|
* handles the 401/refresh dance for you.
|
|
701
719
|
*
|
|
702
|
-
* @returns
|
|
703
|
-
*
|
|
704
|
-
*
|
|
720
|
+
* @returns The stored access token when it has no recorded expiry or more
|
|
721
|
+
* than 30 seconds left, otherwise a freshly refreshed one.
|
|
722
|
+
* @throws {InvalidGrantError} when a refresh is needed and no refresh token
|
|
723
|
+
* is stored, or the stored one is dead.
|
|
724
|
+
* @throws {OAuth2Error} when a needed refresh fails for another reason.
|
|
705
725
|
*/
|
|
706
726
|
getAccessToken(): Promise<string>;
|
|
707
727
|
}
|
|
@@ -121,9 +121,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
121
121
|
this.#tokenStorage = options.tokenStorage ?? new MemoryTokenStorage();
|
|
122
122
|
this.#refreshTokenStorage = options.refreshTokenStorage ??
|
|
123
123
|
new MemoryRefreshTokenStorage();
|
|
124
|
-
this.#authRequestStorage = options.authRequestStorage ??
|
|
125
|
-
defaultAuthRequestStorage();
|
|
126
124
|
this.#authRequestTtlMs = options.authRequestTtlMs ?? 10 * 60 * 1000;
|
|
125
|
+
this.#authRequestStorage = options.authRequestStorage ??
|
|
126
|
+
defaultAuthRequestStorage(this.#authRequestTtlMs);
|
|
127
127
|
}
|
|
128
128
|
/** True when the client was given a secret and authenticates via Basic. */
|
|
129
129
|
get isConfidential() {
|
|
@@ -138,8 +138,8 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
138
138
|
* this works against any compliant server. **Lazy + cached**: it only runs
|
|
139
139
|
* on the first endpoint use of an `issuer`-configured client (never at
|
|
140
140
|
* construction), so there is no startup cost and no per-request fetch.
|
|
141
|
-
* Concurrent calls share one in-flight request; a failure
|
|
142
|
-
*
|
|
141
|
+
* Concurrent calls share one in-flight request; a failure is not cached, so
|
|
142
|
+
* a later call retries.
|
|
143
143
|
*
|
|
144
144
|
* **Re-resolution contract.** The document this client holds is never
|
|
145
145
|
* permanent: every endpoint use re-resolves once the copy in hand is past
|
|
@@ -159,7 +159,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
159
159
|
* @returns The server's metadata document.
|
|
160
160
|
* @throws {Error} when the client was configured with explicit `endpoints`
|
|
161
161
|
* rather than an `issuer` — there is nothing to discover.
|
|
162
|
-
* @throws {
|
|
162
|
+
* @throws {OAuth2Error} the last attempt's error when both well-known paths
|
|
163
|
+
* fail: the server is unreachable, answers non-OK or with something other
|
|
164
|
+
* than a JSON object, or reports an `issuer` other than the configured one.
|
|
163
165
|
*
|
|
164
166
|
* @example
|
|
165
167
|
* ```ts
|
|
@@ -359,9 +361,10 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
359
361
|
* URL, exchanges the code at the token endpoint, persists the token bundle,
|
|
360
362
|
* and emits `authenticated`.
|
|
361
363
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
364
|
+
* A duplicate call with the same `code` while the first is still in flight
|
|
365
|
+
* (a React strict-mode double mount) returns the first call's promise rather
|
|
366
|
+
* than redeeming the single-use code twice. A call after it settles redeems
|
|
367
|
+
* again, and fails.
|
|
365
368
|
*
|
|
366
369
|
* @param input The callback URL, its query string, or the parsed params.
|
|
367
370
|
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
@@ -433,8 +436,12 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
433
436
|
* token state across this client's storage and the caller's external store,
|
|
434
437
|
* so refresh, revocation, and logout all stop working as expected.
|
|
435
438
|
*
|
|
436
|
-
* The verifier and `returnTo` are
|
|
437
|
-
* record is consumed
|
|
439
|
+
* The verifier and `returnTo` are claimed under `state` before the token
|
|
440
|
+
* call, so the record is consumed whether or not the exchange succeeds and
|
|
441
|
+
* two callbacks racing on one `state` reach the token endpoint at most once
|
|
442
|
+
* (atomically so for a storage that implements
|
|
443
|
+
* {@link AuthRequestStorage.take}). Emits nothing — the caller owns session
|
|
444
|
+
* semantics.
|
|
438
445
|
*
|
|
439
446
|
* @param code The authorization code from the callback.
|
|
440
447
|
* @param state The `state` the callback echoed back.
|
|
@@ -448,12 +455,11 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
448
455
|
async exchangeAuthorizationCode(code, state, options = {}) {
|
|
449
456
|
const authRequestStorage = options.authRequestStorage ??
|
|
450
457
|
this.#authRequestStorage;
|
|
451
|
-
const record = await authRequestStorage
|
|
458
|
+
const record = await claimAuthRequest(authRequestStorage, state);
|
|
452
459
|
if (!record)
|
|
453
460
|
throw new InvalidGrantError("unknown state parameter");
|
|
454
461
|
if (this.#authRequestTtlMs !== Infinity &&
|
|
455
462
|
Date.now() - record.createdAt > this.#authRequestTtlMs) {
|
|
456
|
-
await authRequestStorage.delete(state);
|
|
457
463
|
throw new InvalidGrantError("authorization request expired");
|
|
458
464
|
}
|
|
459
465
|
const body = new URLSearchParams({
|
|
@@ -465,7 +471,6 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
465
471
|
if (redirectUri)
|
|
466
472
|
body.set("redirect_uri", redirectUri);
|
|
467
473
|
const raw = await this.#postToken(body);
|
|
468
|
-
await authRequestStorage.delete(state);
|
|
469
474
|
return {
|
|
470
475
|
tokens: responseToBundle(raw),
|
|
471
476
|
refreshToken: raw.refresh_token,
|
|
@@ -679,7 +684,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
679
684
|
* minimum interval and deadline, and `signal` to cancel.
|
|
680
685
|
* @returns The persisted token bundle.
|
|
681
686
|
* @throws {AccessDeniedError} when `signal` aborts, or the user declines.
|
|
682
|
-
* @throws {InvalidGrantError} when
|
|
687
|
+
* @throws {InvalidGrantError} when `expiresAt` passes before approval. A
|
|
688
|
+
* server that reports the code expired first throws `ExpiredTokenError`
|
|
689
|
+
* instead.
|
|
683
690
|
* @throws {OAuth2Error} on any other terminal error from the server.
|
|
684
691
|
*/
|
|
685
692
|
async pollDeviceToken(deviceCode, options = {}) {
|
|
@@ -776,8 +783,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
776
783
|
*
|
|
777
784
|
* @returns The claims the server returns.
|
|
778
785
|
* @throws {Error} when no userinfo endpoint is configured or discoverable.
|
|
779
|
-
* @throws {
|
|
780
|
-
*
|
|
786
|
+
* @throws {InvalidGrantError} when no usable token is stored and there is no
|
|
787
|
+
* refresh token to mint one.
|
|
788
|
+
* @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
|
|
781
789
|
*/
|
|
782
790
|
async getUserInfo() {
|
|
783
791
|
return await this.#fetchUserInfo(false);
|
|
@@ -796,11 +804,11 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
796
804
|
/**
|
|
797
805
|
* Base64url-decodes an OIDC `id_token` payload and returns the claims.
|
|
798
806
|
*
|
|
799
|
-
* **
|
|
800
|
-
*
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
* with
|
|
807
|
+
* **Validates nothing** — not the signature, `iss`, `aud`, `exp`, or
|
|
808
|
+
* `nonce`. Use it only on an `id_token` this client received from its own
|
|
809
|
+
* token endpoint (which is what {@link getUser} does); the client does not
|
|
810
|
+
* require that endpoint to be `https`. For a token obtained any other way,
|
|
811
|
+
* verify it with a JWT library against the server's JWKS.
|
|
804
812
|
*
|
|
805
813
|
* @param idToken A compact-serialized JWS.
|
|
806
814
|
* @returns The decoded payload claims.
|
|
@@ -824,6 +832,11 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
824
832
|
* A `401` without that challenge is returned as-is — it means "not allowed",
|
|
825
833
|
* not "token stale", and re-minting would not help.
|
|
826
834
|
*
|
|
835
|
+
* The stored access token is sent as-is, even when it has expired; recovery
|
|
836
|
+
* depends on the resource server answering with that challenge. It is
|
|
837
|
+
* attached to whatever URL you pass, so send only requests meant for the
|
|
838
|
+
* APIs this token is for.
|
|
839
|
+
*
|
|
827
840
|
* @param input Request target, as for `fetch`. A `Request` keeps the headers
|
|
828
841
|
* and body it was built with; it is cloned up front so the retry has an
|
|
829
842
|
* unread body.
|
|
@@ -871,9 +884,10 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
871
884
|
* state, emits `logged_out`, and returns the OIDC end-session URL when the
|
|
872
885
|
* server advertises one.
|
|
873
886
|
*
|
|
874
|
-
* Best effort covers the
|
|
875
|
-
* skips the revocation
|
|
876
|
-
*
|
|
887
|
+
* Best effort covers the reads too: a refresh-token store that cannot be
|
|
888
|
+
* read skips the revocation, and a token store that cannot be read omits
|
|
889
|
+
* `id_token_hint`, rather than failing the sign-out, because a client that
|
|
890
|
+
* stays signed in locally because its store broke is the worse outcome.
|
|
877
891
|
*
|
|
878
892
|
* @param options `returnTo` becomes `post_logout_redirect_uri` on the
|
|
879
893
|
* end-session URL.
|
|
@@ -893,7 +907,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
893
907
|
async logout(options = {}) {
|
|
894
908
|
const version = ++this.#sessionVersion;
|
|
895
909
|
const refreshToken = await this.#readRefreshToken(false).catch(() => null);
|
|
896
|
-
const idToken =
|
|
910
|
+
const idToken = await Promise.resolve()
|
|
911
|
+
.then(() => this.#tokenStorage.get())
|
|
912
|
+
.then((tokens) => tokens?.idToken, () => undefined);
|
|
897
913
|
if (version !== this.#sessionVersion)
|
|
898
914
|
return {};
|
|
899
915
|
await this.#clearSession();
|
|
@@ -945,7 +961,8 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
945
961
|
}
|
|
946
962
|
/**
|
|
947
963
|
* The session as this client sees it, derived from stored tokens — no
|
|
948
|
-
* network call unless the claims come from a userinfo endpoint
|
|
964
|
+
* network call unless the claims come from a userinfo endpoint, which may
|
|
965
|
+
* refresh the access token first.
|
|
949
966
|
*
|
|
950
967
|
* `isAuthenticated` requires a usable access token: a stored bundle whose
|
|
951
968
|
* `accessToken` is non-empty and not past its expiry. A holder of a valid
|
|
@@ -1032,9 +1049,11 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
1032
1049
|
* For ordinary HTTP calls prefer {@link fetch}, which attaches the token and
|
|
1033
1050
|
* handles the 401/refresh dance for you.
|
|
1034
1051
|
*
|
|
1035
|
-
* @returns
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1052
|
+
* @returns The stored access token when it has no recorded expiry or more
|
|
1053
|
+
* than 30 seconds left, otherwise a freshly refreshed one.
|
|
1054
|
+
* @throws {InvalidGrantError} when a refresh is needed and no refresh token
|
|
1055
|
+
* is stored, or the stored one is dead.
|
|
1056
|
+
* @throws {OAuth2Error} when a needed refresh fails for another reason.
|
|
1038
1057
|
*/
|
|
1039
1058
|
async getAccessToken() {
|
|
1040
1059
|
return await this.#accessTokenFor(false);
|
|
@@ -1051,11 +1070,6 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
1051
1070
|
#basicAuth() {
|
|
1052
1071
|
return encodeBasicAuth(this.#clientId, this.#clientSecret);
|
|
1053
1072
|
}
|
|
1054
|
-
/**
|
|
1055
|
-
* Applies exactly one client authentication method (RFC 6749 §2.3.1):
|
|
1056
|
-
* HTTP Basic for a confidential client — dropping any body `client_id` so
|
|
1057
|
-
* credentials aren't sent twice — or a body `client_id` for a public one.
|
|
1058
|
-
*/
|
|
1059
1073
|
#applyClientAuth(headers, body) {
|
|
1060
1074
|
if (this.isConfidential) {
|
|
1061
1075
|
headers.Authorization = this.#basicAuth();
|
|
@@ -1075,10 +1089,6 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
1075
1089
|
assertTokenResponse(response, "token endpoint");
|
|
1076
1090
|
return response;
|
|
1077
1091
|
}
|
|
1078
|
-
/**
|
|
1079
|
-
* POSTs a form to a credentialed endpoint and returns its JSON object.
|
|
1080
|
-
* Refuses a redirect rather than replaying the credentials in `body`.
|
|
1081
|
-
*/
|
|
1082
1092
|
async #postForm(endpoint, headers, body, what) {
|
|
1083
1093
|
const res = await sendGuarded((input, init) => this.fetchImpl(input, init), endpoint, {
|
|
1084
1094
|
method: "POST",
|
|
@@ -1123,11 +1133,6 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
1123
1133
|
});
|
|
1124
1134
|
}
|
|
1125
1135
|
}
|
|
1126
|
-
/**
|
|
1127
|
-
* Returns `urlStr` with its origin (scheme + host) replaced by `origin`'s,
|
|
1128
|
-
* preserving the path/query. Resolving against the new origin replaces
|
|
1129
|
-
* scheme/host/port cleanly; setting `.host` alone would keep a stale port.
|
|
1130
|
-
*/
|
|
1131
1136
|
function withOrigin(urlStr, origin) {
|
|
1132
1137
|
const url = new URL(urlStr);
|
|
1133
1138
|
return new URL(`${url.pathname}${url.search}${url.hash}`, origin).toString();
|
|
@@ -1176,12 +1181,20 @@ function toSearchParams(input) {
|
|
|
1176
1181
|
return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);
|
|
1177
1182
|
}
|
|
1178
1183
|
}
|
|
1179
|
-
function defaultAuthRequestStorage() {
|
|
1184
|
+
function defaultAuthRequestStorage(ttlMs) {
|
|
1180
1185
|
const inBrowserDocument = typeof document !== "undefined" &&
|
|
1181
1186
|
typeof sessionStorage !== "undefined";
|
|
1182
1187
|
return inBrowserDocument
|
|
1183
|
-
? new SessionStorageAuthRequestStorage()
|
|
1184
|
-
: new MemoryAuthRequestStorage();
|
|
1188
|
+
? new SessionStorageAuthRequestStorage({ ttlMs })
|
|
1189
|
+
: new MemoryAuthRequestStorage({ ttlMs });
|
|
1190
|
+
}
|
|
1191
|
+
async function claimAuthRequest(storage, state) {
|
|
1192
|
+
if (storage.take)
|
|
1193
|
+
return await storage.take(state);
|
|
1194
|
+
const record = await storage.get(state);
|
|
1195
|
+
if (record)
|
|
1196
|
+
await storage.delete(state);
|
|
1197
|
+
return record;
|
|
1185
1198
|
}
|
|
1186
1199
|
function assertUsableSecret(secret) {
|
|
1187
1200
|
if (secret === undefined)
|
|
@@ -1199,12 +1212,6 @@ function assertUsableSecret(secret) {
|
|
|
1199
1212
|
"exchange behind a BffClient.");
|
|
1200
1213
|
}
|
|
1201
1214
|
}
|
|
1202
|
-
/**
|
|
1203
|
-
* RFC 8414 §3.3 / OIDC Discovery §4.3: the `issuer` a metadata document
|
|
1204
|
-
* reports must be the issuer it was fetched from. Without this a document
|
|
1205
|
-
* served (or redirected to) from elsewhere could name another provider's
|
|
1206
|
-
* endpoints, which is the mix-up attack the requirement exists to stop.
|
|
1207
|
-
*/
|
|
1208
1215
|
function assertIssuer(meta, issuer) {
|
|
1209
1216
|
const reported = meta.issuer;
|
|
1210
1217
|
if (typeof reported !== "string" || reported.length === 0) {
|
|
@@ -1223,13 +1230,6 @@ function trimSlash(url) {
|
|
|
1223
1230
|
function isOAuth2ErrorCode(error, code) {
|
|
1224
1231
|
return isOAuth2Error(error) && error.extensions.error === code;
|
|
1225
1232
|
}
|
|
1226
|
-
/**
|
|
1227
|
-
* Sleeps `ms`, or rejects with {@link AccessDeniedError} if `signal` aborts —
|
|
1228
|
-
* so cancelling a device-flow poll mid-wait surfaces as `access_denied`, the
|
|
1229
|
-
* same as the loop-top abort guard in {@link DirectClient.pollDeviceToken}.
|
|
1230
|
-
* The timer/abort plumbing is `@std/async/delay`; only the OAuth2 error
|
|
1231
|
-
* mapping is ours (`delay` rejects solely on abort).
|
|
1232
|
-
*/
|
|
1233
1233
|
function pollDelay(ms, signal) {
|
|
1234
1234
|
return delay(ms, { signal }).catch(() => {
|
|
1235
1235
|
throw new AccessDeniedError("aborted");
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
import type { AuthorizationServerMetadata } from "../models/responses.js";
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Default discovery-document lifetime, one hour: the TTL of a
|
|
10
|
+
* {@link MemoryDiscoveryCache}, and how long a `DirectClient` without a
|
|
11
|
+
* `discoveryCache` keeps its own copy.
|
|
12
|
+
*/
|
|
9
13
|
export declare const DEFAULT_DISCOVERY_TTL_MS: number;
|
|
10
14
|
/** Default entry ceiling for {@link MemoryDiscoveryCache}. */
|
|
11
15
|
export declare const DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES = 100;
|
|
@@ -86,15 +90,15 @@ export interface MemoryDiscoveryCacheOptions {
|
|
|
86
90
|
/**
|
|
87
91
|
* Maximum number of issuers held. Defaults to
|
|
88
92
|
* {@link DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES}. Expired entries are dropped
|
|
89
|
-
* first, then the least recently
|
|
93
|
+
* first, then the least recently used, so a multi-tenant server with an
|
|
90
94
|
* unbounded issuer list cannot grow without limit.
|
|
91
95
|
*/
|
|
92
96
|
maxEntries?: number;
|
|
93
97
|
}
|
|
94
98
|
/**
|
|
95
99
|
* In-memory {@link DiscoveryCache}: TTL entries plus in-flight de-duplication,
|
|
96
|
-
* so a burst of requests against a cold issuer triggers
|
|
97
|
-
*
|
|
100
|
+
* so a burst of requests against a cold issuer triggers one `load`. Failures
|
|
101
|
+
* are never cached.
|
|
98
102
|
*
|
|
99
103
|
* Construct one per process (or per trust boundary) and hand it to every
|
|
100
104
|
* client that should share it.
|
|
@@ -133,6 +137,9 @@ export declare class MemoryDiscoveryCache implements DiscoveryCache {
|
|
|
133
137
|
resolve(issuer: string, load: () => Promise<AuthorizationServerMetadata>): Promise<DiscoveryCacheEntry>;
|
|
134
138
|
/** Drops the entry for `issuer`, forcing the next resolve to re-fetch. */
|
|
135
139
|
delete(issuer: string): void;
|
|
136
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Drops every entry. A load already in flight still settles for its callers
|
|
142
|
+
* and stores its result.
|
|
143
|
+
*/
|
|
137
144
|
clear(): void;
|
|
138
145
|
}
|
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Default discovery-document lifetime, one hour: the TTL of a
|
|
9
|
+
* {@link MemoryDiscoveryCache}, and how long a `DirectClient` without a
|
|
10
|
+
* `discoveryCache` keeps its own copy.
|
|
11
|
+
*/
|
|
8
12
|
export const DEFAULT_DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
|
9
13
|
/** Default entry ceiling for {@link MemoryDiscoveryCache}. */
|
|
10
14
|
export const DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES = 100;
|
|
11
15
|
/**
|
|
12
16
|
* In-memory {@link DiscoveryCache}: TTL entries plus in-flight de-duplication,
|
|
13
|
-
* so a burst of requests against a cold issuer triggers
|
|
14
|
-
*
|
|
17
|
+
* so a burst of requests against a cold issuer triggers one `load`. Failures
|
|
18
|
+
* are never cached.
|
|
15
19
|
*
|
|
16
20
|
* Construct one per process (or per trust boundary) and hand it to every
|
|
17
21
|
* client that should share it.
|
|
@@ -81,7 +85,10 @@ export class MemoryDiscoveryCache {
|
|
|
81
85
|
delete(issuer) {
|
|
82
86
|
this.#entries.delete(normalizeIssuer(issuer));
|
|
83
87
|
}
|
|
84
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Drops every entry. A load already in flight still settles for its callers
|
|
90
|
+
* and stores its result.
|
|
91
|
+
*/
|
|
85
92
|
clear() {
|
|
86
93
|
this.#entries.clear();
|
|
87
94
|
}
|
package/esm/client/events.d.ts
CHANGED
|
@@ -14,11 +14,19 @@ export interface AuthenticatedEvent {
|
|
|
14
14
|
/** The token bundle that was just persisted for the session. */
|
|
15
15
|
tokens: TokenBundle;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The user is signed out, or is about to be: `BffClient.logout` emits it before
|
|
19
|
+
* the browser has navigated to the BFF's logout route.
|
|
20
|
+
*/
|
|
18
21
|
export interface LoggedOutEvent {
|
|
19
22
|
/** Discriminant tag for this event variant. */
|
|
20
23
|
type: "logged_out";
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Why the session ended: `user` when `logout` ran, `invalid_grant` when a
|
|
26
|
+
* `DirectClient` refresh found the grant dead (local tokens are already
|
|
27
|
+
* cleared), `session_expired` when a `BffClient.fetch` got a `401`.
|
|
28
|
+
* `refresh_failed` is never emitted by the shipped clients.
|
|
29
|
+
*/
|
|
22
30
|
reason: "user" | "refresh_failed" | "invalid_grant" | "session_expired";
|
|
23
31
|
}
|
|
24
32
|
/** A refresh has just completed; the access token has rotated. */
|
|
@@ -39,7 +47,7 @@ export interface ErrorEvent {
|
|
|
39
47
|
export type OAuth2ClientEvent = AuthenticatedEvent | LoggedOutEvent | TokenRefreshedEvent | ErrorEvent;
|
|
40
48
|
/** Subscriber callback. Return values are ignored. */
|
|
41
49
|
export type OAuth2ClientEventListener = (event: OAuth2ClientEvent) => void;
|
|
42
|
-
/**
|
|
50
|
+
/** The clients' listener registry; one throwing listener never blocks the rest. */
|
|
43
51
|
export declare class EventBus {
|
|
44
52
|
#private;
|
|
45
53
|
/** Registers `listener` and returns a function that removes it. */
|
package/esm/client/events.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
|
-
/**
|
|
9
|
+
/** The clients' listener registry; one throwing listener never blocks the rest. */
|
|
10
10
|
export class EventBus {
|
|
11
11
|
#listeners = new Set();
|
|
12
12
|
/** Registers `listener` and returns a function that removes it. */
|