@udibo/oauth2 0.6.0 → 0.8.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.
@@ -248,6 +248,26 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
248
248
  * bundle.
249
249
  */
250
250
  isPublicSuffix?: IsPublicSuffix;
251
+ /**
252
+ * The largest request body, in bytes, the server reads at the token,
253
+ * revocation, introspection, device authorization and end-session
254
+ * endpoints. Each reads its form body before it has authenticated the
255
+ * caller, so the cap bounds what an anonymous request can make the server
256
+ * buffer. Bytes are counted as they arrive, so a chunked body or a
257
+ * `Content-Length` that understates the body cannot get past it. A larger
258
+ * body is refused with `413` and `invalid_request` before the request's
259
+ * context is resolved or any client is authenticated.
260
+ *
261
+ * The default fits every body these endpoints legitimately receive: the
262
+ * largest field is a JWT (`id_token_hint`, or a token presented for
263
+ * introspection or revocation), a few KiB even with a large claim set.
264
+ * Raise it only if your clients send larger ones.
265
+ *
266
+ * @default 65536
267
+ * @throws {RangeError} From the constructor when it is not a positive
268
+ * integer.
269
+ */
270
+ maxBodyBytes?: number;
251
271
  }
252
272
  /**
253
273
  * Function to authenticate the user during authorization.
@@ -407,6 +427,7 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
407
427
  *
408
428
  * @throws {Error} If any grant's map key does not equal its `grantType`.
409
429
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
430
+ * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
410
431
  */
411
432
  constructor(options: AuthorizationServerOptions<Client, User, S>);
412
433
  /**
@@ -453,7 +474,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
453
474
  * `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
454
475
  * `invalid_request`. A `grant_type` with no registered grant is
455
476
  * `unsupported_grant_type`, checked before client authentication; a client
456
- * not registered for the grant is `unauthorized_client`.
477
+ * not registered for the grant is `unauthorized_client`. A body over
478
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
479
+ * `invalid_request`, refused before the request context is resolved.
457
480
  */
458
481
  handleTokenRequest(request: Request): Promise<Response>;
459
482
  /**
@@ -522,7 +545,9 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
522
545
  * when sent, names the client ahead of the hint's audience, and when both
523
546
  * are sent `client_id` must be one of the hint's audiences — a mismatch is
524
547
  * a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
525
- * along only to a URI that passed authorization.
548
+ * along only to a URI that passed authorization. A POST body over
549
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
550
+ * `invalid_request`, refused before `endSession` runs.
526
551
  *
527
552
  * Wire the app's session teardown through the `endSession` **constructor
528
553
  * option**; this method answers 404 without it, and the discovery document
@@ -29,6 +29,7 @@ import { DeviceAuthorizationGrant } from "./grants/device-authorization.js";
29
29
  import { ResourceServer, } from "./resource-server.js";
30
30
  import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
31
31
  import { validateCodeChallenge } from "../utils/pkce.js";
32
+ import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
32
33
  const ENDPOINTS = {
33
34
  authorization: {
34
35
  option: "authorizationEndpoint",
@@ -186,6 +187,7 @@ export class AuthorizationServer extends ResourceServer {
186
187
  signingKeys;
187
188
  #endSession;
188
189
  #isPublicSuffix;
190
+ #maxBodyBytes;
189
191
  #subjectOf;
190
192
  #userClaims;
191
193
  #introspectionClaims;
@@ -197,6 +199,7 @@ export class AuthorizationServer extends ResourceServer {
197
199
  *
198
200
  * @throws {Error} If any grant's map key does not equal its `grantType`.
199
201
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
202
+ * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
200
203
  */
201
204
  constructor(options) {
202
205
  super({
@@ -227,6 +230,8 @@ export class AuthorizationServer extends ResourceServer {
227
230
  "plain OAuth2.");
228
231
  }
229
232
  this.#isPublicSuffix = options.isPublicSuffix;
233
+ this.#maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
234
+ assertMaxBodyBytes(this.#maxBodyBytes, "maxBodyBytes");
230
235
  this.#subjectOf = options.subjectOf ?? defaultSubjectOf;
231
236
  this.#userClaims = options.userClaims;
232
237
  this.#introspectionClaims = options.introspectionClaims;
@@ -343,6 +348,18 @@ export class AuthorizationServer extends ResourceServer {
343
348
  }
344
349
  return grant;
345
350
  }
351
+ async #readBody(request, options = {}) {
352
+ const bytes = await readBoundedBody(request, this.#maxBodyBytes, options)
353
+ .catch((cause) => {
354
+ throw new InvalidRequestError("request body could not be read", {
355
+ cause,
356
+ });
357
+ });
358
+ if (!bytes) {
359
+ throw new InvalidRequestError(413, `request body exceeds ${this.#maxBodyBytes} bytes`);
360
+ }
361
+ return bytes;
362
+ }
346
363
  async #beginClientAuthenticatedRequest(request, options = {}) {
347
364
  if (request.method !== "POST") {
348
365
  throw new InvalidRequestError("method must be POST");
@@ -351,10 +368,13 @@ export class AuthorizationServer extends ResourceServer {
351
368
  if (!contentType?.includes("application/x-www-form-urlencoded")) {
352
369
  throw new InvalidRequestError("content-type header must be application/x-www-form-urlencoded");
353
370
  }
354
- const context = await this.authorizationContext(request);
355
- const body = await request.clone().formData().catch(() => {
371
+ const bytes = await this.#readBody(request, { keepReadable: true });
372
+ const body = await new Response(bytes, {
373
+ headers: { "content-type": contentType },
374
+ }).formData().catch(() => {
356
375
  throw new InvalidRequestError("body must be application/x-www-form-urlencoded");
357
376
  });
377
+ const context = await this.authorizationContext(request);
358
378
  const grant = options.grantFromBody?.(body);
359
379
  const client = grant
360
380
  ? await grant.getAuthenticatedClient(request, body)
@@ -372,7 +392,9 @@ export class AuthorizationServer extends ResourceServer {
372
392
  * `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
373
393
  * `invalid_request`. A `grant_type` with no registered grant is
374
394
  * `unsupported_grant_type`, checked before client authentication; a client
375
- * not registered for the grant is `unauthorized_client`.
395
+ * not registered for the grant is `unauthorized_client`. A body over
396
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
397
+ * `invalid_request`, refused before the request context is resolved.
376
398
  */
377
399
  async handleTokenRequest(request) {
378
400
  try {
@@ -523,7 +545,9 @@ export class AuthorizationServer extends ResourceServer {
523
545
  * when sent, names the client ahead of the hint's audience, and when both
524
546
  * are sent `client_id` must be one of the hint's audiences — a mismatch is
525
547
  * a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
526
- * along only to a URI that passed authorization.
548
+ * along only to a URI that passed authorization. A POST body over
549
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
550
+ * `invalid_request`, refused before `endSession` runs.
527
551
  *
528
552
  * Wire the app's session teardown through the `endSession` **constructor
529
553
  * option**; this method answers 404 without it, and the discovery document
@@ -572,7 +596,7 @@ export class AuthorizationServer extends ResourceServer {
572
596
  async #endSessionParameters(request) {
573
597
  const url = new URL(request.url);
574
598
  const params = request.method === "POST"
575
- ? new URLSearchParams(await request.text())
599
+ ? new URLSearchParams(new TextDecoder().decode(await this.#readBody(request)))
576
600
  : url.searchParams;
577
601
  const idTokenHint = params.get("id_token_hint");
578
602
  const hint = idTokenHint ? await this.#readIdTokenHint(idTokenHint) : null;
@@ -22,6 +22,20 @@
22
22
  * });
23
23
  * ```
24
24
  *
25
+ * @example Verify a store shared between users, whose `clear()` removes only expired records
26
+ * ```ts
27
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
28
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
29
+ *
30
+ * declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
31
+ *
32
+ * runAuthRequestStorageContractTests({
33
+ * describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
34
+ * makeStore: freshSharedAuthRequestStorage,
35
+ * clear: "scoped",
36
+ * });
37
+ * ```
38
+ *
25
39
  * @module
26
40
  */
27
41
  import type { AuthRequestStorage } from "../../client/storage.js";
@@ -41,6 +55,18 @@ export interface AuthRequestStorageContractOptions {
41
55
  * that meant to implement it and does not fails loudly.
42
56
  */
43
57
  take?: boolean;
58
+ /**
59
+ * What the store's {@link AuthRequestStorage.clear} removes. Defaults to
60
+ * `"all"`: every record, as a store private to one browser or session
61
+ * does. Pass `"scoped"` for a store shared between users whose `clear()`
62
+ * deliberately removes less — only expired records, say — because a
63
+ * `DirectClient` clears it on every sign-out, and removing everything would
64
+ * cancel other users' sign-ins still in progress. The suite then drops the
65
+ * clear-everything case and instead requires that `clear()` leaves every
66
+ * record still inside its lifetime readable and redeemable, and removes a
67
+ * record created at the epoch (`createdAt: 0`), which is past any lifetime.
68
+ */
69
+ clear?: "all" | "scoped";
44
70
  /**
45
71
  * Overrides the name passed to the outer `describe` block. Defaults to
46
72
  * `"AuthRequestStorage contract"`.
@@ -22,6 +22,20 @@
22
22
  * });
23
23
  * ```
24
24
  *
25
+ * @example Verify a store shared between users, whose `clear()` removes only expired records
26
+ * ```ts
27
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
28
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
29
+ *
30
+ * declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
31
+ *
32
+ * runAuthRequestStorageContractTests({
33
+ * describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
34
+ * makeStore: freshSharedAuthRequestStorage,
35
+ * clear: "scoped",
36
+ * });
37
+ * ```
38
+ *
25
39
  * @module
26
40
  */
27
41
  import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
@@ -47,6 +61,7 @@ function record(overrides = {}) {
47
61
  */
48
62
  export function runAuthRequestStorageContractTests(options) {
49
63
  const expectTake = options.take ?? true;
64
+ const clearScope = options.clear ?? "all";
50
65
  describe(options.describeName ?? "AuthRequestStorage contract", () => {
51
66
  let store;
52
67
  beforeEach(async () => {
@@ -74,13 +89,35 @@ export function runAuthRequestStorageContractTests(options) {
74
89
  assertStrictEquals(await store.get("state-1"), null);
75
90
  assert(await store.get("state-2") !== null, "delete must remove only the named state");
76
91
  });
77
- it("clears every record", async () => {
78
- await store.set("state-1", record());
79
- await store.set("state-2", record());
80
- await store.clear();
81
- assertStrictEquals(await store.get("state-1"), null);
82
- assertStrictEquals(await store.get("state-2"), null);
83
- });
92
+ if (clearScope === "all") {
93
+ it("clears every record", async () => {
94
+ await store.set("state-1", record());
95
+ await store.set("state-2", record());
96
+ await store.clear();
97
+ assertStrictEquals(await store.get("state-1"), null);
98
+ assertStrictEquals(await store.get("state-2"), null);
99
+ });
100
+ }
101
+ else {
102
+ it('leaves every in-progress record in place (clear: "scoped")', async () => {
103
+ const first = record({ codeVerifier: "verifier-1" });
104
+ const second = record({ codeVerifier: "verifier-2" });
105
+ await store.set("state-1", first);
106
+ await store.set("state-2", second);
107
+ await store.clear();
108
+ const message = "clear() on a shared store must not cancel another user's sign-in " +
109
+ "that is still in progress";
110
+ assertEquals(await store.get("state-2"), second, message);
111
+ assertEquals(expectTake
112
+ ? await store.take("state-1")
113
+ : await store.get("state-1"), first, message);
114
+ });
115
+ it('removes a record past any lifetime (clear: "scoped")', async () => {
116
+ await store.set("expired", record({ createdAt: 0 }));
117
+ await store.clear();
118
+ assertStrictEquals(await store.get("expired"), null, "a scoped clear() must still remove expired records");
119
+ });
120
+ }
84
121
  });
85
122
  if (expectTake) {
86
123
  describe("take", () => {
@@ -39,4 +39,4 @@ export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-s
39
39
  export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
40
40
  export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
41
41
  export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
42
- export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractTokens, } from "./tenant.js";
42
+ export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Contract test suite for a Udibo Identity tenant's authorization answers: what
3
- * introspection reports about a signed-in person, what `GET /api/memberships`
4
- * lists for them, and what `POST /api/check` and `POST /api/check/batch`
5
- * answer.
2
+ * Contract test suite for what a Udibo Identity tenant answers an application
3
+ * about its signed-in people: what introspection reports, what
4
+ * `GET /api/memberships` lists, what `POST /api/check` and
5
+ * `POST /api/check/batch` answer, the organization API under
6
+ * `/api/organizations`, and the account API under `/api/account`.
6
7
  *
7
8
  * It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
8
9
  * real identity service in Udibo's own repository, so the fake an app tests
@@ -35,8 +36,9 @@ export interface TenantContractFixture {
35
36
  /** The tenant's origin; its RFC 8414 metadata is served beneath it. */
36
37
  issuer: string;
37
38
  /**
38
- * A confidential client the suite signs in with, introspects with and
39
- * refreshes with. It must be allowed the `refresh_token` grant.
39
+ * A confidential first-party client the suite signs in with, introspects
40
+ * with, refreshes with and revokes with. It must be allowed the
41
+ * `refresh_token` grant.
40
42
  */
41
43
  client: {
42
44
  id: string;
@@ -47,8 +49,19 @@ export interface TenantContractFixture {
47
49
  * one when the tenant's host resolves only through a proxy.
48
50
  */
49
51
  fetch?: typeof fetch;
50
- /** Adds a person holding `permissions` tenant-wide, and returns their id. */
51
- addUser(permissions: string[]): Promise<string>;
52
+ /**
53
+ * Adds a person holding `permissions` tenant-wide, and returns their id. The
54
+ * organization and account checks pass a `profile`; the others do not.
55
+ */
56
+ addUser(permissions: string[], profile?: TenantContractProfile): Promise<string>;
57
+ /**
58
+ * Links an external sign-in account to a person and returns the linked
59
+ * account's id.
60
+ */
61
+ linkAccount(userId: string, account: {
62
+ provider: string;
63
+ email: string | null;
64
+ }): Promise<string>;
52
65
  /** Adds an organization and returns its id. */
53
66
  addOrganization(): Promise<string>;
54
67
  /**
@@ -77,11 +90,38 @@ export interface TenantContractFixture {
77
90
  * Signs the person in through the authorization-code flow and returns the
78
91
  * tokens it issued. With an organization named, the credential is issued in
79
92
  * it; with none, the sign-in picks nothing, leaving the tenant to decide.
93
+ * Each sign-in comes from a new browser and so starts a login session of its
94
+ * own, unless `options.sameBrowser` says otherwise.
80
95
  */
81
- signIn(userId: string, organizationId?: string): Promise<TenantContractTokens>;
96
+ signIn(userId: string, organizationId?: string, options?: TenantContractSignInOptions): Promise<TenantContractTokens>;
82
97
  /** Releases anything `setup` allocated. */
83
98
  cleanup?(): Promise<void> | void;
84
99
  }
100
+ /** How `TenantContractFixture.signIn` signs a person in. */
101
+ export interface TenantContractSignInOptions {
102
+ /**
103
+ * Sign in from the browser this person's previous sign-in used, so the
104
+ * tenant continues that login session instead of starting another. The
105
+ * suite asks for it only straight after that previous sign-in.
106
+ */
107
+ sameBrowser?: boolean;
108
+ }
109
+ /** Who a person added with `TenantContractFixture.addUser` is. */
110
+ export interface TenantContractProfile {
111
+ /**
112
+ * The address the person holds. When it is omitted, the fixture may give them
113
+ * any address or none.
114
+ */
115
+ email?: string;
116
+ /** Whether that address is verified. Defaults to `true`. */
117
+ emailVerified?: boolean;
118
+ /**
119
+ * Whether the person has a password. Defaults to `true`. With `false`, the
120
+ * fixture must still be able to sign them in, the way a tenant signs such a
121
+ * person in through a linked account.
122
+ */
123
+ password?: boolean;
124
+ }
85
125
  /** Options for {@link runTenantContractTests}. */
86
126
  export interface TenantContractOptions {
87
127
  /** Overrides the top-level `describe` name. */