@udibo/oauth2 0.15.2 → 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/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/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);
|
|
@@ -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;
|
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",
|