@udibo/oauth2 0.30.0 → 0.32.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.
@@ -526,7 +526,20 @@ export interface HonoBffOptions {
526
526
  * `auth_time` but no particular age. A token response without an ID token
527
527
  * is not checked, unless {@link HonoBffOptions.forwardedParams} names
528
528
  * `scope`: the browser could then leave out `openid` to skip the check, so
529
- * a missing ID token fails with `login_required` too. A pinned `scope` is
529
+ * a missing ID token fails with `login_required` too.
530
+ *
531
+ * Both rules follow the sign-in, not the instance that finishes it.
532
+ * `/auth/login` records the `max_age` it sent, and whether it forwarded
533
+ * `scope`, on the pending authorization request
534
+ * ({@link AuthRequestRecord.maxAge}). `/auth/callback` checks against that
535
+ * record, so a login-only instance that pins `max_age` for a step-up route
536
+ * is enforced by a callback another instance serves. An instance that pins
537
+ * `max_age` does not check a sign-in that carried none. A record without
538
+ * the field, written by an earlier release or kept by an
539
+ * {@link HonoBffOptions.authRequestStorage} that drops fields it does not
540
+ * know, is checked against the callback instance's own options instead.
541
+ *
542
+ * A pinned `scope` is
530
543
  * accepted only when
531
544
  * {@link scope} is unset; setting both would leave which one applies
532
545
  * ambiguous. Either configuration supplies the scope the login flow records
@@ -168,6 +168,30 @@ function pinnedMaxAge(fixed) {
168
168
  }
169
169
  return strictest;
170
170
  }
171
+ function recordingAuthenticationControls(storage, controls) {
172
+ return {
173
+ get: storage.get.bind(storage),
174
+ set: (state, value) => storage.set(state, { ...value, ...controls }),
175
+ delete: storage.delete.bind(storage),
176
+ clear: storage.clear.bind(storage),
177
+ };
178
+ }
179
+ function recordedAuthenticationControls(recorded) {
180
+ const { maxAge } = recorded;
181
+ const scopeForwarded = recorded.scopeForwarded !== false;
182
+ if (maxAge === null)
183
+ return { maxAge: undefined, scopeForwarded };
184
+ if (typeof maxAge === "number" &&
185
+ Number.isSafeInteger(maxAge) &&
186
+ maxAge >= 0) {
187
+ return { maxAge, scopeForwarded };
188
+ }
189
+ throw new CallbackRefusal({
190
+ error: "login_required",
191
+ error_description: "the pending authorization request recorded a max_age that is not a " +
192
+ "non-negative integer, so the ID token's auth_time could not be checked",
193
+ });
194
+ }
171
195
  class CallbackRefusal extends Error {
172
196
  refusal;
173
197
  constructor(refusal) {
@@ -724,7 +748,10 @@ export class HonoBff {
724
748
  prompt: requested.prompt,
725
749
  extraParams: requested.extraParams,
726
750
  redirectUri: this.#derivedRedirectUri(c, origin),
727
- authRequestStorage: this.#authRequestStorage(c),
751
+ authRequestStorage: recordingAuthenticationControls(this.#authRequestStorage(c), {
752
+ maxAge: this.#extraAuthorizeParams.maxAge ?? null,
753
+ scopeForwarded: this.#extraAuthorizeParams.scopeForwarded,
754
+ }),
728
755
  });
729
756
  this.#writeLoginStateHashes(c, [
730
757
  ...this.#loginStateHashes(c),
@@ -776,15 +803,18 @@ export class HonoBff {
776
803
  this.#writeLoginStateHashes(c, pendingStates.filter((_, index) => index !== matched));
777
804
  try {
778
805
  const origin = this.#options.resolveOrigin?.(c);
779
- const { tokens, refreshToken, returnTo, raw } = await this.#client.exchangeAuthorizationCode(code, state, {
806
+ const exchanged = await this.#client.exchangeAuthorizationCode(code, state, {
780
807
  origin,
781
808
  redirectUri: this.#derivedRedirectUri(c, origin),
782
809
  authRequestStorage: this.#authRequestStorage(c),
783
810
  });
811
+ const { tokens, refreshToken, returnTo, raw } = exchanged;
784
812
  let user = null;
785
813
  let sid;
786
814
  const idToken = raw.id_token;
787
- const { maxAge, scopeForwarded } = this.#extraAuthorizeParams;
815
+ const { maxAge, scopeForwarded } = exchanged.maxAge === undefined
816
+ ? this.#extraAuthorizeParams
817
+ : recordedAuthenticationControls(exchanged);
788
818
  if (maxAge !== undefined && (idToken || scopeForwarded)) {
789
819
  let claims;
790
820
  try {
@@ -210,6 +210,16 @@ export interface ExchangeResult {
210
210
  refreshToken?: string;
211
211
  /** `returnTo` recorded at authorize time, for the code exchange. */
212
212
  returnTo?: string;
213
+ /**
214
+ * {@link AuthRequestRecord.maxAge} as recorded at authorize time, for the
215
+ * code exchange. Absent when the record carries none.
216
+ */
217
+ maxAge?: number | null;
218
+ /**
219
+ * {@link AuthRequestRecord.scopeForwarded} as recorded at authorize time,
220
+ * for the code exchange. Absent when the record carries none.
221
+ */
222
+ scopeForwarded?: boolean;
213
223
  /** The raw token-endpoint response. */
214
224
  raw: TokenResponse;
215
225
  }
@@ -565,6 +565,10 @@ export class DirectClient extends OAuth2ClientBase {
565
565
  tokens: responseToBundle(raw),
566
566
  refreshToken: raw.refresh_token,
567
567
  returnTo: record.returnTo,
568
+ ...(record.maxAge === undefined ? {} : { maxAge: record.maxAge }),
569
+ ...(record.scopeForwarded === undefined
570
+ ? {}
571
+ : { scopeForwarded: record.scopeForwarded }),
568
572
  raw,
569
573
  };
570
574
  }
@@ -64,6 +64,25 @@ export interface AuthRequestRecord {
64
64
  returnTo?: string;
65
65
  /** Scope requested for this authorization, space-delimited. */
66
66
  scope?: string;
67
+ /**
68
+ * The `max_age` the authorize request carried, in seconds, or `null` when
69
+ * it carried none. `HonoBff` records it at `/auth/login` and checks the ID
70
+ * token's `auth_time` against it at `/auth/callback`, whichever instance
71
+ * serves either one. Absent on a record from a release that did not record
72
+ * it; the callback then falls back to its own pinned `max_age`. A storage
73
+ * must return `null` as `null`, not as an absent field, and a number as a
74
+ * number: the callback refuses any other value with `login_required`.
75
+ */
76
+ maxAge?: number | null;
77
+ /**
78
+ * Whether the browser chose this request's `scope` (`HonoBff`'s
79
+ * `forwardedParams` names `scope`). When it did and {@link maxAge} is set,
80
+ * the callback refuses a token response without an ID token, because a
81
+ * scope without `openid` would otherwise skip the `auth_time` check.
82
+ * Recorded alongside {@link maxAge}; the callback reads any value other than
83
+ * `false` as `true`.
84
+ */
85
+ scopeForwarded?: boolean;
67
86
  /** When the request was created (epoch ms), used for TTL expiry. */
68
87
  createdAt: number;
69
88
  }
@@ -64,7 +64,13 @@ export interface RefreshTokenGrantOptions<Client extends ClientInterface, User,
64
64
  *
65
65
  * Needs a token service that sets {@linkcode RefreshToken.rotatedAt} in
66
66
  * `revokeRotated` and implements `hasLiveRefreshToken`; with any other
67
- * service the grace never applies.
67
+ * service the grace never applies. The window is measured by the token
68
+ * service's `isWithinReplayGrace` when it implements one, on the store's
69
+ * clock, and otherwise by comparing `rotatedAt` with this process's clock.
70
+ * The store's answer is honored only while `rotatedAt` is no more than five
71
+ * minutes ahead of this process's clock and no more than the grace plus
72
+ * five minutes behind it, so a faulty store check cannot excuse replays
73
+ * indefinitely.
68
74
  */
69
75
  replayGraceSeconds?: number;
70
76
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, } from "../../errors.js";
7
7
  import { AbstractGrant, } from "./grant.js";
8
+ const REPLAY_GRACE_CLOCK_TOLERANCE_SECONDS = 300;
8
9
  /**
9
10
  * The refresh token grant type.
10
11
  * https://datatracker.ietf.org/doc/html/rfc6749.html#section-6
@@ -16,7 +17,7 @@ export class RefreshTokenGrant extends AbstractGrant {
16
17
  /** The OAuth2 grant type: `refresh_token`. */
17
18
  grantType = "refresh_token";
18
19
  #onTokenReuse;
19
- #replayGraceMs;
20
+ #replayGraceSeconds;
20
21
  /** Creates the grant from its {@linkcode RefreshTokenGrantOptions}. */
21
22
  constructor(options) {
22
23
  super({ ...options, allowRefreshToken: true });
@@ -25,7 +26,7 @@ export class RefreshTokenGrant extends AbstractGrant {
25
26
  if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
26
27
  throw new RangeError(`replayGraceSeconds must be a non-negative number of seconds, got ${graceSeconds}`);
27
28
  }
28
- this.#replayGraceMs = graceSeconds * 1000;
29
+ this.#replayGraceSeconds = graceSeconds;
29
30
  }
30
31
  /**
31
32
  * Handles a token request for the refresh token grant. Issues a new access
@@ -143,12 +144,23 @@ export class RefreshTokenGrant extends AbstractGrant {
143
144
  }
144
145
  async #withinReplayGrace(tokenService, replayed) {
145
146
  const { rotatedAt, familyId } = replayed;
146
- if (this.#replayGraceMs === 0 || !rotatedAt || !familyId)
147
+ if (this.#replayGraceSeconds === 0 || !rotatedAt || !familyId) {
147
148
  return false;
148
- if (Date.now() - rotatedAt.getTime() > this.#replayGraceMs)
149
+ }
150
+ const elapsedMs = Date.now() - rotatedAt.getTime();
151
+ const withinWindow = tokenService.isWithinReplayGrace
152
+ ? this.#withinClockTolerance(elapsedMs) &&
153
+ (await tokenService.isWithinReplayGrace(replayed, this.#replayGraceSeconds))
154
+ : elapsedMs <= this.#replayGraceSeconds * 1000;
155
+ if (!withinWindow)
149
156
  return false;
150
157
  return (await tokenService.hasLiveRefreshToken?.(familyId)) ?? false;
151
158
  }
159
+ #withinClockTolerance(elapsedMs) {
160
+ const toleranceMs = REPLAY_GRACE_CLOCK_TOLERANCE_SECONDS * 1000;
161
+ return (elapsedMs >= -toleranceMs &&
162
+ elapsedMs <= this.#replayGraceSeconds * 1000 + toleranceMs);
163
+ }
152
164
  async #notifyTokenReuse(event) {
153
165
  if (!this.#onTokenReuse)
154
166
  return;
@@ -175,9 +175,10 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
175
175
  * is silently disabled and `/revoke` cannot reach a successor.
176
176
  *
177
177
  * Return the tombstone unconditionally. Apply any replay grace through
178
- * `RefreshTokenGrantOptions.replayGraceSeconds`, never by hiding recent
179
- * tombstones here: a hidden tombstone also hides the family from a sign-out
180
- * racing the refresh, which then leaves the successor live.
178
+ * `RefreshTokenGrantOptions.replayGraceSeconds`, judged on the store's clock
179
+ * by {@linkcode isWithinReplayGrace} when it needs one, never by hiding
180
+ * recent tombstones here: a hidden tombstone also hides the family from a
181
+ * sign-out racing the refresh, which then leaves the successor live.
181
182
  */
182
183
  getRevokedRefreshToken?(refreshToken: string): Promise<RefreshToken<Client, User, Scope> | undefined>;
183
184
  /**
@@ -209,6 +210,25 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
209
210
  * never applies and every replay is treated as reuse.
210
211
  */
211
212
  hasLiveRefreshToken?(familyId: string): Promise<boolean>;
213
+ /**
214
+ * Whether `tombstone`, a record {@linkcode getRevokedRefreshToken} just
215
+ * returned, was rotated out no more than `graceSeconds` ago, measured on the
216
+ * clock that stamped its rotation. Implement it when that clock is the
217
+ * store's own, such as a database's, so instances whose clocks drift
218
+ * apart agree on the refresh grant's `replayGraceSeconds` window. Without
219
+ * it, the grant compares {@linkcode RefreshToken.rotatedAt} with its own
220
+ * `Date.now()`.
221
+ *
222
+ * The grant calls it only when `replayGraceSeconds` is above zero and the
223
+ * tombstone carries `rotatedAt` and a `familyId`; `graceSeconds` may be
224
+ * fractional. The window still needs {@linkcode hasLiveRefreshToken}.
225
+ * The grant treats the replay as outside the window, whatever this
226
+ * resolves, once `rotatedAt` is more than five minutes ahead of the
227
+ * grant's clock or more than `graceSeconds` plus five minutes behind it.
228
+ * Implementing this never licenses hiding a tombstone from
229
+ * `getRevokedRefreshToken`.
230
+ */
231
+ isWithinReplayGrace?(tombstone: RefreshToken<Client, User, Scope>, graceSeconds: number): Promise<boolean>;
212
232
  }
213
233
  /** Options for configuring an abstract token service. */
214
234
  export interface AbstractTokenServiceOptions<Client extends ClientInterface, User> {
@@ -9,6 +9,11 @@
9
9
  * concurrent callers taking one record. This suite races eight of them on one
10
10
  * record.
11
11
  *
12
+ * Every field of a record must come back as it was stored, `maxAge: null`
13
+ * included: a store that keeps only the fields it knows makes `HonoBff` check
14
+ * a sign-in against the callback instance's options instead of the
15
+ * `max_age` the sign-in was started with.
16
+ *
12
17
  * @example Verify a Redis-backed store
13
18
  * ```ts
14
19
  * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
@@ -9,6 +9,11 @@
9
9
  * concurrent callers taking one record. This suite races eight of them on one
10
10
  * record.
11
11
  *
12
+ * Every field of a record must come back as it was stored, `maxAge: null`
13
+ * included: a store that keeps only the fields it knows makes `HonoBff` check
14
+ * a sign-in against the callback instance's options instead of the
15
+ * `max_age` the sign-in was started with.
16
+ *
12
17
  * @example Verify a Redis-backed store
13
18
  * ```ts
14
19
  * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
@@ -45,6 +50,8 @@ function record(overrides = {}) {
45
50
  codeVerifier: "verifier-1",
46
51
  returnTo: "/dashboard",
47
52
  scope: "openid profile",
53
+ maxAge: 300,
54
+ scopeForwarded: true,
48
55
  createdAt: Date.now(),
49
56
  ...overrides,
50
57
  };
@@ -75,6 +82,12 @@ export function runAuthRequestStorageContractTests(options) {
75
82
  await store.set("state-1", stored);
76
83
  expect(await store.get("state-1")).toStrictEqual(stored);
77
84
  });
85
+ it("returns a recorded maxAge of null as null, not as an absent field", async () => {
86
+ const stored = record({ maxAge: null, scopeForwarded: false });
87
+ await store.set("state-1", stored);
88
+ expect(await store.get("state-1"), "null records a sign-in that carried no max_age; an absent field " +
89
+ "makes the callback fall back to its own pinned max_age").toStrictEqual(stored);
90
+ });
78
91
  it("keeps records under different states independent", async () => {
79
92
  await store.set("state-1", record({ codeVerifier: "verifier-1" }));
80
93
  await store.set("state-2", record({ codeVerifier: "verifier-2" }));
@@ -17,6 +17,7 @@
17
17
  *
18
18
  * @module
19
19
  */
20
+ import { setTimeout as delay } from "node:timers/promises";
20
21
  import { assert, beforeEach, describe, expect, it } from "vitest";
21
22
  import { BasicScope } from "../../models/scope.js";
22
23
  const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
@@ -368,6 +369,41 @@ export function runTokenServiceContractTests(options) {
368
369
  expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
369
370
  "predecessor must not be excused as a straggler").toBe(false);
370
371
  });
372
+ async function rotateOut(skip) {
373
+ skip(typeof tokenService.isWithinReplayGrace !== "function", "the service does not implement isWithinReplayGrace");
374
+ skip(typeof tokenService.rotate !== "function" &&
375
+ typeof tokenService.revokeRotated !== "function", "the service implements neither rotate nor revokeRotated");
376
+ const parent = await saveFamilyMember(FAMILY);
377
+ const successor = await familyMember(FAMILY);
378
+ if (tokenService.rotate) {
379
+ assert(await tokenService.rotate(parent, successor));
380
+ }
381
+ else {
382
+ assert(await tokenService.revokeRotated(parent));
383
+ await tokenService.save(successor);
384
+ }
385
+ const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
386
+ assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "a store that judges the replay grace must still return every " +
387
+ "rotated-out tombstone, with its family and rotatedAt; hiding " +
388
+ "one inside the window hides the family from a sign-out " +
389
+ "racing the refresh, which then leaves the successor live");
390
+ return { tombstone, successor };
391
+ }
392
+ it("returns the tombstone of a token inside its replay grace, so revoking it ends the successor", async ({ skip, }) => {
393
+ const { tombstone, successor } = await rotateOut(skip);
394
+ expect(await tokenService.isWithinReplayGrace(tombstone, 60), "a token rotated out a moment ago is inside a 60-second grace").toBe(true);
395
+ expect(await tokenService.revokeFamily(tombstone.familyId)).toBe(true);
396
+ expect(await tokenService.getRefreshToken(successor.refreshToken)).toBe(undefined);
397
+ });
398
+ it("judges a rotated-out token inside a one-second grace, then outside it once the second has passed", async ({ skip, }) => {
399
+ const { tombstone } = await rotateOut(skip);
400
+ expect(await tokenService.isWithinReplayGrace(tombstone, 1), "a token rotated out a moment ago is inside a one-second grace").toBe(true);
401
+ await delay(1_200);
402
+ expect(await tokenService.isWithinReplayGrace(tombstone, 1), "a grace that answers true regardless of elapsed time excuses " +
403
+ "every replay while the family is live, which turns reuse " +
404
+ "detection off").toBe(false);
405
+ expect(await tokenService.isWithinReplayGrace(tombstone, 3600)).toBe(true);
406
+ });
371
407
  });
372
408
  }
373
409
  else {
@@ -256,6 +256,13 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
256
256
  rotate(current: RefreshToken<C, U, S>, next: RefreshToken<C, U, S>): Promise<RefreshToken<C, U, S> | undefined>;
257
257
  /** Whether the family has a live refresh token that has not expired. */
258
258
  hasLiveRefreshToken(familyId: string): Promise<boolean>;
259
+ /**
260
+ * Whether the tombstone was rotated out no more than `graceSeconds` ago,
261
+ * judged from the rotation time this store recorded rather than the one on
262
+ * the record passed in. Resolves `false` for a token it holds no rotated-out
263
+ * tombstone for.
264
+ */
265
+ isWithinReplayGrace(tombstone: RefreshToken<C, U, S>, graceSeconds: number): Promise<boolean>;
259
266
  /**
260
267
  * Looks up a revoked/rotated-out refresh token for the refresh grant's
261
268
  * reuse detection: revoked refresh records are kept as tombstones instead
@@ -427,6 +427,18 @@ export class MemoryTokenService {
427
427
  }
428
428
  return Promise.resolve(false);
429
429
  }
430
+ /**
431
+ * Whether the tombstone was rotated out no more than `graceSeconds` ago,
432
+ * judged from the rotation time this store recorded rather than the one on
433
+ * the record passed in. Resolves `false` for a token it holds no rotated-out
434
+ * tombstone for.
435
+ */
436
+ async isWithinReplayGrace(tombstone, graceSeconds) {
437
+ const rotatedAt = this.#revokedByRefreshHash.get(await sha256Hash(tombstone.refreshToken))?.rotatedAt;
438
+ if (!rotatedAt)
439
+ return false;
440
+ return Date.now() - rotatedAt.getTime() <= graceSeconds * 1000;
441
+ }
430
442
  #tombstone(refreshTokenHash, stored) {
431
443
  this.#revokedByRefreshHash.set(refreshTokenHash, stored);
432
444
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.30.0",
3
+ "version": "0.32.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",