@udibo/oauth2 0.20.0 → 0.21.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/cli/idp/server.js +19 -4
- package/dist/identity/external/_shared.d.ts +17 -2
- package/dist/identity/external/_shared.js +35 -3
- package/dist/identity/external/_token-exchange.js +2 -2
- package/dist/identity/external/apple.js +3 -2
- package/dist/identity/external/github.js +3 -2
- package/dist/identity/external/oauth2.js +3 -2
- package/dist/identity/external/oidc.js +2 -2
- package/dist/identity/hibp.d.ts +10 -0
- package/dist/identity/hibp.js +11 -1
- package/dist/server/_response-json.d.ts +16 -0
- package/dist/server/_response-json.js +35 -0
- package/dist/server/introspection-token-reader.d.ts +17 -5
- package/dist/server/introspection-token-reader.js +16 -15
- package/dist/server/jwks-token-reader.d.ts +16 -5
- package/dist/server/jwks-token-reader.js +15 -11
- package/dist/utils/_body.d.ts +20 -9
- package/dist/utils/_body.js +19 -8
- package/package.json +1 -1
package/dist/cli/idp/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { PasswordGrant } from "../../server/grants/password.js";
|
|
|
23
23
|
import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
|
|
24
24
|
import { StaticSigningKeyProvider, } from "../../server/signing-keys.js";
|
|
25
25
|
import { generateCodeChallenge, generateCodeVerifier, generateState, } from "../../utils/pkce.js";
|
|
26
|
+
import { DEFAULT_MAX_BODY_BYTES, readBoundedBody } from "../../utils/_body.js";
|
|
26
27
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
27
28
|
import { safeReturnTo } from "../../utils/url.js";
|
|
28
29
|
import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "../../testing/services.js";
|
|
@@ -218,8 +219,22 @@ function handleConsentFor(state, request) {
|
|
|
218
219
|
}));
|
|
219
220
|
};
|
|
220
221
|
}
|
|
222
|
+
class BodyTooLargeError extends Error {
|
|
223
|
+
}
|
|
224
|
+
async function readBody(request) {
|
|
225
|
+
const bytes = await readBoundedBody(request, DEFAULT_MAX_BODY_BYTES);
|
|
226
|
+
if (!bytes) {
|
|
227
|
+
throw new BodyTooLargeError(`request body exceeds ${DEFAULT_MAX_BODY_BYTES} bytes`);
|
|
228
|
+
}
|
|
229
|
+
return bytes;
|
|
230
|
+
}
|
|
231
|
+
async function readFormBody(request) {
|
|
232
|
+
return await new Response(await readBody(request), {
|
|
233
|
+
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
|
234
|
+
}).formData();
|
|
235
|
+
}
|
|
221
236
|
async function handleLoginSubmission(state, config, request) {
|
|
222
|
-
const form = await request
|
|
237
|
+
const form = await readFormBody(request);
|
|
223
238
|
const returnTo = safeReturnTo(formValue(form, "return_to"));
|
|
224
239
|
const quickSignIn = formValue(form, "as");
|
|
225
240
|
let user;
|
|
@@ -252,7 +267,7 @@ async function handleConsentSubmission(state, request) {
|
|
|
252
267
|
const user = await currentUser(state, request);
|
|
253
268
|
if (!user)
|
|
254
269
|
return redirect("/login");
|
|
255
|
-
const form = await request
|
|
270
|
+
const form = await readFormBody(request);
|
|
256
271
|
const returnTo = safeReturnTo(formValue(form, "return_to"));
|
|
257
272
|
const target = new URL(returnTo, request.url);
|
|
258
273
|
const clientId = target.searchParams.get("client_id");
|
|
@@ -294,7 +309,7 @@ function handleLogout(state, config, request) {
|
|
|
294
309
|
});
|
|
295
310
|
}
|
|
296
311
|
async function readJsonBody(request) {
|
|
297
|
-
const text = await request
|
|
312
|
+
const text = new TextDecoder().decode(await readBody(request));
|
|
298
313
|
if (text.trim().length === 0)
|
|
299
314
|
return {};
|
|
300
315
|
const parsed = JSON.parse(text);
|
|
@@ -522,7 +537,7 @@ export async function startDevIdentityProvider(options) {
|
|
|
522
537
|
}
|
|
523
538
|
}
|
|
524
539
|
catch (error) {
|
|
525
|
-
return jsonError(400, error instanceof Error ? error.message : String(error));
|
|
540
|
+
return jsonError(error instanceof BodyTooLargeError ? 413 : 400, error instanceof Error ? error.message : String(error));
|
|
526
541
|
}
|
|
527
542
|
};
|
|
528
543
|
const server = serveNode({
|
|
@@ -24,10 +24,25 @@ export declare const ID_TOKEN_EXPIRY_LEEWAY_SECONDS = 120;
|
|
|
24
24
|
* OAuth2 error carries a description the provider wrote.
|
|
25
25
|
*/
|
|
26
26
|
export declare function describeError(error: unknown): string;
|
|
27
|
+
/**
|
|
28
|
+
* Response bytes a connector reads from a provider endpoint — token, profile,
|
|
29
|
+
* UserInfo, or key set — before refusing the response.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_PROVIDER_RESPONSE_BYTES: number;
|
|
32
|
+
/**
|
|
33
|
+
* Reads `res`'s body as text, stopping and cancelling it past
|
|
34
|
+
* {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
|
|
35
|
+
* error message, e.g. `"the Acme profile endpoint"`.
|
|
36
|
+
*
|
|
37
|
+
* @throws {ExternalAuthError} `provider_error` when the body is larger than
|
|
38
|
+
* the cap or stops arriving.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readProviderText(res: Response, provider: string, source: string): Promise<string>;
|
|
27
41
|
/**
|
|
28
42
|
* Reads a failed response's body into a bounded, single-line `": <text>"`
|
|
29
|
-
* suffix to append to an error message, or `""` when the body is empty
|
|
30
|
-
* unreadable
|
|
43
|
+
* suffix to append to an error message, or `""` when the body is empty,
|
|
44
|
+
* unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
|
|
45
|
+
* the body, so the caller must not read it again.
|
|
31
46
|
*/
|
|
32
47
|
export declare function readErrorBody(res: Response): Promise<string>;
|
|
33
48
|
/**
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { isOAuth2Error } from "../../errors.js";
|
|
12
|
+
import { readBoundedBody } from "../../utils/_body.js";
|
|
12
13
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
13
14
|
import { PROVIDER_TEXT_MAX_LENGTH, sanitizeProviderText, } from "../../utils/text.js";
|
|
14
15
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -36,14 +37,45 @@ export function describeError(error) {
|
|
|
36
37
|
}
|
|
37
38
|
return sanitizeProviderText(error instanceof Error ? error.message : String(error));
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Response bytes a connector reads from a provider endpoint — token, profile,
|
|
42
|
+
* UserInfo, or key set — before refusing the response.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_PROVIDER_RESPONSE_BYTES = 64 * 1024;
|
|
45
|
+
/**
|
|
46
|
+
* Reads `res`'s body as text, stopping and cancelling it past
|
|
47
|
+
* {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
|
|
48
|
+
* error message, e.g. `"the Acme profile endpoint"`.
|
|
49
|
+
*
|
|
50
|
+
* @throws {ExternalAuthError} `provider_error` when the body is larger than
|
|
51
|
+
* the cap or stops arriving.
|
|
52
|
+
*/
|
|
53
|
+
export async function readProviderText(res, provider, source) {
|
|
54
|
+
let bytes;
|
|
55
|
+
try {
|
|
56
|
+
bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new ExternalAuthError(provider, "provider_error", `${source} response could not be read (${describeError(error)}).`, { cause: error });
|
|
60
|
+
}
|
|
61
|
+
if (!bytes) {
|
|
62
|
+
throw new ExternalAuthError(provider, "provider_error", `${source} response exceeded ${MAX_PROVIDER_RESPONSE_BYTES} bytes — ` +
|
|
63
|
+
`refusing to parse a response this large.`);
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(bytes);
|
|
66
|
+
}
|
|
39
67
|
/**
|
|
40
68
|
* Reads a failed response's body into a bounded, single-line `": <text>"`
|
|
41
|
-
* suffix to append to an error message, or `""` when the body is empty
|
|
42
|
-
* unreadable
|
|
69
|
+
* suffix to append to an error message, or `""` when the body is empty,
|
|
70
|
+
* unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
|
|
71
|
+
* the body, so the caller must not read it again.
|
|
43
72
|
*/
|
|
44
73
|
export async function readErrorBody(res) {
|
|
45
74
|
try {
|
|
46
|
-
const
|
|
75
|
+
const bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
|
|
76
|
+
if (!bytes)
|
|
77
|
+
return "";
|
|
78
|
+
const snippet = sanitizeProviderText(new TextDecoder().decode(bytes));
|
|
47
79
|
return snippet ? `: ${snippet}` : "";
|
|
48
80
|
}
|
|
49
81
|
catch {
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { packageDeadlineSignal } from "../../utils/_default-fetch.js";
|
|
12
|
-
import { describeError, sanitizeProviderText } from "./_shared.js";
|
|
12
|
+
import { describeError, MAX_PROVIDER_RESPONSE_BYTES, sanitizeProviderText, } from "./_shared.js";
|
|
13
13
|
import { ExternalAuthError } from "./errors.js";
|
|
14
14
|
/** Response bytes accepted from a token endpoint before the exchange is refused. */
|
|
15
|
-
export const MAX_TOKEN_RESPONSE_BYTES =
|
|
15
|
+
export const MAX_TOKEN_RESPONSE_BYTES = MAX_PROVIDER_RESPONSE_BYTES;
|
|
16
16
|
/** Wall-clock budget for the exchange, covering the connect and the body read. */
|
|
17
17
|
export const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000;
|
|
18
18
|
/**
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { toArrayBuffer } from "../../utils/_buffer.js";
|
|
19
19
|
import { base64urlDecode } from "../../utils/crypto.js";
|
|
20
20
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
21
|
-
import { assertIdTokenClaims, describeError } from "./_shared.js";
|
|
21
|
+
import { assertIdTokenClaims, describeError, readProviderText, } from "./_shared.js";
|
|
22
22
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
23
23
|
import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
|
|
24
24
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -110,9 +110,10 @@ export function appleProvider(options) {
|
|
|
110
110
|
await res.body?.cancel();
|
|
111
111
|
throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint responded with HTTP ${res.status}.`);
|
|
112
112
|
}
|
|
113
|
+
const text = await readProviderText(res, id, "Apple's JWKS endpoint");
|
|
113
114
|
let doc;
|
|
114
115
|
try {
|
|
115
|
-
doc =
|
|
116
|
+
doc = JSON.parse(text);
|
|
116
117
|
}
|
|
117
118
|
catch (error) {
|
|
118
119
|
throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint returned invalid JSON (${describeError(error)}).`, { cause: error });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
8
|
-
import { describeError } from "./_shared.js";
|
|
8
|
+
import { describeError, readProviderText } from "./_shared.js";
|
|
9
9
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
10
10
|
import { ExternalAuthError } from "./errors.js";
|
|
11
11
|
const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
|
|
@@ -74,8 +74,9 @@ export function githubProvider(options) {
|
|
|
74
74
|
await res.body?.cancel();
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
|
+
const text = await readProviderText(res, id, url);
|
|
77
78
|
try {
|
|
78
|
-
return
|
|
79
|
+
return JSON.parse(text);
|
|
79
80
|
}
|
|
80
81
|
catch (error) {
|
|
81
82
|
throw new ExternalAuthError(id, "provider_error", `${url} returned a response that was not valid JSON ` +
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { describeError, readErrorBody } from "./_shared.js";
|
|
12
|
+
import { describeError, readErrorBody, readProviderText } from "./_shared.js";
|
|
13
13
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
14
14
|
import { ExternalAuthError } from "./errors.js";
|
|
15
15
|
/**
|
|
@@ -85,8 +85,9 @@ export function oauth2Provider(options) {
|
|
|
85
85
|
`access token was issued but lacks profile access — ensure the ` +
|
|
86
86
|
`identity scope is requested.`);
|
|
87
87
|
}
|
|
88
|
+
const text = await readProviderText(res, id, `the ${options.displayName} profile endpoint`);
|
|
88
89
|
try {
|
|
89
|
-
return
|
|
90
|
+
return JSON.parse(text);
|
|
90
91
|
}
|
|
91
92
|
catch (error) {
|
|
92
93
|
throw new ExternalAuthError(id, "provider_error", `the ${options.displayName} profile endpoint returned a response ` +
|
|
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
|
|
|
9
9
|
import { isOAuth2Error } from "../../errors.js";
|
|
10
10
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { assertIdTokenClaims, describeError, stripTrailingSlash, } from "./_shared.js";
|
|
12
|
+
import { assertIdTokenClaims, describeError, readProviderText, stripTrailingSlash, } from "./_shared.js";
|
|
13
13
|
import { ExternalAuthError } from "./errors.js";
|
|
14
14
|
/**
|
|
15
15
|
* Creates a connector for any spec-compliant OpenID Connect provider.
|
|
@@ -153,7 +153,7 @@ export function oidcProvider(options) {
|
|
|
153
153
|
await res.body?.cancel();
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
|
-
const info = (await res
|
|
156
|
+
const info = JSON.parse(await readProviderText(res, id, "the UserInfo endpoint"));
|
|
157
157
|
return info.sub === idTokenSub ? info : null;
|
|
158
158
|
}
|
|
159
159
|
catch {
|
package/dist/identity/hibp.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface BreachedPasswordOptions {
|
|
|
32
32
|
failOpen?: boolean;
|
|
33
33
|
/** Abort the range request after this many ms. Defaults to 3000. */
|
|
34
34
|
timeoutMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Largest range response, in bytes, the check reads. Defaults to 1048576
|
|
37
|
+
* (1 MiB), about ten times a padded range. The read counts bytes as they
|
|
38
|
+
* arrive and cancels the response at the cap, whether or not it has a
|
|
39
|
+
* `Content-Length`; a larger response counts as the range API being
|
|
40
|
+
* unavailable, so `failOpen` decides the outcome.
|
|
41
|
+
*/
|
|
42
|
+
maxResponseBytes?: number;
|
|
35
43
|
/** Fetch implementation — inject for tests. Defaults to `globalThis.fetch`. */
|
|
36
44
|
fetch?: typeof fetch;
|
|
37
45
|
/** Range API base URL. Defaults to `https://api.pwnedpasswords.com/range/`. */
|
|
@@ -56,6 +64,8 @@ export declare function sha1Hex(input: string): Promise<string>;
|
|
|
56
64
|
* Build a {@link PasswordPolicy} validator that rejects passwords found in the
|
|
57
65
|
* HIBP breach corpus.
|
|
58
66
|
*
|
|
67
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
68
|
+
*
|
|
59
69
|
* @example
|
|
60
70
|
* ```ts
|
|
61
71
|
* const policy: PasswordPolicy = {
|
package/dist/identity/hibp.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
+
import { assertMaxBodyBytes, readBoundedBody } from "../utils/_body.js";
|
|
20
21
|
import { encodeHex } from "../utils/_encoding.js";
|
|
21
22
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
22
23
|
import { dispatchIdentityEvent } from "./events.js";
|
|
23
24
|
const DEFAULT_BASE_URL = "https://api.pwnedpasswords.com/range/";
|
|
25
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
24
26
|
const VALIDATOR_NAME = "breached_password";
|
|
25
27
|
const BREACHED_MESSAGE = "This password has appeared in a data breach; choose a different one.";
|
|
26
28
|
const UNAVAILABLE_MESSAGE = "The password couldn't be checked against known breaches; try again.";
|
|
@@ -33,6 +35,8 @@ export async function sha1Hex(input) {
|
|
|
33
35
|
* Build a {@link PasswordPolicy} validator that rejects passwords found in the
|
|
34
36
|
* HIBP breach corpus.
|
|
35
37
|
*
|
|
38
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
39
|
+
*
|
|
36
40
|
* @example
|
|
37
41
|
* ```ts
|
|
38
42
|
* const policy: PasswordPolicy = {
|
|
@@ -45,6 +49,8 @@ export function breachedPasswordValidator(options = {}) {
|
|
|
45
49
|
const threshold = options.threshold ?? 1;
|
|
46
50
|
const failOpen = options.failOpen ?? true;
|
|
47
51
|
const timeoutMs = options.timeoutMs ?? 3000;
|
|
52
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
53
|
+
assertMaxBodyBytes(maxResponseBytes, "maxResponseBytes");
|
|
48
54
|
const fetchImpl = options.fetch ?? defaultFetch;
|
|
49
55
|
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
50
56
|
const onEvent = options.onEvent;
|
|
@@ -61,7 +67,11 @@ export function breachedPasswordValidator(options = {}) {
|
|
|
61
67
|
await response.body?.cancel();
|
|
62
68
|
throw new Error(`range API responded ${response.status}`);
|
|
63
69
|
}
|
|
64
|
-
const
|
|
70
|
+
const bytes = await readBoundedBody(response, maxResponseBytes);
|
|
71
|
+
if (!bytes) {
|
|
72
|
+
throw new Error(`range API response exceeded ${maxResponseBytes} bytes`);
|
|
73
|
+
}
|
|
74
|
+
const body = new TextDecoder().decode(bytes);
|
|
65
75
|
for (const line of body.split("\n")) {
|
|
66
76
|
const [lineSuffix, countText] = line.trim().split(":");
|
|
67
77
|
if (lineSuffix === suffix && Number(countText) >= threshold) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Not part of the public API. The bounded JSON read the token readers apply
|
|
3
|
+
* to the responses of the authorization server they call.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
|
|
9
|
+
* names the response in error messages.
|
|
10
|
+
*
|
|
11
|
+
* @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
|
|
12
|
+
* the body is cancelled) or is not JSON.
|
|
13
|
+
* @throws {TemporarilyUnavailableError} When the body stops arriving before it
|
|
14
|
+
* is complete: the deadline fired or the connection dropped mid-body.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readResponseJson(response: Response, maxBytes: number, label: string): Promise<unknown>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Not part of the public API. The bounded JSON read the token readers apply
|
|
3
|
+
* to the responses of the authorization server they call.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
8
|
+
import { readBoundedBody } from "../utils/_body.js";
|
|
9
|
+
/**
|
|
10
|
+
* Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
|
|
11
|
+
* names the response in error messages.
|
|
12
|
+
*
|
|
13
|
+
* @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
|
|
14
|
+
* the body is cancelled) or is not JSON.
|
|
15
|
+
* @throws {TemporarilyUnavailableError} When the body stops arriving before it
|
|
16
|
+
* is complete: the deadline fired or the connection dropped mid-body.
|
|
17
|
+
*/
|
|
18
|
+
export async function readResponseJson(response, maxBytes, label) {
|
|
19
|
+
let bytes;
|
|
20
|
+
try {
|
|
21
|
+
bytes = await readBoundedBody(response, maxBytes);
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
throw new TemporarilyUnavailableError(`${label} request failed`, { cause });
|
|
25
|
+
}
|
|
26
|
+
if (!bytes) {
|
|
27
|
+
throw new ServerError(`${label} response exceeded ${maxBytes} bytes`);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new ServerError(`${label} response was not JSON`, { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -67,6 +67,13 @@ export interface IntrospectionTokenReaderOptions<Client extends ClientInterface,
|
|
|
67
67
|
* {@linkcode fetch} that doesn't honour `AbortSignal`.
|
|
68
68
|
*/
|
|
69
69
|
fetchTimeoutMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Largest introspection response, in bytes, the reader accepts. Defaults to
|
|
72
|
+
* 65536 (64 KiB). The read counts bytes as they arrive and cancels the
|
|
73
|
+
* response at the cap, whether or not it has a `Content-Length`; a larger
|
|
74
|
+
* response throws {@linkcode ServerError}.
|
|
75
|
+
*/
|
|
76
|
+
maxResponseBytes?: number;
|
|
70
77
|
/**
|
|
71
78
|
* `fetch` implementation used to call the introspection endpoint. Defaults
|
|
72
79
|
* to the global `fetch`. Inject it to route through a custom client (mTLS,
|
|
@@ -127,10 +134,14 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
|
|
|
127
134
|
private getClient;
|
|
128
135
|
private getUser?;
|
|
129
136
|
private fetchTimeoutMs;
|
|
137
|
+
private maxResponseBytes;
|
|
130
138
|
private customFetch?;
|
|
131
139
|
/**
|
|
132
|
-
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
133
|
-
* `fetch` to the global `fetch` when
|
|
140
|
+
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
141
|
+
* `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
|
|
142
|
+
* omitted from `options`.
|
|
143
|
+
*
|
|
144
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
134
145
|
*/
|
|
135
146
|
constructor(options: IntrospectionTokenReaderOptions<Client, User, S>);
|
|
136
147
|
/**
|
|
@@ -149,9 +160,10 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
|
|
|
149
160
|
* a genuinely invalid token (which is `undefined` → `invalid_token`), not
|
|
150
161
|
* collapsed into it.
|
|
151
162
|
* @throws {ServerError} If the endpoint returns any other non-2xx status
|
|
152
|
-
* (e.g. the reader's own introspection credentials are rejected)
|
|
153
|
-
* that is not JSON
|
|
154
|
-
*
|
|
163
|
+
* (e.g. the reader's own introspection credentials are rejected), a body
|
|
164
|
+
* that is not JSON, or one larger than
|
|
165
|
+
* {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
|
|
166
|
+
* misconfiguration, not a verdict on the caller's token.
|
|
155
167
|
*
|
|
156
168
|
* An active response is only accepted when its `token_type` names a bearer
|
|
157
169
|
* token. RFC 7662 answers for refresh tokens too, and a refresh token's
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
13
13
|
import { BasicScope } from "../models/scope.js";
|
|
14
14
|
import { encodeBasicAuth } from "../utils/basic-auth.js";
|
|
15
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_RESPONSE_BYTES, } from "../utils/_body.js";
|
|
16
|
+
import { readResponseJson } from "./_response-json.js";
|
|
15
17
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
16
18
|
/**
|
|
17
19
|
* Validates tokens by calling an OAuth2 introspection endpoint (RFC 7662).
|
|
@@ -62,10 +64,14 @@ export class IntrospectionTokenReader {
|
|
|
62
64
|
getClient;
|
|
63
65
|
getUser;
|
|
64
66
|
fetchTimeoutMs;
|
|
67
|
+
maxResponseBytes;
|
|
65
68
|
customFetch;
|
|
66
69
|
/**
|
|
67
|
-
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
68
|
-
* `fetch` to the global `fetch` when
|
|
70
|
+
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
71
|
+
* `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
|
|
72
|
+
* omitted from `options`.
|
|
73
|
+
*
|
|
74
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
69
75
|
*/
|
|
70
76
|
constructor(options) {
|
|
71
77
|
this.introspectionEndpoint = options.introspectionEndpoint;
|
|
@@ -76,6 +82,9 @@ export class IntrospectionTokenReader {
|
|
|
76
82
|
this.getClient = options.getClient;
|
|
77
83
|
this.getUser = options.getUser;
|
|
78
84
|
this.fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
|
|
85
|
+
this.maxResponseBytes =
|
|
86
|
+
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
87
|
+
assertMaxBodyBytes(this.maxResponseBytes, "maxResponseBytes");
|
|
79
88
|
this.customFetch = options.fetch;
|
|
80
89
|
}
|
|
81
90
|
/**
|
|
@@ -94,9 +103,10 @@ export class IntrospectionTokenReader {
|
|
|
94
103
|
* a genuinely invalid token (which is `undefined` → `invalid_token`), not
|
|
95
104
|
* collapsed into it.
|
|
96
105
|
* @throws {ServerError} If the endpoint returns any other non-2xx status
|
|
97
|
-
* (e.g. the reader's own introspection credentials are rejected)
|
|
98
|
-
* that is not JSON
|
|
99
|
-
*
|
|
106
|
+
* (e.g. the reader's own introspection credentials are rejected), a body
|
|
107
|
+
* that is not JSON, or one larger than
|
|
108
|
+
* {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
|
|
109
|
+
* misconfiguration, not a verdict on the caller's token.
|
|
100
110
|
*
|
|
101
111
|
* An active response is only accepted when its `token_type` names a bearer
|
|
102
112
|
* token. RFC 7662 answers for refresh tokens too, and a refresh token's
|
|
@@ -153,16 +163,7 @@ export class IntrospectionTokenReader {
|
|
|
153
163
|
? new TemporarilyUnavailableError(`token introspection failed (HTTP ${response.status})`)
|
|
154
164
|
: new ServerError(`token introspection failed (HTTP ${response.status})`);
|
|
155
165
|
}
|
|
156
|
-
|
|
157
|
-
return (await response.json());
|
|
158
|
-
}
|
|
159
|
-
catch (cause) {
|
|
160
|
-
throw controller.signal.aborted
|
|
161
|
-
? new TemporarilyUnavailableError("token introspection request failed", { cause })
|
|
162
|
-
: new ServerError("token introspection response was not JSON", {
|
|
163
|
-
cause,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
+
return (await readResponseJson(response, this.maxResponseBytes, "token introspection"));
|
|
166
167
|
}
|
|
167
168
|
finally {
|
|
168
169
|
clearTimeout(deadline);
|
|
@@ -146,6 +146,14 @@ export interface JwksTokenReaderOptions<Client extends ClientInterface, User, S
|
|
|
146
146
|
* {@linkcode fetch} that doesn't honour `AbortSignal`.
|
|
147
147
|
*/
|
|
148
148
|
fetchTimeoutMs?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Largest discovery document or JWKS, in bytes, the reader accepts.
|
|
151
|
+
* Defaults to 262144 (256 KiB). The read counts bytes as they arrive and
|
|
152
|
+
* cancels the response at the cap, whether or not it has a
|
|
153
|
+
* `Content-Length`; a larger response fails the refresh with a
|
|
154
|
+
* {@linkcode ServerError}, as a malformed one does.
|
|
155
|
+
*/
|
|
156
|
+
maxResponseBytes?: number;
|
|
149
157
|
/** Optional scope constructor for parsing scope strings. Defaults to BasicScope. */
|
|
150
158
|
Scope?: ScopeConstructor<S>;
|
|
151
159
|
/**
|
|
@@ -233,14 +241,15 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
|
|
|
233
241
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
234
242
|
* required as `typ`, `client_id` as the required client-id claim, 30
|
|
235
243
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
236
|
-
* between fetches, a 5-second request deadline,
|
|
237
|
-
* and the global `fetch`.
|
|
244
|
+
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
245
|
+
* {@linkcode BasicScope}, and the global `fetch`.
|
|
238
246
|
*
|
|
239
247
|
* @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
|
|
240
248
|
* empty, or `algorithms` names an algorithm this package cannot verify
|
|
241
249
|
* (a silently ignored entry would read as a policy that isn't enforced).
|
|
242
250
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
243
|
-
* overflows when converted to milliseconds
|
|
251
|
+
* overflows when converted to milliseconds, or `maxResponseBytes` is not
|
|
252
|
+
* a positive integer.
|
|
244
253
|
*/
|
|
245
254
|
constructor(options: JwksTokenReaderOptions<Client, User, S>);
|
|
246
255
|
/**
|
|
@@ -263,8 +272,10 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
|
|
|
263
272
|
* invalid token, not collapsed into it. Once keys are cached, a failed
|
|
264
273
|
* refresh is absorbed and the cached keys keep serving.
|
|
265
274
|
* @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
|
|
266
|
-
* a 4xx or a body that is not a JWKS,
|
|
267
|
-
*
|
|
275
|
+
* a 4xx or a body that is not a JWKS, either document exceeds
|
|
276
|
+
* {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
|
|
277
|
+
* reports no `jwks_uri` — a misconfiguration, not a verdict on the
|
|
278
|
+
* caller's token.
|
|
268
279
|
*/
|
|
269
280
|
getToken(accessToken: string): Promise<Token<Client, User, S> | undefined>;
|
|
270
281
|
}
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
16
16
|
import { BasicScope } from "../models/scope.js";
|
|
17
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_KEY_SET_BYTES, } from "../utils/_body.js";
|
|
17
18
|
import { toArrayBuffer } from "../utils/_buffer.js";
|
|
18
19
|
import { base64urlDecode } from "../utils/crypto.js";
|
|
19
20
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
20
21
|
import { assertClockSkewSeconds } from "./_clock-skew.js";
|
|
22
|
+
import { readResponseJson } from "./_response-json.js";
|
|
21
23
|
const ALGORITHMS = {
|
|
22
24
|
ES256: {
|
|
23
25
|
kty: "EC",
|
|
@@ -210,6 +212,7 @@ export class JwksTokenReader {
|
|
|
210
212
|
#cacheMaxAgeMs;
|
|
211
213
|
#minFetchIntervalMs;
|
|
212
214
|
#fetchTimeoutMs;
|
|
215
|
+
#maxResponseBytes;
|
|
213
216
|
#metadataUrls;
|
|
214
217
|
#Scope;
|
|
215
218
|
#getClient;
|
|
@@ -224,14 +227,15 @@ export class JwksTokenReader {
|
|
|
224
227
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
225
228
|
* required as `typ`, `client_id` as the required client-id claim, 30
|
|
226
229
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
227
|
-
* between fetches, a 5-second request deadline,
|
|
228
|
-
* and the global `fetch`.
|
|
230
|
+
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
231
|
+
* {@linkcode BasicScope}, and the global `fetch`.
|
|
229
232
|
*
|
|
230
233
|
* @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
|
|
231
234
|
* empty, or `algorithms` names an algorithm this package cannot verify
|
|
232
235
|
* (a silently ignored entry would read as a policy that isn't enforced).
|
|
233
236
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
234
|
-
* overflows when converted to milliseconds
|
|
237
|
+
* overflows when converted to milliseconds, or `maxResponseBytes` is not
|
|
238
|
+
* a positive integer.
|
|
235
239
|
*/
|
|
236
240
|
constructor(options) {
|
|
237
241
|
this.#issuer = options.issuer;
|
|
@@ -255,6 +259,9 @@ export class JwksTokenReader {
|
|
|
255
259
|
this.#cacheMaxAgeMs = options.cacheMaxAgeMs ?? 600_000;
|
|
256
260
|
this.#minFetchIntervalMs = options.minFetchIntervalMs ?? 30_000;
|
|
257
261
|
this.#fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
|
|
262
|
+
this.#maxResponseBytes =
|
|
263
|
+
options.maxResponseBytes ?? DEFAULT_MAX_KEY_SET_BYTES;
|
|
264
|
+
assertMaxBodyBytes(this.#maxResponseBytes, "maxResponseBytes");
|
|
258
265
|
this.#Scope =
|
|
259
266
|
options.Scope ?? BasicScope;
|
|
260
267
|
this.#getClient = options.getClient;
|
|
@@ -282,8 +289,10 @@ export class JwksTokenReader {
|
|
|
282
289
|
* invalid token, not collapsed into it. Once keys are cached, a failed
|
|
283
290
|
* refresh is absorbed and the cached keys keep serving.
|
|
284
291
|
* @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
|
|
285
|
-
* a 4xx or a body that is not a JWKS,
|
|
286
|
-
*
|
|
292
|
+
* a 4xx or a body that is not a JWKS, either document exceeds
|
|
293
|
+
* {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
|
|
294
|
+
* reports no `jwks_uri` — a misconfiguration, not a verdict on the
|
|
295
|
+
* caller's token.
|
|
287
296
|
*/
|
|
288
297
|
async getToken(accessToken) {
|
|
289
298
|
const parsed = parseJws(accessToken);
|
|
@@ -499,12 +508,7 @@ export class JwksTokenReader {
|
|
|
499
508
|
? new TemporarilyUnavailableError(`${label} request failed (HTTP ${response.status})`)
|
|
500
509
|
: new ServerError(`${label} request failed (HTTP ${response.status})`);
|
|
501
510
|
}
|
|
502
|
-
|
|
503
|
-
return await response.json();
|
|
504
|
-
}
|
|
505
|
-
catch (cause) {
|
|
506
|
-
throw new ServerError(`${label} response was not JSON`, { cause });
|
|
507
|
-
}
|
|
511
|
+
return await readResponseJson(response, this.#maxResponseBytes, label);
|
|
508
512
|
}
|
|
509
513
|
finally {
|
|
510
514
|
clearTimeout(deadline);
|
package/dist/utils/_body.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal bounded
|
|
3
|
-
* before they have authenticated the caller
|
|
2
|
+
* Internal bounded body reader for the request bodies the endpoints parse
|
|
3
|
+
* before they have authenticated the caller, and for the responses the
|
|
4
|
+
* package reads from the servers it calls.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -17,20 +18,30 @@ export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
|
17
18
|
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
19
|
*/
|
|
19
20
|
export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
|
|
21
|
+
/**
|
|
22
|
+
* The default cap on an introspection response: 64 KiB, the same cap the
|
|
23
|
+
* clients apply to every authorization-server response.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* The default cap on a discovery document or JWKS: 256 KiB, room for dozens
|
|
28
|
+
* of RSA keys that each carry an X.509 certificate chain.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_MAX_KEY_SET_BYTES: number;
|
|
20
31
|
/**
|
|
21
32
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
22
33
|
* safe integer.
|
|
23
34
|
*/
|
|
24
35
|
export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string): void;
|
|
25
36
|
/**
|
|
26
|
-
* Reads `request`'s body
|
|
27
|
-
* once it exceeds `maxBytes
|
|
28
|
-
* A `Content-Length` over the cap is refused without
|
|
29
|
-
* not trusted. Consumes the body unless
|
|
30
|
-
* clone and leaves the original readable
|
|
31
|
-
* the stream errors or was already read.
|
|
37
|
+
* Reads `request`'s body — or a response's — counting bytes as they arrive,
|
|
38
|
+
* and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
|
|
39
|
+
* than draining it. A `Content-Length` over the cap is refused without
|
|
40
|
+
* reading; one under it is not trusted. Consumes the body unless
|
|
41
|
+
* `keepReadable` is set, which reads a clone and leaves the original readable
|
|
42
|
+
* (a refusal cancels both). Rejects if the stream errors or was already read.
|
|
32
43
|
*/
|
|
33
|
-
export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
|
|
44
|
+
export declare function readBoundedBody(request: Request | Response, maxBytes: number, options?: {
|
|
34
45
|
keepReadable?: boolean;
|
|
35
46
|
}): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
36
47
|
/**
|
package/dist/utils/_body.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal bounded
|
|
3
|
-
* before they have authenticated the caller
|
|
2
|
+
* Internal bounded body reader for the request bodies the endpoints parse
|
|
3
|
+
* before they have authenticated the caller, and for the responses the
|
|
4
|
+
* package reads from the servers it calls.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -17,6 +18,16 @@ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
|
17
18
|
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
19
|
*/
|
|
19
20
|
export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* The default cap on an introspection response: 64 KiB, the same cap the
|
|
23
|
+
* clients apply to every authorization-server response.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* The default cap on a discovery document or JWKS: 256 KiB, room for dozens
|
|
28
|
+
* of RSA keys that each carry an X.509 certificate chain.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_MAX_KEY_SET_BYTES = 256 * 1024;
|
|
20
31
|
/**
|
|
21
32
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
22
33
|
* safe integer.
|
|
@@ -27,12 +38,12 @@ export function assertMaxBodyBytes(maxBodyBytes, option) {
|
|
|
27
38
|
}
|
|
28
39
|
}
|
|
29
40
|
/**
|
|
30
|
-
* Reads `request`'s body
|
|
31
|
-
* once it exceeds `maxBytes
|
|
32
|
-
* A `Content-Length` over the cap is refused without
|
|
33
|
-
* not trusted. Consumes the body unless
|
|
34
|
-
* clone and leaves the original readable
|
|
35
|
-
* the stream errors or was already read.
|
|
41
|
+
* Reads `request`'s body — or a response's — counting bytes as they arrive,
|
|
42
|
+
* and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
|
|
43
|
+
* than draining it. A `Content-Length` over the cap is refused without
|
|
44
|
+
* reading; one under it is not trusted. Consumes the body unless
|
|
45
|
+
* `keepReadable` is set, which reads a clone and leaves the original readable
|
|
46
|
+
* (a refusal cancels both). Rejects if the stream errors or was already read.
|
|
36
47
|
*/
|
|
37
48
|
export async function readBoundedBody(request, maxBytes, options = {}) {
|
|
38
49
|
if (!request.body)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|