@udibo/oauth2 0.4.1 → 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.
- package/README.md +3 -3
- package/SECURITY.md +0 -4
- package/esm/adapters/hono/_common.d.ts +10 -13
- package/esm/adapters/hono/_common.js +11 -20
- package/esm/adapters/hono/authorization-server.d.ts +16 -10
- package/esm/adapters/hono/authorization-server.js +13 -8
- package/esm/adapters/hono/bff/auth-request-store.d.ts +4 -0
- package/esm/adapters/hono/bff/auth-request-store.js +4 -0
- package/esm/adapters/hono/bff/bff.d.ts +29 -18
- package/esm/adapters/hono/bff/bff.js +15 -11
- package/esm/adapters/hono/bff/proxy.js +7 -55
- package/esm/adapters/hono/bff/session-store.d.ts +9 -9
- package/esm/adapters/hono/bff/session-store.js +3 -2
- package/esm/adapters/hono/bff/testing.d.ts +14 -3
- package/esm/adapters/hono/bff/testing.js +14 -3
- package/esm/adapters/hono/identity.d.ts +5 -3
- package/esm/adapters/hono/identity.js +1 -17
- package/esm/adapters/hono/resource-server.d.ts +3 -2
- package/esm/adapters/hono/resource-server.js +3 -2
- package/esm/client/_http.d.ts +4 -7
- package/esm/client/_http.js +4 -37
- package/esm/client/base.d.ts +3 -6
- package/esm/client/bff-client.d.ts +7 -6
- package/esm/client/bff-client.js +7 -6
- package/esm/client/browser-storage.d.ts +33 -34
- package/esm/client/browser-storage.js +47 -32
- package/esm/client/check.d.ts +10 -3
- package/esm/client/check.js +28 -8
- package/esm/client/direct-client.d.ts +49 -29
- package/esm/client/direct-client.js +59 -59
- package/esm/client/discovery-cache.d.ts +12 -5
- package/esm/client/discovery-cache.js +11 -4
- package/esm/client/events.d.ts +11 -3
- package/esm/client/events.js +1 -1
- package/esm/client/mod.d.ts +6 -4
- package/esm/client/mod.js +5 -3
- package/esm/client/storage.d.ts +53 -23
- package/esm/client/storage.js +43 -15
- package/esm/errors.d.ts +15 -12
- package/esm/errors.js +12 -8
- package/esm/identity/delivery.d.ts +7 -7
- package/esm/identity/delivery.js +1 -2
- package/esm/identity/errors.d.ts +10 -4
- package/esm/identity/errors.js +4 -1
- package/esm/identity/events.d.ts +21 -5
- package/esm/identity/events.js +4 -2
- package/esm/identity/external/_shared.d.ts +5 -7
- package/esm/identity/external/_shared.js +5 -7
- package/esm/identity/external/_token-exchange.d.ts +3 -6
- package/esm/identity/external/_token-exchange.js +2 -5
- package/esm/identity/external/apple-client-secret.d.ts +13 -8
- package/esm/identity/external/apple-client-secret.js +9 -6
- package/esm/identity/external/apple.d.ts +17 -10
- package/esm/identity/external/apple.js +12 -9
- package/esm/identity/external/discord.d.ts +5 -1
- package/esm/identity/external/errors.d.ts +6 -2
- package/esm/identity/external/errors.js +6 -2
- package/esm/identity/external/flow.d.ts +9 -6
- package/esm/identity/external/flow.js +6 -5
- package/esm/identity/external/github.d.ts +7 -3
- package/esm/identity/external/github.js +2 -2
- package/esm/identity/external/google.d.ts +5 -1
- package/esm/identity/external/oauth2.d.ts +13 -2
- package/esm/identity/external/oauth2.js +3 -3
- package/esm/identity/external/oidc.d.ts +21 -10
- package/esm/identity/external/oidc.js +11 -6
- package/esm/identity/external/provider.d.ts +4 -2
- package/esm/identity/identifier.d.ts +6 -5
- package/esm/identity/identifier.js +6 -5
- package/esm/identity/lockout.d.ts +4 -3
- package/esm/identity/lockout.js +4 -3
- package/esm/identity/mfa/mod.d.ts +2 -1
- package/esm/identity/mfa/mod.js +2 -1
- package/esm/identity/mfa/recovery-codes.d.ts +2 -1
- package/esm/identity/mfa/service.d.ts +29 -11
- package/esm/identity/mfa/service.js +35 -11
- package/esm/identity/mfa/totp.d.ts +4 -2
- package/esm/identity/mfa/totp.js +4 -2
- package/esm/identity/migration.d.ts +8 -7
- package/esm/identity/migration.js +9 -12
- package/esm/identity/mod.d.ts +2 -2
- package/esm/identity/mod.js +2 -2
- package/esm/identity/otp.d.ts +23 -16
- package/esm/identity/otp.js +13 -12
- package/esm/identity/password-policy.d.ts +3 -3
- package/esm/identity/password-policy.js +3 -3
- package/esm/identity/password.d.ts +23 -20
- package/esm/identity/password.js +15 -16
- package/esm/identity/rate-limit.d.ts +1 -7
- package/esm/identity/rate-limit.js +1 -2
- package/esm/identity/service.d.ts +76 -44
- package/esm/identity/service.js +86 -60
- package/esm/identity/session.d.ts +5 -4
- package/esm/identity/session.js +5 -4
- package/esm/identity/token-flow.d.ts +8 -8
- package/esm/identity/token-flow.js +5 -6
- package/esm/models/client.d.ts +7 -4
- package/esm/models/token.d.ts +2 -3
- package/esm/react/callback.d.ts +3 -3
- package/esm/react/callback.js +3 -3
- package/esm/react/components/class-names.d.ts +3 -6
- package/esm/react/components/class-names.js +3 -6
- package/esm/react/components/internal.d.ts +7 -17
- package/esm/react/components/internal.js +7 -17
- package/esm/react/components/mod.d.ts +7 -5
- package/esm/react/components/mod.js +7 -5
- package/esm/react/components/types.d.ts +8 -6
- package/esm/react/context.d.ts +3 -3
- package/esm/react/provider.d.ts +3 -2
- package/esm/react/provider.js +0 -13
- package/esm/react/require-auth.d.ts +0 -3
- package/esm/react/require-auth.js +0 -3
- package/esm/react/testing.d.ts +3 -2
- package/esm/react/testing.js +3 -3
- package/esm/react/use-oauth2.d.ts +1 -1
- package/esm/server/authorization-server.d.ts +81 -54
- package/esm/server/authorization-server.js +76 -100
- package/esm/server/grants/authorization-code.d.ts +7 -3
- package/esm/server/grants/authorization-code.js +6 -2
- package/esm/server/grants/device-authorization.d.ts +1 -1
- package/esm/server/grants/grant.d.ts +15 -8
- package/esm/server/grants/grant.js +26 -18
- package/esm/server/grants/password.d.ts +1 -1
- package/esm/server/grants/refresh-token.d.ts +6 -3
- package/esm/server/grants/refresh-token.js +4 -3
- package/esm/server/introspection-token-reader.d.ts +16 -7
- package/esm/server/introspection-token-reader.js +9 -3
- package/esm/server/jwks-token-reader.d.ts +3 -3
- package/esm/server/jwks-token-reader.js +3 -3
- package/esm/server/redirect-uri.d.ts +6 -9
- package/esm/server/redirect-uri.js +7 -34
- package/esm/server/resource-server.d.ts +5 -3
- package/esm/server/resource-server.js +5 -3
- package/esm/server/services/client.d.ts +5 -1
- package/esm/server/services/device-authorization.d.ts +6 -6
- package/esm/server/services/token.d.ts +15 -8
- package/esm/server/services/token.js +1 -1
- package/esm/server/signing-keys.d.ts +24 -9
- package/esm/server/signing-keys.js +20 -9
- package/esm/testing/contract/mod.d.ts +3 -3
- package/esm/testing/contract/mod.js +3 -3
- package/esm/testing/mod.d.ts +4 -2
- package/esm/testing/mod.js +4 -2
- package/esm/testing/services.d.ts +1 -1
- package/esm/testing/services.js +1 -1
- package/esm/testing/tenant.d.ts +13 -4
- package/esm/utils/_buffer.d.ts +3 -2
- package/esm/utils/_buffer.js +3 -2
- package/esm/utils/_timing-safe.d.ts +8 -6
- package/esm/utils/_timing-safe.js +8 -6
- package/esm/utils/basic-auth.d.ts +5 -12
- package/esm/utils/basic-auth.js +5 -12
- package/esm/utils/crypto.d.ts +2 -1
- package/esm/utils/crypto.js +2 -1
- package/esm/utils/text.d.ts +2 -9
- package/esm/utils/text.js +2 -9
- package/esm/utils/url.d.ts +2 -2
- package/esm/utils/url.js +0 -2
- 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}
|
|
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}
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
|
166
|
-
*
|
|
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
|
|
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.
|
|
195
|
-
* {@link IdentityService.requestSignInLink} takes
|
|
196
|
-
*
|
|
197
|
-
* tokens; their lifetime is
|
|
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
|
|
271
|
+
* for the five flows that make the server **send an email to a
|
|
263
272
|
* caller-supplied address** (`passwordReset`, `emailVerification`,
|
|
264
|
-
* `accountUnlock`, `signInLink`,
|
|
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
|
|
395
|
-
*
|
|
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
|
|
408
|
-
* same time
|
|
409
|
-
*
|
|
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
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
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
|
|
443
|
-
* lockout — the emailed token
|
|
444
|
-
*
|
|
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
|
-
*
|
|
456
|
-
*
|
|
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
|
|
459
|
-
* the user's old sessions still live.
|
|
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 (
|
|
523
|
-
*
|
|
524
|
-
* neither, so the **return
|
|
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.
|
|
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
|
|
570
|
-
* once it runs out
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
*
|
|
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;
|
package/esm/identity/service.js
CHANGED
|
@@ -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.
|
|
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
|
|
142
|
-
*
|
|
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
|
|
155
|
-
* same time
|
|
156
|
-
*
|
|
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
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
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
|
|
257
|
-
* lockout — the emailed token
|
|
258
|
-
*
|
|
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
|
-
*
|
|
270
|
-
*
|
|
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
|
|
273
|
-
* the user's old sessions still live.
|
|
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 (
|
|
460
|
-
*
|
|
461
|
-
* neither, so the **return
|
|
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.
|
|
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
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
557
|
-
* once it runs out
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
|
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.
|
|
11
|
-
*
|
|
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).
|
package/esm/identity/session.js
CHANGED
|
@@ -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
|
|
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.
|
|
11
|
-
*
|
|
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 —
|
|
4
|
-
* mint a
|
|
5
|
-
* to deliver, then validate /
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 —
|
|
4
|
-
* mint a
|
|
5
|
-
* to deliver, then validate /
|
|
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
|
-
*
|
|
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
|
*
|