@udibo/oauth2 0.15.1 → 0.16.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/dist/adapters/hono/bff/auth-request-store.d.ts +8 -1
- package/dist/adapters/hono/bff/auth-request-store.js +21 -4
- package/dist/adapters/hono/bff/bff.d.ts +23 -2
- package/dist/adapters/hono/bff/bff.js +33 -8
- package/dist/adapters/hono/identity.d.ts +22 -0
- package/dist/adapters/hono/identity.js +32 -8
- package/dist/client/direct-client.d.ts +9 -0
- package/dist/client/direct-client.js +11 -0
- package/dist/client/storage.d.ts +24 -5
- package/dist/client/storage.js +32 -5
- package/dist/server/authorization-server.d.ts +2 -1
- package/dist/server/authorization-server.js +3 -1
- package/dist/server/resource-server.d.ts +28 -3
- package/dist/server/resource-server.js +19 -5
- package/dist/utils/_body.d.ts +15 -0
- package/dist/utils/_body.js +21 -0
- package/dist/utils/url.d.ts +23 -1
- package/dist/utils/url.js +15 -2
- package/package.json +1 -1
|
@@ -96,7 +96,11 @@ export interface EncryptedCookieAuthRequestStorageOptions {
|
|
|
96
96
|
/**
|
|
97
97
|
* How many pending requests the cookie carries at once. A second sign-in
|
|
98
98
|
* started in another tab would otherwise evict the first, so its callback
|
|
99
|
-
* would fail. Defaults to 3; the oldest is dropped beyond that
|
|
99
|
+
* would fail. Defaults to 3; the oldest is dropped beyond that, and also
|
|
100
|
+
* whenever keeping it would push the sealed cookie past what every browser
|
|
101
|
+
* stores (about 4 KB), since a browser silently drops an oversized cookie
|
|
102
|
+
* and every pending sign-in in it with it. The sign-in being started is
|
|
103
|
+
* never the one dropped. Must be a positive integer.
|
|
100
104
|
*/
|
|
101
105
|
maxPending?: number;
|
|
102
106
|
}
|
|
@@ -146,6 +150,9 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
|
|
|
146
150
|
* carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
|
|
147
151
|
* or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
|
|
148
152
|
* {@link AuthRequestCookieOptions.secure}.
|
|
153
|
+
* @throws {RangeError} When
|
|
154
|
+
* {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
|
|
155
|
+
* positive integer.
|
|
149
156
|
*/
|
|
150
157
|
constructor(options: EncryptedCookieAuthRequestStorageOptions);
|
|
151
158
|
/** Returns the storage bound to `c`, for the duration of that request. */
|
|
@@ -32,6 +32,8 @@ import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie
|
|
|
32
32
|
const BASE_NAME = "oauth2_auth_request";
|
|
33
33
|
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
34
34
|
const DEFAULT_MAX_PENDING = 3;
|
|
35
|
+
// RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
|
|
36
|
+
const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
|
|
35
37
|
function cookieAttributes(options, maxAgeSeconds) {
|
|
36
38
|
const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
|
|
37
39
|
return {
|
|
@@ -81,6 +83,9 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
81
83
|
* carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
|
|
82
84
|
* or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
|
|
83
85
|
* {@link AuthRequestCookieOptions.secure}.
|
|
86
|
+
* @throws {RangeError} When
|
|
87
|
+
* {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
|
|
88
|
+
* positive integer.
|
|
84
89
|
*/
|
|
85
90
|
constructor(options) {
|
|
86
91
|
this.#cookie = options.cookie;
|
|
@@ -94,6 +99,10 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
94
99
|
});
|
|
95
100
|
this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
96
101
|
this.#maxPending = options.maxPending ?? DEFAULT_MAX_PENDING;
|
|
102
|
+
if (!(Number.isInteger(this.#maxPending) && this.#maxPending > 0)) {
|
|
103
|
+
throw new RangeError("EncryptedCookieAuthRequestStorage maxPending must be a positive " +
|
|
104
|
+
`integer, got ${this.#maxPending}.`);
|
|
105
|
+
}
|
|
97
106
|
}
|
|
98
107
|
#key() {
|
|
99
108
|
return (this.#keyPromise ??= deriveAesKey(this.#secret));
|
|
@@ -108,15 +117,23 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
108
117
|
const cutoff = Date.now() - this.#ttlMs;
|
|
109
118
|
return Object.fromEntries(Object.entries(pending).filter(([, record]) => record.createdAt > cutoff));
|
|
110
119
|
}
|
|
111
|
-
async #write(c, pending) {
|
|
120
|
+
async #write(c, pending, keep) {
|
|
112
121
|
const entries = Object.entries(pending)
|
|
113
|
-
.sort(([, a], [, b]) =>
|
|
122
|
+
.sort(([stateA, a], [stateB, b]) => Number(stateB === keep) - Number(stateA === keep) ||
|
|
123
|
+
b.createdAt - a.createdAt)
|
|
114
124
|
.slice(0, this.#maxPending);
|
|
115
125
|
if (entries.length === 0) {
|
|
116
126
|
this.#clear(c);
|
|
117
127
|
return;
|
|
118
128
|
}
|
|
119
|
-
|
|
129
|
+
const key = await this.#key();
|
|
130
|
+
let sealed = await sealJson(key, Object.fromEntries(entries));
|
|
131
|
+
while (entries.length > 1 &&
|
|
132
|
+
this.#name.length + 1 + sealed.length > MAX_COOKIE_NAME_VALUE_BYTES) {
|
|
133
|
+
entries.pop();
|
|
134
|
+
sealed = await sealJson(key, Object.fromEntries(entries));
|
|
135
|
+
}
|
|
136
|
+
setCookie(c, this.#name, sealed, cookieAttributes(this.#cookie, Math.ceil(this.#ttlMs / 1000)));
|
|
120
137
|
}
|
|
121
138
|
#clear(c) {
|
|
122
139
|
deleteCookie(c, this.#name, cookieAttributes(this.#cookie));
|
|
@@ -128,7 +145,7 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
128
145
|
set: async (state, value) => {
|
|
129
146
|
const pending = await this.#read(c);
|
|
130
147
|
pending[state] = value;
|
|
131
|
-
await this.#write(c, pending);
|
|
148
|
+
await this.#write(c, pending, state);
|
|
132
149
|
},
|
|
133
150
|
delete: async (state) => {
|
|
134
151
|
const pending = await this.#read(c);
|
|
@@ -162,6 +162,9 @@ export interface HonoBffCookieOptions {
|
|
|
162
162
|
* header, so it is guarded instead by a same-origin check that reads
|
|
163
163
|
* `Sec-Fetch-Site` (accepting only `same-origin` and `none`) and falls back to
|
|
164
164
|
* comparing `Origin`, else `Referer`, for clients that send no fetch metadata.
|
|
165
|
+
* That comparison accepts the request's own origin and the application's
|
|
166
|
+
* (see {@link HonoBffOptions.rpInitiatedLogout}), so the fallback still passes
|
|
167
|
+
* behind a TLS-terminating proxy that hands the BFF a plain-HTTP request.
|
|
165
168
|
* `same-site` is rejected along with `cross-site`, so a `GET` logout link
|
|
166
169
|
* followed from a sibling subdomain does not pass. That check **fails closed**:
|
|
167
170
|
* a request carrying none of the three signals is rejected. Two residuals come
|
|
@@ -425,10 +428,18 @@ export interface HonoBffOptions {
|
|
|
425
428
|
* `/auth/logout` — after destroying the local session — additionally
|
|
426
429
|
* redirects the browser to the OP's `end_session_endpoint` with
|
|
427
430
|
* `id_token_hint` (the session's id_token, if any), `client_id`, and a
|
|
428
|
-
* `post_logout_redirect_uri` built from the
|
|
431
|
+
* `post_logout_redirect_uri` built from the application's origin + the
|
|
429
432
|
* open-redirect-guarded `return_to`. This ends the upstream IDP SSO session,
|
|
430
433
|
* not just the local BFF session.
|
|
431
434
|
*
|
|
435
|
+
* The application's origin is {@link resolveOrigin}'s, else the configured
|
|
436
|
+
* client `redirectUri`'s, else the request's — the last only when no
|
|
437
|
+
* `redirectUri` is configured or {@link deriveRedirectUri} is on, matching
|
|
438
|
+
* the origin sign-in sends. Behind a TLS-terminating proxy the request
|
|
439
|
+
* reports `http:`, so a deployment that derives its redirect URI there sets
|
|
440
|
+
* {@link resolveOrigin}; otherwise the URI would miss the registered
|
|
441
|
+
* `https:` one.
|
|
442
|
+
*
|
|
432
443
|
* Defaults to `false` (local logout only): not every IDP supports it, and it
|
|
433
444
|
* adds a redirect hop. When enabled but no end-session endpoint is known,
|
|
434
445
|
* logout falls back to the plain local redirect and warns once via
|
|
@@ -558,6 +569,14 @@ export interface HonoBffOptions {
|
|
|
558
569
|
forwardedParams?: readonly string[];
|
|
559
570
|
/** Default `return_to` applied when the browser doesn't pass one. */
|
|
560
571
|
defaultReturnTo?: string;
|
|
572
|
+
/**
|
|
573
|
+
* Longest `return_to` accepted, in characters. `/auth/login` stores the
|
|
574
|
+
* value with the pending sign-in before any authentication, so a longer one
|
|
575
|
+
* is replaced by {@link defaultReturnTo} rather than kept — at login,
|
|
576
|
+
* callback, logout and in {@link HonoBff.loginContinuation}. Defaults to
|
|
577
|
+
* 2048; `Infinity` accepts any length.
|
|
578
|
+
*/
|
|
579
|
+
maxReturnToLength?: number;
|
|
561
580
|
/**
|
|
562
581
|
* Optional hook run after the authorization code is exchanged. Lets
|
|
563
582
|
* apps enrich the session with database-backed user info before the
|
|
@@ -638,7 +657,9 @@ export declare class HonoBff {
|
|
|
638
657
|
* {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
|
|
639
658
|
* numeric `cookie.maxAge` is not positive or exceeds 400 days, when
|
|
640
659
|
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
|
|
641
|
-
* integer (a `RangeError`),
|
|
660
|
+
* integer (a `RangeError`), when
|
|
661
|
+
* {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
|
|
662
|
+
* integer nor `Infinity` (a `RangeError`), or when
|
|
642
663
|
* {@linkcode HonoBffOptions.extraParams} or
|
|
643
664
|
* {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
|
|
644
665
|
* parameter, gives one a name that is empty or not equal to its trimmed
|
|
@@ -56,7 +56,7 @@ import { inboundRequestWithHeaders } from "../../../utils/_inbound-request.js";
|
|
|
56
56
|
import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
|
|
57
57
|
import { sha256Hash } from "../../../server/utils/hash.js";
|
|
58
58
|
import { OAUTH2_CONTEXT_KEY } from "../_common.js";
|
|
59
|
-
import { loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
|
|
59
|
+
import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
|
|
60
60
|
import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
|
|
61
61
|
import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
|
|
62
62
|
import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
|
|
@@ -289,6 +289,7 @@ export class HonoBff {
|
|
|
289
289
|
#sessionMode;
|
|
290
290
|
#csrf;
|
|
291
291
|
#extraAuthorizeParams;
|
|
292
|
+
#maxReturnToLength;
|
|
292
293
|
#refreshes = new Map();
|
|
293
294
|
/**
|
|
294
295
|
* Defaults are applied for the session store ({@linkcode MemorySessionStore}),
|
|
@@ -307,7 +308,9 @@ export class HonoBff {
|
|
|
307
308
|
* {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
|
|
308
309
|
* numeric `cookie.maxAge` is not positive or exceeds 400 days, when
|
|
309
310
|
* {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
|
|
310
|
-
* integer (a `RangeError`),
|
|
311
|
+
* integer (a `RangeError`), when
|
|
312
|
+
* {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
|
|
313
|
+
* integer nor `Infinity` (a `RangeError`), or when
|
|
311
314
|
* {@linkcode HonoBffOptions.extraParams} or
|
|
312
315
|
* {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
|
|
313
316
|
* parameter, gives one a name that is empty or not equal to its trimmed
|
|
@@ -330,6 +333,13 @@ export class HonoBff {
|
|
|
330
333
|
this.#sessionMode = options.sessionMode ?? "own";
|
|
331
334
|
this.#csrf = resolveCsrf(options.csrf);
|
|
332
335
|
this.#extraAuthorizeParams = resolveExtraAuthorizeParams(options);
|
|
336
|
+
this.#maxReturnToLength =
|
|
337
|
+
options.maxReturnToLength ?? DEFAULT_MAX_RETURN_TO_LENGTH;
|
|
338
|
+
if (this.#maxReturnToLength !== Infinity &&
|
|
339
|
+
!(Number.isInteger(this.#maxReturnToLength) && this.#maxReturnToLength > 0)) {
|
|
340
|
+
throw new RangeError("HonoBff maxReturnToLength must be a positive integer or Infinity, " +
|
|
341
|
+
`got ${this.#maxReturnToLength}.`);
|
|
342
|
+
}
|
|
333
343
|
const sessionMaxAgeMs = positiveMs(options.sessionMaxAgeMs ?? DEFAULT_SESSION_MAX_AGE_MS, "sessionMaxAgeMs");
|
|
334
344
|
this.#sessionMaxAgeMs = sessionMaxAgeMs;
|
|
335
345
|
if (this.#sessionMode === "shared") {
|
|
@@ -454,6 +464,20 @@ export class HonoBff {
|
|
|
454
464
|
}
|
|
455
465
|
setCookie(c, this.#loginStateCookieName, hashes.slice(-MAX_PENDING_LOGIN_STATES).join(LOGIN_STATE_SEPARATOR), loginStateCookieOpts(this.#options.cookie, Math.ceil(this.#loginStateTtlMs / 1000)));
|
|
456
466
|
}
|
|
467
|
+
#safeReturnTo(value) {
|
|
468
|
+
return safeReturnTo(value, this.#options.defaultReturnTo ?? "/", {
|
|
469
|
+
maxLength: this.#maxReturnToLength,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
#appOrigin(c) {
|
|
473
|
+
const resolved = this.#options.resolveOrigin?.(c);
|
|
474
|
+
if (resolved !== undefined)
|
|
475
|
+
return resolved;
|
|
476
|
+
const configured = this.#options.deriveRedirectUri
|
|
477
|
+
? undefined
|
|
478
|
+
: this.#client.redirectUri;
|
|
479
|
+
return new URL(configured ?? c.req.url).origin;
|
|
480
|
+
}
|
|
457
481
|
#derivedRedirectUri(c, origin) {
|
|
458
482
|
if (!this.#options.deriveRedirectUri)
|
|
459
483
|
return undefined;
|
|
@@ -473,12 +497,12 @@ export class HonoBff {
|
|
|
473
497
|
const fetchSite = c.req.header("sec-fetch-site");
|
|
474
498
|
if (fetchSite)
|
|
475
499
|
return fetchSite === "same-origin" || fetchSite === "none";
|
|
476
|
-
const
|
|
500
|
+
const targets = [new URL(c.req.url).origin, this.#appOrigin(c)];
|
|
477
501
|
const check = (val) => {
|
|
478
502
|
if (!val)
|
|
479
503
|
return undefined;
|
|
480
504
|
try {
|
|
481
|
-
return new URL(val).origin
|
|
505
|
+
return targets.includes(new URL(val).origin);
|
|
482
506
|
}
|
|
483
507
|
catch {
|
|
484
508
|
return false;
|
|
@@ -555,6 +579,7 @@ export class HonoBff {
|
|
|
555
579
|
authorizeEndpoint: this.#client.authorizationEndpoint,
|
|
556
580
|
loginPath: this.#paths.login,
|
|
557
581
|
defaultReturnTo: this.#options.defaultReturnTo,
|
|
582
|
+
maxReturnToLength: this.#maxReturnToLength,
|
|
558
583
|
});
|
|
559
584
|
}
|
|
560
585
|
#pathsRelative() {
|
|
@@ -583,7 +608,7 @@ export class HonoBff {
|
|
|
583
608
|
loginHandler() {
|
|
584
609
|
return async (c) => {
|
|
585
610
|
this.#noStore(c);
|
|
586
|
-
const returnTo = safeReturnTo(c.req.query("return_to")
|
|
611
|
+
const returnTo = this.#safeReturnTo(c.req.query("return_to"));
|
|
587
612
|
const origin = this.#options.resolveOrigin?.(c);
|
|
588
613
|
const requested = authorizeParamsForRequest(this.#extraAuthorizeParams, new URL(c.req.url).searchParams);
|
|
589
614
|
const { url, state } = await this.#client.login({
|
|
@@ -683,7 +708,7 @@ export class HonoBff {
|
|
|
683
708
|
if (cookieValue !== existing) {
|
|
684
709
|
setCookie(c, this.#cookieName, cookieValue, this.#cookieOptions());
|
|
685
710
|
}
|
|
686
|
-
return c.redirect(safeReturnTo(returnTo
|
|
711
|
+
return c.redirect(this.#safeReturnTo(returnTo));
|
|
687
712
|
}
|
|
688
713
|
catch (cause) {
|
|
689
714
|
return await fail({
|
|
@@ -723,11 +748,11 @@ export class HonoBff {
|
|
|
723
748
|
await this.#store.destroy(cookieValue);
|
|
724
749
|
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
725
750
|
}
|
|
726
|
-
const returnTo = safeReturnTo(c.req.query("return_to")
|
|
751
|
+
const returnTo = this.#safeReturnTo(c.req.query("return_to"));
|
|
727
752
|
if (this.#options.rpInitiatedLogout) {
|
|
728
753
|
const endSession = this.#client.endSessionEndpoint;
|
|
729
754
|
if (endSession) {
|
|
730
|
-
const origin = this.#
|
|
755
|
+
const origin = this.#appOrigin(c);
|
|
731
756
|
const url = new URL(endSession, origin);
|
|
732
757
|
if (idToken)
|
|
733
758
|
url.searchParams.set("id_token_hint", idToken);
|
|
@@ -88,6 +88,23 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
|
|
|
88
88
|
* outer middleware already terminates CSRF.
|
|
89
89
|
*/
|
|
90
90
|
csrf?: HonoIdentityCsrfOptions | false;
|
|
91
|
+
/**
|
|
92
|
+
* The most bytes a route reads from a request body. Every route reads its
|
|
93
|
+
* body before anyone is authenticated, so the cap bounds what an anonymous
|
|
94
|
+
* request can make the server buffer. Bytes are counted as they arrive, so a
|
|
95
|
+
* chunked body or an understated `Content-Length` cannot get past it. A
|
|
96
|
+
* larger body is refused with `413` `{ error: "invalid_request" }` before
|
|
97
|
+
* the service is called.
|
|
98
|
+
*
|
|
99
|
+
* When a middleware mounted before these routes has already read the body
|
|
100
|
+
* (for example with `c.req.parseBody()`), the routes read Hono's parsed copy
|
|
101
|
+
* and the cap does not apply, so that middleware needs its own limit.
|
|
102
|
+
*
|
|
103
|
+
* @default 65536
|
|
104
|
+
* @throws {RangeError} From {@linkcode honoIdentityRoutes} when it is not a
|
|
105
|
+
* positive integer.
|
|
106
|
+
*/
|
|
107
|
+
maxBodyBytes?: number;
|
|
91
108
|
/**
|
|
92
109
|
* Called after `signup`/`signin` authenticates a user. Your app owns what
|
|
93
110
|
* happens next — create your session and return a `Response` (e.g. a redirect
|
|
@@ -112,7 +129,12 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
|
|
|
112
129
|
* Every mounted route sits behind the same-origin guard described in
|
|
113
130
|
* {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
|
|
114
131
|
*
|
|
132
|
+
* Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
|
|
133
|
+
* body and answers a larger one with `413`.
|
|
134
|
+
*
|
|
115
135
|
* Passwordless, MFA, and social are intentionally not mounted — see the module
|
|
116
136
|
* doc for why and where to hand-wire each.
|
|
137
|
+
*
|
|
138
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
117
139
|
*/
|
|
118
140
|
export declare function honoIdentityRoutes<User extends IdentityUser>(service: IdentityService<User>, options?: HonoIdentityOptions<User>): Hono;
|
|
@@ -41,12 +41,16 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { Hono } from "hono";
|
|
43
43
|
import { identityErrorStatus, isIdentityError, } from "../../identity/errors.js";
|
|
44
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../utils/_body.js";
|
|
44
45
|
function handle(fn) {
|
|
45
46
|
return async (c) => {
|
|
46
47
|
try {
|
|
47
48
|
return await fn(c);
|
|
48
49
|
}
|
|
49
50
|
catch (error) {
|
|
51
|
+
if (error instanceof BodyTooLargeError) {
|
|
52
|
+
return c.json({ error: "invalid_request" }, 413);
|
|
53
|
+
}
|
|
50
54
|
if (!isIdentityError(error))
|
|
51
55
|
throw error;
|
|
52
56
|
if (error.retryAfterMs !== undefined) {
|
|
@@ -57,11 +61,24 @@ function handle(fn) {
|
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
const STRING_FIELDS = new Set(["password", "identifier", "email", "token"]);
|
|
60
|
-
|
|
64
|
+
class BodyTooLargeError extends Error {
|
|
65
|
+
}
|
|
66
|
+
async function boundedBody(c, maxBytes) {
|
|
67
|
+
if (c.req.raw.bodyUsed)
|
|
68
|
+
return c.req;
|
|
69
|
+
const bytes = await readBoundedBody(c.req.raw, maxBytes).catch(() => new Uint8Array());
|
|
70
|
+
if (!bytes)
|
|
71
|
+
throw new BodyTooLargeError();
|
|
72
|
+
return new Response(bytes, {
|
|
73
|
+
headers: { "content-type": c.req.header("content-type") ?? "" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function readBody(c, maxBytes) {
|
|
61
77
|
const contentType = c.req.header("content-type") ?? "";
|
|
78
|
+
const source = await boundedBody(c, maxBytes);
|
|
62
79
|
if (contentType.includes("application/json")) {
|
|
63
80
|
try {
|
|
64
|
-
const body = await
|
|
81
|
+
const body = await source.json();
|
|
65
82
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
66
83
|
return {};
|
|
67
84
|
}
|
|
@@ -79,7 +96,7 @@ async function readBody(c) {
|
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
98
|
try {
|
|
82
|
-
const form = await
|
|
99
|
+
const form = await source.formData();
|
|
83
100
|
const out = {};
|
|
84
101
|
for (const [key, value] of form)
|
|
85
102
|
out[key] = String(value);
|
|
@@ -135,10 +152,17 @@ function sameOriginGuard(options) {
|
|
|
135
152
|
* Every mounted route sits behind the same-origin guard described in
|
|
136
153
|
* {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
|
|
137
154
|
*
|
|
155
|
+
* Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
|
|
156
|
+
* body and answers a larger one with `413`.
|
|
157
|
+
*
|
|
138
158
|
* Passwordless, MFA, and social are intentionally not mounted — see the module
|
|
139
159
|
* doc for why and where to hand-wire each.
|
|
160
|
+
*
|
|
161
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
140
162
|
*/
|
|
141
163
|
export function honoIdentityRoutes(service, options = {}) {
|
|
164
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
165
|
+
assertMaxBodyBytes(maxBodyBytes, "maxBodyBytes");
|
|
142
166
|
const app = new Hono();
|
|
143
167
|
const paths = {
|
|
144
168
|
signUp: "/signup",
|
|
@@ -154,7 +178,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
154
178
|
}
|
|
155
179
|
if (onAuthenticated) {
|
|
156
180
|
app.post(paths.signUp, handle(async (c) => {
|
|
157
|
-
const { password, ...profile } = await readBody(c);
|
|
181
|
+
const { password, ...profile } = await readBody(c, maxBodyBytes);
|
|
158
182
|
if (typeof password !== "string" || !password) {
|
|
159
183
|
return c.json({ error: "invalid_request" }, 400);
|
|
160
184
|
}
|
|
@@ -162,7 +186,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
162
186
|
return await onAuthenticated(c, user, "signUp");
|
|
163
187
|
}));
|
|
164
188
|
app.post(paths.signIn, handle(async (c) => {
|
|
165
|
-
const body = await readBody(c);
|
|
189
|
+
const body = await readBody(c, maxBodyBytes);
|
|
166
190
|
const user = await service.signIn({
|
|
167
191
|
identifier: String(body.identifier ?? ""),
|
|
168
192
|
password: String(body.password ?? ""),
|
|
@@ -173,12 +197,12 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
173
197
|
}));
|
|
174
198
|
}
|
|
175
199
|
app.post(paths.requestPasswordReset, handle(async (c) => {
|
|
176
|
-
const body = await readBody(c);
|
|
200
|
+
const body = await readBody(c, maxBodyBytes);
|
|
177
201
|
await service.requestPasswordReset(String(body.email ?? ""));
|
|
178
202
|
return c.json({ ok: true });
|
|
179
203
|
}));
|
|
180
204
|
app.post(paths.resetPassword, handle(async (c) => {
|
|
181
|
-
const body = await readBody(c);
|
|
205
|
+
const body = await readBody(c, maxBodyBytes);
|
|
182
206
|
const result = await service.resetPassword({
|
|
183
207
|
token: String(body.token ?? ""),
|
|
184
208
|
password: String(body.password ?? ""),
|
|
@@ -188,7 +212,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
188
212
|
: c.json({ error: "invalid_token" }, 400);
|
|
189
213
|
}));
|
|
190
214
|
app.post(paths.verifyEmail, handle(async (c) => {
|
|
191
|
-
const body = await readBody(c);
|
|
215
|
+
const body = await readBody(c, maxBodyBytes);
|
|
192
216
|
const result = await service.verifyEmail(String(body.token ?? ""));
|
|
193
217
|
if (result.status === "success") {
|
|
194
218
|
return c.json({ ok: true, userId: result.userId });
|
|
@@ -342,6 +342,15 @@ export declare class DirectClient extends OAuth2ClientBase {
|
|
|
342
342
|
* available. It is a public identifier, never a credential.
|
|
343
343
|
*/
|
|
344
344
|
get clientId(): string;
|
|
345
|
+
/**
|
|
346
|
+
* The configured `redirect_uri`, or `undefined` when none was given.
|
|
347
|
+
*
|
|
348
|
+
* Exposed so a Backend-for-Frontend behind a TLS-terminating proxy can
|
|
349
|
+
* take the application's public origin from configuration rather than from
|
|
350
|
+
* a request URL that reports the proxy's plain-HTTP hop. See
|
|
351
|
+
* `HonoBffOptions.rpInitiatedLogout`.
|
|
352
|
+
*/
|
|
353
|
+
get redirectUri(): string | undefined;
|
|
345
354
|
/**
|
|
346
355
|
* Builds the PKCE-protected authorize URL and persists the verifier under
|
|
347
356
|
* the returned `state`. Navigate the browser to `url`; the callback route
|
|
@@ -280,6 +280,17 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
280
280
|
get clientId() {
|
|
281
281
|
return this.#clientId;
|
|
282
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* The configured `redirect_uri`, or `undefined` when none was given.
|
|
285
|
+
*
|
|
286
|
+
* Exposed so a Backend-for-Frontend behind a TLS-terminating proxy can
|
|
287
|
+
* take the application's public origin from configuration rather than from
|
|
288
|
+
* a request URL that reports the proxy's plain-HTTP hop. See
|
|
289
|
+
* `HonoBffOptions.rpInitiatedLogout`.
|
|
290
|
+
*/
|
|
291
|
+
get redirectUri() {
|
|
292
|
+
return this.#redirectUri;
|
|
293
|
+
}
|
|
283
294
|
async #endpoint(key) {
|
|
284
295
|
if (this.#issuer && !this.#hasFreshMetadata())
|
|
285
296
|
await this.discover();
|
package/dist/client/storage.d.ts
CHANGED
|
@@ -135,17 +135,35 @@ export interface MemoryAuthRequestStorageOptions {
|
|
|
135
135
|
* in ms. Defaults to 10 minutes; `Infinity` never prunes.
|
|
136
136
|
*/
|
|
137
137
|
ttlMs?: number;
|
|
138
|
+
/**
|
|
139
|
+
* Most records held at once. A write beyond it evicts the oldest-written
|
|
140
|
+
* record, so a flood of sign-in starts that never reach the callback cannot
|
|
141
|
+
* grow the process without bound — at the cost of failing each evicted
|
|
142
|
+
* sign-in with an unknown `state`. Defaults to 1000; `Infinity` never
|
|
143
|
+
* evicts by count.
|
|
144
|
+
*/
|
|
145
|
+
maxEntries?: number;
|
|
138
146
|
}
|
|
139
147
|
/**
|
|
140
148
|
* In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
|
|
141
149
|
* browser document. Data is lost when the process exits. Every write prunes
|
|
142
|
-
* records older than {@link MemoryAuthRequestStorageOptions.ttlMs},
|
|
143
|
-
*
|
|
144
|
-
*
|
|
150
|
+
* records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
|
|
151
|
+
* evicts the oldest-written records beyond
|
|
152
|
+
* {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
|
|
153
|
+
* never return to the callback nor an anonymous burst of sign-in starts can
|
|
154
|
+
* grow a long-running server without bound. A deployment with more than one
|
|
155
|
+
* instance, or more pending sign-ins than the cap, needs storage every
|
|
156
|
+
* instance shares instead.
|
|
145
157
|
*/
|
|
146
158
|
export declare class MemoryAuthRequestStorage implements AuthRequestStorage {
|
|
147
159
|
#private;
|
|
148
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Configures the pruning TTL (10 minutes by default) and the record cap
|
|
162
|
+
* (1000 by default).
|
|
163
|
+
*
|
|
164
|
+
* @throws {RangeError} When `maxEntries` is neither a positive integer nor
|
|
165
|
+
* `Infinity`.
|
|
166
|
+
*/
|
|
149
167
|
constructor(options?: MemoryAuthRequestStorageOptions);
|
|
150
168
|
/** Returns the record for {@linkcode state}, or `null` if none is stored. */
|
|
151
169
|
get(state: string): AuthRequestRecord | null;
|
|
@@ -153,7 +171,8 @@ export declare class MemoryAuthRequestStorage implements AuthRequestStorage {
|
|
|
153
171
|
take(state: string): AuthRequestRecord | null;
|
|
154
172
|
/**
|
|
155
173
|
* Stores {@linkcode value} under {@linkcode state}, first pruning records
|
|
156
|
-
* past the TTL
|
|
174
|
+
* past the TTL, then evicting the oldest-written records beyond
|
|
175
|
+
* `maxEntries`. Pruning walks records oldest-write-first and stops at the
|
|
157
176
|
* first one inside the TTL, so it assumes each `createdAt` is the time of
|
|
158
177
|
* its write, as `DirectClient.login` sets it.
|
|
159
178
|
*/
|
package/dist/client/storage.js
CHANGED
|
@@ -53,19 +53,37 @@ export class MemoryRefreshTokenStorage {
|
|
|
53
53
|
this.#token = null;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
const DEFAULT_MAX_AUTH_REQUESTS = 1000;
|
|
56
57
|
/**
|
|
57
58
|
* In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
|
|
58
59
|
* browser document. Data is lost when the process exits. Every write prunes
|
|
59
|
-
* records older than {@link MemoryAuthRequestStorageOptions.ttlMs},
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
|
|
61
|
+
* evicts the oldest-written records beyond
|
|
62
|
+
* {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
|
|
63
|
+
* never return to the callback nor an anonymous burst of sign-in starts can
|
|
64
|
+
* grow a long-running server without bound. A deployment with more than one
|
|
65
|
+
* instance, or more pending sign-ins than the cap, needs storage every
|
|
66
|
+
* instance shares instead.
|
|
62
67
|
*/
|
|
63
68
|
export class MemoryAuthRequestStorage {
|
|
64
69
|
#records = new Map();
|
|
65
70
|
#ttlMs;
|
|
66
|
-
|
|
71
|
+
#maxEntries;
|
|
72
|
+
/**
|
|
73
|
+
* Configures the pruning TTL (10 minutes by default) and the record cap
|
|
74
|
+
* (1000 by default).
|
|
75
|
+
*
|
|
76
|
+
* @throws {RangeError} When `maxEntries` is neither a positive integer nor
|
|
77
|
+
* `Infinity`.
|
|
78
|
+
*/
|
|
67
79
|
constructor(options = {}) {
|
|
68
80
|
this.#ttlMs = options.ttlMs ?? 10 * 60 * 1000;
|
|
81
|
+
this.#maxEntries = options.maxEntries ?? DEFAULT_MAX_AUTH_REQUESTS;
|
|
82
|
+
if (this.#maxEntries !== Infinity &&
|
|
83
|
+
!(Number.isInteger(this.#maxEntries) && this.#maxEntries > 0)) {
|
|
84
|
+
throw new RangeError("MemoryAuthRequestStorage maxEntries must be a positive integer or " +
|
|
85
|
+
`Infinity, got ${this.#maxEntries}.`);
|
|
86
|
+
}
|
|
69
87
|
}
|
|
70
88
|
/** Returns the record for {@linkcode state}, or `null` if none is stored. */
|
|
71
89
|
get(state) {
|
|
@@ -79,7 +97,8 @@ export class MemoryAuthRequestStorage {
|
|
|
79
97
|
}
|
|
80
98
|
/**
|
|
81
99
|
* Stores {@linkcode value} under {@linkcode state}, first pruning records
|
|
82
|
-
* past the TTL
|
|
100
|
+
* past the TTL, then evicting the oldest-written records beyond
|
|
101
|
+
* `maxEntries`. Pruning walks records oldest-write-first and stops at the
|
|
83
102
|
* first one inside the TTL, so it assumes each `createdAt` is the time of
|
|
84
103
|
* its write, as `DirectClient.login` sets it.
|
|
85
104
|
*/
|
|
@@ -87,6 +106,7 @@ export class MemoryAuthRequestStorage {
|
|
|
87
106
|
this.#pruneExpired();
|
|
88
107
|
this.#records.delete(state);
|
|
89
108
|
this.#records.set(state, value);
|
|
109
|
+
this.#evictOverflow();
|
|
90
110
|
}
|
|
91
111
|
/** Removes the record for {@linkcode state}. */
|
|
92
112
|
delete(state) {
|
|
@@ -96,6 +116,13 @@ export class MemoryAuthRequestStorage {
|
|
|
96
116
|
clear() {
|
|
97
117
|
this.#records.clear();
|
|
98
118
|
}
|
|
119
|
+
#evictOverflow() {
|
|
120
|
+
for (const state of this.#records.keys()) {
|
|
121
|
+
if (this.#records.size <= this.#maxEntries)
|
|
122
|
+
return;
|
|
123
|
+
this.#records.delete(state);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
99
126
|
#pruneExpired() {
|
|
100
127
|
if (this.#ttlMs === Infinity)
|
|
101
128
|
return;
|
|
@@ -444,7 +444,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
444
444
|
*
|
|
445
445
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
446
446
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
447
|
-
* @throws {RangeError} If `maxBodyBytes` is not a
|
|
447
|
+
* @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
|
|
448
|
+
* positive integer.
|
|
448
449
|
*/
|
|
449
450
|
constructor(options: AuthorizationServerOptions<Client, User, S>);
|
|
450
451
|
/**
|
|
@@ -197,7 +197,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
197
197
|
*
|
|
198
198
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
199
199
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
200
|
-
* @throws {RangeError} If `maxBodyBytes` is not a
|
|
200
|
+
* @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
|
|
201
|
+
* positive integer.
|
|
201
202
|
*/
|
|
202
203
|
constructor(options) {
|
|
203
204
|
super({
|
|
@@ -205,6 +206,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
205
206
|
Scope: options.Scope,
|
|
206
207
|
realm: options.realm,
|
|
207
208
|
clockSkewSeconds: options.clockSkewSeconds,
|
|
209
|
+
maxTokenBodyBytes: options.maxTokenBodyBytes,
|
|
208
210
|
errorFormat: options.errorFormat,
|
|
209
211
|
throwOnError: options.throwOnError,
|
|
210
212
|
});
|
|
@@ -55,6 +55,27 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
|
|
|
55
55
|
* matter which reader is in play.
|
|
56
56
|
*/
|
|
57
57
|
clockSkewSeconds?: number;
|
|
58
|
+
/**
|
|
59
|
+
* The most bytes {@linkcode ResourceServer.getAccessToken} reads from a
|
|
60
|
+
* form-encoded `POST` body while looking for an RFC 6750 §2.2
|
|
61
|
+
* `access_token`, which it does only when no bearer `Authorization` header
|
|
62
|
+
* is sent. That read happens before the caller is authenticated, so the cap
|
|
63
|
+
* bounds what an anonymous request can make the server buffer, including
|
|
64
|
+
* through `protect()` middleware mounted ahead of a route's own body limit.
|
|
65
|
+
* Bytes are counted as they arrive, so a chunked body or an understated
|
|
66
|
+
* `Content-Length` cannot get past it.
|
|
67
|
+
*
|
|
68
|
+
* A larger body is not searched: the request is treated as carrying no
|
|
69
|
+
* token (`authenticate` throws {@linkcode AccessDeniedError}, a `401`), and
|
|
70
|
+
* the body is left for the route. A client that sends a bigger form body
|
|
71
|
+
* should send its token in the `Authorization` header instead, as RFC 6750
|
|
72
|
+
* §2.1 recommends.
|
|
73
|
+
*
|
|
74
|
+
* @default 16384
|
|
75
|
+
* @throws {RangeError} From the constructor when it is not a positive
|
|
76
|
+
* integer.
|
|
77
|
+
*/
|
|
78
|
+
maxTokenBodyBytes?: number;
|
|
58
79
|
/**
|
|
59
80
|
* Format for error responses.
|
|
60
81
|
* - `"oauth2"` (default): Standard OAuth2 error format with `error`,
|
|
@@ -162,7 +183,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
|
|
|
162
183
|
* Creates a resource server from the given {@linkcode ResourceServerOptions}.
|
|
163
184
|
*
|
|
164
185
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
165
|
-
* overflows when converted to milliseconds
|
|
186
|
+
* overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
|
|
187
|
+
* not a positive integer.
|
|
166
188
|
*/
|
|
167
189
|
constructor(options: ResourceServerOptions<Client, User, S>);
|
|
168
190
|
/**
|
|
@@ -239,8 +261,11 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
|
|
|
239
261
|
handleAuthError(error: unknown): Response;
|
|
240
262
|
/**
|
|
241
263
|
* Extracts an access token from a request.
|
|
242
|
-
* Looks in the Authorization header (Bearer token) first,
|
|
243
|
-
*
|
|
264
|
+
* Looks in the Authorization header (Bearer token) first, then in the body
|
|
265
|
+
* of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
|
|
266
|
+
* original body stays readable. A body over
|
|
267
|
+
* {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
|
|
268
|
+
* no more of it is read than the cap.
|
|
244
269
|
*/
|
|
245
270
|
getAccessToken(request: Request): Promise<string | null>;
|
|
246
271
|
/**
|
|
@@ -10,6 +10,7 @@ import { BasicScope } from "../models/scope.js";
|
|
|
10
10
|
import { AccessDeniedError, InsufficientPermissionsError, InsufficientScopeError, InvalidTokenError, toOAuth2Error, } from "../errors.js";
|
|
11
11
|
import { authorizationFromClaims, } from "../models/authorization.js";
|
|
12
12
|
import { assertClockSkewSeconds } from "./_clock-skew.js";
|
|
13
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
|
|
13
14
|
/** Bearer token regex pattern. */
|
|
14
15
|
export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
|
|
15
16
|
const FAILURE_LABELS = {
|
|
@@ -80,6 +81,7 @@ export class ResourceServer {
|
|
|
80
81
|
/** Resolves the per-request {@link ResourceServerContext}. */
|
|
81
82
|
resolveContext;
|
|
82
83
|
#clockSkewSeconds = 0;
|
|
84
|
+
#maxTokenBodyBytes;
|
|
83
85
|
/**
|
|
84
86
|
* Leeway in seconds applied to the access token's expiry. Defaults to `0`.
|
|
85
87
|
* May be reassigned to any non-negative finite number with a finite
|
|
@@ -102,13 +104,17 @@ export class ResourceServer {
|
|
|
102
104
|
* Creates a resource server from the given {@linkcode ResourceServerOptions}.
|
|
103
105
|
*
|
|
104
106
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
105
|
-
* overflows when converted to milliseconds
|
|
107
|
+
* overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
|
|
108
|
+
* not a positive integer.
|
|
106
109
|
*/
|
|
107
110
|
constructor(options) {
|
|
108
111
|
this.Scope =
|
|
109
112
|
options.Scope ?? BasicScope;
|
|
110
113
|
this.realm = options.realm ?? "Service";
|
|
111
114
|
this.clockSkewSeconds = options.clockSkewSeconds ?? 0;
|
|
115
|
+
this.#maxTokenBodyBytes =
|
|
116
|
+
options.maxTokenBodyBytes ?? DEFAULT_MAX_TOKEN_BODY_BYTES;
|
|
117
|
+
assertMaxBodyBytes(this.#maxTokenBodyBytes, "maxTokenBodyBytes");
|
|
112
118
|
this.errorFormat = options.errorFormat ?? "oauth2";
|
|
113
119
|
this.throwOnError = options.throwOnError ?? false;
|
|
114
120
|
this.resolveContext = options.resolve;
|
|
@@ -252,8 +258,11 @@ export class ResourceServer {
|
|
|
252
258
|
}
|
|
253
259
|
/**
|
|
254
260
|
* Extracts an access token from a request.
|
|
255
|
-
* Looks in the Authorization header (Bearer token) first,
|
|
256
|
-
*
|
|
261
|
+
* Looks in the Authorization header (Bearer token) first, then in the body
|
|
262
|
+
* of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
|
|
263
|
+
* original body stays readable. A body over
|
|
264
|
+
* {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
|
|
265
|
+
* no more of it is read than the cap.
|
|
257
266
|
*/
|
|
258
267
|
async getAccessToken(request) {
|
|
259
268
|
const authorization = request.headers.get("authorization");
|
|
@@ -266,13 +275,18 @@ export class ResourceServer {
|
|
|
266
275
|
if (request.method === "POST" &&
|
|
267
276
|
contentType?.includes("application/x-www-form-urlencoded")) {
|
|
268
277
|
try {
|
|
269
|
-
const
|
|
278
|
+
const bytes = await peekBoundedBody(request, this.#maxTokenBodyBytes);
|
|
279
|
+
if (!bytes)
|
|
280
|
+
return null;
|
|
281
|
+
const body = await new Response(bytes, {
|
|
282
|
+
headers: { "content-type": contentType },
|
|
283
|
+
}).formData();
|
|
270
284
|
const accessToken = body.get("access_token");
|
|
271
285
|
if (typeof accessToken === "string")
|
|
272
286
|
return accessToken;
|
|
273
287
|
}
|
|
274
288
|
catch {
|
|
275
|
-
|
|
289
|
+
return null;
|
|
276
290
|
}
|
|
277
291
|
}
|
|
278
292
|
return null;
|
package/dist/utils/_body.d.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
12
|
*/
|
|
13
13
|
export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
14
|
+
/**
|
|
15
|
+
* The default cap on a form body searched for an RFC 6750 §2.2
|
|
16
|
+
* `access_token`: 16 KiB, Node's default limit on a request's whole header
|
|
17
|
+
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
|
|
14
20
|
/**
|
|
15
21
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
22
|
* safe integer.
|
|
@@ -27,3 +33,12 @@ export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string)
|
|
|
27
33
|
export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
|
|
28
34
|
keepReadable?: boolean;
|
|
29
35
|
}): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Reads at most `maxBytes` of `request`'s body from a clone and returns
|
|
38
|
+
* `null` once it exceeds `maxBytes`, cancelling only the clone. The original
|
|
39
|
+
* stays readable either way, holding no more than what the clone pulled, so
|
|
40
|
+
* the caller's own handling of the body — or of its absence — is unchanged.
|
|
41
|
+
* A `Content-Length` over the cap returns `null` without reading. Rejects if
|
|
42
|
+
* the body was already read.
|
|
43
|
+
*/
|
|
44
|
+
export declare function peekBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
package/dist/utils/_body.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
12
|
*/
|
|
13
13
|
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The default cap on a form body searched for an RFC 6750 §2.2
|
|
16
|
+
* `access_token`: 16 KiB, Node's default limit on a request's whole header
|
|
17
|
+
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
|
|
14
20
|
/**
|
|
15
21
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
22
|
* safe integer.
|
|
@@ -41,6 +47,21 @@ export async function readBoundedBody(request, maxBytes, options = {}) {
|
|
|
41
47
|
request.body.cancel().catch(() => { });
|
|
42
48
|
return bytes;
|
|
43
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads at most `maxBytes` of `request`'s body from a clone and returns
|
|
52
|
+
* `null` once it exceeds `maxBytes`, cancelling only the clone. The original
|
|
53
|
+
* stays readable either way, holding no more than what the clone pulled, so
|
|
54
|
+
* the caller's own handling of the body — or of its absence — is unchanged.
|
|
55
|
+
* A `Content-Length` over the cap returns `null` without reading. Rejects if
|
|
56
|
+
* the body was already read.
|
|
57
|
+
*/
|
|
58
|
+
export async function peekBoundedBody(request, maxBytes) {
|
|
59
|
+
if (!request.body)
|
|
60
|
+
return new Uint8Array();
|
|
61
|
+
if (Number(request.headers.get("content-length")) > maxBytes)
|
|
62
|
+
return null;
|
|
63
|
+
return await readUpTo(request.clone().body, maxBytes);
|
|
64
|
+
}
|
|
44
65
|
async function readUpTo(body, maxBytes) {
|
|
45
66
|
const reader = body.getReader();
|
|
46
67
|
const chunks = [];
|
package/dist/utils/url.d.ts
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
+
/** {@link SafeReturnToOptions.maxLength}'s default, in UTF-16 code units. */
|
|
7
|
+
export declare const DEFAULT_MAX_RETURN_TO_LENGTH = 2048;
|
|
8
|
+
/** Options for {@link safeReturnTo}. */
|
|
9
|
+
export interface SafeReturnToOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Longest value accepted, in UTF-16 code units (`String.length`), not
|
|
12
|
+
* bytes; a longer one collapses to the fallback. Defaults to {@link DEFAULT_MAX_RETURN_TO_LENGTH} (2048).
|
|
13
|
+
* `Infinity` accepts any length.
|
|
14
|
+
*/
|
|
15
|
+
maxLength?: number;
|
|
16
|
+
}
|
|
6
17
|
/**
|
|
7
18
|
* Open-redirect guard for a `return_to` / redirect target.
|
|
8
19
|
*
|
|
@@ -19,6 +30,11 @@
|
|
|
19
30
|
* Percent-encoded control characters (`/%09/x`) are safe and pass through: a
|
|
20
31
|
* browser does not decode them before parsing, so they stay on the origin.
|
|
21
32
|
*
|
|
33
|
+
* A value longer than {@link SafeReturnToOptions.maxLength} (2048 characters
|
|
34
|
+
* by default) also collapses to `fallback`, so a caller that stores the
|
|
35
|
+
* result — a pending sign-in, a session — cannot be made to hold an
|
|
36
|
+
* arbitrarily large string per anonymous request.
|
|
37
|
+
*
|
|
22
38
|
* Use this anywhere a user-influenced value — a `return_to` / `redirect` query
|
|
23
39
|
* param or form field — is fed into a redirect `Location`, so a crafted
|
|
24
40
|
* absolute target can't turn the (often post-authentication) redirect into an
|
|
@@ -32,9 +48,10 @@
|
|
|
32
48
|
* safeReturnTo("/\\evil.com"); // → "/"
|
|
33
49
|
* safeReturnTo("/\t/evil.com"); // → "/"
|
|
34
50
|
* safeReturnTo(undefined, "/home"); // → "/home"
|
|
51
|
+
* safeReturnTo("/a-long-path", "/", { maxLength: 8 }); // → "/"
|
|
35
52
|
* ```
|
|
36
53
|
*/
|
|
37
|
-
export declare function safeReturnTo(value: string | null | undefined, fallback?: string): string;
|
|
54
|
+
export declare function safeReturnTo(value: string | null | undefined, fallback?: string, options?: SafeReturnToOptions): string;
|
|
38
55
|
/** Trusted application configuration for {@link loginContinuation}. */
|
|
39
56
|
export interface LoginContinuationOptions {
|
|
40
57
|
/**
|
|
@@ -47,6 +64,11 @@ export interface LoginContinuationOptions {
|
|
|
47
64
|
loginPath?: string;
|
|
48
65
|
/** Trusted fallback for missing/unsafe `returnTo`. Defaults to `"/"`. */
|
|
49
66
|
defaultReturnTo?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Longest `returnTo` accepted, in characters; a longer one falls back to
|
|
69
|
+
* {@link defaultReturnTo}. Defaults to 2048 ({@link safeReturnTo}'s cap).
|
|
70
|
+
*/
|
|
71
|
+
maxReturnToLength?: number;
|
|
50
72
|
}
|
|
51
73
|
/**
|
|
52
74
|
* The URL that (re)starts sign-in toward `returnTo`, for an app that owns its
|
package/dist/utils/url.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
const RESOLUTION_ORIGIN = "http://safe-return-to.invalid";
|
|
7
|
+
/** {@link SafeReturnToOptions.maxLength}'s default, in UTF-16 code units. */
|
|
8
|
+
export const DEFAULT_MAX_RETURN_TO_LENGTH = 2048;
|
|
7
9
|
function hasControlCharacter(value) {
|
|
8
10
|
for (let index = 0; index < value.length; index++) {
|
|
9
11
|
const code = value.charCodeAt(index);
|
|
@@ -28,6 +30,11 @@ function hasControlCharacter(value) {
|
|
|
28
30
|
* Percent-encoded control characters (`/%09/x`) are safe and pass through: a
|
|
29
31
|
* browser does not decode them before parsing, so they stay on the origin.
|
|
30
32
|
*
|
|
33
|
+
* A value longer than {@link SafeReturnToOptions.maxLength} (2048 characters
|
|
34
|
+
* by default) also collapses to `fallback`, so a caller that stores the
|
|
35
|
+
* result — a pending sign-in, a session — cannot be made to hold an
|
|
36
|
+
* arbitrarily large string per anonymous request.
|
|
37
|
+
*
|
|
31
38
|
* Use this anywhere a user-influenced value — a `return_to` / `redirect` query
|
|
32
39
|
* param or form field — is fed into a redirect `Location`, so a crafted
|
|
33
40
|
* absolute target can't turn the (often post-authentication) redirect into an
|
|
@@ -41,11 +48,15 @@ function hasControlCharacter(value) {
|
|
|
41
48
|
* safeReturnTo("/\\evil.com"); // → "/"
|
|
42
49
|
* safeReturnTo("/\t/evil.com"); // → "/"
|
|
43
50
|
* safeReturnTo(undefined, "/home"); // → "/home"
|
|
51
|
+
* safeReturnTo("/a-long-path", "/", { maxLength: 8 }); // → "/"
|
|
44
52
|
* ```
|
|
45
53
|
*/
|
|
46
|
-
export function safeReturnTo(value, fallback = "/") {
|
|
54
|
+
export function safeReturnTo(value, fallback = "/", options = {}) {
|
|
47
55
|
if (!value || !value.startsWith("/"))
|
|
48
56
|
return fallback;
|
|
57
|
+
if (value.length > (options.maxLength ?? DEFAULT_MAX_RETURN_TO_LENGTH)) {
|
|
58
|
+
return fallback;
|
|
59
|
+
}
|
|
49
60
|
if (hasControlCharacter(value))
|
|
50
61
|
return fallback;
|
|
51
62
|
let resolved;
|
|
@@ -79,7 +90,9 @@ export function safeReturnTo(value, fallback = "/") {
|
|
|
79
90
|
* endpoint and login path are trusted application configuration.
|
|
80
91
|
*/
|
|
81
92
|
export function loginContinuation(returnTo, options = {}) {
|
|
82
|
-
const safe = safeReturnTo(returnTo, options.defaultReturnTo ?? "/"
|
|
93
|
+
const safe = safeReturnTo(returnTo, options.defaultReturnTo ?? "/", {
|
|
94
|
+
maxLength: options.maxReturnToLength,
|
|
95
|
+
});
|
|
83
96
|
if (options.authorizeEndpoint) {
|
|
84
97
|
const base = "http://login.continuation.local";
|
|
85
98
|
const authorizePath = new URL(options.authorizeEndpoint, base).pathname;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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
|
"keywords": [
|
|
6
6
|
"authorization-server",
|