@fonderie/auth 7.3.6 → 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/brain/outcomes.md +2 -0
- package/brain/signatures.md +5 -2
- package/dist/dtos/user.cjs +2 -0
- package/dist/dtos/user.cjs.map +1 -1
- package/dist/dtos/user.d.cts +2 -0
- package/dist/dtos/user.d.ts +2 -0
- package/dist/dtos/user.js +2 -0
- package/dist/dtos/user.js.map +1 -1
- package/dist/index.cjs +235 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -2
- package/dist/index.d.ts +17 -2
- package/dist/index.js +232 -10
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +62 -6
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +62 -6
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-Dc5-JNoW.d.cts → session-Dwlcgxww.d.cts} +4 -1
- package/dist/{session-Dc5-JNoW.d.ts → session-Dwlcgxww.d.ts} +4 -1
- package/dist/types.cjs.map +1 -1
- package/dist/types.d.cts +7 -0
- package/dist/types.d.ts +7 -0
- package/package.json +3 -3
package/brain/outcomes.md
CHANGED
|
@@ -150,6 +150,8 @@ Raw SQL ships in `node_modules/@fonderie/auth/dist/migrations/sql/` — read it
|
|
|
150
150
|
| POST | `/auth/mfa/disable` | `requireAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.disable` |
|
|
151
151
|
| POST | `/auth/mfa/setup` | `requireAuth → requireEmailLogin → requireVerified → mfa.setup` |
|
|
152
152
|
| POST | `/auth/mfa/verify` | `ipLimit('mfaVerify') → requireAnyAuth → requireEmailLogin → requireVerified → validate(mfaTokenSchema) → mfa.verify` |
|
|
153
|
+
| DELETE | `/auth/oauth/:provider` | `requireAuth → user.unlinkOauth` |
|
|
154
|
+
| GET | `/auth/providers` | `async () => setApiResponse(HTTP.OK, 'AUTH_PROVIDERS', 'Sign-in methods available here', { providers: [...config.providers], })` |
|
|
153
155
|
| POST | `/auth/refresh` | `validate(refreshSchema) → auth.refresh` |
|
|
154
156
|
| POST | `/auth/register` | `ipLimit('register') → validate(registerSchema) → auth.register` |
|
|
155
157
|
| GET | `/auth/send-verification` | `requireAnyAuth → auth.sendVerification` |
|
package/brain/signatures.md
CHANGED
|
@@ -28,6 +28,7 @@ interface IUser {
|
|
|
28
28
|
mfaEnabled: boolean;
|
|
29
29
|
passwordHash: string | null;
|
|
30
30
|
emailVerifiedAt: Date | null;
|
|
31
|
+
provider: string | null;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
interface ISession {
|
|
@@ -99,11 +100,11 @@ interface IDataExportContributor {
|
|
|
99
100
|
|
|
100
101
|
const AUTH_CONFIG_KEYS: { sessionDuration: string; verificationCooldown: string; mfa: string; requireVerification: string; }
|
|
101
102
|
|
|
102
|
-
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"; }
|
|
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
104
|
|
|
104
105
|
type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];
|
|
105
106
|
|
|
106
|
-
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; }; }
|
|
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
108
|
|
|
108
109
|
interface IUserDTO {
|
|
109
110
|
id: string;
|
|
@@ -118,6 +119,8 @@ interface IUserDTO {
|
|
|
118
119
|
isEmailVerified: boolean;
|
|
119
120
|
isPhoneVerified: boolean;
|
|
120
121
|
mfaEnabled: boolean;
|
|
122
|
+
provider: string;
|
|
123
|
+
hasPassword: boolean;
|
|
121
124
|
suspended: boolean;
|
|
122
125
|
whitelist: boolean;
|
|
123
126
|
ipWhitelist: string[];
|
package/dist/dtos/user.cjs
CHANGED
|
@@ -75,6 +75,8 @@ function toUserDTO(user, phoneVerified = false) {
|
|
|
75
75
|
isEmailVerified: user.emailVerifiedAt !== null,
|
|
76
76
|
isPhoneVerified: phoneVerified,
|
|
77
77
|
mfaEnabled: (0, import_core.booleanOrFalse)(user.mfaEnabled),
|
|
78
|
+
provider: (0, import_core.stringOrEmpty)(user.provider),
|
|
79
|
+
hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
|
|
78
80
|
suspended: (0, import_core.booleanOrFalse)(user.suspended),
|
|
79
81
|
whitelist: (0, import_core.booleanOrFalse)(user.whitelist),
|
|
80
82
|
ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
|
package/dist/dtos/user.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA2D;
|
|
1
|
+
{"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\t// OAuth provider linked to this account ('google', 'apple'), or '' if none.\n\tprovider: string;\n\t// Whether a password is set. Together with `provider` this is everything a\n\t// settings screen needs to render sign-in methods AND to know that\n\t// disconnecting the provider is possible — an account with no password has\n\t// no other credential, so unlinking is refused (409 PASSWORD_REQUIRED).\n\t// Exposed as a boolean precisely so the hash never can be.\n\thasPassword: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tprovider: stringOrEmpty(user.provider),\n\t\thasPassword: user.passwordHash !== null && user.passwordHash !== undefined,\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA2D;AAgC3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,QAAI,2BAAc,KAAK,EAAE;AAAA,IACzB,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,eAAW,2BAAc,KAAK,SAAS;AAAA,IACvC,cAAU,2BAAc,KAAK,QAAQ;AAAA,IACrC,WAAO,2BAAc,KAAK,KAAK;AAAA,IAC/B,qBAAiB,2BAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,gBAAY,4BAAe,KAAK,UAAU;AAAA,IAC1C,cAAU,2BAAc,KAAK,QAAQ;AAAA,IACrC,aAAa,KAAK,iBAAiB,QAAQ,KAAK,iBAAiB;AAAA,IACjE,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,eAAW,4BAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,eAAW,yBAAY,KAAK,SAAS;AAAA,IACrC,eAAW,yBAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
|
package/dist/dtos/user.d.cts
CHANGED
package/dist/dtos/user.d.ts
CHANGED
package/dist/dtos/user.js
CHANGED
|
@@ -51,6 +51,8 @@ function toUserDTO(user, phoneVerified = false) {
|
|
|
51
51
|
isEmailVerified: user.emailVerifiedAt !== null,
|
|
52
52
|
isPhoneVerified: phoneVerified,
|
|
53
53
|
mfaEnabled: booleanOrFalse(user.mfaEnabled),
|
|
54
|
+
provider: stringOrEmpty(user.provider),
|
|
55
|
+
hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
|
|
54
56
|
suspended: booleanOrFalse(user.suspended),
|
|
55
57
|
whitelist: booleanOrFalse(user.whitelist),
|
|
56
58
|
ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
|
package/dist/dtos/user.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";AAAA,SAAS,eAAe,gBAAgB,mBAAmB;
|
|
1
|
+
{"version":3,"sources":["../../src/dtos/user.ts"],"sourcesContent":["import { stringOrEmpty, booleanOrFalse, dateOrEmpty } from '@fonderie/core';\n\nimport type { IUser, IUserPreferences } from '../types';\n\nexport interface IUserDTO {\n\tid: string;\n\temail: string;\n\tfirstName: string;\n\tlastName: string;\n\tphone: string;\n\tprofileImageUrl: string;\n\tisActive: boolean;\n\tlastLogin: string;\n\tpreferences: IUserPreferences;\n\tisEmailVerified: boolean;\n\tisPhoneVerified: boolean;\n\tmfaEnabled: boolean;\n\t// OAuth provider linked to this account ('google', 'apple'), or '' if none.\n\tprovider: string;\n\t// Whether a password is set. Together with `provider` this is everything a\n\t// settings screen needs to render sign-in methods AND to know that\n\t// disconnecting the provider is possible — an account with no password has\n\t// no other credential, so unlinking is refused (409 PASSWORD_REQUIRED).\n\t// Exposed as a boolean precisely so the hash never can be.\n\thasPassword: boolean;\n\tsuspended: boolean;\n\twhitelist: boolean;\n\tipWhitelist: string[];\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\nconst DEFAULT_PREFERENCES: IUserPreferences = {\n\tlocale: 'en-US',\n\ttimezone: 'UTC',\n\tnotifications: { email: true, inApp: true, sms: false, push: false },\n\temailDigest: 'immediate',\n\tdateFormat: 'MM/DD/YYYY',\n\ttimeFormat: 'hh:mm A',\n};\n\n// Historical rows may hold garbage preferences (the update schema accepted\n// `unknown` for four keys until it was typed). Only well-typed values\n// survive into the DTO, and notifications deep-merge over the defaults so a\n// partial stored object can't shrink the shape the client type promises.\nfunction cleanPreferences(prefs: IUserPreferences): Partial<IUserPreferences> {\n\tconst out: Partial<IUserPreferences> = {};\n\tif (typeof prefs.locale === 'string') out.locale = prefs.locale;\n\tif (typeof prefs.timezone === 'string') out.timezone = prefs.timezone;\n\tif (typeof prefs.emailDigest === 'string') out.emailDigest = prefs.emailDigest;\n\tif (typeof prefs.dateFormat === 'string') out.dateFormat = prefs.dateFormat;\n\tif (typeof prefs.timeFormat === 'string') out.timeFormat = prefs.timeFormat;\n\treturn out;\n}\n\nfunction cleanNotifications(value: unknown): Partial<IUserPreferences['notifications']> {\n\tif (!value || typeof value !== 'object') return {};\n\tconst out: Partial<IUserPreferences['notifications']> = {};\n\tfor (const key of ['email', 'inApp', 'sms', 'push'] as const) {\n\t\tconst v = (value as Record<string, unknown>)[key];\n\t\tif (typeof v === 'boolean') out[key] = v;\n\t}\n\treturn out;\n}\n\nexport function toUserDTO(user: IUser, phoneVerified = false): IUserDTO {\n\tconst prefs = user.preferences ?? ({} as IUserPreferences);\n\tconst cleaned = cleanPreferences(prefs);\n\treturn {\n\t\tid: stringOrEmpty(user.id),\n\t\temail: stringOrEmpty(user.email),\n\t\tfirstName: stringOrEmpty(user.firstName),\n\t\tlastName: stringOrEmpty(user.lastName),\n\t\tphone: stringOrEmpty(user.phone),\n\t\tprofileImageUrl: stringOrEmpty(user.profileImageUrl),\n\t\tisActive: typeof user.isActive === 'boolean' ? user.isActive : true,\n\t\tlastLogin: dateOrEmpty(user.lastLogin),\n\t\tpreferences: {\n\t\t\t...DEFAULT_PREFERENCES,\n\t\t\t...cleaned,\n\t\t\tnotifications: {\n\t\t\t\t...DEFAULT_PREFERENCES.notifications,\n\t\t\t\t...cleanNotifications(prefs.notifications),\n\t\t\t},\n\t\t\tlocale: user.locale || cleaned.locale || 'en-US',\n\t\t\ttimezone: user.timezone || cleaned.timezone || 'UTC',\n\t\t},\n\t\tisEmailVerified: user.emailVerifiedAt !== null,\n\t\tisPhoneVerified: phoneVerified,\n\t\tmfaEnabled: booleanOrFalse(user.mfaEnabled),\n\t\tprovider: stringOrEmpty(user.provider),\n\t\thasPassword: user.passwordHash !== null && user.passwordHash !== undefined,\n\t\tsuspended: booleanOrFalse(user.suspended),\n\t\twhitelist: booleanOrFalse(user.whitelist),\n\t\tipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],\n\t\tcreatedAt: dateOrEmpty(user.createdAt),\n\t\tupdatedAt: dateOrEmpty(user.updatedAt),\n\t};\n}\n"],"mappings":";AAAA,SAAS,eAAe,gBAAgB,mBAAmB;AAgC3D,IAAM,sBAAwC;AAAA,EAC7C,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,eAAe,EAAE,OAAO,MAAM,OAAO,MAAM,KAAK,OAAO,MAAM,MAAM;AAAA,EACnE,aAAa;AAAA,EACb,YAAY;AAAA,EACZ,YAAY;AACb;AAMA,SAAS,iBAAiB,OAAoD;AAC7E,QAAM,MAAiC,CAAC;AACxC,MAAI,OAAO,MAAM,WAAW,SAAU,KAAI,SAAS,MAAM;AACzD,MAAI,OAAO,MAAM,aAAa,SAAU,KAAI,WAAW,MAAM;AAC7D,MAAI,OAAO,MAAM,gBAAgB,SAAU,KAAI,cAAc,MAAM;AACnE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,MAAI,OAAO,MAAM,eAAe,SAAU,KAAI,aAAa,MAAM;AACjE,SAAO;AACR;AAEA,SAAS,mBAAmB,OAA4D;AACvF,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO,CAAC;AACjD,QAAM,MAAkD,CAAC;AACzD,aAAW,OAAO,CAAC,SAAS,SAAS,OAAO,MAAM,GAAY;AAC7D,UAAM,IAAK,MAAkC,GAAG;AAChD,QAAI,OAAO,MAAM,UAAW,KAAI,GAAG,IAAI;AAAA,EACxC;AACA,SAAO;AACR;AAEO,SAAS,UAAU,MAAa,gBAAgB,OAAiB;AACvE,QAAM,QAAQ,KAAK,eAAgB,CAAC;AACpC,QAAM,UAAU,iBAAiB,KAAK;AACtC,SAAO;AAAA,IACN,IAAI,cAAc,KAAK,EAAE;AAAA,IACzB,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,WAAW,cAAc,KAAK,SAAS;AAAA,IACvC,UAAU,cAAc,KAAK,QAAQ;AAAA,IACrC,OAAO,cAAc,KAAK,KAAK;AAAA,IAC/B,iBAAiB,cAAc,KAAK,eAAe;AAAA,IACnD,UAAU,OAAO,KAAK,aAAa,YAAY,KAAK,WAAW;AAAA,IAC/D,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,aAAa;AAAA,MACZ,GAAG;AAAA,MACH,GAAG;AAAA,MACH,eAAe;AAAA,QACd,GAAG,oBAAoB;AAAA,QACvB,GAAG,mBAAmB,MAAM,aAAa;AAAA,MAC1C;AAAA,MACA,QAAQ,KAAK,UAAU,QAAQ,UAAU;AAAA,MACzC,UAAU,KAAK,YAAY,QAAQ,YAAY;AAAA,IAChD;AAAA,IACA,iBAAiB,KAAK,oBAAoB;AAAA,IAC1C,iBAAiB;AAAA,IACjB,YAAY,eAAe,KAAK,UAAU;AAAA,IAC1C,UAAU,cAAc,KAAK,QAAQ;AAAA,IACrC,aAAa,KAAK,iBAAiB,QAAQ,KAAK,iBAAiB;AAAA,IACjE,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,WAAW,eAAe,KAAK,SAAS;AAAA,IACxC,aAAa,MAAM,QAAQ,KAAK,WAAW,IAAI,KAAK,cAAc,CAAC;AAAA,IACnE,WAAW,YAAY,KAAK,SAAS;AAAA,IACrC,WAAW,YAAY,KAAK,SAAS;AAAA,EACtC;AACD;","names":[]}
|
package/dist/index.cjs
CHANGED
|
@@ -89,6 +89,7 @@ __export(index_exports, {
|
|
|
89
89
|
module.exports = __toCommonJS(index_exports);
|
|
90
90
|
|
|
91
91
|
// src/routes.ts
|
|
92
|
+
var import_core10 = require("@fonderie/core");
|
|
92
93
|
var import_middlewares2 = require("@fonderie/core/middlewares");
|
|
93
94
|
|
|
94
95
|
// src/middlewares/require-email-login.ts
|
|
@@ -295,7 +296,16 @@ var MESSAGE_KEYS = {
|
|
|
295
296
|
mfaDisabled: "mfa-disabled",
|
|
296
297
|
mfaBackupCodesRegenerated: "mfa-backup-codes-regenerated",
|
|
297
298
|
emailChanged: "email-changed",
|
|
298
|
-
phoneChanged: "phone-changed"
|
|
299
|
+
phoneChanged: "phone-changed",
|
|
300
|
+
// Changing HOW an account can be signed into is a security event for the
|
|
301
|
+
// account's owner, who may not be the person doing it. Both directions are
|
|
302
|
+
// notified for the same reason password changes are.
|
|
303
|
+
// Welcome for an account CREATED by an OAuth sign-in. Distinct from
|
|
304
|
+
// emailRegistration, which carries a verification PIN — an OAuth signup
|
|
305
|
+
// arrives already verified by the provider and has nothing to confirm.
|
|
306
|
+
oauthRegistration: "oauth-registration",
|
|
307
|
+
oauthLinked: "oauth-linked",
|
|
308
|
+
oauthUnlinked: "oauth-unlinked"
|
|
299
309
|
};
|
|
300
310
|
var EVENT_KEYS = {
|
|
301
311
|
userRegistered: "fonderie.user.registered",
|
|
@@ -527,6 +537,8 @@ function toUserDTO(user, phoneVerified = false) {
|
|
|
527
537
|
isEmailVerified: user.emailVerifiedAt !== null,
|
|
528
538
|
isPhoneVerified: phoneVerified,
|
|
529
539
|
mfaEnabled: (0, import_core3.booleanOrFalse)(user.mfaEnabled),
|
|
540
|
+
provider: (0, import_core3.stringOrEmpty)(user.provider),
|
|
541
|
+
hasPassword: user.passwordHash !== null && user.passwordHash !== void 0,
|
|
530
542
|
suspended: (0, import_core3.booleanOrFalse)(user.suspended),
|
|
531
543
|
whitelist: (0, import_core3.booleanOrFalse)(user.whitelist),
|
|
532
544
|
ipWhitelist: Array.isArray(user.ipWhitelist) ? user.ipWhitelist : [],
|
|
@@ -554,6 +566,7 @@ var USER_COLUMNS = `
|
|
|
554
566
|
ip_whitelist AS "ipWhitelist",
|
|
555
567
|
mfa_enabled AS "mfaEnabled",
|
|
556
568
|
email_verified_at AS "emailVerifiedAt",
|
|
569
|
+
provider,
|
|
557
570
|
deleted_at AS "deletedAt",
|
|
558
571
|
created_at AS "createdAt",
|
|
559
572
|
updated_at AS "updatedAt"
|
|
@@ -631,6 +644,31 @@ var UserModel = class {
|
|
|
631
644
|
);
|
|
632
645
|
return row ?? null;
|
|
633
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Clear the OAuth provider, but ONLY when the account can still be signed
|
|
649
|
+
* into afterwards.
|
|
650
|
+
*
|
|
651
|
+
* An account created through Google has no password. Unlinking it without
|
|
652
|
+
* checking would not be "removing a sign-in method" — it would be locking
|
|
653
|
+
* the owner out permanently, which is account deletion wearing a friendlier
|
|
654
|
+
* label. The guard lives in the WHERE clause so the check and the write are
|
|
655
|
+
* one atomic statement: a password cannot be removed between them.
|
|
656
|
+
*
|
|
657
|
+
* Returns false when the account has no password (nothing was changed) so
|
|
658
|
+
* the caller can say why rather than reporting a silent success.
|
|
659
|
+
*/
|
|
660
|
+
async clearProvider(id) {
|
|
661
|
+
const rows = await this.store.query(
|
|
662
|
+
`UPDATE fonderie_users
|
|
663
|
+
SET provider = NULL, provider_id = NULL
|
|
664
|
+
WHERE id = $1
|
|
665
|
+
AND password_hash IS NOT NULL
|
|
666
|
+
AND provider IS NOT NULL
|
|
667
|
+
RETURNING id`,
|
|
668
|
+
[id]
|
|
669
|
+
);
|
|
670
|
+
return rows.length > 0;
|
|
671
|
+
}
|
|
634
672
|
async updatePassword(id, passwordHash) {
|
|
635
673
|
await this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [
|
|
636
674
|
passwordHash,
|
|
@@ -731,14 +769,44 @@ var UserModel = class {
|
|
|
731
769
|
);
|
|
732
770
|
return row?.mfa_secret ?? null;
|
|
733
771
|
}
|
|
772
|
+
/**
|
|
773
|
+
* Link an OAuth identity to the account owning `email`, creating it if there
|
|
774
|
+
* is none.
|
|
775
|
+
*
|
|
776
|
+
* Returns what HAPPENED, not just who it happened to. Three outcomes hide
|
|
777
|
+
* behind one upsert, and callers must tell them apart:
|
|
778
|
+
*
|
|
779
|
+
* • `inserted` — a brand-new account. Whoever provisions on signup (the
|
|
780
|
+
* personal workspace, a wallet, a welcome email) must run, exactly as
|
|
781
|
+
* it does for a password registration. This is the case that was
|
|
782
|
+
* previously invisible: the upsert returned an id and looked identical
|
|
783
|
+
* to a returning user signing in, so OAuth signups silently skipped
|
|
784
|
+
* every signup side effect.
|
|
785
|
+
* • `previousProvider` differs — an existing account gained, or switched,
|
|
786
|
+
* a way to sign in. A security event for the owner.
|
|
787
|
+
* • `previousProvider` is the same — an ordinary login. Nothing to do, and
|
|
788
|
+
* notifying here would email the user on every single sign-in.
|
|
789
|
+
*
|
|
790
|
+
* The prior state is read in a CTE of the SAME statement, so it sees the
|
|
791
|
+
* snapshot from before the insert. Reading it in a separate query would
|
|
792
|
+
* race two concurrent logins into both seeing "new".
|
|
793
|
+
*/
|
|
734
794
|
async upsertByProvider(email2, provider, providerId) {
|
|
735
795
|
const [row] = await this.store.query(
|
|
736
|
-
`
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
796
|
+
`WITH prior AS (
|
|
797
|
+
SELECT provider FROM fonderie_users WHERE email = $1
|
|
798
|
+
), upserted AS (
|
|
799
|
+
INSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)
|
|
800
|
+
VALUES ($1, now(), $2, $3)
|
|
801
|
+
ON CONFLICT (email) DO UPDATE
|
|
802
|
+
SET provider = $2, provider_id = $3,
|
|
803
|
+
email_verified_at = COALESCE(fonderie_users.email_verified_at, now())
|
|
804
|
+
RETURNING id
|
|
805
|
+
)
|
|
806
|
+
SELECT upserted.id,
|
|
807
|
+
NOT EXISTS (SELECT 1 FROM prior) AS inserted,
|
|
808
|
+
(SELECT provider FROM prior) AS "previousProvider"
|
|
809
|
+
FROM upserted`,
|
|
742
810
|
[email2, provider, providerId]
|
|
743
811
|
);
|
|
744
812
|
return row ?? null;
|
|
@@ -2231,6 +2299,50 @@ function userController(store, config, bus) {
|
|
|
2231
2299
|
await sessions.deleteByUser(ctx.user.id);
|
|
2232
2300
|
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "PASSWORD_CHANGED", "Password updated successfully.");
|
|
2233
2301
|
},
|
|
2302
|
+
/**
|
|
2303
|
+
* Remove an OAuth provider from the account.
|
|
2304
|
+
*
|
|
2305
|
+
* Refuses when the account has no password, because unlinking would
|
|
2306
|
+
* then leave NO way to sign in — that is account deletion, not a
|
|
2307
|
+
* settings toggle, and a user clicking "disconnect Google" is not
|
|
2308
|
+
* asking for it. The check is inside the UPDATE's WHERE clause, so a
|
|
2309
|
+
* password cannot disappear between the check and the write.
|
|
2310
|
+
*
|
|
2311
|
+
* Notifies the account's email either way: changing how an account can
|
|
2312
|
+
* be signed into is a security event for its owner, who is not
|
|
2313
|
+
* necessarily the person doing it.
|
|
2314
|
+
*/
|
|
2315
|
+
unlinkOauth: async (ctx) => {
|
|
2316
|
+
const provider = String(ctx.meta["params"]?.["provider"] ?? "");
|
|
2317
|
+
const current = await users.findById(ctx.user.id);
|
|
2318
|
+
if (!current?.provider) {
|
|
2319
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.NOT_FOUND, "NOT_LINKED", "No sign-in provider is linked.");
|
|
2320
|
+
}
|
|
2321
|
+
if (provider && provider.toLowerCase() !== current.provider.toLowerCase()) {
|
|
2322
|
+
return (0, import_core8.setApiResponse)(
|
|
2323
|
+
import_core8.HTTP.NOT_FOUND,
|
|
2324
|
+
"NOT_LINKED",
|
|
2325
|
+
`This account is not linked to ${provider}.`
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
const removed = await users.clearProvider(ctx.user.id);
|
|
2329
|
+
if (!removed) {
|
|
2330
|
+
return (0, import_core8.setApiResponse)(
|
|
2331
|
+
import_core8.HTTP.CONFLICT,
|
|
2332
|
+
"PASSWORD_REQUIRED",
|
|
2333
|
+
"Set a password before disconnecting this provider \u2014 it is currently the only way to sign in."
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
if (ctx.user.email) {
|
|
2337
|
+
await (0, import_core8.background)(bus?.emit(import_events3.NOTIFICATION_EVENT, {
|
|
2338
|
+
type: MESSAGE_KEYS.oauthUnlinked,
|
|
2339
|
+
locale: ctx.user.locale,
|
|
2340
|
+
data: { provider: current.provider },
|
|
2341
|
+
recipient: { email: ctx.user.email, phone: null, deviceToken: null }
|
|
2342
|
+
}));
|
|
2343
|
+
}
|
|
2344
|
+
return (0, import_core8.setApiResponse)(import_core8.HTTP.OK, "OAUTH_UNLINKED", "Sign-in provider removed.");
|
|
2345
|
+
},
|
|
2234
2346
|
deleteMe: async (ctx) => {
|
|
2235
2347
|
const userId = ctx.user.id;
|
|
2236
2348
|
await users.softDelete(userId);
|
|
@@ -2291,6 +2403,7 @@ function userController(store, config, bus) {
|
|
|
2291
2403
|
var import_node_crypto7 = require("crypto");
|
|
2292
2404
|
var import_jsonwebtoken2 = __toESM(require("jsonwebtoken"), 1);
|
|
2293
2405
|
var import_core9 = require("@fonderie/core");
|
|
2406
|
+
var import_events4 = require("@fonderie/events");
|
|
2294
2407
|
|
|
2295
2408
|
// src/models/consumed-token.model.ts
|
|
2296
2409
|
var ConsumedTokenModel = class {
|
|
@@ -2391,11 +2504,56 @@ async function verifyAppleIdToken(idToken, opts) {
|
|
|
2391
2504
|
}
|
|
2392
2505
|
}
|
|
2393
2506
|
var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
|
|
2394
|
-
function oauthController(store, config) {
|
|
2507
|
+
function oauthController(store, config, bus) {
|
|
2395
2508
|
const users = new UserModel(store);
|
|
2396
2509
|
const sessions = new SessionModel(store);
|
|
2397
2510
|
const loginEvents = new LoginEventModel(store);
|
|
2398
2511
|
const consumedTokens = new ConsumedTokenModel(store);
|
|
2512
|
+
const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
|
|
2513
|
+
if (!bus) return;
|
|
2514
|
+
const reqId = ctx.meta["requestId"];
|
|
2515
|
+
const reqOpts = reqId !== void 0 ? { requestId: reqId } : void 0;
|
|
2516
|
+
const label = provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
2517
|
+
const recipient = { email: user.email, phone: null, deviceToken: null };
|
|
2518
|
+
if (upserted.inserted) {
|
|
2519
|
+
await (0, import_core9.background)(bus.emit(
|
|
2520
|
+
EVENT_KEYS.userRegistered,
|
|
2521
|
+
{
|
|
2522
|
+
userId: upserted.id,
|
|
2523
|
+
email: user.email,
|
|
2524
|
+
firstName: user.firstName,
|
|
2525
|
+
lastName: user.lastName,
|
|
2526
|
+
loginMethod: provider
|
|
2527
|
+
},
|
|
2528
|
+
reqOpts
|
|
2529
|
+
));
|
|
2530
|
+
if (user.email) {
|
|
2531
|
+
await (0, import_core9.background)(bus.emit(
|
|
2532
|
+
import_events4.NOTIFICATION_EVENT,
|
|
2533
|
+
{
|
|
2534
|
+
type: MESSAGE_KEYS.oauthRegistration,
|
|
2535
|
+
locale: user.locale,
|
|
2536
|
+
data: { provider: label, appName: config.appName ?? "Fonderie" },
|
|
2537
|
+
recipient
|
|
2538
|
+
},
|
|
2539
|
+
reqOpts
|
|
2540
|
+
));
|
|
2541
|
+
}
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
if (upserted.previousProvider !== provider && user.email) {
|
|
2545
|
+
await (0, import_core9.background)(bus.emit(
|
|
2546
|
+
import_events4.NOTIFICATION_EVENT,
|
|
2547
|
+
{
|
|
2548
|
+
type: MESSAGE_KEYS.oauthLinked,
|
|
2549
|
+
locale: user.locale,
|
|
2550
|
+
data: { provider: label },
|
|
2551
|
+
recipient
|
|
2552
|
+
},
|
|
2553
|
+
reqOpts
|
|
2554
|
+
));
|
|
2555
|
+
}
|
|
2556
|
+
};
|
|
2399
2557
|
const completeAppleLogin = async (ctx, claims) => {
|
|
2400
2558
|
const meta = requestMeta(ctx);
|
|
2401
2559
|
if (!claims.email) {
|
|
@@ -2424,6 +2582,7 @@ function oauthController(store, config) {
|
|
|
2424
2582
|
if (!fullUser) {
|
|
2425
2583
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "Apple login failed");
|
|
2426
2584
|
}
|
|
2585
|
+
await announceOAuthUpsert(ctx, upserted, "apple", fullUser);
|
|
2427
2586
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, { loginMethod: "apple" });
|
|
2428
2587
|
await sessions.create(upserted.id, refreshToken, refreshTokenExpiry(refreshToken), sid, meta);
|
|
2429
2588
|
await loginEvents.recordSafe({
|
|
@@ -2574,6 +2733,7 @@ function oauthController(store, config) {
|
|
|
2574
2733
|
if (!fullUser) {
|
|
2575
2734
|
return (0, import_core9.setApiResponse)(import_core9.HTTP.SERVER_ERROR, "SERVER_ERROR", "OAuth login failed");
|
|
2576
2735
|
}
|
|
2736
|
+
await announceOAuthUpsert(ctx, upserted, "google", fullUser);
|
|
2577
2737
|
const { accessToken, refreshToken, sid } = issueTokenPair(upserted.id, config, {
|
|
2578
2738
|
loginMethod: "google"
|
|
2579
2739
|
});
|
|
@@ -2738,7 +2898,7 @@ function oauthController(store, config) {
|
|
|
2738
2898
|
function buildAuthRoutes(store, config, bus) {
|
|
2739
2899
|
const user = userController(store, config, bus);
|
|
2740
2900
|
const auth = authController(store, config, bus);
|
|
2741
|
-
const oauth = oauthController(store, config);
|
|
2901
|
+
const oauth = oauthController(store, config, bus);
|
|
2742
2902
|
const mfa = mfaController(store, config, config.appName ?? "Fonderie", bus);
|
|
2743
2903
|
const verifyGate = config.requireVerification ? import_middlewares2.requireVerified : (_ctx, next) => next();
|
|
2744
2904
|
const passthrough = (_ctx, next) => next();
|
|
@@ -2751,6 +2911,27 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2751
2911
|
return [o.method ?? method, o.path ?? path, ...handlers];
|
|
2752
2912
|
};
|
|
2753
2913
|
const routes = [
|
|
2914
|
+
// Which sign-in methods this deployment can actually honour (Public).
|
|
2915
|
+
//
|
|
2916
|
+
// A frontend has to decide which buttons to draw, and the only honest
|
|
2917
|
+
// source is the side holding the credentials. The alternative — a
|
|
2918
|
+
// build-time flag in the app — stores the same fact twice and lets the
|
|
2919
|
+
// two disagree; the symptom is a user clicking a provider the server
|
|
2920
|
+
// cannot complete and landing on the provider's error page, which the
|
|
2921
|
+
// app cannot explain.
|
|
2922
|
+
//
|
|
2923
|
+
// Public and unauthenticated on purpose: the login screen needs it
|
|
2924
|
+
// before anyone has signed in. It discloses nothing a visitor could not
|
|
2925
|
+
// learn by looking at the buttons, and deliberately nothing else — no
|
|
2926
|
+
// client ids, no redirect URIs, no module inventory.
|
|
2927
|
+
R(
|
|
2928
|
+
"providers",
|
|
2929
|
+
"GET",
|
|
2930
|
+
"/auth/providers",
|
|
2931
|
+
async () => (0, import_core10.setApiResponse)(import_core10.HTTP.OK, "AUTH_PROVIDERS", "Sign-in methods available here", {
|
|
2932
|
+
providers: [...config.providers]
|
|
2933
|
+
})
|
|
2934
|
+
),
|
|
2754
2935
|
// Registration & Login (Public)
|
|
2755
2936
|
R("register", "POST", "/auth/register", ipLimit("register"), (0, import_middlewares.validate)(registerSchema), auth.register),
|
|
2756
2937
|
R("login", "POST", "/auth/login", ipLimit("login"), (0, import_middlewares.validate)(loginSchema), acctLimit("login"), auth.login),
|
|
@@ -2775,6 +2956,9 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2775
2956
|
R("updateEmail", "PUT", "/users/email", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updateEmailSchema), user.updateEmail),
|
|
2776
2957
|
R("updatePhone", "PUT", "/users/phone", import_middlewares2.requireAuth, verifyGate, (0, import_middlewares.validate)(updatePhoneSchema), user.updatePhone),
|
|
2777
2958
|
R("changePassword", "PUT", "/users/password", import_middlewares2.requireAuth, (0, import_middlewares.validate)(changePasswordSchema), user.changePassword),
|
|
2959
|
+
// Disconnect an OAuth provider. requireAuth only — the controller
|
|
2960
|
+
// refuses when it would leave the account with no way to sign in.
|
|
2961
|
+
R("unlinkOauth", "DELETE", "/auth/oauth/:provider", import_middlewares2.requireAuth, user.unlinkOauth),
|
|
2778
2962
|
R("deleteMe", "DELETE", "/users", import_middlewares2.requireAuth, verifyGate, user.deleteMe),
|
|
2779
2963
|
R("exportMe", "GET", "/users/export", import_middlewares2.requireAuth, user.exportMe),
|
|
2780
2964
|
// Security surfaces (Protected; the caller's own login history + sessions).
|
|
@@ -2817,17 +3001,17 @@ function buildAuthRoutes(store, config, bus) {
|
|
|
2817
3001
|
}
|
|
2818
3002
|
|
|
2819
3003
|
// src/services/config-guard.ts
|
|
2820
|
-
var
|
|
3004
|
+
var import_core11 = require("@fonderie/core");
|
|
2821
3005
|
var MODULE = "@fonderie/auth";
|
|
2822
3006
|
function collectAuthConfigProblems(config) {
|
|
2823
3007
|
const problems = [];
|
|
2824
3008
|
const secret = config.jwtSecret ?? "";
|
|
2825
|
-
const secretProblem = (0,
|
|
3009
|
+
const secretProblem = (0, import_core11.secretStrengthProblem)(secret);
|
|
2826
3010
|
if (secretProblem === "too-short") {
|
|
2827
3011
|
problems.push({
|
|
2828
3012
|
module: MODULE,
|
|
2829
3013
|
severity: "error",
|
|
2830
|
-
message: `jwtSecret must be at least ${
|
|
3014
|
+
message: `jwtSecret must be at least ${import_core11.MIN_SECRET_LENGTH} characters (got ${secret.length})`
|
|
2831
3015
|
});
|
|
2832
3016
|
} else if (secretProblem === "placeholder") {
|
|
2833
3017
|
problems.push({
|
|
@@ -2851,7 +3035,7 @@ function collectAuthConfigProblems(config) {
|
|
|
2851
3035
|
severity: "error",
|
|
2852
3036
|
message: "google OAuth is configured but clientId, clientSecret, or redirectUri is missing"
|
|
2853
3037
|
});
|
|
2854
|
-
} else if (
|
|
3038
|
+
} else if (import_core11.PLACEHOLDER_SECRET.test(clientSecret)) {
|
|
2855
3039
|
problems.push({
|
|
2856
3040
|
module: MODULE,
|
|
2857
3041
|
severity: "error",
|
|
@@ -3087,9 +3271,47 @@ If you made this change, you're all set. If not, contact support right away \u20
|
|
|
3087
3271
|
The phone number on your account was just updated.
|
|
3088
3272
|
|
|
3089
3273
|
If you made this change, you're all set. If not, contact support right away \u2014 someone may have access to your account.`
|
|
3274
|
+
},
|
|
3275
|
+
[MESSAGE_KEYS.oauthRegistration]: {
|
|
3276
|
+
subject: "Welcome to {{appName}}",
|
|
3277
|
+
html: `<h1>Welcome to {{appName}}</h1>
|
|
3278
|
+
<p>Your account was created using <strong>{{provider}}</strong>. Sign in any time with the same {{provider}} account — there is no password to remember.</p>
|
|
3279
|
+
<p class="muted">If you did not create this account, contact support.</p>`,
|
|
3280
|
+
text: `Welcome
|
|
3281
|
+
|
|
3282
|
+
Your account was created using {{provider}}. Sign in any time with the same
|
|
3283
|
+
{{provider}} account \u2014 there is no password to remember.
|
|
3284
|
+
|
|
3285
|
+
If you did not create this account, contact support.`
|
|
3286
|
+
},
|
|
3287
|
+
[MESSAGE_KEYS.oauthLinked]: {
|
|
3288
|
+
subject: "A new sign-in method was added to your account",
|
|
3289
|
+
html: `<h1>{{provider}} sign-in was added</h1>
|
|
3290
|
+
<p>Your account can now also be signed into with <strong>{{provider}}</strong>.</p>
|
|
3291
|
+
<p class="muted">If you did this, nothing more is needed. If not, contact support right away and change your password — someone else may be able to sign in as you.</p>`,
|
|
3292
|
+
text: `{{provider}} sign-in was added
|
|
3293
|
+
|
|
3294
|
+
Your account can now also be signed into with {{provider}}.
|
|
3295
|
+
|
|
3296
|
+
If you did this, nothing more is needed. If not, contact support right away and
|
|
3297
|
+
change your password \u2014 someone else may be able to sign in as you.`
|
|
3298
|
+
},
|
|
3299
|
+
[MESSAGE_KEYS.oauthUnlinked]: {
|
|
3300
|
+
subject: "A sign-in method was removed from your account",
|
|
3301
|
+
html: `<h1>{{provider}} sign-in was removed</h1>
|
|
3302
|
+
<p><strong>{{provider}}</strong> can no longer be used to sign in. Your email and password still work.</p>
|
|
3303
|
+
<p class="muted">If you did this, you’re all set. If not, contact support right away.</p>`,
|
|
3304
|
+
text: `{{provider}} sign-in was removed
|
|
3305
|
+
|
|
3306
|
+
{{provider}} can no longer be used to sign in. Your email and password still work.
|
|
3307
|
+
|
|
3308
|
+
If you did this, you're all set. If not, contact support right away.`
|
|
3090
3309
|
}
|
|
3091
3310
|
};
|
|
3092
3311
|
var SAMPLE_PAYLOADS = {
|
|
3312
|
+
[MESSAGE_KEYS.oauthRegistration]: { provider: "Google", appName: "Fonderie" },
|
|
3313
|
+
[MESSAGE_KEYS.oauthLinked]: { provider: "Google" },
|
|
3314
|
+
[MESSAGE_KEYS.oauthUnlinked]: { provider: "Google" },
|
|
3093
3315
|
[MESSAGE_KEYS.emailRegistration]: { firstName: "", pin: "123456" },
|
|
3094
3316
|
[MESSAGE_KEYS.emailVerification]: { firstName: "Ada", pin: "123456" },
|
|
3095
3317
|
[MESSAGE_KEYS.passwordReset]: {
|