@udibo/oauth2 0.6.0 → 0.7.0
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/esm/adapters/hono/bff/bff.d.ts +23 -2
- package/esm/adapters/hono/bff/bff.js +28 -3
- package/esm/client/_http.d.ts +5 -2
- package/esm/client/_http.js +6 -3
- package/esm/client/check.js +11 -32
- package/esm/client/direct-client.d.ts +41 -2
- package/esm/client/direct-client.js +41 -2
- package/esm/client/storage.d.ts +8 -1
- package/esm/server/authorization-server.d.ts +27 -2
- package/esm/server/authorization-server.js +29 -5
- package/esm/testing/contract/auth-request-storage.d.ts +26 -0
- package/esm/testing/contract/auth-request-storage.js +44 -7
- package/esm/utils/_body.d.ts +29 -0
- package/esm/utils/_body.js +66 -0
- package/package.json +1 -1
|
@@ -260,6 +260,23 @@ export interface HonoBffBackchannelOptions {
|
|
|
260
260
|
* `{ sub, sid }` to log out. Throw or return `null` to reject the request.
|
|
261
261
|
*/
|
|
262
262
|
verifyLogoutToken: (logoutToken: string) => Promise<BackchannelLogoutSubject | null> | BackchannelLogoutSubject | null;
|
|
263
|
+
/**
|
|
264
|
+
* The largest request body, in bytes, the receiver reads. The body is read
|
|
265
|
+
* before the `logout_token` is verified, so the cap bounds what an
|
|
266
|
+
* anonymous caller can make the BFF buffer. Bytes are counted as they
|
|
267
|
+
* arrive, so a chunked body or a `Content-Length` that understates the body
|
|
268
|
+
* cannot get past it. A larger body is refused with `400 invalid_request` —
|
|
269
|
+
* the status OIDC Back-Channel Logout 1.0 §2.8 requires for a failed
|
|
270
|
+
* request — without calling
|
|
271
|
+
* {@linkcode HonoBffBackchannelOptions.verifyLogoutToken}.
|
|
272
|
+
*
|
|
273
|
+
* The default is far above a `logout_token`, a JWT of a few KiB. When an
|
|
274
|
+
* earlier middleware has already read the body, the receiver uses Hono's
|
|
275
|
+
* parsed copy and the cap does not apply; bound the body in that middleware.
|
|
276
|
+
*
|
|
277
|
+
* @default 65536
|
|
278
|
+
*/
|
|
279
|
+
maxBodyBytes?: number;
|
|
263
280
|
}
|
|
264
281
|
/**
|
|
265
282
|
* Why `/auth/callback` could not complete a sign-in. Passed to
|
|
@@ -609,7 +626,9 @@ export declare class HonoBff {
|
|
|
609
626
|
* {@linkcode HonoBffPaths.basePath}, when the session cookie would die
|
|
610
627
|
* before the session behind it (see
|
|
611
628
|
* {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
|
|
612
|
-
* numeric `cookie.maxAge` is not positive or exceeds 400 days,
|
|
629
|
+
* numeric `cookie.maxAge` is not positive or exceeds 400 days, when
|
|
630
|
+
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
|
|
631
|
+
* integer (a `RangeError`), or when
|
|
613
632
|
* {@linkcode HonoBffOptions.extraParams} or
|
|
614
633
|
* {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
|
|
615
634
|
* parameter, gives one a name that is empty or not equal to its trimmed
|
|
@@ -717,7 +736,9 @@ export declare class HonoBff {
|
|
|
717
736
|
* carrying a signed `logout_token`; the configured verifier validates it (and
|
|
718
737
|
* returns the subject / OP session id), then the matching server-side
|
|
719
738
|
* session(s) are destroyed. Responds `200` on success and `400` on a
|
|
720
|
-
* missing/invalid token
|
|
739
|
+
* missing/invalid token or a body over
|
|
740
|
+
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes}, with
|
|
741
|
+
* `Cache-Control: no-store`, per the spec.
|
|
721
742
|
*/
|
|
722
743
|
backchannelHandler(): Handler;
|
|
723
744
|
/**
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { Hono } from "hono";
|
|
51
51
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
52
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../../utils/_body.js";
|
|
52
53
|
import { InvalidRequestError, InvalidTokenError, isOAuth2Error, TemporarilyUnavailableError, } from "../../../errors.js";
|
|
53
54
|
import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
|
|
54
55
|
import { sha256Hash } from "../../../server/utils/hash.js";
|
|
@@ -303,7 +304,9 @@ export class HonoBff {
|
|
|
303
304
|
* {@linkcode HonoBffPaths.basePath}, when the session cookie would die
|
|
304
305
|
* before the session behind it (see
|
|
305
306
|
* {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
|
|
306
|
-
* numeric `cookie.maxAge` is not positive or exceeds 400 days,
|
|
307
|
+
* numeric `cookie.maxAge` is not positive or exceeds 400 days, when
|
|
308
|
+
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
|
|
309
|
+
* integer (a `RangeError`), or when
|
|
307
310
|
* {@linkcode HonoBffOptions.extraParams} or
|
|
308
311
|
* {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
|
|
309
312
|
* parameter, gives one a name that is empty or not equal to its trimmed
|
|
@@ -363,6 +366,9 @@ export class HonoBff {
|
|
|
363
366
|
}
|
|
364
367
|
}
|
|
365
368
|
}
|
|
369
|
+
if (options.backchannelLogout?.maxBodyBytes !== undefined) {
|
|
370
|
+
assertMaxBodyBytes(options.backchannelLogout.maxBodyBytes, "backchannelLogout.maxBodyBytes");
|
|
371
|
+
}
|
|
366
372
|
if (options.backchannelLogout && !supportsBackchannelLogout(this.#store)) {
|
|
367
373
|
throw new Error("backchannelLogout requires a SessionStore that implements " +
|
|
368
374
|
"destroyByLogout() (a stateful store). The stateless " +
|
|
@@ -775,7 +781,9 @@ export class HonoBff {
|
|
|
775
781
|
* carrying a signed `logout_token`; the configured verifier validates it (and
|
|
776
782
|
* returns the subject / OP session id), then the matching server-side
|
|
777
783
|
* session(s) are destroyed. Responds `200` on success and `400` on a
|
|
778
|
-
* missing/invalid token
|
|
784
|
+
* missing/invalid token or a body over
|
|
785
|
+
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes}, with
|
|
786
|
+
* `Cache-Control: no-store`, per the spec.
|
|
779
787
|
*/
|
|
780
788
|
backchannelHandler() {
|
|
781
789
|
return async (c) => {
|
|
@@ -785,7 +793,16 @@ export class HonoBff {
|
|
|
785
793
|
return c.json({ error: "not_supported" }, 400);
|
|
786
794
|
let logoutToken;
|
|
787
795
|
try {
|
|
788
|
-
|
|
796
|
+
const form = c.req.raw.bodyUsed
|
|
797
|
+
? await c.req.formData()
|
|
798
|
+
: await this.#readBackchannelForm(c, opts);
|
|
799
|
+
if (!form) {
|
|
800
|
+
return c.json({
|
|
801
|
+
error: "invalid_request",
|
|
802
|
+
error_description: "request body is too large",
|
|
803
|
+
}, 400);
|
|
804
|
+
}
|
|
805
|
+
logoutToken = form.get("logout_token")?.toString();
|
|
789
806
|
}
|
|
790
807
|
catch {
|
|
791
808
|
logoutToken = undefined;
|
|
@@ -816,6 +833,14 @@ export class HonoBff {
|
|
|
816
833
|
return c.body(null, 200);
|
|
817
834
|
};
|
|
818
835
|
}
|
|
836
|
+
async #readBackchannelForm(c, opts) {
|
|
837
|
+
const bytes = await readBoundedBody(c.req.raw, opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
|
838
|
+
if (!bytes)
|
|
839
|
+
return null;
|
|
840
|
+
return await new Response(bytes, {
|
|
841
|
+
headers: { "content-type": c.req.header("content-type") ?? "" },
|
|
842
|
+
}).formData();
|
|
843
|
+
}
|
|
819
844
|
async #resolveSessionEntry(c) {
|
|
820
845
|
const cookieValue = getCookie(c, this.#cookieName);
|
|
821
846
|
if (!cookieValue)
|
package/esm/client/_http.d.ts
CHANGED
|
@@ -32,8 +32,11 @@ export type RedirectPolicy = "refuse" | "follow";
|
|
|
32
32
|
* @param what The endpoint's name, for error messages.
|
|
33
33
|
* @param options `redirect` policy and `timeoutMs` deadline.
|
|
34
34
|
* @returns The response, which may still be a non-OK status.
|
|
35
|
-
* @throws {
|
|
36
|
-
* `
|
|
35
|
+
* @throws {TemporarilyUnavailableError} when the request cannot be made or its
|
|
36
|
+
* response headers do not arrive before the deadline; `cause` is the
|
|
37
|
+
* transport failure.
|
|
38
|
+
* @throws {ServerError} when (under `"refuse"`) the endpoint answers with a
|
|
39
|
+
* redirect.
|
|
37
40
|
*/
|
|
38
41
|
export declare function sendGuarded(fetchImpl: typeof fetch, url: string, init: RequestInit, what: Endpoint, options?: {
|
|
39
42
|
redirect?: RedirectPolicy;
|
package/esm/client/_http.js
CHANGED
|
@@ -47,8 +47,11 @@ const ERROR_CLASS_BY_CODE = {
|
|
|
47
47
|
* @param what The endpoint's name, for error messages.
|
|
48
48
|
* @param options `redirect` policy and `timeoutMs` deadline.
|
|
49
49
|
* @returns The response, which may still be a non-OK status.
|
|
50
|
-
* @throws {
|
|
51
|
-
* `
|
|
50
|
+
* @throws {TemporarilyUnavailableError} when the request cannot be made or its
|
|
51
|
+
* response headers do not arrive before the deadline; `cause` is the
|
|
52
|
+
* transport failure.
|
|
53
|
+
* @throws {ServerError} when (under `"refuse"`) the endpoint answers with a
|
|
54
|
+
* redirect.
|
|
52
55
|
*/
|
|
53
56
|
export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
|
|
54
57
|
const redirect = options.redirect ?? "refuse";
|
|
@@ -62,7 +65,7 @@ export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
|
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
catch (error) {
|
|
65
|
-
throw new
|
|
68
|
+
throw new TemporarilyUnavailableError(`could not reach the ${what} (${describeError(error)})`, { cause: error });
|
|
66
69
|
}
|
|
67
70
|
if (redirect === "refuse" && res.status >= 300 && res.status < 400) {
|
|
68
71
|
await res.body?.cancel();
|
package/esm/client/check.js
CHANGED
|
@@ -74,38 +74,17 @@ const CHECK_ENDPOINT = "check endpoint";
|
|
|
74
74
|
* ```
|
|
75
75
|
*/
|
|
76
76
|
export async function checkPermissions(options) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
let response;
|
|
89
|
-
try {
|
|
90
|
-
response = await sendGuarded(reportUnreachable, options.endpoint, {
|
|
91
|
-
method: "POST",
|
|
92
|
-
headers: {
|
|
93
|
-
authorization: `Bearer ${options.accessToken}`,
|
|
94
|
-
"content-type": "application/json",
|
|
95
|
-
},
|
|
96
|
-
body: JSON.stringify({
|
|
97
|
-
permissions: options.permissions,
|
|
98
|
-
...(options.resource ? { resource: options.resource } : {}),
|
|
99
|
-
}),
|
|
100
|
-
}, CHECK_ENDPOINT);
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
if (error instanceof Error &&
|
|
104
|
-
error.cause instanceof TemporarilyUnavailableError) {
|
|
105
|
-
throw error.cause;
|
|
106
|
-
}
|
|
107
|
-
throw error;
|
|
108
|
-
}
|
|
77
|
+
const response = await sendGuarded(options.fetch ?? fetch, options.endpoint, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
authorization: `Bearer ${options.accessToken}`,
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
permissions: options.permissions,
|
|
85
|
+
...(options.resource ? { resource: options.resource } : {}),
|
|
86
|
+
}),
|
|
87
|
+
}, CHECK_ENDPOINT);
|
|
109
88
|
if (response.status >= 500) {
|
|
110
89
|
await response.body?.cancel();
|
|
111
90
|
throw new TemporarilyUnavailableError(`check endpoint answered ${response.status}`);
|
|
@@ -290,9 +290,13 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
290
290
|
* @returns The server's metadata document.
|
|
291
291
|
* @throws {Error} when the client was configured with explicit `endpoints`
|
|
292
292
|
* rather than an `issuer` — there is nothing to discover.
|
|
293
|
+
* @throws {TemporarilyUnavailableError} the last attempt's error when both
|
|
294
|
+
* well-known paths fail because the server cannot be reached or its
|
|
295
|
+
* response does not arrive in full before the deadline; `cause` is the
|
|
296
|
+
* transport failure.
|
|
293
297
|
* @throws {OAuth2Error} the last attempt's error when both well-known paths
|
|
294
|
-
* fail: the server
|
|
295
|
-
*
|
|
298
|
+
* fail otherwise: the server answers non-OK or with something other than a
|
|
299
|
+
* JSON object, or reports an `issuer` other than the configured one.
|
|
296
300
|
*
|
|
297
301
|
* @example
|
|
298
302
|
* ```ts
|
|
@@ -377,6 +381,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
377
381
|
*
|
|
378
382
|
* @param input The callback URL, its query string, or the parsed params.
|
|
379
383
|
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
384
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
385
|
+
* reached or its response does not arrive in full before the deadline;
|
|
386
|
+
* `cause` is the transport failure.
|
|
380
387
|
* @throws {OAuth2Error} when the callback carries `?error=…`, or the token
|
|
381
388
|
* exchange fails.
|
|
382
389
|
* @throws {TypeError} when `code` or `state` is missing.
|
|
@@ -422,6 +429,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
422
429
|
* `returnTo`, plus the raw response.
|
|
423
430
|
* @throws {InvalidGrantError} when `state` is unknown or older than
|
|
424
431
|
* `authRequestTtlMs`.
|
|
432
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
433
|
+
* reached or its response does not arrive in full before the deadline;
|
|
434
|
+
* `cause` is the transport failure.
|
|
425
435
|
*/
|
|
426
436
|
exchangeAuthorizationCode(code: string, state: string, options?: ExchangeOptions): Promise<ExchangeResult>;
|
|
427
437
|
/**
|
|
@@ -440,6 +450,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
440
450
|
*
|
|
441
451
|
* @param refreshToken The refresh token to redeem.
|
|
442
452
|
* @returns The new tokens, the rotated refresh token, and the raw response.
|
|
453
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
454
|
+
* reached or its response does not arrive in full before the deadline;
|
|
455
|
+
* `cause` is the transport failure.
|
|
443
456
|
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
444
457
|
*/
|
|
445
458
|
exchangeRefreshToken(refreshToken: string): Promise<ExchangeResult>;
|
|
@@ -451,6 +464,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
451
464
|
* @returns The persisted token bundle.
|
|
452
465
|
* @throws {Error} when the client has no `clientSecret` — RFC 6749 §4.4
|
|
453
466
|
* restricts this grant to confidential clients.
|
|
467
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
468
|
+
* reached or its response does not arrive in full before the deadline;
|
|
469
|
+
* `cause` is the transport failure.
|
|
454
470
|
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
455
471
|
*
|
|
456
472
|
* @example
|
|
@@ -491,6 +507,11 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
491
507
|
* one is dead.
|
|
492
508
|
* @throws {Error} whatever the configured `RefreshTokenStorage` threw when
|
|
493
509
|
* the read itself failed.
|
|
510
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
511
|
+
* reached or its response does not arrive in full before the deadline;
|
|
512
|
+
* `cause` is the transport failure.
|
|
513
|
+
* @throws {OAuth2Error} when the token endpoint rejects the refresh for
|
|
514
|
+
* another reason.
|
|
494
515
|
*/
|
|
495
516
|
refresh(): Promise<string>;
|
|
496
517
|
/**
|
|
@@ -502,6 +523,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
502
523
|
* @returns The server's device authorization response.
|
|
503
524
|
* @throws {Error} when no device authorization endpoint is configured or
|
|
504
525
|
* discoverable.
|
|
526
|
+
* @throws {TemporarilyUnavailableError} when the device authorization
|
|
527
|
+
* endpoint cannot be reached or its response does not arrive in full before
|
|
528
|
+
* the deadline; `cause` is the transport failure.
|
|
505
529
|
* @throws {OAuth2Error} when the server rejects the request.
|
|
506
530
|
*
|
|
507
531
|
* @example
|
|
@@ -534,6 +558,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
534
558
|
* @throws {InvalidGrantError} when `expiresAt` passes before approval. A
|
|
535
559
|
* server that reports the code expired first throws `ExpiredTokenError`
|
|
536
560
|
* instead.
|
|
561
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
562
|
+
* reached or its response does not arrive in full before the deadline;
|
|
563
|
+
* `cause` is the transport failure. Polling stops on it.
|
|
537
564
|
* @throws {OAuth2Error} on any other terminal error from the server.
|
|
538
565
|
*/
|
|
539
566
|
pollDeviceToken(deviceCode: string, options?: PollDeviceTokenOptions): Promise<TokenBundle>;
|
|
@@ -547,6 +574,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
547
574
|
* @returns The introspection response; check `active`.
|
|
548
575
|
* @throws {Error} when the client has no `clientSecret` — RFC 7662 requires
|
|
549
576
|
* an authenticated caller.
|
|
577
|
+
* @throws {TemporarilyUnavailableError} when the introspection endpoint
|
|
578
|
+
* cannot be reached or its response does not arrive in full before the
|
|
579
|
+
* deadline; `cause` is the transport failure.
|
|
550
580
|
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
551
581
|
*/
|
|
552
582
|
introspect(token: string, options?: {
|
|
@@ -560,6 +590,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
560
590
|
* @param options `tokenTypeHint` tells the server which kind to look up
|
|
561
591
|
* first.
|
|
562
592
|
* @throws {Error} when no revocation endpoint is configured or discoverable.
|
|
593
|
+
* @throws {TemporarilyUnavailableError} when the revocation endpoint cannot
|
|
594
|
+
* be reached or its response does not arrive in full before the deadline;
|
|
595
|
+
* `cause` is the transport failure.
|
|
563
596
|
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
564
597
|
*/
|
|
565
598
|
revoke(token: string, options?: {
|
|
@@ -573,6 +606,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
573
606
|
* @throws {Error} when no userinfo endpoint is configured or discoverable.
|
|
574
607
|
* @throws {InvalidGrantError} when no usable token is stored and there is no
|
|
575
608
|
* refresh token to mint one.
|
|
609
|
+
* @throws {TemporarilyUnavailableError} when the token or userinfo endpoint
|
|
610
|
+
* cannot be reached or its response does not arrive in full before the
|
|
611
|
+
* deadline; `cause` is the transport failure.
|
|
576
612
|
* @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
|
|
577
613
|
*/
|
|
578
614
|
getUserInfo(): Promise<UserInfoClaims>;
|
|
@@ -721,6 +757,9 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
721
757
|
* than 30 seconds left, otherwise a freshly refreshed one.
|
|
722
758
|
* @throws {InvalidGrantError} when a refresh is needed and no refresh token
|
|
723
759
|
* is stored, or the stored one is dead.
|
|
760
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
761
|
+
* reached or its response does not arrive in full before the deadline;
|
|
762
|
+
* `cause` is the transport failure.
|
|
724
763
|
* @throws {OAuth2Error} when a needed refresh fails for another reason.
|
|
725
764
|
*/
|
|
726
765
|
getAccessToken(): Promise<string>;
|
|
@@ -159,9 +159,13 @@ 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 {TemporarilyUnavailableError} the last attempt's error when both
|
|
163
|
+
* well-known paths fail because the server cannot be reached or its
|
|
164
|
+
* response does not arrive in full before the deadline; `cause` is the
|
|
165
|
+
* transport failure.
|
|
162
166
|
* @throws {OAuth2Error} the last attempt's error when both well-known paths
|
|
163
|
-
* fail: the server
|
|
164
|
-
*
|
|
167
|
+
* fail otherwise: the server answers non-OK or with something other than a
|
|
168
|
+
* JSON object, or reports an `issuer` other than the configured one.
|
|
165
169
|
*
|
|
166
170
|
* @example
|
|
167
171
|
* ```ts
|
|
@@ -368,6 +372,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
368
372
|
*
|
|
369
373
|
* @param input The callback URL, its query string, or the parsed params.
|
|
370
374
|
* @returns The persisted tokens and the `returnTo` recorded at login.
|
|
375
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
376
|
+
* reached or its response does not arrive in full before the deadline;
|
|
377
|
+
* `cause` is the transport failure.
|
|
371
378
|
* @throws {OAuth2Error} when the callback carries `?error=…`, or the token
|
|
372
379
|
* exchange fails.
|
|
373
380
|
* @throws {TypeError} when `code` or `state` is missing.
|
|
@@ -451,6 +458,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
451
458
|
* `returnTo`, plus the raw response.
|
|
452
459
|
* @throws {InvalidGrantError} when `state` is unknown or older than
|
|
453
460
|
* `authRequestTtlMs`.
|
|
461
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
462
|
+
* reached or its response does not arrive in full before the deadline;
|
|
463
|
+
* `cause` is the transport failure.
|
|
454
464
|
*/
|
|
455
465
|
async exchangeAuthorizationCode(code, state, options = {}) {
|
|
456
466
|
const authRequestStorage = options.authRequestStorage ??
|
|
@@ -494,6 +504,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
494
504
|
*
|
|
495
505
|
* @param refreshToken The refresh token to redeem.
|
|
496
506
|
* @returns The new tokens, the rotated refresh token, and the raw response.
|
|
507
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
508
|
+
* reached or its response does not arrive in full before the deadline;
|
|
509
|
+
* `cause` is the transport failure.
|
|
497
510
|
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
498
511
|
*/
|
|
499
512
|
async exchangeRefreshToken(refreshToken) {
|
|
@@ -515,6 +528,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
515
528
|
* @returns The persisted token bundle.
|
|
516
529
|
* @throws {Error} when the client has no `clientSecret` — RFC 6749 §4.4
|
|
517
530
|
* restricts this grant to confidential clients.
|
|
531
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
532
|
+
* reached or its response does not arrive in full before the deadline;
|
|
533
|
+
* `cause` is the transport failure.
|
|
518
534
|
* @throws {OAuth2Error} when the token endpoint rejects the grant.
|
|
519
535
|
*
|
|
520
536
|
* @example
|
|
@@ -566,6 +582,11 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
566
582
|
* one is dead.
|
|
567
583
|
* @throws {Error} whatever the configured `RefreshTokenStorage` threw when
|
|
568
584
|
* the read itself failed.
|
|
585
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
586
|
+
* reached or its response does not arrive in full before the deadline;
|
|
587
|
+
* `cause` is the transport failure.
|
|
588
|
+
* @throws {OAuth2Error} when the token endpoint rejects the refresh for
|
|
589
|
+
* another reason.
|
|
569
590
|
*/
|
|
570
591
|
refresh() {
|
|
571
592
|
return this.#sharedRefresh(true);
|
|
@@ -642,6 +663,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
642
663
|
* @returns The server's device authorization response.
|
|
643
664
|
* @throws {Error} when no device authorization endpoint is configured or
|
|
644
665
|
* discoverable.
|
|
666
|
+
* @throws {TemporarilyUnavailableError} when the device authorization
|
|
667
|
+
* endpoint cannot be reached or its response does not arrive in full before
|
|
668
|
+
* the deadline; `cause` is the transport failure.
|
|
645
669
|
* @throws {OAuth2Error} when the server rejects the request.
|
|
646
670
|
*
|
|
647
671
|
* @example
|
|
@@ -687,6 +711,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
687
711
|
* @throws {InvalidGrantError} when `expiresAt` passes before approval. A
|
|
688
712
|
* server that reports the code expired first throws `ExpiredTokenError`
|
|
689
713
|
* instead.
|
|
714
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
715
|
+
* reached or its response does not arrive in full before the deadline;
|
|
716
|
+
* `cause` is the transport failure. Polling stops on it.
|
|
690
717
|
* @throws {OAuth2Error} on any other terminal error from the server.
|
|
691
718
|
*/
|
|
692
719
|
async pollDeviceToken(deviceCode, options = {}) {
|
|
@@ -734,6 +761,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
734
761
|
* @returns The introspection response; check `active`.
|
|
735
762
|
* @throws {Error} when the client has no `clientSecret` — RFC 7662 requires
|
|
736
763
|
* an authenticated caller.
|
|
764
|
+
* @throws {TemporarilyUnavailableError} when the introspection endpoint
|
|
765
|
+
* cannot be reached or its response does not arrive in full before the
|
|
766
|
+
* deadline; `cause` is the transport failure.
|
|
737
767
|
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
738
768
|
*/
|
|
739
769
|
async introspect(token, options = {}) {
|
|
@@ -760,6 +790,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
760
790
|
* @param options `tokenTypeHint` tells the server which kind to look up
|
|
761
791
|
* first.
|
|
762
792
|
* @throws {Error} when no revocation endpoint is configured or discoverable.
|
|
793
|
+
* @throws {TemporarilyUnavailableError} when the revocation endpoint cannot
|
|
794
|
+
* be reached or its response does not arrive in full before the deadline;
|
|
795
|
+
* `cause` is the transport failure.
|
|
763
796
|
* @throws {OAuth2Error} when the endpoint rejects the request.
|
|
764
797
|
*/
|
|
765
798
|
async revoke(token, options = {}) {
|
|
@@ -785,6 +818,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
785
818
|
* @throws {Error} when no userinfo endpoint is configured or discoverable.
|
|
786
819
|
* @throws {InvalidGrantError} when no usable token is stored and there is no
|
|
787
820
|
* refresh token to mint one.
|
|
821
|
+
* @throws {TemporarilyUnavailableError} when the token or userinfo endpoint
|
|
822
|
+
* cannot be reached or its response does not arrive in full before the
|
|
823
|
+
* deadline; `cause` is the transport failure.
|
|
788
824
|
* @throws {OAuth2Error} when the refresh or the userinfo endpoint fails.
|
|
789
825
|
*/
|
|
790
826
|
async getUserInfo() {
|
|
@@ -1053,6 +1089,9 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
1053
1089
|
* than 30 seconds left, otherwise a freshly refreshed one.
|
|
1054
1090
|
* @throws {InvalidGrantError} when a refresh is needed and no refresh token
|
|
1055
1091
|
* is stored, or the stored one is dead.
|
|
1092
|
+
* @throws {TemporarilyUnavailableError} when the token endpoint cannot be
|
|
1093
|
+
* reached or its response does not arrive in full before the deadline;
|
|
1094
|
+
* `cause` is the transport failure.
|
|
1056
1095
|
* @throws {OAuth2Error} when a needed refresh fails for another reason.
|
|
1057
1096
|
*/
|
|
1058
1097
|
async getAccessToken() {
|
package/esm/client/storage.d.ts
CHANGED
|
@@ -95,7 +95,14 @@ export interface AuthRequestStorage {
|
|
|
95
95
|
take?(state: string): Promise<AuthRequestRecord | null> | AuthRequestRecord | null;
|
|
96
96
|
/** Removes one entry — a claimed or stale one. */
|
|
97
97
|
delete(state: string): Promise<void> | void;
|
|
98
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* Removes every entry. `DirectClient` calls it whenever it clears its
|
|
100
|
+
* session — on sign-out, and when a refresh finds the grant dead. A store
|
|
101
|
+
* shared between users may scope it instead, removing only expired records,
|
|
102
|
+
* so that one user's sign-out does not cancel another user's sign-in still
|
|
103
|
+
* in progress; register such a store with the contract suite's
|
|
104
|
+
* `clear: "scoped"`.
|
|
105
|
+
*/
|
|
99
106
|
clear(): Promise<void> | void;
|
|
100
107
|
}
|
|
101
108
|
/** Default in-memory {@link TokenStorage}. Data is lost when the process exits. */
|
|
@@ -248,6 +248,26 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
|
|
|
248
248
|
* bundle.
|
|
249
249
|
*/
|
|
250
250
|
isPublicSuffix?: IsPublicSuffix;
|
|
251
|
+
/**
|
|
252
|
+
* The largest request body, in bytes, the server reads at the token,
|
|
253
|
+
* revocation, introspection, device authorization and end-session
|
|
254
|
+
* endpoints. Each reads its form body before it has authenticated the
|
|
255
|
+
* caller, so the cap bounds what an anonymous request can make the server
|
|
256
|
+
* buffer. Bytes are counted as they arrive, so a chunked body or a
|
|
257
|
+
* `Content-Length` that understates the body cannot get past it. A larger
|
|
258
|
+
* body is refused with `413` and `invalid_request` before the request's
|
|
259
|
+
* context is resolved or any client is authenticated.
|
|
260
|
+
*
|
|
261
|
+
* The default fits every body these endpoints legitimately receive: the
|
|
262
|
+
* largest field is a JWT (`id_token_hint`, or a token presented for
|
|
263
|
+
* introspection or revocation), a few KiB even with a large claim set.
|
|
264
|
+
* Raise it only if your clients send larger ones.
|
|
265
|
+
*
|
|
266
|
+
* @default 65536
|
|
267
|
+
* @throws {RangeError} From the constructor when it is not a positive
|
|
268
|
+
* integer.
|
|
269
|
+
*/
|
|
270
|
+
maxBodyBytes?: number;
|
|
251
271
|
}
|
|
252
272
|
/**
|
|
253
273
|
* Function to authenticate the user during authorization.
|
|
@@ -407,6 +427,7 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
407
427
|
*
|
|
408
428
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
409
429
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
430
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
410
431
|
*/
|
|
411
432
|
constructor(options: AuthorizationServerOptions<Client, User, S>);
|
|
412
433
|
/**
|
|
@@ -453,7 +474,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
453
474
|
* `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
|
|
454
475
|
* `invalid_request`. A `grant_type` with no registered grant is
|
|
455
476
|
* `unsupported_grant_type`, checked before client authentication; a client
|
|
456
|
-
* not registered for the grant is `unauthorized_client`.
|
|
477
|
+
* not registered for the grant is `unauthorized_client`. A body over
|
|
478
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
479
|
+
* `invalid_request`, refused before the request context is resolved.
|
|
457
480
|
*/
|
|
458
481
|
handleTokenRequest(request: Request): Promise<Response>;
|
|
459
482
|
/**
|
|
@@ -522,7 +545,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
522
545
|
* when sent, names the client ahead of the hint's audience, and when both
|
|
523
546
|
* are sent `client_id` must be one of the hint's audiences — a mismatch is
|
|
524
547
|
* a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
|
|
525
|
-
* along only to a URI that passed authorization.
|
|
548
|
+
* along only to a URI that passed authorization. A POST body over
|
|
549
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
550
|
+
* `invalid_request`, refused before `endSession` runs.
|
|
526
551
|
*
|
|
527
552
|
* Wire the app's session teardown through the `endSession` **constructor
|
|
528
553
|
* option**; this method answers 404 without it, and the discovery document
|
|
@@ -29,6 +29,7 @@ import { DeviceAuthorizationGrant } from "./grants/device-authorization.js";
|
|
|
29
29
|
import { ResourceServer, } from "./resource-server.js";
|
|
30
30
|
import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
|
|
31
31
|
import { validateCodeChallenge } from "../utils/pkce.js";
|
|
32
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
|
|
32
33
|
const ENDPOINTS = {
|
|
33
34
|
authorization: {
|
|
34
35
|
option: "authorizationEndpoint",
|
|
@@ -186,6 +187,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
186
187
|
signingKeys;
|
|
187
188
|
#endSession;
|
|
188
189
|
#isPublicSuffix;
|
|
190
|
+
#maxBodyBytes;
|
|
189
191
|
#subjectOf;
|
|
190
192
|
#userClaims;
|
|
191
193
|
#introspectionClaims;
|
|
@@ -197,6 +199,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
197
199
|
*
|
|
198
200
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
199
201
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
202
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
200
203
|
*/
|
|
201
204
|
constructor(options) {
|
|
202
205
|
super({
|
|
@@ -227,6 +230,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
227
230
|
"plain OAuth2.");
|
|
228
231
|
}
|
|
229
232
|
this.#isPublicSuffix = options.isPublicSuffix;
|
|
233
|
+
this.#maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
234
|
+
assertMaxBodyBytes(this.#maxBodyBytes, "maxBodyBytes");
|
|
230
235
|
this.#subjectOf = options.subjectOf ?? defaultSubjectOf;
|
|
231
236
|
this.#userClaims = options.userClaims;
|
|
232
237
|
this.#introspectionClaims = options.introspectionClaims;
|
|
@@ -343,6 +348,18 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
343
348
|
}
|
|
344
349
|
return grant;
|
|
345
350
|
}
|
|
351
|
+
async #readBody(request, options = {}) {
|
|
352
|
+
const bytes = await readBoundedBody(request, this.#maxBodyBytes, options)
|
|
353
|
+
.catch((cause) => {
|
|
354
|
+
throw new InvalidRequestError("request body could not be read", {
|
|
355
|
+
cause,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
if (!bytes) {
|
|
359
|
+
throw new InvalidRequestError(413, `request body exceeds ${this.#maxBodyBytes} bytes`);
|
|
360
|
+
}
|
|
361
|
+
return bytes;
|
|
362
|
+
}
|
|
346
363
|
async #beginClientAuthenticatedRequest(request, options = {}) {
|
|
347
364
|
if (request.method !== "POST") {
|
|
348
365
|
throw new InvalidRequestError("method must be POST");
|
|
@@ -351,10 +368,13 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
351
368
|
if (!contentType?.includes("application/x-www-form-urlencoded")) {
|
|
352
369
|
throw new InvalidRequestError("content-type header must be application/x-www-form-urlencoded");
|
|
353
370
|
}
|
|
354
|
-
const
|
|
355
|
-
const body = await
|
|
371
|
+
const bytes = await this.#readBody(request, { keepReadable: true });
|
|
372
|
+
const body = await new Response(bytes, {
|
|
373
|
+
headers: { "content-type": contentType },
|
|
374
|
+
}).formData().catch(() => {
|
|
356
375
|
throw new InvalidRequestError("body must be application/x-www-form-urlencoded");
|
|
357
376
|
});
|
|
377
|
+
const context = await this.authorizationContext(request);
|
|
358
378
|
const grant = options.grantFromBody?.(body);
|
|
359
379
|
const client = grant
|
|
360
380
|
? await grant.getAuthenticatedClient(request, body)
|
|
@@ -372,7 +392,9 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
372
392
|
* `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
|
|
373
393
|
* `invalid_request`. A `grant_type` with no registered grant is
|
|
374
394
|
* `unsupported_grant_type`, checked before client authentication; a client
|
|
375
|
-
* not registered for the grant is `unauthorized_client`.
|
|
395
|
+
* not registered for the grant is `unauthorized_client`. A body over
|
|
396
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
397
|
+
* `invalid_request`, refused before the request context is resolved.
|
|
376
398
|
*/
|
|
377
399
|
async handleTokenRequest(request) {
|
|
378
400
|
try {
|
|
@@ -523,7 +545,9 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
523
545
|
* when sent, names the client ahead of the hint's audience, and when both
|
|
524
546
|
* are sent `client_id` must be one of the hint's audiences — a mismatch is
|
|
525
547
|
* a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
|
|
526
|
-
* along only to a URI that passed authorization.
|
|
548
|
+
* along only to a URI that passed authorization. A POST body over
|
|
549
|
+
* {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
|
|
550
|
+
* `invalid_request`, refused before `endSession` runs.
|
|
527
551
|
*
|
|
528
552
|
* Wire the app's session teardown through the `endSession` **constructor
|
|
529
553
|
* option**; this method answers 404 without it, and the discovery document
|
|
@@ -572,7 +596,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
572
596
|
async #endSessionParameters(request) {
|
|
573
597
|
const url = new URL(request.url);
|
|
574
598
|
const params = request.method === "POST"
|
|
575
|
-
? new URLSearchParams(await request
|
|
599
|
+
? new URLSearchParams(new TextDecoder().decode(await this.#readBody(request)))
|
|
576
600
|
: url.searchParams;
|
|
577
601
|
const idTokenHint = params.get("id_token_hint");
|
|
578
602
|
const hint = idTokenHint ? await this.#readIdTokenHint(idTokenHint) : null;
|
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* });
|
|
23
23
|
* ```
|
|
24
24
|
*
|
|
25
|
+
* @example Verify a store shared between users, whose `clear()` removes only expired records
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
28
|
+
* import type { AuthRequestStorage } from "@udibo/oauth2/client";
|
|
29
|
+
*
|
|
30
|
+
* declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
|
|
31
|
+
*
|
|
32
|
+
* runAuthRequestStorageContractTests({
|
|
33
|
+
* describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
|
|
34
|
+
* makeStore: freshSharedAuthRequestStorage,
|
|
35
|
+
* clear: "scoped",
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
25
39
|
* @module
|
|
26
40
|
*/
|
|
27
41
|
import type { AuthRequestStorage } from "../../client/storage.js";
|
|
@@ -41,6 +55,18 @@ export interface AuthRequestStorageContractOptions {
|
|
|
41
55
|
* that meant to implement it and does not fails loudly.
|
|
42
56
|
*/
|
|
43
57
|
take?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* What the store's {@link AuthRequestStorage.clear} removes. Defaults to
|
|
60
|
+
* `"all"`: every record, as a store private to one browser or session
|
|
61
|
+
* does. Pass `"scoped"` for a store shared between users whose `clear()`
|
|
62
|
+
* deliberately removes less — only expired records, say — because a
|
|
63
|
+
* `DirectClient` clears it on every sign-out, and removing everything would
|
|
64
|
+
* cancel other users' sign-ins still in progress. The suite then drops the
|
|
65
|
+
* clear-everything case and instead requires that `clear()` leaves every
|
|
66
|
+
* record still inside its lifetime readable and redeemable, and removes a
|
|
67
|
+
* record created at the epoch (`createdAt: 0`), which is past any lifetime.
|
|
68
|
+
*/
|
|
69
|
+
clear?: "all" | "scoped";
|
|
44
70
|
/**
|
|
45
71
|
* Overrides the name passed to the outer `describe` block. Defaults to
|
|
46
72
|
* `"AuthRequestStorage contract"`.
|
|
@@ -22,6 +22,20 @@
|
|
|
22
22
|
* });
|
|
23
23
|
* ```
|
|
24
24
|
*
|
|
25
|
+
* @example Verify a store shared between users, whose `clear()` removes only expired records
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
28
|
+
* import type { AuthRequestStorage } from "@udibo/oauth2/client";
|
|
29
|
+
*
|
|
30
|
+
* declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
|
|
31
|
+
*
|
|
32
|
+
* runAuthRequestStorageContractTests({
|
|
33
|
+
* describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
|
|
34
|
+
* makeStore: freshSharedAuthRequestStorage,
|
|
35
|
+
* clear: "scoped",
|
|
36
|
+
* });
|
|
37
|
+
* ```
|
|
38
|
+
*
|
|
25
39
|
* @module
|
|
26
40
|
*/
|
|
27
41
|
import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
|
|
@@ -47,6 +61,7 @@ function record(overrides = {}) {
|
|
|
47
61
|
*/
|
|
48
62
|
export function runAuthRequestStorageContractTests(options) {
|
|
49
63
|
const expectTake = options.take ?? true;
|
|
64
|
+
const clearScope = options.clear ?? "all";
|
|
50
65
|
describe(options.describeName ?? "AuthRequestStorage contract", () => {
|
|
51
66
|
let store;
|
|
52
67
|
beforeEach(async () => {
|
|
@@ -74,13 +89,35 @@ export function runAuthRequestStorageContractTests(options) {
|
|
|
74
89
|
assertStrictEquals(await store.get("state-1"), null);
|
|
75
90
|
assert(await store.get("state-2") !== null, "delete must remove only the named state");
|
|
76
91
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
if (clearScope === "all") {
|
|
93
|
+
it("clears every record", async () => {
|
|
94
|
+
await store.set("state-1", record());
|
|
95
|
+
await store.set("state-2", record());
|
|
96
|
+
await store.clear();
|
|
97
|
+
assertStrictEquals(await store.get("state-1"), null);
|
|
98
|
+
assertStrictEquals(await store.get("state-2"), null);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
it('leaves every in-progress record in place (clear: "scoped")', async () => {
|
|
103
|
+
const first = record({ codeVerifier: "verifier-1" });
|
|
104
|
+
const second = record({ codeVerifier: "verifier-2" });
|
|
105
|
+
await store.set("state-1", first);
|
|
106
|
+
await store.set("state-2", second);
|
|
107
|
+
await store.clear();
|
|
108
|
+
const message = "clear() on a shared store must not cancel another user's sign-in " +
|
|
109
|
+
"that is still in progress";
|
|
110
|
+
assertEquals(await store.get("state-2"), second, message);
|
|
111
|
+
assertEquals(expectTake
|
|
112
|
+
? await store.take("state-1")
|
|
113
|
+
: await store.get("state-1"), first, message);
|
|
114
|
+
});
|
|
115
|
+
it('removes a record past any lifetime (clear: "scoped")', async () => {
|
|
116
|
+
await store.set("expired", record({ createdAt: 0 }));
|
|
117
|
+
await store.clear();
|
|
118
|
+
assertStrictEquals(await store.get("expired"), null, "a scoped clear() must still remove expired records");
|
|
119
|
+
});
|
|
120
|
+
}
|
|
84
121
|
});
|
|
85
122
|
if (expectTake) {
|
|
86
123
|
describe("take", () => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal bounded request-body reader for the endpoints that parse a body
|
|
3
|
+
* before they have authenticated the caller.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The default cap on those bodies: 64 KiB. The largest legitimate field is a
|
|
9
|
+
* JWT (`id_token_hint`, `logout_token`, an introspected access token), and a
|
|
10
|
+
* token that also travels in an `Authorization` header already has to fit
|
|
11
|
+
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
14
|
+
/**
|
|
15
|
+
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
|
+
* safe integer.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Reads `request`'s body, counting bytes as they arrive, and returns `null`
|
|
21
|
+
* once it exceeds `maxBytes` — cancelling the stream rather than draining it.
|
|
22
|
+
* A `Content-Length` over the cap is refused without reading; one under it is
|
|
23
|
+
* not trusted. Consumes the body unless `keepReadable` is set, which reads a
|
|
24
|
+
* clone and leaves the original readable (a refusal cancels both). Rejects if
|
|
25
|
+
* the stream errors or was already read.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
|
|
28
|
+
keepReadable?: boolean;
|
|
29
|
+
}): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal bounded request-body reader for the endpoints that parse a body
|
|
3
|
+
* before they have authenticated the caller.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The default cap on those bodies: 64 KiB. The largest legitimate field is a
|
|
9
|
+
* JWT (`id_token_hint`, `logout_token`, an introspected access token), and a
|
|
10
|
+
* token that also travels in an `Authorization` header already has to fit
|
|
11
|
+
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
|
+
* safe integer.
|
|
17
|
+
*/
|
|
18
|
+
export function assertMaxBodyBytes(maxBodyBytes, option) {
|
|
19
|
+
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes < 1) {
|
|
20
|
+
throw new RangeError(`${option} must be a positive integer, got ${maxBodyBytes}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Reads `request`'s body, counting bytes as they arrive, and returns `null`
|
|
25
|
+
* once it exceeds `maxBytes` — cancelling the stream rather than draining it.
|
|
26
|
+
* A `Content-Length` over the cap is refused without reading; one under it is
|
|
27
|
+
* not trusted. Consumes the body unless `keepReadable` is set, which reads a
|
|
28
|
+
* clone and leaves the original readable (a refusal cancels both). Rejects if
|
|
29
|
+
* the stream errors or was already read.
|
|
30
|
+
*/
|
|
31
|
+
export async function readBoundedBody(request, maxBytes, options = {}) {
|
|
32
|
+
if (!request.body)
|
|
33
|
+
return new Uint8Array();
|
|
34
|
+
if (Number(request.headers.get("content-length")) > maxBytes) {
|
|
35
|
+
request.body.cancel().catch(() => { });
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const body = options.keepReadable ? request.clone().body : request.body;
|
|
39
|
+
const bytes = await readUpTo(body, maxBytes);
|
|
40
|
+
if (!bytes && options.keepReadable)
|
|
41
|
+
request.body.cancel().catch(() => { });
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
async function readUpTo(body, maxBytes) {
|
|
45
|
+
const reader = body.getReader();
|
|
46
|
+
const chunks = [];
|
|
47
|
+
let length = 0;
|
|
48
|
+
while (true) {
|
|
49
|
+
const { done, value } = await reader.read();
|
|
50
|
+
if (done)
|
|
51
|
+
break;
|
|
52
|
+
length += value.byteLength;
|
|
53
|
+
if (length > maxBytes) {
|
|
54
|
+
reader.cancel().catch(() => { });
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
chunks.push(value);
|
|
58
|
+
}
|
|
59
|
+
const bytes = new Uint8Array(length);
|
|
60
|
+
let offset = 0;
|
|
61
|
+
for (const chunk of chunks) {
|
|
62
|
+
bytes.set(chunk, offset);
|
|
63
|
+
offset += chunk.byteLength;
|
|
64
|
+
}
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|