@udibo/oauth2 0.18.0 → 0.20.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/errors.d.ts CHANGED
@@ -64,11 +64,11 @@ export declare function isOAuth2Error(value: unknown): value is OAuth2Error;
64
64
  * anything else is wrapped in a {@link ServerError} with the original error
65
65
  * attached as `cause`.
66
66
  *
67
- * Either way, `type` and `extensions.error_uri` are synced so that if one is
68
- * set the other is too — **mutating an OAuth2Error input in place**. This lets
69
- * both the standard OAuth2 error format (which emits `error_uri`) and the
70
- * RFC 9457 Problem Details format (which emits `type`) carry the same URI
71
- * without the handler having to set both fields.
67
+ * An error that sets `extensions.error_uri` but no `type` gets `type` set to
68
+ * the same URI — **mutating an OAuth2Error input in place** — so the RFC 9457
69
+ * Problem Details format, which emits `type`, carries it. `type` is never
70
+ * copied into `extensions.error_uri`, so a problem-details body does not repeat
71
+ * it; the RFC 6749 error format emits `error_uri` from either field.
72
72
  */
73
73
  export declare function toOAuth2Error(error: unknown): OAuth2Error;
74
74
  /**
package/dist/errors.js CHANGED
@@ -38,20 +38,17 @@ export function isOAuth2Error(value) {
38
38
  * anything else is wrapped in a {@link ServerError} with the original error
39
39
  * attached as `cause`.
40
40
  *
41
- * Either way, `type` and `extensions.error_uri` are synced so that if one is
42
- * set the other is too — **mutating an OAuth2Error input in place**. This lets
43
- * both the standard OAuth2 error format (which emits `error_uri`) and the
44
- * RFC 9457 Problem Details format (which emits `type`) carry the same URI
45
- * without the handler having to set both fields.
41
+ * An error that sets `extensions.error_uri` but no `type` gets `type` set to
42
+ * the same URI — **mutating an OAuth2Error input in place** — so the RFC 9457
43
+ * Problem Details format, which emits `type`, carries it. `type` is never
44
+ * copied into `extensions.error_uri`, so a problem-details body does not repeat
45
+ * it; the RFC 6749 error format emits `error_uri` from either field.
46
46
  */
47
47
  export function toOAuth2Error(error) {
48
48
  const oauth2Error = isOAuth2Error(error)
49
49
  ? error
50
50
  : new ServerError("unexpected error", { cause: error });
51
- if (oauth2Error.type && !oauth2Error.extensions.error_uri) {
52
- oauth2Error.extensions.error_uri = oauth2Error.type;
53
- }
54
- else if (oauth2Error.extensions.error_uri && !oauth2Error.type) {
51
+ if (oauth2Error.extensions.error_uri && !oauth2Error.type) {
55
52
  oauth2Error.type = oauth2Error.extensions.error_uri;
56
53
  }
57
54
  return oauth2Error;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Character-set rules for `WWW-Authenticate` challenge parameters (RFC 7235
3
+ * §2.2, RFC 9110 §5.6.4, RFC 6750 §3).
4
+ *
5
+ * @module
6
+ */
7
+ /** Throws `TypeError` unless `realm` is printable ASCII (U+0020–U+007E), the only text every HTTP stack carries in a header unaltered. */
8
+ export declare function assertRealm(realm: string): void;
9
+ /** The `realm` parameter as a quoted-string; `realm` must already satisfy {@linkcode assertRealm}. */
10
+ export declare function quoteRealm(realm: string): string;
11
+ /**
12
+ * Maps text into the RFC 6750 §3 `error_description` set
13
+ * (`%x20-21 / %x23-5B / %x5D-7E`): control characters are dropped, `"`
14
+ * becomes `'`, and every other code point outside the set becomes `?`.
15
+ */
16
+ export declare function toChallengeDescription(text: string): string;
17
+ /** `scope` with its space-separated tokens rejoined by single spaces, or `undefined` unless every token is an RFC 6750 §3 scope-token. */
18
+ export declare function toChallengeScope(scope: string): string | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Character-set rules for `WWW-Authenticate` challenge parameters (RFC 7235
3
+ * §2.2, RFC 9110 §5.6.4, RFC 6750 §3).
4
+ *
5
+ * @module
6
+ */
7
+ import { NQCHAR } from "../models/scope.js";
8
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
9
+ // oxlint-disable-next-line no-control-regex
10
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/g;
11
+ const OUTSIDE_DESCRIPTION_SET = /[^\x20\x21\x23-\x5b\x5d-\x7e]/gu;
12
+ const SCOPE_TOKEN_ONLY = new RegExp(`^${NQCHAR.source}+$`);
13
+ /** Throws `TypeError` unless `realm` is printable ASCII (U+0020–U+007E), the only text every HTTP stack carries in a header unaltered. */
14
+ export function assertRealm(realm) {
15
+ if (typeof realm !== "string" || !PRINTABLE_ASCII.test(realm)) {
16
+ throw new TypeError(`realm must contain only printable ASCII characters (U+0020 to U+007E), got ${JSON.stringify(realm)}`);
17
+ }
18
+ }
19
+ /** The `realm` parameter as a quoted-string; `realm` must already satisfy {@linkcode assertRealm}. */
20
+ export function quoteRealm(realm) {
21
+ return `"${realm.replace(/["\\]/g, "\\$&")}"`;
22
+ }
23
+ /**
24
+ * Maps text into the RFC 6750 §3 `error_description` set
25
+ * (`%x20-21 / %x23-5B / %x5D-7E`): control characters are dropped, `"`
26
+ * becomes `'`, and every other code point outside the set becomes `?`.
27
+ */
28
+ export function toChallengeDescription(text) {
29
+ return text
30
+ .replace(CONTROL_CHARACTERS, "")
31
+ .replaceAll('"', "'")
32
+ .replace(OUTSIDE_DESCRIPTION_SET, "?");
33
+ }
34
+ /** `scope` with its space-separated tokens rejoined by single spaces, or `undefined` unless every token is an RFC 6750 §3 scope-token. */
35
+ export function toChallengeScope(scope) {
36
+ const tokens = scope.split(" ").filter((token) => token !== "");
37
+ return tokens.length > 0 &&
38
+ tokens.every((token) => SCOPE_TOKEN_ONLY.test(token))
39
+ ? tokens.join(" ")
40
+ : undefined;
41
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Serializes OAuth2 errors into responses for the resource and authorization
3
+ * servers.
4
+ *
5
+ * @module
6
+ */
7
+ import type { OAuth2Error } from "../errors.ts";
8
+ /** Copies the error's headers onto the response, except `Content-Type`, which the serializer owns. */
9
+ export declare function copyErrorHeaders(oauth2Error: OAuth2Error, response: Response): Response;
10
+ /** The RFC 6749 §5.2 error response: `{ error, error_description?, error_uri? }` as `application/json`, carrying the error's headers. */
11
+ export declare function oauth2ErrorResponse(oauth2Error: OAuth2Error): Response;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Serializes OAuth2 errors into responses for the resource and authorization
3
+ * servers.
4
+ *
5
+ * @module
6
+ */
7
+ /** Copies the error's headers onto the response, except `Content-Type`, which the serializer owns. */
8
+ export function copyErrorHeaders(oauth2Error, response) {
9
+ for (const [key, value] of oauth2Error.headers) {
10
+ if (key.toLowerCase() === "content-type")
11
+ continue;
12
+ response.headers.set(key, value);
13
+ }
14
+ return response;
15
+ }
16
+ /** The RFC 6749 §5.2 error response: `{ error, error_description?, error_uri? }` as `application/json`, carrying the error's headers. */
17
+ export function oauth2ErrorResponse(oauth2Error) {
18
+ const body = {
19
+ error: oauth2Error.extensions.error ?? "server_error",
20
+ };
21
+ const description = oauth2Error.extensions.error_description ?? oauth2Error.exposedMessage;
22
+ if (description) {
23
+ body.error_description = description;
24
+ }
25
+ const errorUri = oauth2Error.extensions.error_uri ?? oauth2Error.type;
26
+ if (errorUri) {
27
+ body.error_uri = errorUri;
28
+ }
29
+ return copyErrorHeaders(oauth2Error, new Response(JSON.stringify(body), {
30
+ status: oauth2Error.status,
31
+ headers: { "Content-Type": "application/json;charset=UTF-8" },
32
+ }));
33
+ }
@@ -446,6 +446,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
446
446
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
447
447
  * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
448
448
  * positive integer.
449
+ * @throws {TypeError} If `realm` contains a character outside printable
450
+ * ASCII.
449
451
  */
450
452
  constructor(options: AuthorizationServerOptions<Client, User, S>);
451
453
  /**
@@ -20,7 +20,7 @@
20
20
  * @module
21
21
  */
22
22
  import { snapshotAuthenticationContext } from "../utils/authentication-context.js";
23
- import { AccessDeniedError, InvalidRequestError, InvalidTokenError, isOAuth2Error, OAuth2Error, ServerError, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, } from "../errors.js";
23
+ import { AccessDeniedError, InvalidRequestError, InvalidTokenError, isOAuth2Error, OAuth2Error, ServerError, toOAuth2Error, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, } from "../errors.js";
24
24
  import { authenticateClientCredentials, extractClientCredentials, } from "./client-authentication.js";
25
25
  import { isRedirectUriPattern, matchRedirectUri } from "./redirect-uri.js";
26
26
  import { AuthorizationCodeGrant } from "./grants/authorization-code.js";
@@ -28,6 +28,8 @@ import { DeviceAuthorizationGrant } from "./grants/device-authorization.js";
28
28
  import { ResourceServer, } from "./resource-server.js";
29
29
  import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
30
30
  import { validateCodeChallenge } from "../utils/pkce.js";
31
+ import { oauth2ErrorResponse } from "./_error-response.js";
32
+ import { quoteRealm } from "./_challenge.js";
31
33
  import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
32
34
  const ENDPOINTS = {
33
35
  authorization: {
@@ -199,6 +201,8 @@ export class AuthorizationServer extends ResourceServer {
199
201
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
200
202
  * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
201
203
  * positive integer.
204
+ * @throws {TypeError} If `realm` contains a character outside printable
205
+ * ASCII.
202
206
  */
203
207
  constructor(options) {
204
208
  super({
@@ -237,6 +241,13 @@ export class AuthorizationServer extends ResourceServer {
237
241
  this.#introspectionClaims = options.introspectionClaims;
238
242
  this.#canIntrospectToken = options.canIntrospectToken;
239
243
  }
244
+ #handleEndpointError(error, prepare) {
245
+ const oauth2Error = toOAuth2Error(error);
246
+ prepare?.(oauth2Error);
247
+ if (this.throwOnError)
248
+ throw oauth2Error;
249
+ return oauth2ErrorResponse(oauth2Error);
250
+ }
240
251
  /**
241
252
  * Whether this instance will actually serve the OIDC surface: `true` once
242
253
  * `signingKeys` is set, which is what gates `id_token` issuance and the
@@ -328,7 +339,7 @@ export class AuthorizationServer extends ResourceServer {
328
339
  return (error) => {
329
340
  this.addCacheHeaders(error);
330
341
  if (error.status === 401 && request.headers.get("authorization")) {
331
- error.headers.set("WWW-Authenticate", `Basic realm="${this.realm}"`);
342
+ error.headers.set("WWW-Authenticate", `Basic realm=${quoteRealm(this.realm)}`);
332
343
  }
333
344
  };
334
345
  }
@@ -441,7 +452,7 @@ export class AuthorizationServer extends ResourceServer {
441
452
  return await this.createTokenResponse(token, context, grant);
442
453
  }
443
454
  catch (error) {
444
- return this.handleError(error, this.prepareClientAuthError(request));
455
+ return this.#handleEndpointError(error, this.prepareClientAuthError(request));
445
456
  }
446
457
  }
447
458
  /**
@@ -485,7 +496,7 @@ export class AuthorizationServer extends ResourceServer {
485
496
  */
486
497
  async handleOidcMetadataRequest(request) {
487
498
  if (!this.signingKeys) {
488
- return this.handleError(new OAuth2Error(404, "OIDC issuance is not enabled", {
499
+ return this.#handleEndpointError(new OAuth2Error(404, "OIDC issuance is not enabled", {
489
500
  extensions: { error: "not_found" },
490
501
  }));
491
502
  }
@@ -497,7 +508,7 @@ export class AuthorizationServer extends ResourceServer {
497
508
  */
498
509
  async handleJwksRequest(_request) {
499
510
  if (!this.signingKeys) {
500
- return this.handleError(new OAuth2Error(404, "OIDC issuance is not enabled", {
511
+ return this.#handleEndpointError(new OAuth2Error(404, "OIDC issuance is not enabled", {
501
512
  extensions: { error: "not_found" },
502
513
  }));
503
514
  }
@@ -512,7 +523,7 @@ export class AuthorizationServer extends ResourceServer {
512
523
  });
513
524
  }
514
525
  catch (error) {
515
- return this.handleError(error);
526
+ return this.#handleEndpointError(error);
516
527
  }
517
528
  }
518
529
  /**
@@ -626,7 +637,7 @@ export class AuthorizationServer extends ResourceServer {
626
637
  return new Response(null, { status: 302, headers });
627
638
  }
628
639
  catch (error) {
629
- return this.handleError(error);
640
+ return this.#handleEndpointError(error);
630
641
  }
631
642
  }
632
643
  // RP-Initiated Logout §2: parameters come from the GET query or POST body.
@@ -799,7 +810,7 @@ export class AuthorizationServer extends ResourceServer {
799
810
  }
800
811
  #authorizeErrorResponse(error, redirectUrl) {
801
812
  if (!redirectUrl || !(error instanceof Error) || !isOAuth2Error(error)) {
802
- return this.handleError(error);
813
+ return this.#handleEndpointError(error);
803
814
  }
804
815
  const { searchParams } = redirectUrl;
805
816
  searchParams.set("error", error.extensions.error ?? "server_error");
@@ -958,7 +969,7 @@ export class AuthorizationServer extends ResourceServer {
958
969
  return new Response(null, { status: 200 });
959
970
  }
960
971
  catch (error) {
961
- return this.handleError(error, this.prepareClientAuthError(request));
972
+ return this.#handleEndpointError(error, this.prepareClientAuthError(request));
962
973
  }
963
974
  }
964
975
  async #resolveToken(tokenService, value, hint) {
@@ -1086,7 +1097,7 @@ export class AuthorizationServer extends ResourceServer {
1086
1097
  });
1087
1098
  }
1088
1099
  catch (error) {
1089
- return this.handleError(error, this.prepareClientAuthError(request));
1100
+ return this.#handleEndpointError(error, this.prepareClientAuthError(request));
1090
1101
  }
1091
1102
  }
1092
1103
  /**
@@ -1189,7 +1200,7 @@ export class AuthorizationServer extends ResourceServer {
1189
1200
  });
1190
1201
  }
1191
1202
  catch (error) {
1192
- return this.handleError(error);
1203
+ return this.#handleEndpointError(error);
1193
1204
  }
1194
1205
  }
1195
1206
  /**
@@ -1244,7 +1255,7 @@ export class AuthorizationServer extends ResourceServer {
1244
1255
  });
1245
1256
  }
1246
1257
  catch (error) {
1247
- return this.handleError(error, this.prepareClientAuthError(request));
1258
+ return this.#handleEndpointError(error, this.prepareClientAuthError(request));
1248
1259
  }
1249
1260
  }
1250
1261
  }
@@ -37,7 +37,14 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
37
37
  resolve: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
38
38
  /** Scope constructor used to parse and compare scopes. Defaults to {@linkcode BasicScope}. */
39
39
  Scope?: ScopeConstructor<S>;
40
- /** Protection space named in `WWW-Authenticate` challenges. Defaults to `"Service"`. */
40
+ /**
41
+ * Protection space named in `WWW-Authenticate` challenges. Defaults to
42
+ * `"Service"`. Must be printable ASCII (U+0020 to U+007E); a product name
43
+ * with other characters needs an ASCII form here.
44
+ *
45
+ * @throws {TypeError} From the constructor when it contains any other
46
+ * character.
47
+ */
41
48
  realm?: string;
42
49
  /**
43
50
  * Leeway in seconds applied to the access token's expiry, to absorb clock
@@ -77,12 +84,24 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
77
84
  */
78
85
  maxTokenBodyBytes?: number;
79
86
  /**
80
- * Format for error responses.
81
- * - `"oauth2"` (default): Standard OAuth2 error format with `error`,
82
- * `error_description`, and `error_uri` fields.
83
- * - `"problem-details"`: RFC 9457 Problem Details format with OAuth2
84
- * extensions. `type` and `error_uri` carry the same URI; `detail` and
85
- * `error_description` carry the same message.
87
+ * Format for protected-resource refusals: the responses from `protect()`,
88
+ * {@linkcode ResourceServer.handleAuthError} and
89
+ * {@linkcode ResourceServer.createErrorResponse}, including an
90
+ * authorization server's own protected resources such as UserInfo.
91
+ * - `"oauth2"` (default): `{ error, error_description?, error_uri? }` as
92
+ * `application/json`.
93
+ * - `"problem-details"`: RFC 9457 Problem Details as
94
+ * `application/problem+json`: `status`, `title`, `detail` (the message),
95
+ * `type` when set, the OAuth2 `error` code and any other extension members
96
+ * such as `requiredScope`. `error_description` and `error_uri` appear only
97
+ * when the error sets them as extensions.
98
+ *
99
+ * Either way, a 401 or 403 from `protect()` or `handleAuthError` carries the
100
+ * `error` code and `error_description` in its `WWW-Authenticate` challenge.
101
+ *
102
+ * An authorization server's OAuth endpoints (token, revocation,
103
+ * introspection, device authorization, authorize, end-session, metadata and
104
+ * JWKS) ignore it and always answer in the RFC 6749 §5.2 format.
86
105
  */
87
106
  errorFormat?: "oauth2" | "problem-details";
88
107
  /**
@@ -158,14 +177,22 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
158
177
  #private;
159
178
  /** Scope constructor used to parse and compare scopes. */
160
179
  Scope: ScopeConstructor<S>;
161
- /** Protection space named in `WWW-Authenticate` challenges. */
162
- realm: string;
163
- /** Body format used for error responses: OAuth2 or RFC 9457 problem details. */
180
+ /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
164
181
  errorFormat: "oauth2" | "problem-details";
165
182
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
166
183
  throwOnError: boolean;
167
184
  /** Resolves the per-request {@link ResourceServerContext}. */
168
185
  protected resolveContext: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
186
+ /** Protection space named in `WWW-Authenticate` challenges. */
187
+ get realm(): string;
188
+ /**
189
+ * Sets the protection space, preserving the previous value if validation
190
+ * fails.
191
+ *
192
+ * @throws {TypeError} If the value contains a character outside printable
193
+ * ASCII (U+0020 to U+007E).
194
+ */
195
+ set realm(value: string);
169
196
  /**
170
197
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
171
198
  * May be reassigned to any non-negative finite number with a finite
@@ -185,6 +212,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
185
212
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
186
213
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
187
214
  * not a positive integer.
215
+ * @throws {TypeError} If `realm` contains a character outside printable
216
+ * ASCII.
188
217
  */
189
218
  constructor(options: ResourceServerOptions<Client, User, S>);
190
219
  /**
@@ -205,6 +234,14 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
205
234
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
206
235
  * fine, it just lacks scope" rather than sending it off to refresh.
207
236
  *
237
+ * The result is always printable ASCII, so it never makes `Headers` throw.
238
+ * `error_description` is the same text the oauth2 response body uses (the
239
+ * `error_description` extension, else the error's exposed message, so an
240
+ * unexposed message stays out of both), narrowed to the RFC 6750 character
241
+ * set: control characters are dropped, `"` becomes `'`, and any other
242
+ * character outside the set becomes `?`. `scope` is omitted entirely when
243
+ * any of its tokens falls outside the RFC 6750 set.
244
+ *
208
245
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
209
246
  */
210
247
  buildWwwAuthenticate(error?: OAuth2Error, requiredScope?: string): string;
@@ -9,7 +9,9 @@
9
9
  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
+ import { copyErrorHeaders, oauth2ErrorResponse } from "./_error-response.js";
12
13
  import { assertClockSkewSeconds } from "./_clock-skew.js";
14
+ import { assertRealm, quoteRealm, toChallengeDescription, toChallengeScope, } from "./_challenge.js";
13
15
  import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
14
16
  /** Bearer token regex pattern. */
15
17
  export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
@@ -72,9 +74,7 @@ const CHALLENGE_ERROR_CODES = new Set([
72
74
  export class ResourceServer {
73
75
  /** Scope constructor used to parse and compare scopes. */
74
76
  Scope;
75
- /** Protection space named in `WWW-Authenticate` challenges. */
76
- realm;
77
- /** Body format used for error responses: OAuth2 or RFC 9457 problem details. */
77
+ /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
78
78
  errorFormat;
79
79
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
80
80
  throwOnError;
@@ -82,6 +82,22 @@ export class ResourceServer {
82
82
  resolveContext;
83
83
  #clockSkewSeconds = 0;
84
84
  #maxTokenBodyBytes;
85
+ #realm = "Service";
86
+ /** Protection space named in `WWW-Authenticate` challenges. */
87
+ get realm() {
88
+ return this.#realm;
89
+ }
90
+ /**
91
+ * Sets the protection space, preserving the previous value if validation
92
+ * fails.
93
+ *
94
+ * @throws {TypeError} If the value contains a character outside printable
95
+ * ASCII (U+0020 to U+007E).
96
+ */
97
+ set realm(value) {
98
+ assertRealm(value);
99
+ this.#realm = value;
100
+ }
85
101
  /**
86
102
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
87
103
  * May be reassigned to any non-negative finite number with a finite
@@ -106,6 +122,8 @@ export class ResourceServer {
106
122
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
107
123
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
108
124
  * not a positive integer.
125
+ * @throws {TypeError} If `realm` contains a character outside printable
126
+ * ASCII.
109
127
  */
110
128
  constructor(options) {
111
129
  this.Scope =
@@ -137,31 +155,33 @@ export class ResourceServer {
137
155
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
138
156
  * fine, it just lacks scope" rather than sending it off to refresh.
139
157
  *
158
+ * The result is always printable ASCII, so it never makes `Headers` throw.
159
+ * `error_description` is the same text the oauth2 response body uses (the
160
+ * `error_description` extension, else the error's exposed message, so an
161
+ * unexposed message stays out of both), narrowed to the RFC 6750 character
162
+ * set: control characters are dropped, `"` becomes `'`, and any other
163
+ * character outside the set becomes `?`. `scope` is omitted entirely when
164
+ * any of its tokens falls outside the RFC 6750 set.
165
+ *
140
166
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
141
167
  */
142
168
  buildWwwAuthenticate(error, requiredScope) {
143
- const quote = (value) => value
144
- // oxlint-disable-next-line no-control-regex
145
- .replace(/[\x00-\x1f\x7f]/g, "")
146
- .replace(/\\/g, "\\\\")
147
- .replace(/"/g, '\\"');
148
- const parts = [`realm="${quote(this.realm)}"`];
169
+ const parts = [`realm=${quoteRealm(this.realm)}`];
149
170
  const code = error?.extensions.error;
150
171
  if (error && code && CHALLENGE_ERROR_CODES.has(code)) {
151
- parts.push(`error="${quote(code)}"`);
152
- if (error.message) {
153
- parts.push(`error_description="${quote(error.message)}"`);
172
+ parts.push(`error="${code}"`);
173
+ const description = toChallengeDescription(error.extensions.error_description ?? error.exposedMessage);
174
+ if (description) {
175
+ parts.push(`error_description="${description}"`);
154
176
  }
155
177
  }
156
- if (requiredScope) {
157
- parts.push(`scope="${quote(requiredScope)}"`);
158
- }
159
- else if (error instanceof InsufficientScopeError) {
160
- const scope = error.extensions
161
- .requiredScope;
162
- if (scope) {
163
- parts.push(`scope="${quote(scope)}"`);
164
- }
178
+ const scope = requiredScope ||
179
+ (error instanceof InsufficientScopeError
180
+ ? error.extensions.requiredScope
181
+ : undefined);
182
+ const challengeScope = scope ? toChallengeScope(scope) : undefined;
183
+ if (challengeScope) {
184
+ parts.push(`scope="${challengeScope}"`);
165
185
  }
166
186
  return `Bearer ${parts.join(", ")}`;
167
187
  }
@@ -179,32 +199,10 @@ export class ResourceServer {
179
199
  */
180
200
  createErrorResponse(error) {
181
201
  const oauth2Error = toOAuth2Error(error);
182
- const response = this.errorFormat === "problem-details"
183
- ? oauth2Error.getResponse()
184
- : this.#createOAuth2ErrorResponse(oauth2Error);
185
- for (const [key, value] of oauth2Error.headers) {
186
- if (key.toLowerCase() === "content-type")
187
- continue;
188
- response.headers.set(key, value);
189
- }
190
- return response;
191
- }
192
- #createOAuth2ErrorResponse(oauth2Error) {
193
- const body = {
194
- error: oauth2Error.extensions.error ?? "server_error",
195
- };
196
- const description = oauth2Error.extensions.error_description ?? oauth2Error.exposedMessage;
197
- if (description) {
198
- body.error_description = description;
202
+ if (this.errorFormat !== "problem-details") {
203
+ return oauth2ErrorResponse(oauth2Error);
199
204
  }
200
- const errorUri = oauth2Error.extensions.error_uri ?? oauth2Error.type;
201
- if (errorUri) {
202
- body.error_uri = errorUri;
203
- }
204
- return new Response(JSON.stringify(body), {
205
- status: oauth2Error.status,
206
- headers: { "Content-Type": "application/json;charset=UTF-8" },
207
- });
205
+ return copyErrorHeaders(oauth2Error, oauth2Error.getResponse());
208
206
  }
209
207
  /**
210
208
  * Normalizes an error at a handler boundary and either rethrows it (when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",