@udibo/oauth2 0.43.0 → 0.44.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.
@@ -45,7 +45,14 @@ export interface IdentityUserStore<User extends IdentityUser> {
45
45
  create(profile: Record<string, unknown>, credential: PasswordCredential): Promise<User>;
46
46
  /** Find a user by a sign-in identifier (email/username/phone). */
47
47
  findByIdentifier(identifier: string): Promise<User | undefined>;
48
- /** Find a user by email (password-reset / re-verification requests). */
48
+ /**
49
+ * Find a user by email (password-reset / sign-in-link requests).
50
+ *
51
+ * Also called when a mailed reset, sign-in or account-unlock link is
52
+ * redeemed: the link works only while this still resolves the address it
53
+ * was sent to to the same user. Normalize as at write time; a lookup that
54
+ * case-folds lets a link requested with different casing still redeem.
55
+ */
49
56
  findByEmail(email: string): Promise<User | undefined>;
50
57
  /**
51
58
  * The user's stored password credential, or `undefined`.
@@ -482,7 +489,10 @@ export declare class IdentityService<User extends IdentityUser> {
482
489
  * was configured) revoke **all** of the user's sessions via
483
490
  * `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
484
491
  * proves the same account ownership the unlock flow does. Returns
485
- * `{ userId }`, or `null` for an invalid/expired/used token.
492
+ * `{ userId }`, or `null` for an invalid/expired/used token — including one
493
+ * whose address no longer finds this user through
494
+ * {@link IdentityUserStore.findByEmail}, because the account's email moved
495
+ * after the link was mailed. That refusal leaves the token unspent.
486
496
  *
487
497
  * A reset is the user's "lock everyone else out" action, so the outstanding
488
498
  * passwordless credentials for the same account are voided too: sign-in links
@@ -558,7 +568,10 @@ export declare class IdentityService<User extends IdentityUser> {
558
568
  * Mint + deliver a single-use account-unlock link after a lockout. Call it
559
569
  * with the locked account's email (typically from your `lockout` event
560
570
  * handler — the service only knows opaque user ids). Requires `tokens` and
561
- * a `delivery.sendAccountUnlock` hook to actually send. With a `rateLimiter`
571
+ * a `delivery.sendAccountUnlock` hook to actually send. `email` must be an
572
+ * address {@link IdentityUserStore.findByEmail} resolves to `userId`:
573
+ * {@link IdentityService.unlockAccount} refuses the link once it does not.
574
+ * With a `rateLimiter`
562
575
  * (or a tighter `rateLimiters.accountUnlock`), requests throttle per user and
563
576
  * throw {@link IdentityError} `rate_limited` when exceeded.
564
577
  */
@@ -568,7 +581,9 @@ export declare class IdentityService<User extends IdentityUser> {
568
581
  }): Promise<void>;
569
582
  /**
570
583
  * Consume an unlock token and clear the account's lockout state (failures
571
- * and lock). The self-service unlock path: the emailed link lands here.
584
+ * and lock). The self-service unlock path: the emailed link lands here. A
585
+ * link whose address no longer finds this user through
586
+ * {@link IdentityUserStore.findByEmail} reports `invalid` and stays unspent.
572
587
  */
573
588
  unlockAccount(token: string): Promise<UnlockAccountResult>;
574
589
  /**
@@ -598,7 +613,9 @@ export declare class IdentityService<User extends IdentityUser> {
598
613
  * stays your app's job. So does re-resolving the user: a user deleted or
599
614
  * disabled since the link was minted must be treated as `invalid` by your
600
615
  * user store, not signed in. `expired` and `invalid` are distinguished so
601
- * the UI can offer a fresh link.
616
+ * the UI can offer a fresh link. A link whose address no longer finds this
617
+ * user through {@link IdentityUserStore.findByEmail} — the account's email
618
+ * moved after it was mailed — reports `invalid` and stays unspent.
602
619
  */
603
620
  consumeSignInLink(token: string): Promise<ConsumeSignInLinkResult>;
604
621
  /**
@@ -236,7 +236,6 @@ export class IdentityService {
236
236
  hook: "sendPasswordReset",
237
237
  to: normalized,
238
238
  subject: user.id,
239
- data: { email: normalized },
240
239
  ttlMs: this.#ttl.passwordReset,
241
240
  requested: {
242
241
  type: "password_reset.requested",
@@ -250,7 +249,10 @@ export class IdentityService {
250
249
  * was configured) revoke **all** of the user's sessions via
251
250
  * `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
252
251
  * proves the same account ownership the unlock flow does. Returns
253
- * `{ userId }`, or `null` for an invalid/expired/used token.
252
+ * `{ userId }`, or `null` for an invalid/expired/used token — including one
253
+ * whose address no longer finds this user through
254
+ * {@link IdentityUserStore.findByEmail}, because the account's email moved
255
+ * after the link was mailed. That refusal leaves the token unspent.
254
256
  *
255
257
  * A reset is the user's "lock everyone else out" action, so the outstanding
256
258
  * passwordless credentials for the same account are voided too: sign-in links
@@ -298,6 +300,7 @@ export class IdentityService {
298
300
  type: "password_reset.failed",
299
301
  reason: "invalid_token",
300
302
  }),
303
+ addressBound: true,
301
304
  });
302
305
  if (consumed.status !== "success")
303
306
  return null;
@@ -371,7 +374,6 @@ export class IdentityService {
371
374
  to: input.email,
372
375
  subject: input.userId,
373
376
  ttlMs: this.#ttl.emailVerification,
374
- data: { email: input.email },
375
377
  requested: {
376
378
  type: "email_verification.requested",
377
379
  userId: input.userId,
@@ -421,7 +423,10 @@ export class IdentityService {
421
423
  * Mint + deliver a single-use account-unlock link after a lockout. Call it
422
424
  * with the locked account's email (typically from your `lockout` event
423
425
  * handler — the service only knows opaque user ids). Requires `tokens` and
424
- * a `delivery.sendAccountUnlock` hook to actually send. With a `rateLimiter`
426
+ * a `delivery.sendAccountUnlock` hook to actually send. `email` must be an
427
+ * address {@link IdentityUserStore.findByEmail} resolves to `userId`:
428
+ * {@link IdentityService.unlockAccount} refuses the link once it does not.
429
+ * With a `rateLimiter`
425
430
  * (or a tighter `rateLimiters.accountUnlock`), requests throttle per user and
426
431
  * throw {@link IdentityError} `rate_limited` when exceeded.
427
432
  */
@@ -446,7 +451,9 @@ export class IdentityService {
446
451
  }
447
452
  /**
448
453
  * Consume an unlock token and clear the account's lockout state (failures
449
- * and lock). The self-service unlock path: the emailed link lands here.
454
+ * and lock). The self-service unlock path: the emailed link lands here. A
455
+ * link whose address no longer finds this user through
456
+ * {@link IdentityUserStore.findByEmail} reports `invalid` and stays unspent.
450
457
  */
451
458
  async unlockAccount(token) {
452
459
  const tokens = this.#requireTokens("unlockAccount");
@@ -455,6 +462,7 @@ export class IdentityService {
455
462
  purpose: TokenPurpose.AccountUnlock,
456
463
  token,
457
464
  failed: (reason) => ({ type: "account_unlock.failed", reason }),
465
+ addressBound: true,
458
466
  });
459
467
  if (result.status !== "success")
460
468
  return result;
@@ -527,7 +535,9 @@ export class IdentityService {
527
535
  * stays your app's job. So does re-resolving the user: a user deleted or
528
536
  * disabled since the link was minted must be treated as `invalid` by your
529
537
  * user store, not signed in. `expired` and `invalid` are distinguished so
530
- * the UI can offer a fresh link.
538
+ * the UI can offer a fresh link. A link whose address no longer finds this
539
+ * user through {@link IdentityUserStore.findByEmail} — the account's email
540
+ * moved after it was mailed — reports `invalid` and stays unspent.
531
541
  */
532
542
  async consumeSignInLink(token) {
533
543
  const tokens = this.#requireTokens("consumeSignInLink");
@@ -536,6 +546,7 @@ export class IdentityService {
536
546
  purpose: TokenPurpose.SignIn,
537
547
  token,
538
548
  failed: (reason) => ({ type: "signin_link.failed", reason }),
549
+ addressBound: true,
539
550
  });
540
551
  if (result.status !== "success")
541
552
  return result;
@@ -783,7 +794,7 @@ export class IdentityService {
783
794
  const { token, expiresAt } = await tokens.create({
784
795
  purpose: DELIVERY_PURPOSES[request.hook],
785
796
  subject: request.subject,
786
- data: request.data,
797
+ data: { email: request.to },
787
798
  ttlMs: request.ttlMs,
788
799
  invalidateExisting: true,
789
800
  });
@@ -797,7 +808,9 @@ export class IdentityService {
797
808
  await this.#emit(input.failed("expired"));
798
809
  return { status: "expired" };
799
810
  }
800
- if (status.status !== "valid") {
811
+ if (status.status !== "valid" ||
812
+ (input.addressBound &&
813
+ !(await this.#stillAddressedTo(status.subject, status.data)))) {
801
814
  await this.#emit(input.failed("invalid"));
802
815
  return { status: "invalid" };
803
816
  }
@@ -809,6 +822,12 @@ export class IdentityService {
809
822
  }
810
823
  return { status: "success", resolved };
811
824
  }
825
+ async #stillAddressedTo(subject, data) {
826
+ const email = emailFromData(data);
827
+ if (email === undefined)
828
+ return true;
829
+ return (await this.#users.findByEmail(email))?.id === subject;
830
+ }
812
831
  #emit(event) {
813
832
  return dispatchIdentityEvent(this.#onEvent, "identity", event);
814
833
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",