@fonderie/auth 7.4.0 → 7.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/dist/index.d.cts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.cjs';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CvNqy0Kd.cjs';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CvNqy0Kd.cjs';
5
+ import { I as IAuthConfig } from './session-Dwlcgxww.cjs';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Dwlcgxww.cjs';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.cjs';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -63,6 +63,21 @@ declare const DEFAULT_TEMPLATES: {
63
63
  html: string;
64
64
  text: string;
65
65
  };
66
+ "oauth-registration": {
67
+ subject: string;
68
+ html: string;
69
+ text: string;
70
+ };
71
+ "oauth-linked": {
72
+ subject: string;
73
+ html: string;
74
+ text: string;
75
+ };
76
+ "oauth-unlinked": {
77
+ subject: string;
78
+ html: string;
79
+ text: string;
80
+ };
66
81
  };
67
82
 
68
83
  interface ILoginEventDTO {
package/dist/index.d.ts CHANGED
@@ -2,8 +2,8 @@ export { IMfaChallenge, ISession, IUser } from './types.js';
2
2
  import { IFonderieModule, IReadinessProblem, IFonderieApp } from '@fonderie/core';
3
3
  import { IStoreAdapter } from '@fonderie/store';
4
4
  import { EventBus } from '@fonderie/events';
5
- import { I as IAuthConfig } from './session-CvNqy0Kd.js';
6
- export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-CvNqy0Kd.js';
5
+ import { I as IAuthConfig } from './session-Dwlcgxww.js';
6
+ export { A as AUTH_CONFIG_KEYS, a as AuthLimitedRoute, b as AuthMessageKey, c as IAuthRateLimitConfig, d as IAuthRuntimeConfig, e as IAuthSecrets, f as IDataExportContributor, M as MESSAGE_KEYS, g as buildAuthAccountLimiter, h as buildAuthIpLimiter, w as withSession } from './session-Dwlcgxww.js';
7
7
  export { IUserDTO, toUserDTO } from './dtos/user.js';
8
8
  export { requireAuth, validate } from '@fonderie/core/middlewares';
9
9
  import { z } from 'zod';
@@ -63,6 +63,21 @@ declare const DEFAULT_TEMPLATES: {
63
63
  html: string;
64
64
  text: string;
65
65
  };
66
+ "oauth-registration": {
67
+ subject: string;
68
+ html: string;
69
+ text: string;
70
+ };
71
+ "oauth-linked": {
72
+ subject: string;
73
+ html: string;
74
+ text: string;
75
+ };
76
+ "oauth-unlinked": {
77
+ subject: string;
78
+ html: string;
79
+ text: string;
80
+ };
66
81
  };
67
82
 
68
83
  interface ILoginEventDTO {
package/dist/index.js CHANGED
@@ -256,7 +256,16 @@ var MESSAGE_KEYS = {
256
256
  mfaDisabled: "mfa-disabled",
257
257
  mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated",
258
258
  emailChanged: "email-changed",
259
- phoneChanged: "phone-changed"
259
+ phoneChanged: "phone-changed",
260
+ // Changing HOW an account can be signed into is a security event for the
261
+ // account's owner, who may not be the person doing it. Both directions are
262
+ // notified for the same reason password changes are.
263
+ // Welcome for an account CREATED by an OAuth sign-in. Distinct from
264
+ // emailRegistration, which carries a verification PIN — an OAuth signup
265
+ // arrives already verified by the provider and has nothing to confirm.
266
+ oauthRegistration: "oauth-registration",
267
+ oauthLinked: "oauth-linked",
268
+ oauthUnlinked: "oauth-unlinked"
260
269
  };
261
270
  var EVENT_KEYS = {
262
271
  userRegistered: "fonderie.user.registered",
@@ -488,6 +497,8 @@ function toUserDTO(user, phoneVerified = false) {
488
497
  isEmailVerified: user.emailVerifiedAt !== null,
489
498
  isPhoneVerified: phoneVerified,
490
499
  mfaEnabled: booleanOrFalse(user.mfaEnabled),
500
+ provider: stringOrEmpty(user.provider),
501
+ hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
491
502
  suspended: booleanOrFalse(user.suspended),
492
503
  whitelist: booleanOrFalse(user.whitelist),
493
504
  ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
@@ -515,6 +526,7 @@ var USER_COLUMNS = `
515
526
  ip_whitelist AS "ipWhitelist",
516
527
  mfa_enabled AS "mfaEnabled",
517
528
  email_verified_at AS "emailVerifiedAt",
529
+ provider,
518
530
  deleted_at AS "deletedAt",
519
531
  created_at AS "createdAt",
520
532
  updated_at AS "updatedAt"
@@ -592,6 +604,31 @@ var UserModel = class {
592
604
  );
593
605
  return row ?? null;
594
606
  }
607
+ /**
608
+ * Clear the OAuth provider, but ONLY when the account can still be signed
609
+ * into afterwards.
610
+ *
611
+ * An account created through Google has no password. Unlinking it without
612
+ * checking would not be "removing a sign-in method" — it would be locking
613
+ * the owner out permanently, which is account deletion wearing a friendlier
614
+ * label. The guard lives in the WHERE clause so the check and the write are
615
+ * one atomic statement: a password cannot be removed between them.
616
+ *
617
+ * Returns false when the account has no password (nothing was changed) so
618
+ * the caller can say why rather than reporting a silent success.
619
+ */
620
+ async clearProvider(id) {
621
+ const rows = await this.store.query(
622
+ `UPDATE fonderie_users
623
+ SET provider = NULL, provider_id = NULL
624
+ WHERE id = $1
625
+ AND password_hash IS NOT NULL
626
+ AND provider IS NOT NULL
627
+ RETURNING id`,
628
+ [id]
629
+ );
630
+ return rows.length > 0;
631
+ }
595
632
  async updatePassword(id, passwordHash) {
596
633
  await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
597
634
  passwordHash,
@@ -692,14 +729,44 @@ var UserModel = class {
692
729
  );
693
730
  return row?.mfa_secret ?? null;
694
731
  }
732
+ /**
733
+ * Link an OAuth identity to the account owning `email`, creating it if there
734
+ * is none.
735
+ *
736
+ * Returns what HAPPENED, not just who it happened to. Three outcomes hide
737
+ * behind one upsert, and callers must tell them apart:
738
+ *
739
+ * • `inserted` — a brand-new account. Whoever provisions on signup (the
740
+ * personal workspace, a wallet, a welcome email) must run, exactly as
741
+ * it does for a password registration. This is the case that was
742
+ * previously invisible: the upsert returned an id and looked identical
743
+ * to a returning user signing in, so OAuth signups silently skipped
744
+ * every signup side effect.
745
+ * • `previousProvider` differs — an existing account gained, or switched,
746
+ * a way to sign in. A security event for the owner.
747
+ * • `previousProvider` is the same — an ordinary login. Nothing to do, and
748
+ * notifying here would email the user on every single sign-in.
749
+ *
750
+ * The prior state is read in a CTE of the SAME statement, so it sees the
751
+ * snapshot from before the insert. Reading it in a separate query would
752
+ * race two concurrent logins into both seeing "new".
753
+ */
695
754
  async upsertByProvider(email2, provider, providerId) {
696
755
  const [row] = await this.store.query(
697
- `INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
698
- VALUES ($1, now(), $2, $3)
699
- ON CONFLICT (email) DO UPDATE
700
- SET provider = $2, provider_id = $3,
701
- email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
702
- RETURNING id`,
756
+ `WITH prior AS (
757
+ SELECT provider FROM fonderie_users WHERE email = $1
758
+ ), upserted AS (
759
+ INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
760
+ VALUES ($1, now(), $2, $3)
761
+ ON CONFLICT (email) DO UPDATE
762
+ SET provider = $2, provider_id = $3,
763
+ email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
764
+ RETURNING id
765
+ )
766
+ SELECT upserted.id,
767
+ NOT EXISTS (SELECT 1 FROM prior) AS inserted,
768
+ (SELECT provider FROM prior) AS "previousProvider"
769
+ FROM upserted`,
703
770
  [email2, provider, providerId]
704
771
  );
705
772
  return row ?? null;
@@ -2192,6 +2259,50 @@ function userController(store, config, bus) {
2192
2259
  await sessions.deleteByUser(ctx.user.id);
2193
2260
  return setApiResponse4(HTTP4.OK, "PASSWORD_CHANGED", "Password updated successfully.");
2194
2261
  },
2262
+ /**
2263
+ * Remove an OAuth provider from the account.
2264
+ *
2265
+ * Refuses when the account has no password, because unlinking would
2266
+ * then leave NO way to sign in — that is account deletion, not a
2267
+ * settings toggle, and a user clicking "disconnect Google" is not
2268
+ * asking for it. The check is inside the UPDATE's WHERE clause, so a
2269
+ * password cannot disappear between the check and the write.
2270
+ *
2271
+ * Notifies the account's email either way: changing how an account can
2272
+ * be signed into is a security event for its owner, who is not
2273
+ * necessarily the person doing it.
2274
+ */
2275
+ unlinkOauth: async (ctx) => {
2276
+ const provider = String(ctx.meta["params"]?.["provider"] ?? "");
2277
+ const current = await users.findById(ctx.user.id);
2278
+ if (!current?.provider) {
2279
+ return setApiResponse4(HTTP4.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
2280
+ }
2281
+ if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
2282
+ return setApiResponse4(
2283
+ HTTP4.NOT_FOUND,
2284
+ "NOT_LINKED",
2285
+ `This account is not linked to ${provider}.`
2286
+ );
2287
+ }
2288
+ const removed = await users.clearProvider(ctx.user.id);
2289
+ if (!removed) {
2290
+ return setApiResponse4(
2291
+ HTTP4.CONFLICT,
2292
+ "PASSWORD_REQUIRED",
2293
+ "Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
2294
+ );
2295
+ }
2296
+ if (ctx.user.email) {
2297
+ await background4(bus?.emit(NOTIFICATION_EVENT3, {
2298
+ type: MESSAGE_KEYS.oauthUnlinked,
2299
+ locale: ctx.user.locale,
2300
+ data: { provider: current.provider },
2301
+ recipient: { email: ctx.user.email, phone: null, deviceToken: null }
2302
+ }));
2303
+ }
2304
+ return setApiResponse4(HTTP4.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
2305
+ },
2195
2306
  deleteMe: async (ctx) => {
2196
2307
  const userId = ctx.user.id;
2197
2308
  await users.softDelete(userId);
@@ -2251,7 +2362,8 @@ function userController(store, config, bus) {
2251
2362
  // src/controllers/oauth.controller.ts
2252
2363
  import { randomBytes as randomBytes4, createPublicKey, createHash as createHash2 } from "crypto";
2253
2364
  import jwt2 from "jsonwebtoken";
2254
- import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2 } from "@fonderie/core";
2365
+ import { setApiResponse as setApiResponse5, HTTP as HTTP5, constantTimeEqual as constantTimeEqual2, background as background5 } from "@fonderie/core";
2366
+ import { NOTIFICATION_EVENT as NOTIFICATION_EVENT4 } from "@fonderie/events";
2255
2367
 
2256
2368
  // src/models/consumed-token.model.ts
2257
2369
  var ConsumedTokenModel = class {
@@ -2352,11 +2464,56 @@ async function verifyAppleIdToken(idToken, opts) {
2352
2464
  }
2353
2465
  }
2354
2466
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2355
- function oauthController(store, config) {
2467
+ function oauthController(store, config, bus) {
2356
2468
  const users = new UserModel(store);
2357
2469
  const sessions = new SessionModel(store);
2358
2470
  const loginEvents = new LoginEventModel(store);
2359
2471
  const consumedTokens = new ConsumedTokenModel(store);
2472
+ const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2473
+ if (!bus) return;
2474
+ const reqId = ctx.meta["requestId"];
2475
+ const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
2476
+ const label = provider.charAt(0).toUpperCase() + provider.slice(1);
2477
+ const recipient = { email: user.email, phone: null, deviceToken: null };
2478
+ if (upserted.inserted) {
2479
+ await background5(bus.emit(
2480
+ EVENT_KEYS.userRegistered,
2481
+ {
2482
+ userId: upserted.id,
2483
+ email: user.email,
2484
+ firstName: user.firstName,
2485
+ lastName: user.lastName,
2486
+ loginMethod: provider
2487
+ },
2488
+ reqOpts
2489
+ ));
2490
+ if (user.email) {
2491
+ await background5(bus.emit(
2492
+ NOTIFICATION_EVENT4,
2493
+ {
2494
+ type: MESSAGE_KEYS.oauthRegistration,
2495
+ locale: user.locale,
2496
+ data: { provider: label, appName: config.appName ?? "Fonderie" },
2497
+ recipient
2498
+ },
2499
+ reqOpts
2500
+ ));
2501
+ }
2502
+ return;
2503
+ }
2504
+ if (upserted.previousProvider !== provider && user.email) {
2505
+ await background5(bus.emit(
2506
+ NOTIFICATION_EVENT4,
2507
+ {
2508
+ type: MESSAGE_KEYS.oauthLinked,
2509
+ locale: user.locale,
2510
+ data: { provider: label },
2511
+ recipient
2512
+ },
2513
+ reqOpts
2514
+ ));
2515
+ }
2516
+ };
2360
2517
  const completeAppleLogin = async (ctx, claims) => {
2361
2518
  const meta = requestMeta(ctx);
2362
2519
  if (!claims.email) {
@@ -2385,6 +2542,7 @@ function oauthController(store, config) {
2385
2542
  if (!fullUser) {
2386
2543
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
2387
2544
  }
2545
+ await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
2388
2546
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
2389
2547
  await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
2390
2548
  await loginEvents.recordSafe({
@@ -2535,6 +2693,7 @@ function oauthController(store, config) {
2535
2693
  if (!fullUser) {
2536
2694
  return setApiResponse5(HTTP5.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
2537
2695
  }
2696
+ await announceOAuthUpsert(ctx, upserted, "google", fullUser);
2538
2697
  const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
2539
2698
  loginMethod: "google"
2540
2699
  });
@@ -2699,7 +2858,7 @@ function oauthController(store, config) {
2699
2858
  function buildAuthRoutes(store, config, bus) {
2700
2859
  const user = userController(store, config, bus);
2701
2860
  const auth = authController(store, config, bus);
2702
- const oauth = oauthController(store, config);
2861
+ const oauth = oauthController(store, config, bus);
2703
2862
  const mfa = mfaController(store, config, config.appName ?? "Fonderie", bus);
2704
2863
  const verifyGate = config.requireVerification ? requireVerified : (_ctx, next) => next();
2705
2864
  const passthrough = (_ctx, next) => next();
@@ -2757,6 +2916,9 @@ function buildAuthRoutes(store, config, bus) {
2757
2916
  R("updateEmail", "PUT", "/users/email", requireAuth, verifyGate, validate(updateEmailSchema), user.updateEmail),
2758
2917
  R("updatePhone", "PUT", "/users/phone", requireAuth, verifyGate, validate(updatePhoneSchema), user.updatePhone),
2759
2918
  R("changePassword", "PUT", "/users/password", requireAuth, validate(changePasswordSchema), user.changePassword),
2919
+ // Disconnect an OAuth provider. requireAuth only — the controller
2920
+ // refuses when it would leave the account with no way to sign in.
2921
+ R("unlinkOauth", "DELETE", "/auth/oauth/:provider", requireAuth, user.unlinkOauth),
2760
2922
  R("deleteMe", "DELETE", "/users", requireAuth, verifyGate, user.deleteMe),
2761
2923
  R("exportMe", "GET", "/users/export", requireAuth, user.exportMe),
2762
2924
  // Security surfaces (Protected; the caller's own login history + sessions).
@@ -3069,9 +3231,47 @@ If you made this change, you're all set. If not, contact support right away \u20
3069
3231
  The phone number on your account was just updated.
3070
3232
 
3071
3233
  If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
3234
+ },
3235
+ [MESSAGE_KEYS.oauthRegistration]: {
3236
+ subject: "Welcome to {{appName}}",
3237
+ html: `<h1>Welcome to {{appName}}</h1>
3238
+ <p>Your account was created using <strong>{{provider}}</strong>. Sign in any time with the same {{provider}} account &mdash; there is no password to remember.</p>
3239
+ <p class="muted">If you did not create this account, contact support.</p>`,
3240
+ text: `Welcome
3241
+
3242
+ Your account was created using {{provider}}. Sign in any time with the same
3243
+ {{provider}} account \u2014 there is no password to remember.
3244
+
3245
+ If you did not create this account, contact support.`
3246
+ },
3247
+ [MESSAGE_KEYS.oauthLinked]: {
3248
+ subject: "A new sign-in method was added to your account",
3249
+ html: `<h1>{{provider}} sign-in was added</h1>
3250
+ <p>Your account can now also be signed into with <strong>{{provider}}</strong>.</p>
3251
+ <p class="muted">If you did this, nothing more is needed. If not, contact support right away and change your password &mdash; someone else may be able to sign in as you.</p>`,
3252
+ text: `{{provider}} sign-in was added
3253
+
3254
+ Your account can now also be signed into with {{provider}}.
3255
+
3256
+ If you did this, nothing more is needed. If not, contact support right away and
3257
+ change your password \u2014 someone else may be able to sign in as you.`
3258
+ },
3259
+ [MESSAGE_KEYS.oauthUnlinked]: {
3260
+ subject: "A sign-in method was removed from your account",
3261
+ html: `<h1>{{provider}} sign-in was removed</h1>
3262
+ <p><strong>{{provider}}</strong> can no longer be used to sign in. Your email and password still work.</p>
3263
+ <p class="muted">If you did this, you&rsquo;re all set. If not, contact support right away.</p>`,
3264
+ text: `{{provider}} sign-in was removed
3265
+
3266
+ {{provider}} can no longer be used to sign in. Your email and password still work.
3267
+
3268
+ If you did this, you're all set. If not, contact support right away.`
3072
3269
  }
3073
3270
  };
3074
3271
  var SAMPLE_PAYLOADS = {
3272
+ [MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
3273
+ [MESSAGE_KEYS.oauthLinked]: { provider: "Google" },
3274
+ [MESSAGE_KEYS.oauthUnlinked]: { provider: "Google" },
3075
3275
  [MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
3076
3276
  [MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
3077
3277
  [MESSAGE_KEYS.passwordReset]: {