@udibo/oauth2 0.4.1 → 0.5.1

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/redirect-uri.d.ts +6 -9
  131. package/esm/server/redirect-uri.js +7 -34
  132. package/esm/server/resource-server.d.ts +5 -3
  133. package/esm/server/resource-server.js +5 -3
  134. package/esm/server/services/client.d.ts +5 -1
  135. package/esm/server/services/device-authorization.d.ts +6 -6
  136. package/esm/server/services/token.d.ts +15 -8
  137. package/esm/server/services/token.js +1 -1
  138. package/esm/server/signing-keys.d.ts +24 -9
  139. package/esm/server/signing-keys.js +20 -9
  140. package/esm/testing/contract/mod.d.ts +3 -3
  141. package/esm/testing/contract/mod.js +3 -3
  142. package/esm/testing/contract/tenant.d.ts +1 -1
  143. package/esm/testing/contract/tenant.js +2 -2
  144. package/esm/testing/mod.d.ts +6 -4
  145. package/esm/testing/mod.js +6 -4
  146. package/esm/testing/services.d.ts +1 -1
  147. package/esm/testing/services.js +1 -1
  148. package/esm/testing/tenant.d.ts +14 -5
  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
@@ -110,7 +110,9 @@ export interface MfaServiceOptions {
110
110
  * Throttles {@link verify} per user id (a 6-digit code is brute-forceable
111
111
  * without one). When the limit is hit the service emits
112
112
  * `mfa.verify.rate_limited` and — under `protectionMode: "enforce"` —
113
- * throws an `IdentityError` with code `rate_limited`. A successful
113
+ * throws an `IdentityError` with code `rate_limited`; a limiter whose
114
+ * `check` throws is treated as limited under `"enforce"` and allowed under
115
+ * `"log-only"`. A successful
114
116
  * verification resets the user's window, so legitimate sign-ins never
115
117
  * accumulate toward a lockout.
116
118
  */
@@ -236,7 +238,8 @@ export declare class MfaService {
236
238
  * Builds the service around an app-owned {@link MfaStore}.
237
239
  *
238
240
  * @throws {TypeError} on unusable configuration — non-positive-integer
239
- * `digits`/`periodSeconds`/`recoveryCodeCount` or negative `windows` — so a
241
+ * `digits`/`periodSeconds`/`recoveryCodeCount` or a negative or non-integer
242
+ * `windows` — so a
240
243
  * miswired deploy fails at construction instead of silently rejecting every
241
244
  * code an enrolled user submits.
242
245
  */
@@ -257,32 +260,47 @@ export declare class MfaService {
257
260
  * and stored, and the plaintext codes are returned for one-time display.
258
261
  * Returns `{ confirmed: false }` when the code is wrong, no enrollment is
259
262
  * pending, or the pending secret changed under a concurrent enrollment. If
260
- * storing the recovery codes fails after activation, the just-activated
261
- * credential is rolled back and the error rethrown — a failure leaves the
262
- * user *not* enrolled (free to retry), never enrolled without the recovery
263
- * codes they were promised.
263
+ * storing the recovery codes fails after activation, the service calls
264
+ * `clearTotp` to roll the credential back and rethrows the original error,
265
+ * leaving the user *not* enrolled and free to retry. If `clearTotp` also
266
+ * fails, its error is thrown instead and the credential stays active
267
+ * without recovery codes.
268
+ *
269
+ * Guesses against the pending secret count toward the same per-user window
270
+ * {@link verify} uses (`mfa:verify:<userId>`), and a confirmed enrollment
271
+ * resets it — an unthrottled confirm would otherwise be a brute-force path
272
+ * to a secret the user is about to rely on.
264
273
  *
265
274
  * @throws {IdentityError} `mfa_already_enrolled` when an active credential
266
275
  * exists (see {@link startEnrollment}).
276
+ * @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
277
+ * `protectionMode` is `"enforce"`, and the per-user limit is hit or the
278
+ * limiter's `check` throws (it fails closed).
267
279
  */
268
280
  confirmEnrollment(userId: string, code: string, options?: Pick<MfaVerifyOptions, "timestamp">): Promise<MfaEnrollmentConfirmation>;
269
281
  /**
270
282
  * Verify a second-factor code: first as TOTP against the *active* secret
271
283
  * (pending secrets are ignored — they need {@link confirmEnrollment}), then
272
284
  * as a recovery code (restrict with {@link MfaVerifyOptions.method}). An
273
- * accepted TOTP code atomically advances the stored last step, so the same
274
- * code is rejected on replay even under concurrent submission; an accepted
275
- * recovery code is atomically burned, so it is single-use. Without an
285
+ * accepted TOTP code advances the stored last step through
286
+ * {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
287
+ * an accepted recovery code is burned through
288
+ * {@link MfaStore.consumeRecoveryHash}, so it is single-use. Both hold under
289
+ * concurrent submission only if the store implements those methods
290
+ * atomically. Without an
276
291
  * active credential every code is rejected — recovery codes are a fallback
277
292
  * *for* the enrolled factor, so stale hashes can never authenticate a user
278
293
  * whose MFA was disabled. A successful verification resets the per-user
279
- * rate-limit window. On failure the result carries a
294
+ * rate-limit window; a `reset` that throws is logged and does not fail the
295
+ * verification, since the code is already spent by then — the window just
296
+ * stays filled until it lapses. On failure the result carries a
280
297
  * {@link MfaVerifyFailureReason} — `"replayed"` for a valid-but-spent TOTP
281
298
  * code, `"invalid"` otherwise — so a caller can distinguish a reused code
282
299
  * from a wrong one without subscribing to `onEvent`.
283
300
  *
284
301
  * @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
285
- * the per-user limit is hit, and `protectionMode` is `"enforce"`.
302
+ * `protectionMode` is `"enforce"`, and the per-user limit is hit or the
303
+ * limiter's `check` throws (it fails closed).
286
304
  */
287
305
  verify(userId: string, code: string, options?: MfaVerifyOptions): Promise<MfaVerification>;
288
306
  /** Whether the user has an *active* (confirmed) TOTP credential. */
@@ -50,7 +50,8 @@ export class MfaService {
50
50
  * Builds the service around an app-owned {@link MfaStore}.
51
51
  *
52
52
  * @throws {TypeError} on unusable configuration — non-positive-integer
53
- * `digits`/`periodSeconds`/`recoveryCodeCount` or negative `windows` — so a
53
+ * `digits`/`periodSeconds`/`recoveryCodeCount` or a negative or non-integer
54
+ * `windows` — so a
54
55
  * miswired deploy fails at construction instead of silently rejecting every
55
56
  * code an enrolled user submits.
56
57
  */
@@ -98,15 +99,25 @@ export class MfaService {
98
99
  * and stored, and the plaintext codes are returned for one-time display.
99
100
  * Returns `{ confirmed: false }` when the code is wrong, no enrollment is
100
101
  * pending, or the pending secret changed under a concurrent enrollment. If
101
- * storing the recovery codes fails after activation, the just-activated
102
- * credential is rolled back and the error rethrown — a failure leaves the
103
- * user *not* enrolled (free to retry), never enrolled without the recovery
104
- * codes they were promised.
102
+ * storing the recovery codes fails after activation, the service calls
103
+ * `clearTotp` to roll the credential back and rethrows the original error,
104
+ * leaving the user *not* enrolled and free to retry. If `clearTotp` also
105
+ * fails, its error is thrown instead and the credential stays active
106
+ * without recovery codes.
107
+ *
108
+ * Guesses against the pending secret count toward the same per-user window
109
+ * {@link verify} uses (`mfa:verify:<userId>`), and a confirmed enrollment
110
+ * resets it — an unthrottled confirm would otherwise be a brute-force path
111
+ * to a secret the user is about to rely on.
105
112
  *
106
113
  * @throws {IdentityError} `mfa_already_enrolled` when an active credential
107
114
  * exists (see {@link startEnrollment}).
115
+ * @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
116
+ * `protectionMode` is `"enforce"`, and the per-user limit is hit or the
117
+ * limiter's `check` throws (it fails closed).
108
118
  */
109
119
  async confirmEnrollment(userId, code, options) {
120
+ await this.#throttle(userId);
110
121
  const record = await this.#store.getTotp(userId);
111
122
  this.#assertNotEnrolled(record);
112
123
  const pending = record?.pendingSecretBase32;
@@ -131,6 +142,7 @@ export class MfaService {
131
142
  await this.#store.clearTotp(userId);
132
143
  throw error;
133
144
  }
145
+ await this.#resetThrottle(userId);
134
146
  await this.#emit({ type: "mfa.enrollment.confirmed", userId });
135
147
  return { confirmed: true, recoveryCodes };
136
148
  }
@@ -138,19 +150,25 @@ export class MfaService {
138
150
  * Verify a second-factor code: first as TOTP against the *active* secret
139
151
  * (pending secrets are ignored — they need {@link confirmEnrollment}), then
140
152
  * as a recovery code (restrict with {@link MfaVerifyOptions.method}). An
141
- * accepted TOTP code atomically advances the stored last step, so the same
142
- * code is rejected on replay even under concurrent submission; an accepted
143
- * recovery code is atomically burned, so it is single-use. Without an
153
+ * accepted TOTP code advances the stored last step through
154
+ * {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
155
+ * an accepted recovery code is burned through
156
+ * {@link MfaStore.consumeRecoveryHash}, so it is single-use. Both hold under
157
+ * concurrent submission only if the store implements those methods
158
+ * atomically. Without an
144
159
  * active credential every code is rejected — recovery codes are a fallback
145
160
  * *for* the enrolled factor, so stale hashes can never authenticate a user
146
161
  * whose MFA was disabled. A successful verification resets the per-user
147
- * rate-limit window. On failure the result carries a
162
+ * rate-limit window; a `reset` that throws is logged and does not fail the
163
+ * verification, since the code is already spent by then — the window just
164
+ * stays filled until it lapses. On failure the result carries a
148
165
  * {@link MfaVerifyFailureReason} — `"replayed"` for a valid-but-spent TOTP
149
166
  * code, `"invalid"` otherwise — so a caller can distinguish a reused code
150
167
  * from a wrong one without subscribing to `onEvent`.
151
168
  *
152
169
  * @throws {IdentityError} `rate_limited` when a `rateLimiter` is configured,
153
- * the per-user limit is hit, and `protectionMode` is `"enforce"`.
170
+ * `protectionMode` is `"enforce"`, and the per-user limit is hit or the
171
+ * limiter's `check` throws (it fails closed).
154
172
  */
155
173
  async verify(userId, code, options) {
156
174
  await this.#throttle(userId);
@@ -276,7 +294,13 @@ export class MfaService {
276
294
  });
277
295
  }
278
296
  async #resetThrottle(userId) {
279
- await this.#rateLimiter?.reset(this.#rateKey(userId));
297
+ try {
298
+ await this.#rateLimiter?.reset(this.#rateKey(userId));
299
+ }
300
+ catch (error) {
301
+ console.error("[@udibo/oauth2] MFA rate-limit reset failed; the window stays " +
302
+ "filled until it lapses:", error instanceof Error ? error.message : error);
303
+ }
280
304
  }
281
305
  async #verifyFailed(userId, reason) {
282
306
  await this.#emit({ type: "mfa.verify.failed", userId, reason });
@@ -87,8 +87,10 @@ export interface OtpauthUriOptions {
87
87
  * `issuer` query parameter, so apps that read either convention group the
88
88
  * entry correctly.
89
89
  *
90
- * @throws {TypeError} if the secret is not base32 or `digits`/`periodSeconds`
91
- * are not positive integers, so a corrupted value cannot inject URI parameters.
90
+ * @throws {TypeError} if the secret contains characters outside the base32
91
+ * alphabet (after stripping whitespace and `=`) or `digits`/`periodSeconds`
92
+ * are not positive integers, so a corrupted value cannot inject URI
93
+ * parameters. The secret's length is not checked.
92
94
  */
93
95
  export declare function buildOtpauthUri(options: OtpauthUriOptions): string;
94
96
  /** Options for {@link generateTotpCode}. */
@@ -80,8 +80,10 @@ export function generateTotpSecret() {
80
80
  * `issuer` query parameter, so apps that read either convention group the
81
81
  * entry correctly.
82
82
  *
83
- * @throws {TypeError} if the secret is not base32 or `digits`/`periodSeconds`
84
- * are not positive integers, so a corrupted value cannot inject URI parameters.
83
+ * @throws {TypeError} if the secret contains characters outside the base32
84
+ * alphabet (after stripping whitespace and `=`) or `digits`/`periodSeconds`
85
+ * are not positive integers, so a corrupted value cannot inject URI
86
+ * parameters. The secret's length is not checked.
85
87
  */
86
88
  export function buildOtpauthUri(options) {
87
89
  validateTotpParams(options);
@@ -77,9 +77,9 @@ export declare function findLegacyVerifier(verifiers: readonly LegacyPasswordVer
77
77
  /**
78
78
  * Verify `password` against a stored foreign hash `phc` using the first matching
79
79
  * verifier in `verifiers`. Resolves `false` when no verifier recognizes `phc` or
80
- * the matched verifier rejects the password. Useful for a bulk-import
81
- * dry-run/validation pass; {@link IdentityService.signIn} uses the same
82
- * selection internally for upgrade-on-login.
80
+ * the matched verifier rejects the password or throws. Useful for a
81
+ * bulk-import dry-run/validation pass; {@link IdentityService.signIn} uses the
82
+ * same selection for upgrade-on-login.
83
83
  */
84
84
  export declare function verifyLegacyPassword(verifiers: readonly LegacyPasswordVerifier[], password: string, phc: string): Promise<boolean>;
85
85
  /**
@@ -100,8 +100,8 @@ export interface PhcHash {
100
100
  }
101
101
  /**
102
102
  * Parse a PHC / modular-crypt string (`$id[$v=n][$k=v,...]$salt$hash`) into its
103
- * parts. Returns `null` for anything that isn't a `$`-led PHC string. Salt and
104
- * hash are decoded as base64 (padding optional). This is the parsing helper for
103
+ * parts. Returns `null` for anything that isn't a `$`-led PHC string, or whose
104
+ * salt or hash segment is not valid base64 (padding optional). This is the parsing helper for
105
105
  * writing a bring-your-own argon2/scrypt {@link LegacyPasswordVerifier}: parse
106
106
  * the stored string, feed `params`/`salt`/`hash` to your KDF dependency, and
107
107
  * constant-time compare.
@@ -128,8 +128,9 @@ export interface Pbkdf2VerifierOptions {
128
128
  * - **Django** — `pbkdf2_<digest>$<iterations>$<salt>$<base64 hash>`, where the
129
129
  * salt is used as raw UTF-8 bytes and the hash is standard base64. This is
130
130
  * Django's `PBKDF2PasswordHasher` output.
131
- * - **PHC** — `$pbkdf2-<digest>$i=<iterations>$<base64 salt>$<base64 hash>`,
132
- * with base64 (padding optional) salt and hash.
131
+ * - **PHC** — `$pbkdf2-<digest>$i=<iterations>$<base64 salt>$<base64 hash>`
132
+ * (`rounds=` is accepted in place of `i=`), with base64 (padding optional)
133
+ * salt and hash.
133
134
  *
134
135
  * `<digest>` is one of `sha1`, `sha256`, `sha512`. The derived-key length is
135
136
  * taken from the stored hash. Comparison is constant-time. On a match,
@@ -45,9 +45,9 @@ export function findLegacyVerifier(verifiers, phc) {
45
45
  /**
46
46
  * Verify `password` against a stored foreign hash `phc` using the first matching
47
47
  * verifier in `verifiers`. Resolves `false` when no verifier recognizes `phc` or
48
- * the matched verifier rejects the password. Useful for a bulk-import
49
- * dry-run/validation pass; {@link IdentityService.signIn} uses the same
50
- * selection internally for upgrade-on-login.
48
+ * the matched verifier rejects the password or throws. Useful for a
49
+ * bulk-import dry-run/validation pass; {@link IdentityService.signIn} uses the
50
+ * same selection for upgrade-on-login.
51
51
  */
52
52
  export async function verifyLegacyPassword(verifiers, password, phc) {
53
53
  const verifier = findLegacyVerifier(verifiers, phc);
@@ -66,8 +66,8 @@ function decodeB64(segment) {
66
66
  }
67
67
  /**
68
68
  * Parse a PHC / modular-crypt string (`$id[$v=n][$k=v,...]$salt$hash`) into its
69
- * parts. Returns `null` for anything that isn't a `$`-led PHC string. Salt and
70
- * hash are decoded as base64 (padding optional). This is the parsing helper for
69
+ * parts. Returns `null` for anything that isn't a `$`-led PHC string, or whose
70
+ * salt or hash segment is not valid base64 (padding optional). This is the parsing helper for
71
71
  * writing a bring-your-own argon2/scrypt {@link LegacyPasswordVerifier}: parse
72
72
  * the stored string, feed `params`/`salt`/`hash` to your KDF dependency, and
73
73
  * constant-time compare.
@@ -126,11 +126,7 @@ const PBKDF2_DIGEST = {
126
126
  sha512: "SHA-512",
127
127
  };
128
128
  const encoder = new TextEncoder();
129
- /**
130
- * Reject an imported hash whose derived-key segment is implausibly short: a
131
- * 0-byte checksum would make `deriveBits(0)` compare empty-to-empty and turn
132
- * any password into a match on a runtime where that returns an empty buffer.
133
- */
129
+ // A 0-byte checksum would compare empty-to-empty and match any password.
134
130
  const MIN_HASH_BYTES = 16;
135
131
  const MAX_PBKDF2_ITERATIONS = 1_000_000;
136
132
  function parseDjango(phc) {
@@ -192,8 +188,9 @@ function parsePbkdf2(phc) {
192
188
  * - **Django** — `pbkdf2_<digest>$<iterations>$<salt>$<base64 hash>`, where the
193
189
  * salt is used as raw UTF-8 bytes and the hash is standard base64. This is
194
190
  * Django's `PBKDF2PasswordHasher` output.
195
- * - **PHC** — `$pbkdf2-<digest>$i=<iterations>$<base64 salt>$<base64 hash>`,
196
- * with base64 (padding optional) salt and hash.
191
+ * - **PHC** — `$pbkdf2-<digest>$i=<iterations>$<base64 salt>$<base64 hash>`
192
+ * (`rounds=` is accepted in place of `i=`), with base64 (padding optional)
193
+ * salt and hash.
197
194
  *
198
195
  * `<digest>` is one of `sha1`, `sha256`, `sha512`. The derived-key length is
199
196
  * taken from the stored hash. Comparison is constant-time. On a match,
@@ -9,8 +9,8 @@
9
9
  * - {@link IdentityService} — orchestrates the primitives below into sign-up,
10
10
  * sign-in, password-reset, and email-verification flows you call from your
11
11
  * own routes (storage stays an app-owned {@link IdentityUserStore}).
12
- * - {@link PasswordIdentityService} — PBKDF2 password hashing + constant-time
13
- * verify (no more hand-rolled crypto); implement {@link PasswordHasherLike}
12
+ * - {@link PasswordIdentityService} — PBKDF2 password hashing with a
13
+ * constant-time digest comparison; implement {@link PasswordHasherLike}
14
14
  * instead to plug in argon2 or scrypt.
15
15
  * - {@link TokenFlowService} — single-use, hashed-at-rest verify/reset/sign-in
16
16
  * tokens over an app-owned {@link TokenFlowStore} (magic links reuse it via
@@ -9,8 +9,8 @@
9
9
  * - {@link IdentityService} — orchestrates the primitives below into sign-up,
10
10
  * sign-in, password-reset, and email-verification flows you call from your
11
11
  * own routes (storage stays an app-owned {@link IdentityUserStore}).
12
- * - {@link PasswordIdentityService} — PBKDF2 password hashing + constant-time
13
- * verify (no more hand-rolled crypto); implement {@link PasswordHasherLike}
12
+ * - {@link PasswordIdentityService} — PBKDF2 password hashing with a
13
+ * constant-time digest comparison; implement {@link PasswordHasherLike}
14
14
  * instead to plug in argon2 or scrypt.
15
15
  * - {@link TokenFlowService} — single-use, hashed-at-rest verify/reset/sign-in
16
16
  * tokens over an app-owned {@link TokenFlowStore} (magic links reuse it via
@@ -4,17 +4,16 @@
4
4
  *
5
5
  * A short numeric code has a different threat model than a URL token: it is
6
6
  * **guessable online** (a 6-digit code is one in a million), so unlike
7
- * {@link TokenFlowService} tokens every code carries an attempt budget — wrong
8
- * tries are counted and the code is invalidated (`locked`) when the budget is
9
- * spent. Codes are stored **hashed** (SHA-256, domain-bound to the email +
10
- * purpose) and verified with a constant-time compare; the raw code exists only
11
- * in the delivery callback.
7
+ * {@link TokenFlowService} tokens every code carries an attempt budget — each
8
+ * verification attempt is counted and the code is invalidated (`locked`) when
9
+ * the budget is spent. Codes are stored **hashed** (SHA-256, domain-bound to
10
+ * the email + purpose) and the hashes are compared in constant time; the raw
11
+ * code exists only in the delivery callback.
12
12
  *
13
13
  * Storage is an app-owned {@link OtpStore} (a {@link MemoryOtpStore} ships for
14
14
  * dev/tests) and delivery is a caller-supplied callback — the package owns the
15
15
  * lifecycle, never your tables or your mailer. `purpose` is a free string, so
16
- * the same service covers sign-in codes today and step-up / verification codes
17
- * later.
16
+ * one service can issue sign-in, step-up, and verification codes.
18
17
  *
19
18
  * @module
20
19
  */
@@ -46,9 +45,15 @@ export interface OtpRecord {
46
45
  codeHash: string;
47
46
  /** Expiry, epoch ms. */
48
47
  expiresAt: number;
49
- /** Wrong verification attempts so far. */
48
+ /**
49
+ * Verification attempts recorded so far — every checked attempt, counted
50
+ * before the code is compared, not only the wrong ones.
51
+ */
50
52
  attempts: number;
51
- /** Attempt budget; reaching it invalidates the code. */
53
+ /**
54
+ * Attempt budget; a wrong code on the attempt that reaches it invalidates
55
+ * the code.
56
+ */
52
57
  maxAttempts: number;
53
58
  /** Created, epoch ms. */
54
59
  createdAt: number;
@@ -158,8 +163,8 @@ export interface VerifyOtpOptions {
158
163
  /**
159
164
  * The outcome of {@link EmailOtpService.verify}. `invalid` covers both a wrong
160
165
  * code and no active code — indistinguishable on purpose. `locked` is reported
161
- * once, on the attempt that spends the budget; afterwards the code is gone and
162
- * further tries report `invalid`.
166
+ * on the attempt that spends the budget (and on any concurrent attempt past
167
+ * it); afterwards the code is gone and further tries report `invalid`.
163
168
  */
164
169
  export type VerifyOtpResult = {
165
170
  status: "success";
@@ -172,8 +177,8 @@ export type VerifyOtpResult = {
172
177
  };
173
178
  /**
174
179
  * Issues and verifies short-lived numeric one-time codes over an app-owned
175
- * {@link OtpStore}. Purpose-generic: `"signin"` today, step-up or verification
176
- * codes tomorrow — one instance can serve them all.
180
+ * {@link OtpStore}. Purpose-generic: one instance can serve sign-in, step-up,
181
+ * and verification codes.
177
182
  *
178
183
  * @example
179
184
  * ```ts
@@ -224,9 +229,11 @@ export declare class EmailOtpService {
224
229
  invalidateCode(id: string): Promise<void>;
225
230
  /**
226
231
  * Check a submitted code against the active record for `(email, purpose)`.
227
- * The hash compare is constant-time and runs even when no active record
228
- * exists, so a missing account or code is not distinguishable by timing. The
229
- * attempt is reserved atomically — via the store's `recordAttempt` return
232
+ * The submitted code is hashed and a constant-time compare runs even when no
233
+ * active record exists. Timing is not fully uniform, though: an expired
234
+ * record returns before any attempt is recorded, and a live record adds the
235
+ * `recordAttempt` store write the no-record path skips. The attempt is
236
+ * reserved atomically — via the store's `recordAttempt` return
230
237
  * value — **before** the compare, so concurrent verifies of one live code
231
238
  * can't each test a guess against a stale pre-increment count: at most
232
239
  * `maxAttempts` guesses ever reach the comparison. Spending the budget
@@ -4,17 +4,16 @@
4
4
  *
5
5
  * A short numeric code has a different threat model than a URL token: it is
6
6
  * **guessable online** (a 6-digit code is one in a million), so unlike
7
- * {@link TokenFlowService} tokens every code carries an attempt budget — wrong
8
- * tries are counted and the code is invalidated (`locked`) when the budget is
9
- * spent. Codes are stored **hashed** (SHA-256, domain-bound to the email +
10
- * purpose) and verified with a constant-time compare; the raw code exists only
11
- * in the delivery callback.
7
+ * {@link TokenFlowService} tokens every code carries an attempt budget — each
8
+ * verification attempt is counted and the code is invalidated (`locked`) when
9
+ * the budget is spent. Codes are stored **hashed** (SHA-256, domain-bound to
10
+ * the email + purpose) and the hashes are compared in constant time; the raw
11
+ * code exists only in the delivery callback.
12
12
  *
13
13
  * Storage is an app-owned {@link OtpStore} (a {@link MemoryOtpStore} ships for
14
14
  * dev/tests) and delivery is a caller-supplied callback — the package owns the
15
15
  * lifecycle, never your tables or your mailer. `purpose` is a free string, so
16
- * the same service covers sign-in codes today and step-up / verification codes
17
- * later.
16
+ * one service can issue sign-in, step-up, and verification codes.
18
17
  *
19
18
  * @module
20
19
  */
@@ -59,8 +58,8 @@ const DEFAULT_TTL_MS = 10 * 60 * 1000;
59
58
  const DEFAULT_MAX_ATTEMPTS = 5;
60
59
  /**
61
60
  * Issues and verifies short-lived numeric one-time codes over an app-owned
62
- * {@link OtpStore}. Purpose-generic: `"signin"` today, step-up or verification
63
- * codes tomorrow — one instance can serve them all.
61
+ * {@link OtpStore}. Purpose-generic: one instance can serve sign-in, step-up,
62
+ * and verification codes.
64
63
  *
65
64
  * @example
66
65
  * ```ts
@@ -153,9 +152,11 @@ export class EmailOtpService {
153
152
  }
154
153
  /**
155
154
  * Check a submitted code against the active record for `(email, purpose)`.
156
- * The hash compare is constant-time and runs even when no active record
157
- * exists, so a missing account or code is not distinguishable by timing. The
158
- * attempt is reserved atomically — via the store's `recordAttempt` return
155
+ * The submitted code is hashed and a constant-time compare runs even when no
156
+ * active record exists. Timing is not fully uniform, though: an expired
157
+ * record returns before any attempt is recorded, and a live record adds the
158
+ * `recordAttempt` store write the no-record path skips. The attempt is
159
+ * reserved atomically — via the store's `recordAttempt` return
159
160
  * value — **before** the compare, so concurrent verifies of one live code
160
161
  * can't each test a guess against a stale pre-increment count: at most
161
162
  * `maxAttempts` guesses ever reach the comparison. Spending the budget
@@ -28,9 +28,9 @@ export interface PasswordPolicyResult {
28
28
  /**
29
29
  * Check a password against a policy, collecting all issues.
30
30
  *
31
- * Fails closed on a non-string `password` — an untyped value off a JSON body
32
- * has no `length`, so the length checks would silently pass it through to a
33
- * hash function that then `String`-coerces it.
31
+ * A non-string `password` (an untyped value off a JSON body) fails with a
32
+ * single issue. A validator that throws rejects this call; use
33
+ * {@link assertPasswordPolicy} to have that surface as `weak_password`.
34
34
  */
35
35
  export declare function checkPasswordPolicy(password: string, policy?: PasswordPolicy): Promise<PasswordPolicyResult>;
36
36
  /**
@@ -9,9 +9,9 @@ import { IdentityError } from "./errors.js";
9
9
  /**
10
10
  * Check a password against a policy, collecting all issues.
11
11
  *
12
- * Fails closed on a non-string `password` — an untyped value off a JSON body
13
- * has no `length`, so the length checks would silently pass it through to a
14
- * hash function that then `String`-coerces it.
12
+ * A non-string `password` (an untyped value off a JSON body) fails with a
13
+ * single issue. A validator that throws rejects this call; use
14
+ * {@link assertPasswordPolicy} to have that surface as `weak_password`.
15
15
  */
16
16
  export async function checkPasswordPolicy(password, policy = {}) {
17
17
  if (typeof password !== "string") {
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * Password hashing primitive for the identity self-service layer.
3
3
  *
4
- * Promotes the package's internal PBKDF2 helpers (previously reachable only via
5
- * a relative import) to a public, documented service so apps stop hand-rolling
6
- * password crypto — the single riskiest thing to reinvent. The built-in
7
- * algorithm is PBKDF2-SHA-256 at {@link DEFAULT_PBKDF2_ITERATIONS} iterations
8
- * with a constant-time verify. Want argon2 or scrypt instead? Implement
9
- * {@link PasswordHasherLike} with your own dependency and pass it wherever a
10
- * hasher is accepted — nothing in the package requires the concrete class.
4
+ * The built-in algorithm is PBKDF2-SHA-256 at {@link DEFAULT_PBKDF2_ITERATIONS}
5
+ * iterations, with a constant-time comparison of the derived digest. Want
6
+ * argon2 or scrypt instead? Implement {@link PasswordHasherLike} with your own
7
+ * dependency and pass it wherever a hasher is accepted — nothing in the package
8
+ * requires the concrete class.
11
9
  *
12
10
  * **Storage is yours.** Persist the whole {@link PasswordCredential} — including
13
11
  * its `params` — on your user record and pass it back to
@@ -32,10 +30,9 @@ export declare function generateSalt(length?: number): string;
32
30
  export declare const DEFAULT_PBKDF2_ITERATIONS = 600000;
33
31
  /**
34
32
  * The iteration count a {@link PasswordCredential} with no `params` is assumed
35
- * to have been hashed at — the package's original default. Credentials minted
36
- * before `params` existed keep verifying against this, and
33
+ * to have been hashed at. Such credentials keep verifying against this, and
37
34
  * {@link PasswordIdentityService.needsRehash} reports them as due for an
38
- * upgrade.
35
+ * upgrade whenever the service mints at a higher count.
39
36
  */
40
37
  export declare const LEGACY_PBKDF2_ITERATIONS = 100000;
41
38
  /** The algorithm identifier {@link PasswordIdentityService} records and accepts. */
@@ -51,8 +48,8 @@ export interface PasswordHashParams {
51
48
  iterations: number;
52
49
  }
53
50
  /**
54
- * A stored password credential: the hash, the salt it used, and (for
55
- * credentials minted since `params` existed) the work factor behind it.
51
+ * A stored password credential: the hash, the salt it used, and the work
52
+ * factor behind it.
56
53
  *
57
54
  * Persist all three fields. A credential with no `params` is interpreted as
58
55
  * PBKDF2-SHA-256 at {@link LEGACY_PBKDF2_ITERATIONS}.
@@ -73,12 +70,16 @@ export interface PasswordCredential {
73
70
  *
74
71
  * Implement the optional `needsRehash` to get rehash-on-successful-sign-in: the
75
72
  * identity flows call it after a password verifies and, when it returns `true`,
76
- * re-hash the just-verified password and persist the result.
73
+ * re-hash the just-verified password and persist the result — provided the
74
+ * user store implements `replaceCredential`.
77
75
  */
78
76
  export interface PasswordHasherLike {
79
77
  /** Hashes a new password into a credential to store. */
80
78
  hash(password: string): Promise<PasswordCredential>;
81
- /** Constant-time verify of a password against a stored credential. */
79
+ /**
80
+ * Whether `password` matches the stored credential. Compare in constant time;
81
+ * resolve `false` rather than throw for a credential you cannot verify.
82
+ */
82
83
  verify(password: string, credential: PasswordCredential): Promise<boolean>;
83
84
  /**
84
85
  * Whether a stored credential is weaker than what this hasher mints today.
@@ -101,7 +102,8 @@ export interface PasswordHasherLike {
101
102
  */
102
103
  export declare function hashPassword(password: string, salt: string, iterations?: number): Promise<string>;
103
104
  /**
104
- * Verifies a password against a stored hash in constant time.
105
+ * Verifies a password against a stored hash, comparing the digests in constant
106
+ * time.
105
107
  *
106
108
  * @param password The password to verify
107
109
  * @param salt The salt used for the hash
@@ -120,8 +122,8 @@ export interface PasswordIdentityServiceOptions {
120
122
  iterations?: number;
121
123
  }
122
124
  /**
123
- * Hashes and verifies passwords (PBKDF2-SHA-256, constant-time verify).
124
- * Construct once and reuse.
125
+ * Hashes and verifies passwords (PBKDF2-SHA-256, constant-time digest
126
+ * comparison). Construct once and reuse.
125
127
  *
126
128
  * Verification reads the work factor off the credential, so credentials minted
127
129
  * under an older default keep verifying; {@link needsRehash} tells you when one
@@ -157,9 +159,10 @@ export declare class PasswordIdentityService implements PasswordHasherLike {
157
159
  /** Hashes a new password, generating a fresh random salt. */
158
160
  hash(password: string): Promise<PasswordCredential>;
159
161
  /**
160
- * Constant-time verify of a password against a stored credential, using the
161
- * credential's own recorded work factor. Resolves `false` for a credential
162
- * recorded under an algorithm this service does not implement.
162
+ * Verifies a password against a stored credential at the credential's own
163
+ * recorded work factor, comparing digests in constant time. Resolves `false`
164
+ * for a credential recorded under an algorithm this service does not
165
+ * implement.
163
166
  */
164
167
  verify(password: string, credential: PasswordCredential): Promise<boolean>;
165
168
  /**
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * Password hashing primitive for the identity self-service layer.
3
3
  *
4
- * Promotes the package's internal PBKDF2 helpers (previously reachable only via
5
- * a relative import) to a public, documented service so apps stop hand-rolling
6
- * password crypto — the single riskiest thing to reinvent. The built-in
7
- * algorithm is PBKDF2-SHA-256 at {@link DEFAULT_PBKDF2_ITERATIONS} iterations
8
- * with a constant-time verify. Want argon2 or scrypt instead? Implement
9
- * {@link PasswordHasherLike} with your own dependency and pass it wherever a
10
- * hasher is accepted — nothing in the package requires the concrete class.
4
+ * The built-in algorithm is PBKDF2-SHA-256 at {@link DEFAULT_PBKDF2_ITERATIONS}
5
+ * iterations, with a constant-time comparison of the derived digest. Want
6
+ * argon2 or scrypt instead? Implement {@link PasswordHasherLike} with your own
7
+ * dependency and pass it wherever a hasher is accepted — nothing in the package
8
+ * requires the concrete class.
11
9
  *
12
10
  * **Storage is yours.** Persist the whole {@link PasswordCredential} — including
13
11
  * its `params` — on your user record and pass it back to
@@ -39,10 +37,9 @@ const encoder = new TextEncoder();
39
37
  export const DEFAULT_PBKDF2_ITERATIONS = 600_000;
40
38
  /**
41
39
  * The iteration count a {@link PasswordCredential} with no `params` is assumed
42
- * to have been hashed at — the package's original default. Credentials minted
43
- * before `params` existed keep verifying against this, and
40
+ * to have been hashed at. Such credentials keep verifying against this, and
44
41
  * {@link PasswordIdentityService.needsRehash} reports them as due for an
45
- * upgrade.
42
+ * upgrade whenever the service mints at a higher count.
46
43
  */
47
44
  export const LEGACY_PBKDF2_ITERATIONS = 100_000;
48
45
  /** The algorithm identifier {@link PasswordIdentityService} records and accepts. */
@@ -70,7 +67,8 @@ export async function hashPassword(password, salt, iterations = DEFAULT_PBKDF2_I
70
67
  return encodeHex(new Uint8Array(derivedBits, 0, 32));
71
68
  }
72
69
  /**
73
- * Verifies a password against a stored hash in constant time.
70
+ * Verifies a password against a stored hash, comparing the digests in constant
71
+ * time.
74
72
  *
75
73
  * @param password The password to verify
76
74
  * @param salt The salt used for the hash
@@ -86,8 +84,8 @@ export async function verifyPassword(password, salt, hash, iterations = DEFAULT_
86
84
  return timingSafeEqual(encoder.encode(computedHash), encoder.encode(hash));
87
85
  }
88
86
  /**
89
- * Hashes and verifies passwords (PBKDF2-SHA-256, constant-time verify).
90
- * Construct once and reuse.
87
+ * Hashes and verifies passwords (PBKDF2-SHA-256, constant-time digest
88
+ * comparison). Construct once and reuse.
91
89
  *
92
90
  * Verification reads the work factor off the credential, so credentials minted
93
91
  * under an older default keep verifying; {@link needsRehash} tells you when one
@@ -134,9 +132,10 @@ export class PasswordIdentityService {
134
132
  };
135
133
  }
136
134
  /**
137
- * Constant-time verify of a password against a stored credential, using the
138
- * credential's own recorded work factor. Resolves `false` for a credential
139
- * recorded under an algorithm this service does not implement.
135
+ * Verifies a password against a stored credential at the credential's own
136
+ * recorded work factor, comparing digests in constant time. Resolves `false`
137
+ * for a credential recorded under an algorithm this service does not
138
+ * implement.
140
139
  */
141
140
  verify(password, credential) {
142
141
  const params = credential.params;