@udibo/oauth2 0.19.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.
@@ -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
+ }
@@ -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
  /**
@@ -29,6 +29,7 @@ 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
31
  import { oauth2ErrorResponse } from "./_error-response.js";
32
+ import { quoteRealm } from "./_challenge.js";
32
33
  import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
33
34
  const ENDPOINTS = {
34
35
  authorization: {
@@ -200,6 +201,8 @@ export class AuthorizationServer extends ResourceServer {
200
201
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
201
202
  * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
202
203
  * positive integer.
204
+ * @throws {TypeError} If `realm` contains a character outside printable
205
+ * ASCII.
203
206
  */
204
207
  constructor(options) {
205
208
  super({
@@ -336,7 +339,7 @@ export class AuthorizationServer extends ResourceServer {
336
339
  return (error) => {
337
340
  this.addCacheHeaders(error);
338
341
  if (error.status === 401 && request.headers.get("authorization")) {
339
- error.headers.set("WWW-Authenticate", `Basic realm="${this.realm}"`);
342
+ error.headers.set("WWW-Authenticate", `Basic realm=${quoteRealm(this.realm)}`);
340
343
  }
341
344
  };
342
345
  }
@@ -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
@@ -170,14 +177,22 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
170
177
  #private;
171
178
  /** Scope constructor used to parse and compare scopes. */
172
179
  Scope: ScopeConstructor<S>;
173
- /** Protection space named in `WWW-Authenticate` challenges. */
174
- realm: string;
175
180
  /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
176
181
  errorFormat: "oauth2" | "problem-details";
177
182
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
178
183
  throwOnError: boolean;
179
184
  /** Resolves the per-request {@link ResourceServerContext}. */
180
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);
181
196
  /**
182
197
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
183
198
  * May be reassigned to any non-negative finite number with a finite
@@ -197,6 +212,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
197
212
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
198
213
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
199
214
  * not a positive integer.
215
+ * @throws {TypeError} If `realm` contains a character outside printable
216
+ * ASCII.
200
217
  */
201
218
  constructor(options: ResourceServerOptions<Client, User, S>);
202
219
  /**
@@ -217,6 +234,14 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
217
234
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
218
235
  * fine, it just lacks scope" rather than sending it off to refresh.
219
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
+ *
220
245
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
221
246
  */
222
247
  buildWwwAuthenticate(error?: OAuth2Error, requiredScope?: string): string;
@@ -11,6 +11,7 @@ import { AccessDeniedError, InsufficientPermissionsError, InsufficientScopeError
11
11
  import { authorizationFromClaims, } from "../models/authorization.js";
12
12
  import { copyErrorHeaders, oauth2ErrorResponse } from "./_error-response.js";
13
13
  import { assertClockSkewSeconds } from "./_clock-skew.js";
14
+ import { assertRealm, quoteRealm, toChallengeDescription, toChallengeScope, } from "./_challenge.js";
14
15
  import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
15
16
  /** Bearer token regex pattern. */
16
17
  export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
@@ -73,8 +74,6 @@ const CHALLENGE_ERROR_CODES = new Set([
73
74
  export class ResourceServer {
74
75
  /** Scope constructor used to parse and compare scopes. */
75
76
  Scope;
76
- /** Protection space named in `WWW-Authenticate` challenges. */
77
- realm;
78
77
  /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
79
78
  errorFormat;
80
79
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
@@ -83,6 +82,22 @@ export class ResourceServer {
83
82
  resolveContext;
84
83
  #clockSkewSeconds = 0;
85
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
+ }
86
101
  /**
87
102
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
88
103
  * May be reassigned to any non-negative finite number with a finite
@@ -107,6 +122,8 @@ export class ResourceServer {
107
122
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
108
123
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
109
124
  * not a positive integer.
125
+ * @throws {TypeError} If `realm` contains a character outside printable
126
+ * ASCII.
110
127
  */
111
128
  constructor(options) {
112
129
  this.Scope =
@@ -138,31 +155,33 @@ export class ResourceServer {
138
155
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
139
156
  * fine, it just lacks scope" rather than sending it off to refresh.
140
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
+ *
141
166
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
142
167
  */
143
168
  buildWwwAuthenticate(error, requiredScope) {
144
- const quote = (value) => value
145
- // oxlint-disable-next-line no-control-regex
146
- .replace(/[\x00-\x1f\x7f]/g, "")
147
- .replace(/\\/g, "\\\\")
148
- .replace(/"/g, '\\"');
149
- const parts = [`realm="${quote(this.realm)}"`];
169
+ const parts = [`realm=${quoteRealm(this.realm)}`];
150
170
  const code = error?.extensions.error;
151
171
  if (error && code && CHALLENGE_ERROR_CODES.has(code)) {
152
- parts.push(`error="${quote(code)}"`);
153
- if (error.message) {
154
- 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}"`);
155
176
  }
156
177
  }
157
- if (requiredScope) {
158
- parts.push(`scope="${quote(requiredScope)}"`);
159
- }
160
- else if (error instanceof InsufficientScopeError) {
161
- const scope = error.extensions
162
- .requiredScope;
163
- if (scope) {
164
- parts.push(`scope="${quote(scope)}"`);
165
- }
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}"`);
166
185
  }
167
186
  return `Bearer ${parts.join(", ")}`;
168
187
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.19.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",