@fonderie/auth 7.5.0 → 7.6.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.
@@ -100,11 +100,11 @@ interface IDataExportContributor {
100
100
 
101
101
  const AUTH_CONFIG_KEYS: { sessionDuration: string; verificationCooldown: string; mfa: string; requireVerification: string; }
102
102
 
103
- const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; readonly oauthRegistration: "oauth-registration"; readonly oauthLinked: "oauth-linked"; readonly oauthUnlinked: "oauth-unlinked"; }
103
+ const MESSAGE_KEYS: { readonly emailRegistration: "email-registration"; readonly emailVerification: "email-verification"; readonly passwordReset: "password-reset"; readonly phoneOtp: "phone-otp"; readonly mfaEnabled: "mfa-enabled"; readonly mfaDisabled: "mfa-disabled"; readonly mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated"; readonly emailChanged: "email-changed"; readonly phoneChanged: "phone-changed"; readonly passwordRevoked: "password-revoked"; readonly oauthRegistration: "oauth-registration"; readonly oauthLinked: "oauth-linked"; readonly oauthUnlinked: "oauth-unlinked"; }
104
104
 
105
105
  type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
106
106
 
107
- const DEFAULT_TEMPLATES: { "email-registration": { subject: string; html: string; text: string; }; "email-verification": { subject: string; html: string; text: string; }; "password-reset": { subject: string; html: string; text: string; }; "phone-otp": { text: string; }; "mfa-enabled": { subject: string; html: string; text: string; }; "mfa-disabled": { subject: string; html: string; text: string; }; "mfa-backup-codes-regenerated": { subject: string; html: string; text: string; }; "email-changed": { subject: string; html: string; text: string; }; "phone-changed": { subject: string; html: string; text: string; }; "oauth-registration": { subject: string; html: string; text: string; }; "oauth-linked": { subject: string; html: string; text: string; }; "oauth-unlinked": { subject: string; html: string; text: string; }; }
107
+ const DEFAULT_TEMPLATES: { "email-registration": { subject: string; html: string; text: string; }; "email-verification": { subject: string; html: string; text: string; }; "password-reset": { subject: string; html: string; text: string; }; "phone-otp": { text: string; }; "mfa-enabled": { subject: string; html: string; text: string; }; "mfa-disabled": { subject: string; html: string; text: string; }; "mfa-backup-codes-regenerated": { subject: string; html: string; text: string; }; "email-changed": { subject: string; html: string; text: string; }; "phone-changed": { subject: string; html: string; text: string; }; "password-revoked": { subject: string; html: string; text: string; }; "oauth-registration": { subject: string; html: string; text: string; }; "oauth-linked": { subject: string; html: string; text: string; }; "oauth-unlinked": { subject: string; html: string; text: string; }; }
108
108
 
109
109
  interface IUserDTO {
110
110
  id: string;
package/dist/index.cjs CHANGED
@@ -300,6 +300,11 @@ var MESSAGE_KEYS = {
300
300
  // Changing HOW an account can be signed into is a security event for the
301
301
  // account's owner, who may not be the person doing it. Both directions are
302
302
  // notified for the same reason password changes are.
303
+ // A password was REVOKED because the account it sat on had never been
304
+ // verified and an OAuth identity proved ownership of the mailbox. The
305
+ // recipient is now known to own that address, so this reaches the right
306
+ // person — and it is the only way they learn a password stopped working.
307
+ passwordRevoked: "password-revoked",
303
308
  // Welcome for an account CREATED by an OAuth sign-in. Distinct from
304
309
  // emailRegistration, which carries a verification PIN — an OAuth signup
305
310
  // arrives already verified by the provider and has nothing to confirm.
@@ -790,22 +795,49 @@ var UserModel = class {
790
795
  * The prior state is read in a CTE of the SAME statement, so it sees the
791
796
  * snapshot from before the insert. Reading it in a separate query would
792
797
  * race two concurrent logins into both seeing "new".
798
+ *
799
+ * SECURITY — linking is BY EMAIL, so this merges into whatever row already
800
+ * holds the address. If that row was never verified, its password was never
801
+ * proven to belong to the mailbox; anyone can register an address they do
802
+ * not own. The provider HAS proven ownership. So an unverified password is
803
+ * revoked at the moment of linking: without that, linking sets
804
+ * email_verified_at and the earlier registrant is left holding a working
805
+ * password on an account now treated as verified.
806
+ *
807
+ * The legitimate user loses nothing but a password reset — they own the
808
+ * mailbox. Someone who does not own it cannot receive that mail.
793
809
  */
794
810
  async upsertByProvider(email2, provider, providerId) {
795
811
  const [row] = await this.store.query(
796
812
  `WITH prior AS (
797
- SELECT provider FROM fonderie_users WHERE email = $1
813
+ SELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password
814
+ FROM fonderie_users WHERE email = $1
798
815
  ), upserted AS (
799
816
  INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
800
817
  VALUES ($1, now(), $2, $3)
801
818
  ON CONFLICT (email) DO UPDATE
802
819
  SET provider = $2, provider_id = $3,
803
- email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
820
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),
821
+ -- A password on an UNVERIFIED account is a claim, not a
822
+ -- credential: nobody ever proved that mailbox belongs to
823
+ -- whoever set it. The provider has now proven it belongs to
824
+ -- the person signing in. Drop the unproven claim rather than
825
+ -- promote it \u2014 otherwise linking marks the account verified
826
+ -- and silently hands the earlier registrant a working
827
+ -- password on a now-trusted account.
828
+ password_hash = CASE
829
+ WHEN fonderie_users.email_verified_at IS NULL THEN NULL
830
+ ELSE fonderie_users.password_hash
831
+ END
804
832
  RETURNING id
805
833
  )
806
834
  SELECT upserted.id,
807
835
  NOT EXISTS (SELECT 1 FROM prior) AS inserted,
808
- (SELECT provider FROM prior) AS "previousProvider"
836
+ (SELECT provider FROM prior) AS "previousProvider",
837
+ -- True only when a password was actually revoked, so the
838
+ -- caller can tell the owner their sign-in method changed.
839
+ COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)
840
+ AS "clearedUnverifiedPassword"
809
841
  FROM upserted`,
810
842
  [email2, provider, providerId]
811
843
  );
@@ -2515,6 +2547,18 @@ function oauthController(store, config, bus) {
2515
2547
  const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
2516
2548
  const label = provider.charAt(0).toUpperCase() + provider.slice(1);
2517
2549
  const recipient = { email: user.email, phone: null, deviceToken: null };
2550
+ if (upserted.clearedUnverifiedPassword && user.email) {
2551
+ await (0, import_core9.background)(bus.emit(
2552
+ import_events4.NOTIFICATION_EVENT,
2553
+ {
2554
+ type: MESSAGE_KEYS.passwordRevoked,
2555
+ locale: user.locale,
2556
+ data: { provider: label },
2557
+ recipient
2558
+ },
2559
+ reqOpts
2560
+ ));
2561
+ }
2518
2562
  if (upserted.inserted) {
2519
2563
  await (0, import_core9.background)(bus.emit(
2520
2564
  EVENT_KEYS.userRegistered,
@@ -3271,6 +3315,24 @@ If you made this change, you're all set. If not, contact support right away \u20
3271
3315
  The phone number on your account was just updated.
3272
3316
 
3273
3317
  If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
3318
+ },
3319
+ [MESSAGE_KEYS.passwordRevoked]: {
3320
+ subject: "The password on your account was removed",
3321
+ html: `<h1>Your password was removed</h1>
3322
+ <p>You just signed in with <strong>{{provider}}</strong>. A password had been set on this account, but this email address had never been confirmed &mdash; so we could not tell who set it, and it has been removed.</p>
3323
+ <p>Signing in with {{provider}} works as before. If you want a password as well, set one from your account settings.</p>
3324
+ <p class="muted">If you never set a password here, someone else may have tried to register with your address. Nothing further is needed &mdash; the account is yours.</p>`,
3325
+ text: `Your password was removed
3326
+
3327
+ You just signed in with {{provider}}. A password had been set on this account,
3328
+ but this email address had never been confirmed \u2014 so we could not tell who set
3329
+ it, and it has been removed.
3330
+
3331
+ Signing in with {{provider}} works as before. If you want a password as well,
3332
+ set one from your account settings.
3333
+
3334
+ If you never set a password here, someone else may have tried to register with
3335
+ your address. Nothing further is needed \u2014 the account is yours.`
3274
3336
  },
3275
3337
  [MESSAGE_KEYS.oauthRegistration]: {
3276
3338
  subject: "Welcome to {{appName}}",
@@ -3309,6 +3371,7 @@ If you did this, you're all set. If not, contact support right away.`
3309
3371
  }
3310
3372
  };
3311
3373
  var SAMPLE_PAYLOADS = {
3374
+ [MESSAGE_KEYS.passwordRevoked]: { provider: "Google" },
3312
3375
  [MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
3313
3376
  [MESSAGE_KEYS.oauthLinked]: { provider: "Google" },
3314
3377
  [MESSAGE_KEYS.oauthUnlinked]: { provider: "Google" },