@udibo/oauth2 0.4.0 → 0.5.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.
Files changed (161) hide show
  1. package/README.md +3 -3
  2. package/SECURITY.md +0 -4
  3. package/esm/adapters/hono/_common.d.ts +10 -13
  4. package/esm/adapters/hono/_common.js +11 -20
  5. package/esm/adapters/hono/authorization-server.d.ts +16 -10
  6. package/esm/adapters/hono/authorization-server.js +13 -8
  7. package/esm/adapters/hono/bff/auth-request-store.d.ts +4 -0
  8. package/esm/adapters/hono/bff/auth-request-store.js +4 -0
  9. package/esm/adapters/hono/bff/bff.d.ts +29 -18
  10. package/esm/adapters/hono/bff/bff.js +15 -11
  11. package/esm/adapters/hono/bff/proxy.js +7 -55
  12. package/esm/adapters/hono/bff/session-store.d.ts +9 -9
  13. package/esm/adapters/hono/bff/session-store.js +3 -2
  14. package/esm/adapters/hono/bff/testing.d.ts +14 -3
  15. package/esm/adapters/hono/bff/testing.js +14 -3
  16. package/esm/adapters/hono/identity.d.ts +5 -3
  17. package/esm/adapters/hono/identity.js +1 -17
  18. package/esm/adapters/hono/resource-server.d.ts +3 -2
  19. package/esm/adapters/hono/resource-server.js +3 -2
  20. package/esm/client/_http.d.ts +4 -7
  21. package/esm/client/_http.js +4 -37
  22. package/esm/client/base.d.ts +3 -6
  23. package/esm/client/bff-client.d.ts +7 -6
  24. package/esm/client/bff-client.js +7 -6
  25. package/esm/client/browser-storage.d.ts +33 -34
  26. package/esm/client/browser-storage.js +47 -32
  27. package/esm/client/check.d.ts +10 -3
  28. package/esm/client/check.js +28 -8
  29. package/esm/client/direct-client.d.ts +49 -29
  30. package/esm/client/direct-client.js +59 -59
  31. package/esm/client/discovery-cache.d.ts +12 -5
  32. package/esm/client/discovery-cache.js +11 -4
  33. package/esm/client/events.d.ts +11 -3
  34. package/esm/client/events.js +1 -1
  35. package/esm/client/mod.d.ts +6 -4
  36. package/esm/client/mod.js +5 -3
  37. package/esm/client/storage.d.ts +53 -23
  38. package/esm/client/storage.js +43 -15
  39. package/esm/errors.d.ts +15 -12
  40. package/esm/errors.js +12 -8
  41. package/esm/identity/delivery.d.ts +7 -7
  42. package/esm/identity/delivery.js +1 -2
  43. package/esm/identity/errors.d.ts +10 -4
  44. package/esm/identity/errors.js +4 -1
  45. package/esm/identity/events.d.ts +21 -5
  46. package/esm/identity/events.js +4 -2
  47. package/esm/identity/external/_shared.d.ts +5 -7
  48. package/esm/identity/external/_shared.js +5 -7
  49. package/esm/identity/external/_token-exchange.d.ts +3 -6
  50. package/esm/identity/external/_token-exchange.js +2 -5
  51. package/esm/identity/external/apple-client-secret.d.ts +13 -8
  52. package/esm/identity/external/apple-client-secret.js +9 -6
  53. package/esm/identity/external/apple.d.ts +17 -10
  54. package/esm/identity/external/apple.js +12 -9
  55. package/esm/identity/external/discord.d.ts +5 -1
  56. package/esm/identity/external/errors.d.ts +6 -2
  57. package/esm/identity/external/errors.js +6 -2
  58. package/esm/identity/external/flow.d.ts +9 -6
  59. package/esm/identity/external/flow.js +6 -5
  60. package/esm/identity/external/github.d.ts +7 -3
  61. package/esm/identity/external/github.js +2 -2
  62. package/esm/identity/external/google.d.ts +5 -1
  63. package/esm/identity/external/oauth2.d.ts +13 -2
  64. package/esm/identity/external/oauth2.js +3 -3
  65. package/esm/identity/external/oidc.d.ts +21 -10
  66. package/esm/identity/external/oidc.js +11 -6
  67. package/esm/identity/external/provider.d.ts +4 -2
  68. package/esm/identity/identifier.d.ts +6 -5
  69. package/esm/identity/identifier.js +6 -5
  70. package/esm/identity/lockout.d.ts +4 -3
  71. package/esm/identity/lockout.js +4 -3
  72. package/esm/identity/mfa/mod.d.ts +2 -1
  73. package/esm/identity/mfa/mod.js +2 -1
  74. package/esm/identity/mfa/recovery-codes.d.ts +2 -1
  75. package/esm/identity/mfa/service.d.ts +29 -11
  76. package/esm/identity/mfa/service.js +35 -11
  77. package/esm/identity/mfa/totp.d.ts +4 -2
  78. package/esm/identity/mfa/totp.js +4 -2
  79. package/esm/identity/migration.d.ts +8 -7
  80. package/esm/identity/migration.js +9 -12
  81. package/esm/identity/mod.d.ts +2 -2
  82. package/esm/identity/mod.js +2 -2
  83. package/esm/identity/otp.d.ts +23 -16
  84. package/esm/identity/otp.js +13 -12
  85. package/esm/identity/password-policy.d.ts +3 -3
  86. package/esm/identity/password-policy.js +3 -3
  87. package/esm/identity/password.d.ts +23 -20
  88. package/esm/identity/password.js +15 -16
  89. package/esm/identity/rate-limit.d.ts +1 -7
  90. package/esm/identity/rate-limit.js +1 -2
  91. package/esm/identity/service.d.ts +76 -44
  92. package/esm/identity/service.js +86 -60
  93. package/esm/identity/session.d.ts +5 -4
  94. package/esm/identity/session.js +5 -4
  95. package/esm/identity/token-flow.d.ts +8 -8
  96. package/esm/identity/token-flow.js +5 -6
  97. package/esm/models/client.d.ts +7 -4
  98. package/esm/models/token.d.ts +2 -3
  99. package/esm/react/callback.d.ts +3 -3
  100. package/esm/react/callback.js +3 -3
  101. package/esm/react/components/class-names.d.ts +3 -6
  102. package/esm/react/components/class-names.js +3 -6
  103. package/esm/react/components/internal.d.ts +7 -17
  104. package/esm/react/components/internal.js +7 -17
  105. package/esm/react/components/mod.d.ts +7 -5
  106. package/esm/react/components/mod.js +7 -5
  107. package/esm/react/components/types.d.ts +8 -6
  108. package/esm/react/context.d.ts +3 -3
  109. package/esm/react/provider.d.ts +3 -2
  110. package/esm/react/provider.js +0 -13
  111. package/esm/react/require-auth.d.ts +0 -3
  112. package/esm/react/require-auth.js +0 -3
  113. package/esm/react/testing.d.ts +3 -2
  114. package/esm/react/testing.js +3 -3
  115. package/esm/react/use-oauth2.d.ts +1 -1
  116. package/esm/server/authorization-server.d.ts +81 -54
  117. package/esm/server/authorization-server.js +76 -100
  118. package/esm/server/grants/authorization-code.d.ts +7 -3
  119. package/esm/server/grants/authorization-code.js +6 -2
  120. package/esm/server/grants/device-authorization.d.ts +1 -1
  121. package/esm/server/grants/grant.d.ts +15 -8
  122. package/esm/server/grants/grant.js +26 -18
  123. package/esm/server/grants/password.d.ts +1 -1
  124. package/esm/server/grants/refresh-token.d.ts +6 -3
  125. package/esm/server/grants/refresh-token.js +4 -3
  126. package/esm/server/introspection-token-reader.d.ts +16 -7
  127. package/esm/server/introspection-token-reader.js +9 -3
  128. package/esm/server/jwks-token-reader.d.ts +3 -3
  129. package/esm/server/jwks-token-reader.js +3 -3
  130. package/esm/server/public-suffix/rules.d.ts +4 -4
  131. package/esm/server/public-suffix/rules.js +4 -4
  132. package/esm/server/redirect-uri.d.ts +6 -9
  133. package/esm/server/redirect-uri.js +7 -34
  134. package/esm/server/resource-server.d.ts +5 -3
  135. package/esm/server/resource-server.js +5 -3
  136. package/esm/server/services/client.d.ts +5 -1
  137. package/esm/server/services/device-authorization.d.ts +6 -6
  138. package/esm/server/services/token.d.ts +15 -8
  139. package/esm/server/services/token.js +1 -1
  140. package/esm/server/signing-keys.d.ts +24 -9
  141. package/esm/server/signing-keys.js +20 -9
  142. package/esm/testing/contract/mod.d.ts +3 -3
  143. package/esm/testing/contract/mod.js +3 -3
  144. package/esm/testing/mod.d.ts +4 -2
  145. package/esm/testing/mod.js +4 -2
  146. package/esm/testing/services.d.ts +1 -1
  147. package/esm/testing/services.js +1 -1
  148. package/esm/testing/tenant.d.ts +13 -4
  149. package/esm/utils/_buffer.d.ts +3 -2
  150. package/esm/utils/_buffer.js +3 -2
  151. package/esm/utils/_timing-safe.d.ts +8 -6
  152. package/esm/utils/_timing-safe.js +8 -6
  153. package/esm/utils/basic-auth.d.ts +5 -12
  154. package/esm/utils/basic-auth.js +5 -12
  155. package/esm/utils/crypto.d.ts +2 -1
  156. package/esm/utils/crypto.js +2 -1
  157. package/esm/utils/text.d.ts +2 -9
  158. package/esm/utils/text.js +2 -9
  159. package/esm/utils/url.d.ts +2 -2
  160. package/esm/utils/url.js +0 -2
  161. package/package.json +1 -1
@@ -94,8 +94,7 @@ export interface RateLimiterOptions {
94
94
  store?: RateLimitStore;
95
95
  }
96
96
  /**
97
- * Fixed-window limiter — the default {@link RateLimiterLike}, deliberately
98
- * feature-frozen as a floor rather than a product. Swap in your own
97
+ * Fixed-window limiter — the default {@link RateLimiterLike}. Swap in your own
99
98
  * `RateLimiterLike` for anything the fixed window cannot express.
100
99
  *
101
100
  * @example
@@ -115,11 +114,6 @@ export declare class RateLimiter implements RateLimiterLike {
115
114
  /** Clear `key`'s counter. */
116
115
  reset(key: string): Promise<void>;
117
116
  }
118
- /**
119
- * How a throttled flow reports hitting its limit. Both halves travel together
120
- * or neither does — supplying one without the other would type-check into a
121
- * silently dropped audit event.
122
- */
123
117
  type RateLimitReport = {
124
118
  /** The flow's `*.rate_limited` event, minus the fields filled in here. */
125
119
  event: RateLimitedEventInit;
@@ -13,8 +13,7 @@ import { IdentityError } from "./errors.js";
13
13
  const DEFAULT_LIMIT = 10;
14
14
  const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
15
15
  /**
16
- * Fixed-window limiter — the default {@link RateLimiterLike}, deliberately
17
- * feature-frozen as a floor rather than a product. Swap in your own
16
+ * Fixed-window limiter — the default {@link RateLimiterLike}. Swap in your own
18
17
  * `RateLimiterLike` for anything the fixed window cannot express.
19
18
  *
20
19
  * @example
@@ -13,7 +13,7 @@
13
13
  * Storage-agnostic by design: every flow addresses users by opaque id and the
14
14
  * {@link IdentityUserStore} is yours — back it with whatever database, ORM, and
15
15
  * schema your app already uses. The library composes the flows; it never owns
16
- * your tables. It stays below the turnkey-identity-service line.
16
+ * your tables.
17
17
  *
18
18
  * @module
19
19
  */
@@ -134,7 +134,10 @@ export interface IdentityRateLimiters {
134
134
  export interface IdentityServiceOptions<User extends IdentityUser> {
135
135
  /** App-owned user storage. */
136
136
  users: IdentityUserStore<User>;
137
- /** Token flow for verify/reset links. Required for those flows. */
137
+ /**
138
+ * Token flow for the emailed links (verification, reset, unlock, sign-in).
139
+ * The methods that mint or consume them throw an `Error` without it.
140
+ */
138
141
  tokens?: TokenFlowService;
139
142
  /**
140
143
  * Password hasher. Defaults to a new {@link PasswordIdentityService}
@@ -150,10 +153,14 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
150
153
  * When set, {@link IdentityService.signIn} — for a user with no native
151
154
  * credential yet — reads the imported hash via
152
155
  * {@link IdentityUserStore.getLegacyCredential} and verifies it with the first
153
- * verifier whose `canVerify` accepts it. On a match it rehashes the password
154
- * into the native format ({@link IdentityUserStore.replaceCredential}), clears the
155
- * imported hash ({@link IdentityUserStore.clearLegacyCredential}), emits a
156
- * `password.upgraded` event, and signs the user in. The package ships
156
+ * verifier whose `canVerify` accepts it. On a match it signs the user in.
157
+ * When the store implements {@link IdentityUserStore.replaceCredential}, it
158
+ * first rehashes the password into the native format; on success it clears
159
+ * the imported hash ({@link IdentityUserStore.clearLegacyCredential}) and
160
+ * emits a `password.upgraded` event. If that write reports the credential
161
+ * already replaced, the password must verify against the new credential or
162
+ * sign-in fails; if the write throws, the user is signed in without the
163
+ * upgrade. The package ships
157
164
  * {@link pbkdf2Verifier} built-in; bcrypt/argon2/scrypt are bring-your-own via
158
165
  * the {@link LegacyPasswordVerifier} seam (no dep-free implementation exists).
159
166
  */
@@ -162,8 +169,9 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
162
169
  * Minimum wall-clock duration of a **failed** {@link IdentityService.signIn},
163
170
  * in ms. Defaults to `250`. Every rejection — unknown identifier, locked
164
171
  * account, no password set, wrong native password, wrong imported password —
165
- * is held to the same deadline measured from the call's first statement, so
166
- * the response time reveals nothing about which branch ran.
172
+ * is held to the same minimum duration, measured from the call's first
173
+ * statement, so branches that finish under it are indistinguishable by
174
+ * response time.
167
175
  *
168
176
  * Set it above the cost of your slowest configured
169
177
  * {@link IdentityServiceOptions.legacyVerifiers} plus one password hash —
@@ -182,7 +190,7 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
182
190
  failedSignInFloorMs?: number;
183
191
  /** Session revocation, called on a successful password reset. Optional. */
184
192
  sessions?: RevocableSessionService;
185
- /** Delivery transports for verify/reset links. Optional. */
193
+ /** Delivery transports for the emailed links and sign-in codes. Optional. */
186
194
  delivery?: DeliveryHooks;
187
195
  /**
188
196
  * Base URL (origin, or origin + prefix) used to build the action links
@@ -191,10 +199,11 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
191
199
  baseUrl?: string;
192
200
  /**
193
201
  * Token lifetimes in ms. Defaults: password reset 1h, email verification 24h,
194
- * account unlock 1h, sign-in link 15m. A per-call `ttlMs` — currently only
195
- * {@link IdentityService.requestSignInLink} takes one — overrides the
196
- * configured default for that request. One-time sign-in **codes** are not
197
- * tokens; their lifetime is {@link IdentityServiceOptions.otp}'s `ttlMs`.
202
+ * account unlock 1h, sign-in link 15m.
203
+ * {@link IdentityService.requestSignInLink} also takes a per-call `ttlMs`
204
+ * that overrides the sign-in link default for that request. One-time sign-in
205
+ * **codes** are not tokens; their lifetime is
206
+ * {@link IdentityServiceOptions.otp}'s `ttlMs`.
198
207
  */
199
208
  ttl?: {
200
209
  passwordReset?: number;
@@ -259,9 +268,9 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
259
268
  /**
260
269
  * Per-flow limiter overrides. Set one when a single threshold cannot serve
261
270
  * every flow — a limit tuned as a sign-in floor is usually far too permissive
262
- * for the four flows that make the server **send an email to a
271
+ * for the five flows that make the server **send an email to a
263
272
  * caller-supplied address** (`passwordReset`, `emailVerification`,
264
- * `accountUnlock`, `signInLink`, plus `signInCode`), which typically want a
273
+ * `accountUnlock`, `signInLink`, and `signInCode`), which typically want a
265
274
  * handful of requests per hour rather than per 15 minutes.
266
275
  *
267
276
  * Each entry is a whole {@link RateLimiterLike} — build one
@@ -391,8 +400,9 @@ export declare class IdentityService<User extends IdentityUser> {
391
400
  profile: Record<string, unknown>;
392
401
  }): Promise<User>;
393
402
  /**
394
- * Verify credentials; returns the user, or `null` for an unknown identifier or
395
- * wrong password (don't reveal which). Establishing a session is your app's
403
+ * Verify credentials; returns the user, or `null` for an unknown identifier,
404
+ * an account with no password, a wrong password, or (under `"enforce"`) a
405
+ * locked account — don't reveal which. Establishing a session is your app's
396
406
  * job — do it on a non-null result.
397
407
  *
398
408
  * The throttle window is keyed on the identifier as submitted (trimmed, not
@@ -401,12 +411,13 @@ export declare class IdentityService<User extends IdentityUser> {
401
411
  * the identifiers you later hand {@link IdentityService.resetSignInThrottle}
402
412
  * won't match the ones that filled it.
403
413
  *
404
- * Every rejection is held to
414
+ * Every rejection is held to at least
405
415
  * {@link IdentityServiceOptions.failedSignInFloorMs} (250 ms by default),
406
416
  * measured from entry, so an unknown identifier, a locked account, a wrong
407
- * native password, and a wrong password against an imported hash all take the
408
- * same time — the branch that runs a foreign KDF is not visible from
409
- * outside. A success returns as soon as it is done.
417
+ * native password, and a wrong password against an imported hash take the
418
+ * same time as long as each finishes under the floor — a branch that runs
419
+ * longer (typically a foreign KDF) stays visible by its excess. A success
420
+ * returns as soon as it is done.
410
421
  *
411
422
  * @throws {IdentityError} `rate_limited` (with `retryAfterMs`) when a
412
423
  * `rateLimiter` is configured, the identifier's limit is hit, and
@@ -427,11 +438,18 @@ export declare class IdentityService<User extends IdentityUser> {
427
438
  */
428
439
  resetSignInThrottle(identifier: string): Promise<void>;
429
440
  /**
430
- * Enumeration-safe: always resolves or throws identically whether or not the
431
- * email maps to a user. If it does, mints a single-use reset token and calls
432
- * `delivery.sendPasswordReset`. With a `rateLimiter`, requests throttle per
433
- * target email, case-folded so casing variants share one window — the check
434
- * runs before the lookup, so a 429 reveals nothing —
441
+ * Resolves with the same `void` whether or not the email maps to a user. If
442
+ * it does, mints a single-use reset token (voiding outstanding ones when the
443
+ * token store implements `deleteBySubject`) and calls
444
+ * `delivery.sendPasswordReset`. Only that branch awaits minting and delivery,
445
+ * so it is not timing-uniform — send from a background queue and throttle by
446
+ * IP at the route, as for {@link IdentityService.requestSignInLink}. A
447
+ * token store that throws while minting is trapped like a throwing delivery
448
+ * hook: the call still resolves `void`, a `credential_mint.failed` event is
449
+ * emitted, and nothing is sent — so a store outage is not an enumeration
450
+ * oracle through the error either. With a `rateLimiter`,
451
+ * requests throttle per target email, case-folded so casing variants share
452
+ * one window — the check runs before the lookup, so a 429 reveals nothing —
435
453
  * and throw {@link IdentityError} `rate_limited` when exceeded; catch it and
436
454
  * return your uniform success response. Give it a tighter threshold than
437
455
  * sign-in via `rateLimiters.passwordReset`.
@@ -439,10 +457,10 @@ export declare class IdentityService<User extends IdentityUser> {
439
457
  requestPasswordReset(email: string): Promise<void>;
440
458
  /**
441
459
  * Consume a reset token, set the new password, and (if a `sessions` service
442
- * was configured) revoke the user's other sessions. Clears any failed-attempt
443
- * lockout — the emailed token proves the same account ownership the unlock
444
- * flow does. Returns `{ userId }`, or `null` for an invalid/expired/used
445
- * token.
460
+ * was configured) revoke **all** of the user's sessions via
461
+ * `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
462
+ * proves the same account ownership the unlock flow does. Returns
463
+ * `{ userId }`, or `null` for an invalid/expired/used token.
446
464
  *
447
465
  * A reset is the user's "lock everyone else out" action, so the outstanding
448
466
  * passwordless credentials for the same account are voided too: sign-in links
@@ -452,11 +470,17 @@ export declare class IdentityService<User extends IdentityUser> {
452
470
  * logged and the reset still completes, since the password has already
453
471
  * changed by then.
454
472
  *
455
- * Sessions are revoked **after** the password is changed. If that revocation
456
- * throws, the reset is reported failed — a `password_reset.failed`
473
+ * The token is consumed **first**, before the password is written, so two
474
+ * concurrent submissions of one link resolve to exactly one `{ userId }` and
475
+ * only that call's password is set — the loser gets `null` and writes
476
+ * nothing. Sessions are revoked **after** the password is changed. If that
477
+ * revocation throws, the reset is reported failed — a `password_reset.failed`
457
478
  * (`session_revocation_failed`) event fires, no `password_reset.completed`
458
- * fires, and the error rethrows — rather than silently returning success with
459
- * the user's old sessions still live. Retry the reset to clear them.
479
+ * fires, and the error rethrows — rather than silently returning success
480
+ * with the user's old sessions still live. The token is already spent and
481
+ * the new password already set by then, so the operator clears the still-live
482
+ * sessions by calling `revokeAllByUser` again (subscribe to the event), or
483
+ * the user completes a fresh reset link.
460
484
  *
461
485
  * @throws {IdentityError} `weak_password` when `password` fails the password
462
486
  * policy — including the 8–256 character defaults that apply when
@@ -519,13 +543,18 @@ export declare class IdentityService<User extends IdentityUser> {
519
543
  unlockAccount(token: string): Promise<UnlockAccountResult>;
520
544
  /**
521
545
  * Passwordless request: always resolves with the same `void` result. If the
522
- * email maps to a user, mints a single-use sign-in token (invalidating any
523
- * outstanding one) and calls `delivery.sendSignInLink`; an unknown email does
524
- * neither, so the **return value** never reveals whether an account exists.
546
+ * email maps to a user, mints a single-use sign-in token (voiding outstanding
547
+ * ones when the token store implements `deleteBySubject`) and calls
548
+ * `delivery.sendSignInLink`; an unknown email does neither, so the **return
549
+ * value** never reveals whether an account exists.
525
550
  * The synchronous path is not fully timing-uniform, though — only the
526
551
  * known-email branch awaits token minting and delivery — so send email from a
527
552
  * background queue and apply IP-level throttling at the route to close the
528
- * residual latency/side-channel oracle. Throttled per email (case-folded,
553
+ * residual latency/side-channel oracle. A token store that throws while
554
+ * minting is trapped: the call still resolves `void` and a
555
+ * `credential_mint.failed` event is the only signal, so neither a store nor
556
+ * a mailer outage surfaces as an error on the known branch alone.
557
+ * Throttled per email (case-folded,
529
558
  * key `pwless:<email>`) when a `rateLimiter` — or a tighter
530
559
  * `rateLimiters.signInLink` — is configured; throws {@link IdentityError}
531
560
  * `rate_limited` in enforce mode. Default link lifetime is 15 minutes.
@@ -560,17 +589,20 @@ export declare class IdentityService<User extends IdentityUser> {
560
589
  * {@link DeliveryHooks}): that exact code is invalidated — never a newer one a
561
590
  * retry may have delivered meanwhile — a `delivery.failed` event is emitted,
562
591
  * and the call still resolves `void`, so a mailer outage is not an enumeration
563
- * oracle either.
592
+ * oracle either. Nor is an OTP store outage: a throw while minting the code
593
+ * is trapped the same way, with a `credential_mint.failed` event as the only
594
+ * signal.
564
595
  */
565
596
  requestSignInCode(email: string): Promise<void>;
566
597
  /**
567
598
  * Verify a one-time sign-in code. An unknown email performs the same hash
568
599
  * work as a known one and reports plain `invalid` — the result is not an
569
- * enumeration oracle. Wrong tries spend the code's attempt budget (`locked`
570
- * once it runs out); success consumes the code (single-use), resets the
571
- * flow's throttle window, and carries the `userId` to sign in — session
572
- * creation and your MFA gate stay your app's job, as does treating a
573
- * since-deleted/disabled user as a failed sign-in.
600
+ * enumeration oracle. Wrong tries spend the code's attempt budget and the
601
+ * code is invalidated once it runs out — the result still reads `invalid`;
602
+ * the `signin_code.failed` event carries `locked`. Success consumes the code
603
+ * (single-use), resets the flow's throttle window, and carries the `userId`
604
+ * to sign in — session creation and your MFA gate stay your app's job, as
605
+ * does treating a since-deleted/disabled user as a failed sign-in.
574
606
  */
575
607
  verifySignInCode(input: {
576
608
  email: string;
@@ -13,7 +13,7 @@
13
13
  * Storage-agnostic by design: every flow addresses users by opaque id and the
14
14
  * {@link IdentityUserStore} is yours — back it with whatever database, ORM, and
15
15
  * schema your app already uses. The library composes the flows; it never owns
16
- * your tables. It stays below the turnkey-identity-service line.
16
+ * your tables.
17
17
  *
18
18
  * @module
19
19
  */
@@ -22,6 +22,7 @@ import { buildResetUrl, buildSignInUrl, buildUnlockUrl, buildVerificationUrl, }
22
22
  import { PasswordIdentityService, } from "./password.js";
23
23
  import { TokenPurpose, } from "./token-flow.js";
24
24
  import { assertPasswordPolicy, } from "./password-policy.js";
25
+ import { IdentityError } from "./errors.js";
25
26
  import { enforceRateLimit } from "./rate-limit.js";
26
27
  import { EmailOtpService, otpRateKey } from "./otp.js";
27
28
  import { dispatchIdentityEvent, } from "./events.js";
@@ -34,21 +35,6 @@ const DEFAULT_FAILED_SIGN_IN_FLOOR_MS = 250;
34
35
  function foldEmail(email) {
35
36
  return email.trim().toLowerCase();
36
37
  }
37
- /**
38
- * The rate-limit key for each throttled flow, one entry per
39
- * {@link IdentityRateLimiters} key. Every check and every reset derives its key
40
- * here, so the window a flow fills is always the window its reset clears.
41
- *
42
- * Case-folding is a decision per key type. Email-addressed keys fold, so casing
43
- * variants of one mailbox share a window instead of each earning a fresh send
44
- * budget (`signInCode` folds inside {@link otpRateKey}, so the window it fills
45
- * is the one a standalone {@link EmailOtpService} would). Opaque user ids pass
46
- * through untouched — only the app knows whether they are case-sensitive.
47
- * `signin:` is trimmed but not folded: an identifier
48
- * may be a username or phone whose equality only the app's `findByIdentifier`
49
- * defines, and folding would merge two distinct accounts (`Bob`, `bob`) into
50
- * one window, letting failures against either deny sign-in to the other.
51
- */
52
38
  const RATE_KEYS = {
53
39
  signIn: (identifier) => `signin:${identifier.trim()}`,
54
40
  passwordReset: (email) => `pwreset:${foldEmail(email)}`,
@@ -138,8 +124,9 @@ export class IdentityService {
138
124
  return user;
139
125
  }
140
126
  /**
141
- * Verify credentials; returns the user, or `null` for an unknown identifier or
142
- * wrong password (don't reveal which). Establishing a session is your app's
127
+ * Verify credentials; returns the user, or `null` for an unknown identifier,
128
+ * an account with no password, a wrong password, or (under `"enforce"`) a
129
+ * locked account — don't reveal which. Establishing a session is your app's
143
130
  * job — do it on a non-null result.
144
131
  *
145
132
  * The throttle window is keyed on the identifier as submitted (trimmed, not
@@ -148,12 +135,13 @@ export class IdentityService {
148
135
  * the identifiers you later hand {@link IdentityService.resetSignInThrottle}
149
136
  * won't match the ones that filled it.
150
137
  *
151
- * Every rejection is held to
138
+ * Every rejection is held to at least
152
139
  * {@link IdentityServiceOptions.failedSignInFloorMs} (250 ms by default),
153
140
  * measured from entry, so an unknown identifier, a locked account, a wrong
154
- * native password, and a wrong password against an imported hash all take the
155
- * same time — the branch that runs a foreign KDF is not visible from
156
- * outside. A success returns as soon as it is done.
141
+ * native password, and a wrong password against an imported hash take the
142
+ * same time as long as each finishes under the floor — a branch that runs
143
+ * longer (typically a foreign KDF) stays visible by its excess. A success
144
+ * returns as soon as it is done.
157
145
  *
158
146
  * @throws {IdentityError} `rate_limited` (with `retryAfterMs`) when a
159
147
  * `rateLimiter` is configured, the identifier's limit is hit, and
@@ -217,11 +205,18 @@ export class IdentityService {
217
205
  await this.#resetThrottle("signIn", identifier);
218
206
  }
219
207
  /**
220
- * Enumeration-safe: always resolves or throws identically whether or not the
221
- * email maps to a user. If it does, mints a single-use reset token and calls
222
- * `delivery.sendPasswordReset`. With a `rateLimiter`, requests throttle per
223
- * target email, case-folded so casing variants share one window — the check
224
- * runs before the lookup, so a 429 reveals nothing —
208
+ * Resolves with the same `void` whether or not the email maps to a user. If
209
+ * it does, mints a single-use reset token (voiding outstanding ones when the
210
+ * token store implements `deleteBySubject`) and calls
211
+ * `delivery.sendPasswordReset`. Only that branch awaits minting and delivery,
212
+ * so it is not timing-uniform — send from a background queue and throttle by
213
+ * IP at the route, as for {@link IdentityService.requestSignInLink}. A
214
+ * token store that throws while minting is trapped like a throwing delivery
215
+ * hook: the call still resolves `void`, a `credential_mint.failed` event is
216
+ * emitted, and nothing is sent — so a store outage is not an enumeration
217
+ * oracle through the error either. With a `rateLimiter`,
218
+ * requests throttle per target email, case-folded so casing variants share
219
+ * one window — the check runs before the lookup, so a 429 reveals nothing —
225
220
  * and throw {@link IdentityError} `rate_limited` when exceeded; catch it and
226
221
  * return your uniform success response. Give it a tighter threshold than
227
222
  * sign-in via `rateLimiters.passwordReset`.
@@ -238,7 +233,7 @@ export class IdentityService {
238
233
  });
239
234
  return;
240
235
  }
241
- await this.#issueAndDeliver(tokens, {
236
+ await this.#trapMintFailure("password_reset", () => this.#issueAndDeliver(tokens, {
242
237
  hook: "sendPasswordReset",
243
238
  to: normalized,
244
239
  subject: user.id,
@@ -249,14 +244,14 @@ export class IdentityService {
249
244
  email: normalized,
250
245
  userId: user.id,
251
246
  },
252
- });
247
+ }));
253
248
  }
254
249
  /**
255
250
  * Consume a reset token, set the new password, and (if a `sessions` service
256
- * was configured) revoke the user's other sessions. Clears any failed-attempt
257
- * lockout — the emailed token proves the same account ownership the unlock
258
- * flow does. Returns `{ userId }`, or `null` for an invalid/expired/used
259
- * token.
251
+ * was configured) revoke **all** of the user's sessions via
252
+ * `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
253
+ * proves the same account ownership the unlock flow does. Returns
254
+ * `{ userId }`, or `null` for an invalid/expired/used token.
260
255
  *
261
256
  * A reset is the user's "lock everyone else out" action, so the outstanding
262
257
  * passwordless credentials for the same account are voided too: sign-in links
@@ -266,11 +261,17 @@ export class IdentityService {
266
261
  * logged and the reset still completes, since the password has already
267
262
  * changed by then.
268
263
  *
269
- * Sessions are revoked **after** the password is changed. If that revocation
270
- * throws, the reset is reported failed — a `password_reset.failed`
264
+ * The token is consumed **first**, before the password is written, so two
265
+ * concurrent submissions of one link resolve to exactly one `{ userId }` and
266
+ * only that call's password is set — the loser gets `null` and writes
267
+ * nothing. Sessions are revoked **after** the password is changed. If that
268
+ * revocation throws, the reset is reported failed — a `password_reset.failed`
271
269
  * (`session_revocation_failed`) event fires, no `password_reset.completed`
272
- * fires, and the error rethrows — rather than silently returning success with
273
- * the user's old sessions still live. Retry the reset to clear them.
270
+ * fires, and the error rethrows — rather than silently returning success
271
+ * with the user's old sessions still live. The token is already spent and
272
+ * the new password already set by then, so the operator clears the still-live
273
+ * sessions by calling `revokeAllByUser` again (subscribe to the event), or
274
+ * the user completes a fresh reset link.
274
275
  *
275
276
  * @throws {IdentityError} `weak_password` when `password` fails the password
276
277
  * policy — including the 8–256 character defaults that apply when
@@ -299,8 +300,6 @@ export class IdentityService {
299
300
  await this.#users.clearLegacyCredential?.(resolved.subject);
300
301
  }
301
302
  catch (error) {
302
- // A throwing hook must not skip session revocation after the password
303
- // already changed.
304
303
  console.error("[@udibo/oauth2] resetPassword legacy-credential clear failed:", error instanceof Error ? error.message : error);
305
304
  }
306
305
  await this.#voidPasswordlessCredentials(tokens, resolved.subject, emailFromData(resolved.data));
@@ -456,13 +455,18 @@ export class IdentityService {
456
455
  }
457
456
  /**
458
457
  * Passwordless request: always resolves with the same `void` result. If the
459
- * email maps to a user, mints a single-use sign-in token (invalidating any
460
- * outstanding one) and calls `delivery.sendSignInLink`; an unknown email does
461
- * neither, so the **return value** never reveals whether an account exists.
458
+ * email maps to a user, mints a single-use sign-in token (voiding outstanding
459
+ * ones when the token store implements `deleteBySubject`) and calls
460
+ * `delivery.sendSignInLink`; an unknown email does neither, so the **return
461
+ * value** never reveals whether an account exists.
462
462
  * The synchronous path is not fully timing-uniform, though — only the
463
463
  * known-email branch awaits token minting and delivery — so send email from a
464
464
  * background queue and apply IP-level throttling at the route to close the
465
- * residual latency/side-channel oracle. Throttled per email (case-folded,
465
+ * residual latency/side-channel oracle. A token store that throws while
466
+ * minting is trapped: the call still resolves `void` and a
467
+ * `credential_mint.failed` event is the only signal, so neither a store nor
468
+ * a mailer outage surfaces as an error on the known branch alone.
469
+ * Throttled per email (case-folded,
466
470
  * key `pwless:<email>`) when a `rateLimiter` — or a tighter
467
471
  * `rateLimiters.signInLink` — is configured; throws {@link IdentityError}
468
472
  * `rate_limited` in enforce mode. Default link lifetime is 15 minutes.
@@ -476,7 +480,7 @@ export class IdentityService {
476
480
  await this.#emit({ type: "signin_link.requested", email: normalized });
477
481
  return;
478
482
  }
479
- await this.#issueAndDeliver(tokens, {
483
+ await this.#trapMintFailure("signin_link", () => this.#issueAndDeliver(tokens, {
480
484
  hook: "sendSignInLink",
481
485
  to: normalized,
482
486
  subject: user.id,
@@ -486,7 +490,7 @@ export class IdentityService {
486
490
  email: normalized,
487
491
  userId: user.id,
488
492
  },
489
- });
493
+ }));
490
494
  }
491
495
  /**
492
496
  * Consume a sign-in link token (single-use). `success` carries the `userId`
@@ -528,7 +532,9 @@ export class IdentityService {
528
532
  * {@link DeliveryHooks}): that exact code is invalidated — never a newer one a
529
533
  * retry may have delivered meanwhile — a `delivery.failed` event is emitted,
530
534
  * and the call still resolves `void`, so a mailer outage is not an enumeration
531
- * oracle either.
535
+ * oracle either. Nor is an OTP store outage: a throw while minting the code
536
+ * is trapped the same way, with a `credential_mint.failed` event as the only
537
+ * signal.
532
538
  */
533
539
  async requestSignInCode(email) {
534
540
  const otp = this.#requireOtp("requestSignInCode");
@@ -539,25 +545,28 @@ export class IdentityService {
539
545
  await this.#emit({ type: "signin_code.requested", email: normalized });
540
546
  return;
541
547
  }
542
- await otp.request({
543
- email: normalized,
544
- purpose: TokenPurpose.SignIn,
545
- onDeliver: (code, expiresAt, codeId) => this.#deliverCode(normalized, code, user.id, expiresAt, codeId),
546
- });
547
- await this.#emit({
548
- type: "signin_code.requested",
549
- email: normalized,
550
- userId: user.id,
548
+ await this.#trapMintFailure("signin_code", async () => {
549
+ await otp.request({
550
+ email: normalized,
551
+ purpose: TokenPurpose.SignIn,
552
+ onDeliver: (code, expiresAt, codeId) => this.#deliverCode(normalized, code, user.id, expiresAt, codeId),
553
+ });
554
+ await this.#emit({
555
+ type: "signin_code.requested",
556
+ email: normalized,
557
+ userId: user.id,
558
+ });
551
559
  });
552
560
  }
553
561
  /**
554
562
  * Verify a one-time sign-in code. An unknown email performs the same hash
555
563
  * work as a known one and reports plain `invalid` — the result is not an
556
- * enumeration oracle. Wrong tries spend the code's attempt budget (`locked`
557
- * once it runs out); success consumes the code (single-use), resets the
558
- * flow's throttle window, and carries the `userId` to sign in — session
559
- * creation and your MFA gate stay your app's job, as does treating a
560
- * since-deleted/disabled user as a failed sign-in.
564
+ * enumeration oracle. Wrong tries spend the code's attempt budget and the
565
+ * code is invalidated once it runs out — the result still reads `invalid`;
566
+ * the `signin_code.failed` event carries `locked`. Success consumes the code
567
+ * (single-use), resets the flow's throttle window, and carries the `userId`
568
+ * to sign in — session creation and your MFA gate stay your app's job, as
569
+ * does treating a since-deleted/disabled user as a failed sign-in.
561
570
  */
562
571
  async verifySignInCode(input) {
563
572
  const otp = this.#requireOtp("verifySignInCode");
@@ -721,6 +730,23 @@ export class IdentityService {
721
730
  "passwordless credentials; they stay usable until they expire:", error instanceof Error ? error.message : error);
722
731
  }
723
732
  }
733
+ async #trapMintFailure(flow, mint) {
734
+ try {
735
+ await mint();
736
+ }
737
+ catch (error) {
738
+ if (error instanceof IdentityError)
739
+ throw error;
740
+ const message = error instanceof Error ? error.message : String(error);
741
+ console.error(`[@udibo/oauth2] ${flow} request could not mint its credential; ` +
742
+ `nothing was sent:`, message);
743
+ await this.#emit({
744
+ type: "credential_mint.failed",
745
+ flow,
746
+ error: message,
747
+ });
748
+ }
749
+ }
724
750
  async #issueAndDeliver(tokens, request) {
725
751
  const { token, expiresAt } = await tokens.create({
726
752
  purpose: DELIVERY_PURPOSES[request.hook],
@@ -2,13 +2,13 @@
2
2
  * Session revocation + step-up contracts for the identity layer.
3
3
  *
4
4
  * Three security primitives the own-auth flows need, kept as **contracts + a
5
- * pure helper** — storage stays app-owned (the `SessionStore` philosophy, and
6
- * the Lucia-deprecation lesson: don't ship a DB-adapter zoo):
5
+ * pure helper** — storage stays app-owned (the `SessionStore` philosophy):
7
6
  *
8
7
  * - {@link RevocableSessionService} — revoke a user's sessions on a credential
9
8
  * change. After a password reset the app must end the *other* sessions, or a
10
- * compromised account's other devices stay logged in. The reset flow calls
11
- * this; your app implements it over its session store.
9
+ * compromised account's other devices stay logged in.
10
+ * `IdentityService.resetPassword` calls its `revokeAllByUser`; your app
11
+ * implements it over its session store.
12
12
  * - {@link ListableSessionService} — the revocation seam's optional listing
13
13
  * half: a user's live sessions as {@link SessionSummary} display rows, so a
14
14
  * "where you're signed in" screen has one typed shape to render and wire the
@@ -107,6 +107,7 @@ export declare function supportsSessionListing(service: RevocableSessionService)
107
107
  /**
108
108
  * Whether a session authenticated within `maxAgeMs` — a recent-auth ("step-up")
109
109
  * check to gate sensitive actions. Re-prompt for credentials when this is false.
110
+ * An `authenticatedAt` of `0` or less (unknown) is never recent.
110
111
  *
111
112
  * @param authenticatedAt When the session last actively authenticated (epoch ms).
112
113
  * @param maxAgeMs How recent counts as "fresh" (e.g. 5 minutes).
@@ -2,13 +2,13 @@
2
2
  * Session revocation + step-up contracts for the identity layer.
3
3
  *
4
4
  * Three security primitives the own-auth flows need, kept as **contracts + a
5
- * pure helper** — storage stays app-owned (the `SessionStore` philosophy, and
6
- * the Lucia-deprecation lesson: don't ship a DB-adapter zoo):
5
+ * pure helper** — storage stays app-owned (the `SessionStore` philosophy):
7
6
  *
8
7
  * - {@link RevocableSessionService} — revoke a user's sessions on a credential
9
8
  * change. After a password reset the app must end the *other* sessions, or a
10
- * compromised account's other devices stay logged in. The reset flow calls
11
- * this; your app implements it over its session store.
9
+ * compromised account's other devices stay logged in.
10
+ * `IdentityService.resetPassword` calls its `revokeAllByUser`; your app
11
+ * implements it over its session store.
12
12
  * - {@link ListableSessionService} — the revocation seam's optional listing
13
13
  * half: a user's live sessions as {@link SessionSummary} display rows, so a
14
14
  * "where you're signed in" screen has one typed shape to render and wire the
@@ -31,6 +31,7 @@ export function supportsSessionListing(service) {
31
31
  /**
32
32
  * Whether a session authenticated within `maxAgeMs` — a recent-auth ("step-up")
33
33
  * check to gate sensitive actions. Re-prompt for credentials when this is false.
34
+ * An `authenticatedAt` of `0` or less (unknown) is never recent.
34
35
  *
35
36
  * @param authenticatedAt When the session last actively authenticated (epoch ms).
36
37
  * @param maxAgeMs How recent counts as "fresh" (e.g. 5 minutes).
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * One token-flow primitive for the single-use, expiring links an identity layer
3
- * needs — **email verification** and **password reset** are the same mechanics:
4
- * mint a high-entropy token, store only its hash, hand the raw token to the app
5
- * to deliver, then validate / single-use-consume it later.
3
+ * needs — email verification, password reset, account unlock, and sign-in
4
+ * links share the same mechanics: mint a 32-byte random token, store only its
5
+ * SHA-256 hash, hand the raw token to the app to deliver, then validate /
6
+ * single-use-consume it later.
6
7
  *
7
- * The app reimplements this once per flow today (random bytes → sha256 → store
8
- * with expiry → consume). This collapses it into one tested, enumeration-safe
9
- * building block. Storage is an app-owned {@link TokenFlowStore} (a
8
+ * Storage is an app-owned {@link TokenFlowStore} (a
10
9
  * {@link MemoryTokenFlowStore} ships for dev/tests); the package owns the
11
10
  * lifecycle, not the database.
12
11
  *
@@ -91,8 +90,9 @@ export interface CreateTokenOptions {
91
90
  /** Lifetime in milliseconds. */
92
91
  ttlMs: number;
93
92
  /**
94
- * Invalidate any existing pending tokens for this `(purpose, subject)` first
95
- * (requires {@link TokenFlowStore.deleteBySubject}). Defaults to `false`.
93
+ * Invalidate any existing pending tokens for this `(purpose, subject)` first.
94
+ * Silently skipped when the store lacks
95
+ * {@link TokenFlowStore.deleteBySubject}. Defaults to `false`.
96
96
  */
97
97
  invalidateExisting?: boolean;
98
98
  }
@@ -1,12 +1,11 @@
1
1
  /**
2
2
  * One token-flow primitive for the single-use, expiring links an identity layer
3
- * needs — **email verification** and **password reset** are the same mechanics:
4
- * mint a high-entropy token, store only its hash, hand the raw token to the app
5
- * to deliver, then validate / single-use-consume it later.
3
+ * needs — email verification, password reset, account unlock, and sign-in
4
+ * links share the same mechanics: mint a 32-byte random token, store only its
5
+ * SHA-256 hash, hand the raw token to the app to deliver, then validate /
6
+ * single-use-consume it later.
6
7
  *
7
- * The app reimplements this once per flow today (random bytes → sha256 → store
8
- * with expiry → consume). This collapses it into one tested, enumeration-safe
9
- * building block. Storage is an app-owned {@link TokenFlowStore} (a
8
+ * Storage is an app-owned {@link TokenFlowStore} (a
10
9
  * {@link MemoryTokenFlowStore} ships for dev/tests); the package owns the
11
10
  * lifecycle, not the database.
12
11
  *