@molecule/api-resource-user 1.0.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.
Files changed (164) hide show
  1. package/LICENSE +115 -0
  2. package/dist/authorization.d.ts +107 -0
  3. package/dist/authorization.d.ts.map +1 -0
  4. package/dist/authorization.js +375 -0
  5. package/dist/authorization.js.map +1 -0
  6. package/dist/authorizers/auth.d.ts +8 -0
  7. package/dist/authorizers/auth.d.ts.map +1 -0
  8. package/dist/authorizers/auth.js +19 -0
  9. package/dist/authorizers/auth.js.map +1 -0
  10. package/dist/authorizers/authSelf.d.ts +14 -0
  11. package/dist/authorizers/authSelf.d.ts.map +1 -0
  12. package/dist/authorizers/authSelf.js +31 -0
  13. package/dist/authorizers/authSelf.js.map +1 -0
  14. package/dist/authorizers/index.d.ts +5 -0
  15. package/dist/authorizers/index.d.ts.map +1 -0
  16. package/dist/authorizers/index.js +5 -0
  17. package/dist/authorizers/index.js.map +1 -0
  18. package/dist/authorizers/rateLimit.d.ts +84 -0
  19. package/dist/authorizers/rateLimit.d.ts.map +1 -0
  20. package/dist/authorizers/rateLimit.js +144 -0
  21. package/dist/authorizers/rateLimit.js.map +1 -0
  22. package/dist/authorizers/requireWebhookAuthenticity.d.ts +23 -0
  23. package/dist/authorizers/requireWebhookAuthenticity.d.ts.map +1 -0
  24. package/dist/authorizers/requireWebhookAuthenticity.js +76 -0
  25. package/dist/authorizers/requireWebhookAuthenticity.js.map +1 -0
  26. package/dist/browser-guard.d.ts +2 -0
  27. package/dist/browser-guard.d.ts.map +1 -0
  28. package/dist/browser-guard.js +19 -0
  29. package/dist/browser-guard.js.map +1 -0
  30. package/dist/handlers/create.d.ts +52 -0
  31. package/dist/handlers/create.d.ts.map +1 -0
  32. package/dist/handlers/create.js +208 -0
  33. package/dist/handlers/create.js.map +1 -0
  34. package/dist/handlers/del.d.ts +30 -0
  35. package/dist/handlers/del.d.ts.map +1 -0
  36. package/dist/handlers/del.js +55 -0
  37. package/dist/handlers/del.js.map +1 -0
  38. package/dist/handlers/forgotPassword.d.ts +34 -0
  39. package/dist/handlers/forgotPassword.d.ts.map +1 -0
  40. package/dist/handlers/forgotPassword.js +119 -0
  41. package/dist/handlers/forgotPassword.js.map +1 -0
  42. package/dist/handlers/index.d.ts +16 -0
  43. package/dist/handlers/index.d.ts.map +1 -0
  44. package/dist/handlers/index.js +16 -0
  45. package/dist/handlers/index.js.map +1 -0
  46. package/dist/handlers/logIn.d.ts +91 -0
  47. package/dist/handlers/logIn.d.ts.map +1 -0
  48. package/dist/handlers/logIn.js +223 -0
  49. package/dist/handlers/logIn.js.map +1 -0
  50. package/dist/handlers/logInOAuth.d.ts +66 -0
  51. package/dist/handlers/logInOAuth.d.ts.map +1 -0
  52. package/dist/handlers/logInOAuth.js +462 -0
  53. package/dist/handlers/logInOAuth.js.map +1 -0
  54. package/dist/handlers/logout.d.ts +22 -0
  55. package/dist/handlers/logout.d.ts.map +1 -0
  56. package/dist/handlers/logout.js +57 -0
  57. package/dist/handlers/logout.js.map +1 -0
  58. package/dist/handlers/oauthAuthorize.d.ts +44 -0
  59. package/dist/handlers/oauthAuthorize.d.ts.map +1 -0
  60. package/dist/handlers/oauthAuthorize.js +141 -0
  61. package/dist/handlers/oauthAuthorize.js.map +1 -0
  62. package/dist/handlers/payments/handlePaymentNotification.d.ts +28 -0
  63. package/dist/handlers/payments/handlePaymentNotification.d.ts.map +1 -0
  64. package/dist/handlers/payments/handlePaymentNotification.js +217 -0
  65. package/dist/handlers/payments/handlePaymentNotification.js.map +1 -0
  66. package/dist/handlers/payments/registry.d.ts +13 -0
  67. package/dist/handlers/payments/registry.d.ts.map +1 -0
  68. package/dist/handlers/payments/registry.js +13 -0
  69. package/dist/handlers/payments/registry.js.map +1 -0
  70. package/dist/handlers/payments/verifyPayment.d.ts +37 -0
  71. package/dist/handlers/payments/verifyPayment.d.ts.map +1 -0
  72. package/dist/handlers/payments/verifyPayment.js +383 -0
  73. package/dist/handlers/payments/verifyPayment.js.map +1 -0
  74. package/dist/handlers/read.d.ts +17 -0
  75. package/dist/handlers/read.d.ts.map +1 -0
  76. package/dist/handlers/read.js +60 -0
  77. package/dist/handlers/read.js.map +1 -0
  78. package/dist/handlers/readSelf.d.ts +23 -0
  79. package/dist/handlers/readSelf.d.ts.map +1 -0
  80. package/dist/handlers/readSelf.js +77 -0
  81. package/dist/handlers/readSelf.js.map +1 -0
  82. package/dist/handlers/resetPassword.d.ts +40 -0
  83. package/dist/handlers/resetPassword.d.ts.map +1 -0
  84. package/dist/handlers/resetPassword.js +147 -0
  85. package/dist/handlers/resetPassword.js.map +1 -0
  86. package/dist/handlers/update.d.ts +28 -0
  87. package/dist/handlers/update.d.ts.map +1 -0
  88. package/dist/handlers/update.js +250 -0
  89. package/dist/handlers/update.js.map +1 -0
  90. package/dist/handlers/updatePassword.d.ts +35 -0
  91. package/dist/handlers/updatePassword.d.ts.map +1 -0
  92. package/dist/handlers/updatePassword.js +113 -0
  93. package/dist/handlers/updatePassword.js.map +1 -0
  94. package/dist/handlers/updatePlan.d.ts +41 -0
  95. package/dist/handlers/updatePlan.d.ts.map +1 -0
  96. package/dist/handlers/updatePlan.js +242 -0
  97. package/dist/handlers/updatePlan.js.map +1 -0
  98. package/dist/handlers/verifyTwoFactor.d.ts +46 -0
  99. package/dist/handlers/verifyTwoFactor.d.ts.map +1 -0
  100. package/dist/handlers/verifyTwoFactor.js +162 -0
  101. package/dist/handlers/verifyTwoFactor.js.map +1 -0
  102. package/dist/i18n.d.ts +10 -0
  103. package/dist/i18n.d.ts.map +1 -0
  104. package/dist/i18n.js +25 -0
  105. package/dist/i18n.js.map +1 -0
  106. package/dist/index.d.ts +88 -0
  107. package/dist/index.d.ts.map +1 -0
  108. package/dist/index.js +88 -0
  109. package/dist/index.js.map +1 -0
  110. package/dist/requestHandlerMap.d.ts +44 -0
  111. package/dist/requestHandlerMap.d.ts.map +1 -0
  112. package/dist/requestHandlerMap.js +68 -0
  113. package/dist/requestHandlerMap.js.map +1 -0
  114. package/dist/resource.d.ts +22 -0
  115. package/dist/resource.d.ts.map +1 -0
  116. package/dist/resource.js +23 -0
  117. package/dist/resource.js.map +1 -0
  118. package/dist/routes.d.ts +44 -0
  119. package/dist/routes.d.ts.map +1 -0
  120. package/dist/routes.js +121 -0
  121. package/dist/routes.js.map +1 -0
  122. package/dist/schema.d.ts +193 -0
  123. package/dist/schema.d.ts.map +1 -0
  124. package/dist/schema.js +229 -0
  125. package/dist/schema.js.map +1 -0
  126. package/dist/secrets.d.ts +16 -0
  127. package/dist/secrets.d.ts.map +1 -0
  128. package/dist/secrets.js +28 -0
  129. package/dist/secrets.js.map +1 -0
  130. package/dist/types.d.ts +14 -0
  131. package/dist/types.d.ts.map +1 -0
  132. package/dist/types.js +9 -0
  133. package/dist/types.js.map +1 -0
  134. package/dist/utilities/getPlan.d.ts +19 -0
  135. package/dist/utilities/getPlan.d.ts.map +1 -0
  136. package/dist/utilities/getPlan.js +26 -0
  137. package/dist/utilities/getPlan.js.map +1 -0
  138. package/dist/utilities/hashResetToken.d.ts +16 -0
  139. package/dist/utilities/hashResetToken.d.ts.map +1 -0
  140. package/dist/utilities/hashResetToken.js +19 -0
  141. package/dist/utilities/hashResetToken.js.map +1 -0
  142. package/dist/utilities/index.d.ts +5 -0
  143. package/dist/utilities/index.d.ts.map +1 -0
  144. package/dist/utilities/index.js +5 -0
  145. package/dist/utilities/index.js.map +1 -0
  146. package/dist/utilities/invalidateEntitlements.d.ts +24 -0
  147. package/dist/utilities/invalidateEntitlements.d.ts.map +1 -0
  148. package/dist/utilities/invalidateEntitlements.js +41 -0
  149. package/dist/utilities/invalidateEntitlements.js.map +1 -0
  150. package/dist/utilities/normalizeEmail.d.ts +22 -0
  151. package/dist/utilities/normalizeEmail.d.ts.map +1 -0
  152. package/dist/utilities/normalizeEmail.js +28 -0
  153. package/dist/utilities/normalizeEmail.js.map +1 -0
  154. package/dist/utilities/notify.d.ts +21 -0
  155. package/dist/utilities/notify.d.ts.map +1 -0
  156. package/dist/utilities/notify.js +39 -0
  157. package/dist/utilities/notify.js.map +1 -0
  158. package/dist/utilities/stripSensitiveUserColumns.d.ts +25 -0
  159. package/dist/utilities/stripSensitiveUserColumns.d.ts.map +1 -0
  160. package/dist/utilities/stripSensitiveUserColumns.js +43 -0
  161. package/dist/utilities/stripSensitiveUserColumns.js.map +1 -0
  162. package/package.json +94 -0
  163. package/setup/users.sql +22 -0
  164. package/setup/usersSecrets.sql +9 -0
@@ -0,0 +1,462 @@
1
+ import crypto from 'node:crypto';
2
+ import { v4 as uuid } from 'uuid';
3
+ import { get, getAnalytics, getLogger } from '@molecule/api-bond';
4
+ import { get as getConfig } from '@molecule/api-config';
5
+ import { create as storeCreate, findById, findOne, updateById } from '@molecule/api-database';
6
+ import { t } from '@molecule/api-i18n';
7
+ import { create as resourceCreate } from '@molecule/api-resource';
8
+ import * as authorization from '../authorization.js';
9
+ import { normalizeEmail } from '../utilities/normalizeEmail.js';
10
+ const analytics = getAnalytics();
11
+ const logger = getLogger();
12
+ /**
13
+ * Constant-time comparison of two strings. Returns `false` on length mismatch
14
+ * (lengths are not secret here) and otherwise uses `crypto.timingSafeEqual`, so
15
+ * the OAuth state ↔ cookie check does not leak via comparison timing.
16
+ * @param a - First string.
17
+ * @param b - Second string.
18
+ * @returns `true` if the strings are equal.
19
+ */
20
+ const timingSafeEqualString = (a, b) => {
21
+ const ab = Buffer.from(a);
22
+ const bb = Buffer.from(b);
23
+ if (ab.length !== bb.length) {
24
+ return false;
25
+ }
26
+ return crypto.timingSafeEqual(ab, bb);
27
+ };
28
+ /**
29
+ * Logs in or creates a user via OAuth. Verifies the authorization code with the bonded OAuth
30
+ * provider (`get('oauth', serverName)`), then either finds an existing user by oauthServer+oauthId
31
+ * or creates a new one with a unique username. Sets JWT authorization and creates a session device.
32
+ * @param resource - The user resource configuration (name, tableName, schema).
33
+ * @param resource.name - The resource name.
34
+ * @param resource.tableName - The database table name for users.
35
+ * @param resource.schema - The validation schema for user properties.
36
+ * @returns A request handler that responds with `{ statusCode: 200, body: { props } }` on success.
37
+ */
38
+ export const logInOAuth = ({ name, tableName, schema }) => {
39
+ const createResource = resourceCreate({
40
+ name,
41
+ tableName,
42
+ schema,
43
+ });
44
+ return async (req, res) => {
45
+ const { body } = req;
46
+ if (!body.server || !body.code) {
47
+ return {
48
+ statusCode: 400,
49
+ body: { error: t('user.error.badRequest'), errorKey: 'user.error.badRequest' },
50
+ };
51
+ }
52
+ // Validate the OAuth CSRF state parameter. This is MANDATORY, not gated on
53
+ // whether the (attacker-controlled) request happened to send a `state` —
54
+ // otherwise a forged cross-site callback that simply omits `state` would
55
+ // skip the check entirely (login-CSRF / session fixation: the attacker
56
+ // plants their own session in the victim's browser). The `oauth_state`
57
+ // cookie is set by GET /users/oauth/:provider; callback `state` must equal
58
+ // it. Deployments that genuinely don't wire the state-cookie endpoint can
59
+ // opt out with OAUTH_REQUIRE_STATE=false, but the secure behavior ships ON
60
+ // by default and is NEVER relaxed based on request presence.
61
+ const requireState = getConfig('OAUTH_REQUIRE_STATE', 'true') !== 'false';
62
+ if (requireState) {
63
+ // In production the state cookie is `__Host-`-prefixed (unshadowable by a
64
+ // sibling subdomain). [C4-1] We must NOT accept the plain name in production:
65
+ // a tenant on a sibling `*.molecule.dev` preview can toss a `.molecule.dev`
66
+ // plain `oauth_state` cookie, and a plain-name fallback would let that tossed
67
+ // value satisfy the CSRF check (login-CSRF / session fixation). `__Host-`
68
+ // cookies cannot carry a Domain, so they can't be tossed. oauth_state has a
69
+ // 5-min TTL, so there is no legacy plain cookie to support — drop the fallback
70
+ // in production. In dev (no `__Host-`) the plain name is the real cookie.
71
+ const stateCookies = req.cookies;
72
+ const cookieState = getConfig('NODE_ENV') === 'production'
73
+ ? stateCookies?.[authorization.getAuthCookieName('oauth_state')]
74
+ : (stateCookies?.[authorization.getAuthCookieName('oauth_state')] ??
75
+ stateCookies?.oauth_state);
76
+ const expressRes = res;
77
+ // The state cookie is one-time use — always clear it, match or not. Clear
78
+ // BOTH names so a leftover plain/prefixed copy can't linger. The CLEAR must
79
+ // carry the SAME attributes the initiation endpoint SET it with
80
+ // (getAuthCookieOptions: Secure + Path=/ in prod; + SameSite=None +
81
+ // Partitioned in the sandbox live-preview) or the browser won't match &
82
+ // delete the (partitioned) cookie.
83
+ const clearStateCookie = () => {
84
+ if (typeof expressRes.clearCookie === 'function') {
85
+ expressRes.clearCookie(authorization.getAuthCookieName('oauth_state'), {
86
+ ...authorization.getAuthCookieOptions(),
87
+ });
88
+ expressRes.clearCookie('oauth_state', { path: '/' });
89
+ }
90
+ };
91
+ if (!body.state || !cookieState || !timingSafeEqualString(cookieState, body.state)) {
92
+ clearStateCookie();
93
+ return {
94
+ statusCode: 403,
95
+ body: {
96
+ error: t('user.error.oauthStateMismatch', undefined, {
97
+ defaultValue: 'OAuth state mismatch — possible CSRF attack',
98
+ }),
99
+ errorKey: 'user.error.oauthStateMismatch',
100
+ },
101
+ };
102
+ }
103
+ clearStateCookie();
104
+ }
105
+ // [M1-1] Read the PKCE code_verifier from the httpOnly cookie set by the OAuth
106
+ // initiation endpoint — SERVER-SIDE, never trusting a client-supplied body value.
107
+ // The verifier is the per-session secret that binds the authorization code to THIS
108
+ // session (S256). Trusting `body.codeVerifier` would let an attacker replay a
109
+ // victim's intercepted code with their own verifier (authorization-code injection,
110
+ // RFC 9700 §2.1). One-time use: cleared whether or not verification succeeds. The
111
+ // body fallback is only for legacy consumers that never set the cookie — the random
112
+ // S256 challenge issued at initiation is the actual injection guard either way.
113
+ const verifierCookies = req.cookies;
114
+ const cookieVerifier = getConfig('NODE_ENV') === 'production'
115
+ ? verifierCookies?.[authorization.getAuthCookieName('oauth_verifier')]
116
+ : (verifierCookies?.[authorization.getAuthCookieName('oauth_verifier')] ??
117
+ verifierCookies?.oauth_verifier);
118
+ const expressResVerifier = res;
119
+ if (typeof expressResVerifier.clearCookie === 'function') {
120
+ // Same one-time-use clear as oauth_state: match the SET attributes
121
+ // (Partitioned + SameSite=None + Secure in the live-preview) so the
122
+ // deletion hits the right cookie jar.
123
+ expressResVerifier.clearCookie(authorization.getAuthCookieName('oauth_verifier'), {
124
+ ...authorization.getAuthCookieOptions(),
125
+ });
126
+ expressResVerifier.clearCookie('oauth_verifier', { path: '/' });
127
+ }
128
+ const codeVerifier = cookieVerifier ?? body.codeVerifier;
129
+ const oauthProvider = get('oauth', body.server);
130
+ if (!oauthProvider) {
131
+ return {
132
+ statusCode: 400,
133
+ body: {
134
+ error: t('user.error.oauthServerNotConfigured', { server: body.server }),
135
+ errorKey: 'user.error.oauthServerNotConfigured',
136
+ },
137
+ };
138
+ }
139
+ try {
140
+ // Verify the OAuth code with the bonded provider.
141
+ // SECURITY: do NOT pass the client-supplied `body.redirect_uri` — it is a value
142
+ // the browser controls, but the token-exchange redirect_uri MUST equal the one
143
+ // used to build the authorization request. That URI is `APP_ORIGIN` plus the
144
+ // sanitized same-origin path the flow started on (e.g. `/login`) — persisted
145
+ // at initiation in the httpOnly `oauth_redirect` cookie so the exchange can
146
+ // reconstruct it byte-for-byte (RFC 6749 §4.1.3; GitHub/Google reject with
147
+ // `redirect_uri_mismatch` otherwise) without trusting the request body.
148
+ const redirectCookies = req.cookies;
149
+ const cookieRedirect = getConfig('NODE_ENV') === 'production'
150
+ ? redirectCookies?.[authorization.getAuthCookieName('oauth_redirect')]
151
+ : (redirectCookies?.[authorization.getAuthCookieName('oauth_redirect')] ??
152
+ redirectCookies?.oauth_redirect);
153
+ if (typeof expressResVerifier.clearCookie === 'function') {
154
+ expressResVerifier.clearCookie(authorization.getAuthCookieName('oauth_redirect'), {
155
+ ...authorization.getAuthCookieOptions(),
156
+ });
157
+ expressResVerifier.clearCookie('oauth_redirect', { path: '/' });
158
+ }
159
+ const appOrigin = getConfig('APP_ORIGIN', '');
160
+ // Re-validate defensively (same rule the initiation applied) so a planted
161
+ // cookie can never turn the exchange redirect_uri into an open redirect.
162
+ const safeRedirectPath = typeof cookieRedirect === 'string' &&
163
+ cookieRedirect.startsWith('/') &&
164
+ !cookieRedirect.startsWith('//') &&
165
+ !cookieRedirect.includes('\\')
166
+ ? cookieRedirect
167
+ : '';
168
+ const oauthProps = await oauthProvider.verify(body.code, codeVerifier, appOrigin ? `${appOrigin}${safeRedirectPath}` : undefined);
169
+ if (!oauthProps?.oauthId || !oauthProps?.oauthServer) {
170
+ analytics
171
+ .track({
172
+ name: 'user.login_failed',
173
+ properties: { reason: 'oauth_verification_failed', provider: body.server },
174
+ })
175
+ .catch(() => { });
176
+ return {
177
+ statusCode: 403,
178
+ body: {
179
+ error: t('user.error.oauthVerificationFailed'),
180
+ errorKey: 'user.error.oauthVerificationFailed',
181
+ },
182
+ };
183
+ }
184
+ // Normalize the provider email once so storage and every collision
185
+ // lookup compare the same canonical form — a case/whitespace variant
186
+ // (`Foo@x.com` vs `foo@x.com`) must not slip past the UNIQUE column.
187
+ const email = normalizeEmail(oauthProps.email);
188
+ // Only an explicit provider-verified flag counts as proof of mailbox
189
+ // ownership. Undefined/false from a provider that can't confirm is
190
+ // treated as unverified.
191
+ const oauthEmailVerified = oauthProps.emailVerified === true;
192
+ // Check if a user already exists with this OAuth ID.
193
+ let user = await findOne(tableName, [
194
+ { field: 'oauthServer', operator: '=', value: oauthProps.oauthServer },
195
+ { field: 'oauthId', operator: '=', value: oauthProps.oauthId },
196
+ ]);
197
+ if (!user) {
198
+ // Account-takeover + UNIQUE-crash guard. An OAuth login whose email
199
+ // already belongs to ANOTHER account must never silently create a
200
+ // second user (the `email` column is UNIQUE, so that throws and
201
+ // surfaces as an opaque 500). What we do on collision depends on who
202
+ // can prove they own the mailbox:
203
+ //
204
+ // • OAuth email is provider-VERIFIED and the existing account is
205
+ // UNVERIFIED → the OAuth user is the proven owner and the existing
206
+ // account never proved ownership (classic squatting: an unverified
207
+ // local signup at someone else's address). Link this provider into
208
+ // the existing account so the verified owner isn't locked out.
209
+ // • Otherwise (OAuth email unverified, OR the existing account is
210
+ // already verified) → do NOT auto-link; an unverified provider email
211
+ // could be typo-squatted/compromised, and we never wrest a verified
212
+ // account away. Reject with an actionable 409.
213
+ if (email) {
214
+ const existingByEmail = await findOne(tableName, [
215
+ { field: 'email', operator: '=', value: email },
216
+ ]);
217
+ if (existingByEmail) {
218
+ const existingVerified = existingByEmail.emailVerified === true;
219
+ if (oauthEmailVerified && !existingVerified) {
220
+ // Trust the provider-verified owner: link this OAuth identity
221
+ // into the existing (unverified) account and mark it verified.
222
+ // Also reset twoFactorEnabled — the squatter's prior 2FA state must
223
+ // not carry over onto an account a different verified human now owns.
224
+ await updateById(tableName, existingByEmail.id, {
225
+ oauthServer: oauthProps.oauthServer,
226
+ oauthId: oauthProps.oauthId,
227
+ oauthData: JSON.stringify(oauthProps.oauthData),
228
+ emailVerified: true,
229
+ email,
230
+ twoFactorEnabled: false,
231
+ updatedAt: new Date().toISOString(),
232
+ });
233
+ // PRE-ACCOUNT-HIJACKING DEFENSE. The pre-existing unverified row
234
+ // could be a squatter who registered the victim's email with a
235
+ // password (and 2FA) THEY control — and may already hold a live
236
+ // session. Now that a provider-verified human owns this email,
237
+ // every prior credential on the row is untrusted: wipe the
238
+ // password / reset-token / 2FA secrets so the squatter's password
239
+ // can no longer authenticate via POST /users/log-in, and revoke
240
+ // all existing devices/sessions so any session the squatter
241
+ // already holds is killed. A fresh session for the verified owner
242
+ // is issued below (device.createOrUpdate → authorization.set).
243
+ await updateById(`${tableName}Secrets`, existingByEmail.id, {
244
+ passwordHash: null,
245
+ passwordResetToken: null,
246
+ passwordResetTokenAt: null,
247
+ pendingTwoFactorSecret: null,
248
+ twoFactorSecret: null,
249
+ lastTwoFactorTimeStep: null,
250
+ });
251
+ try {
252
+ await get('device')?.deleteByUserId(existingByEmail.id);
253
+ // Evict this process's positive device-exists cache so any live
254
+ // session the squatter holds is rejected on its next request,
255
+ // not after the cache TTL — immediate in-process revocation.
256
+ authorization.invalidateAllDeviceExistsCache();
257
+ }
258
+ catch (err) {
259
+ logger.warn('Failed to revoke prior sessions while claiming squatted account', {
260
+ userId: existingByEmail.id,
261
+ error: err,
262
+ });
263
+ }
264
+ analytics
265
+ .track({
266
+ name: 'user.oauth_linked',
267
+ userId: existingByEmail.id,
268
+ properties: {
269
+ provider: oauthProps.oauthServer,
270
+ reason: 'verified_email_claimed_unverified_account',
271
+ },
272
+ })
273
+ .catch((err) => {
274
+ logger.debug('Failed to track user.oauth_linked', { error: err });
275
+ });
276
+ // Re-read the merged row so the response + session reflect DB truth.
277
+ user = (await findById(tableName, existingByEmail.id)) ?? existingByEmail;
278
+ }
279
+ else {
280
+ analytics
281
+ .track({
282
+ name: 'user.login_failed',
283
+ properties: {
284
+ reason: 'oauth_email_collision',
285
+ provider: oauthProps.oauthServer,
286
+ },
287
+ })
288
+ .catch((err) => {
289
+ logger.debug('Failed to track user.login_failed', { error: err });
290
+ });
291
+ return {
292
+ statusCode: 409,
293
+ body: {
294
+ error: t('user.error.emailAlreadyRegistered', undefined, {
295
+ defaultValue: 'An account already exists for this email. Please sign in with your original method, then link this provider from your account settings.',
296
+ }),
297
+ errorKey: 'user.error.emailAlreadyRegistered',
298
+ },
299
+ };
300
+ }
301
+ }
302
+ }
303
+ }
304
+ if (!user) {
305
+ // Create a new user.
306
+ const id = uuid();
307
+ // Generate a unique username from OAuth data.
308
+ let username = oauthProps.username || oauthProps.oauthId;
309
+ username = username.replace(/[^a-zA-Z0-9]/g, '').substring(0, 255);
310
+ // Ensure username is unique.
311
+ const existing = await findOne(tableName, [
312
+ { field: 'username', operator: '=', value: username },
313
+ ]);
314
+ if (existing) {
315
+ username = `${username}${id.substring(0, 8)}`;
316
+ }
317
+ const createResponse = await createResource({
318
+ props: {
319
+ username,
320
+ name: oauthProps.name,
321
+ email: email || null,
322
+ emailVerified: oauthEmailVerified,
323
+ oauthServer: oauthProps.oauthServer,
324
+ oauthId: oauthProps.oauthId,
325
+ oauthData: oauthProps.oauthData,
326
+ },
327
+ id,
328
+ });
329
+ if (createResponse?.statusCode === 201) {
330
+ user = createResponse.body?.props;
331
+ }
332
+ if (!user) {
333
+ // The insert failed. The most likely non-bug cause is a TOCTOU race:
334
+ // a concurrent request created an account with this same email
335
+ // between our pre-check and this insert, so the UNIQUE constraint
336
+ // rejected it. Detect that DB-agnostically (no provider-specific
337
+ // error codes) by re-checking the email, and surface a clean 409
338
+ // instead of an opaque 500.
339
+ if (email) {
340
+ const racedByEmail = await findOne(tableName, [
341
+ { field: 'email', operator: '=', value: email },
342
+ ]);
343
+ if (racedByEmail) {
344
+ analytics
345
+ .track({
346
+ name: 'user.login_failed',
347
+ properties: {
348
+ reason: 'oauth_email_collision_race',
349
+ provider: oauthProps.oauthServer,
350
+ },
351
+ })
352
+ .catch((err) => {
353
+ logger.debug('Failed to track user.login_failed', { error: err });
354
+ });
355
+ return {
356
+ statusCode: 409,
357
+ body: {
358
+ error: t('user.error.emailAlreadyRegistered', undefined, {
359
+ defaultValue: 'An account already exists for this email. Please sign in with your original method, then link this provider from your account settings.',
360
+ }),
361
+ errorKey: 'user.error.emailAlreadyRegistered',
362
+ },
363
+ };
364
+ }
365
+ }
366
+ return {
367
+ statusCode: 500,
368
+ body: {
369
+ error: t('user.error.failedToCreateUser'),
370
+ errorKey: 'user.error.failedToCreateUser',
371
+ },
372
+ };
373
+ }
374
+ // Create secrets row (no password for OAuth users).
375
+ await storeCreate(`${tableName}Secrets`, { id }).catch((err) => {
376
+ logger.warn('Failed to store OAuth secrets', { userId: id, error: err });
377
+ });
378
+ analytics
379
+ .track({
380
+ name: 'user.signup',
381
+ userId: user.id,
382
+ properties: { method: 'oauth', provider: oauthProps.oauthServer },
383
+ })
384
+ .catch(() => { });
385
+ analytics
386
+ .identify({
387
+ userId: user.id,
388
+ email: oauthProps.email || undefined,
389
+ name: oauthProps.name || undefined,
390
+ })
391
+ .catch(() => { });
392
+ }
393
+ else {
394
+ // Update OAuth data on existing user.
395
+ await updateById(tableName, user.id, {
396
+ oauthData: JSON.stringify(oauthProps.oauthData),
397
+ updatedAt: new Date().toISOString(),
398
+ }).catch((err) => {
399
+ logger.warn('Failed to update OAuth data', { userId: user.id, error: err });
400
+ });
401
+ analytics
402
+ .track({
403
+ name: 'user.login',
404
+ userId: user.id,
405
+ properties: { method: 'oauth', provider: oauthProps.oauthServer },
406
+ })
407
+ .catch(() => { });
408
+ }
409
+ // SECURITY (M4): if the account has two-factor enabled, an OAuth login must
410
+ // NOT issue a session on its own — otherwise anyone who compromises the
411
+ // linked provider account bypasses the 2FA the user deliberately turned on.
412
+ // OAuth's single-use authorization code cannot carry the "resubmit with the
413
+ // TOTP" round-trip the password flow uses, so we fail CLOSED and direct the
414
+ // user to complete 2FA via password sign-in. (A native OAuth-2FA completion
415
+ // — a short-lived pending token + a verify endpoint + the picker UI — is a
416
+ // tracked follow-on; this closes the bypass safely without that flow.)
417
+ if (user.twoFactorEnabled) {
418
+ return {
419
+ statusCode: 403,
420
+ body: {
421
+ error: t('user.error.twoFactorRequiresPassword', undefined, {
422
+ defaultValue: 'Two-factor authentication is enabled on this account. Sign in with your email and password to continue.',
423
+ }),
424
+ errorKey: 'user.error.twoFactorRequiresPassword',
425
+ twoFactorRequired: true,
426
+ },
427
+ };
428
+ }
429
+ // Create or update device.
430
+ const deviceId = await get('device')?.createOrUpdate(user.id, body.deviceName || 'Unknown');
431
+ if (!deviceId) {
432
+ return {
433
+ statusCode: 500,
434
+ body: {
435
+ error: t('user.error.failedToCreateSession'),
436
+ errorKey: 'user.error.failedToCreateSession',
437
+ },
438
+ };
439
+ }
440
+ // Set authorization.
441
+ authorization.set(req, res, {
442
+ userId: user.id,
443
+ deviceId,
444
+ oauthServer: oauthProps.oauthServer,
445
+ oauthId: oauthProps.oauthId,
446
+ });
447
+ return { statusCode: 200, body: { props: user } };
448
+ }
449
+ catch (error) {
450
+ // Never log the raw error here: an OAuth verify failure rethrows an
451
+ // error whose attached request/response could carry the token-exchange
452
+ // body (client_secret, code) or an Authorization header. Log only the
453
+ // message (CWE-532).
454
+ logger.error('OAuth login failed:', error instanceof Error ? error.message : String(error));
455
+ return {
456
+ statusCode: 500,
457
+ body: { error: t('user.error.oauthLoginFailed'), errorKey: 'user.error.oauthLoginFailed' },
458
+ };
459
+ }
460
+ };
461
+ };
462
+ //# sourceMappingURL=logInOAuth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logInOAuth.js","sourceRoot":"","sources":["../../src/handlers/logInOAuth.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import type { MoleculeRequest, MoleculeResponse } from '@molecule/api-resource';
2
+ /**
3
+ * Logs the current user out. Revokes the session device server-side (so the JWT
4
+ * is rejected on its next use — covering EVERY copy of the token, not just this
5
+ * browser) and clears the credential cookies.
6
+ *
7
+ * [M1-1] Under the in-memory-token model the httpOnly `token` cookie is the
8
+ * persistent credential, so logout MUST clear it (and the `mol_auth` presence
9
+ * hint) — otherwise the app's cookie-based session restore (`GET /users/me` on
10
+ * init) would silently log the user back in on the next page load, and a
11
+ * client-side-only logout would be cosmetic (the cookie would still authenticate
12
+ * direct API calls). Cookie-authed via `auth` (no `:id`; the session identifies
13
+ * the user).
14
+ * @returns A request handler responding `{ statusCode: 200, body: { success: true } }`.
15
+ */
16
+ export declare const logout: () => (_req: MoleculeRequest, res: MoleculeResponse) => Promise<{
17
+ statusCode: number;
18
+ body: {
19
+ success: boolean;
20
+ };
21
+ }>;
22
+ //# sourceMappingURL=logout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logout.d.ts","sourceRoot":"","sources":["../../src/handlers/logout.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AAM/E;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,MAAM,SACH,MAAM,eAAe,EAAE,KAAK,gBAAgB;;;;;EAyC3D,CAAA"}
@@ -0,0 +1,57 @@
1
+ import { get, getLogger } from '@molecule/api-bond';
2
+ import { getAuthCookieName, getAuthCookieOptions } from '../authorization.js';
3
+ const logger = getLogger();
4
+ /**
5
+ * Logs the current user out. Revokes the session device server-side (so the JWT
6
+ * is rejected on its next use — covering EVERY copy of the token, not just this
7
+ * browser) and clears the credential cookies.
8
+ *
9
+ * [M1-1] Under the in-memory-token model the httpOnly `token` cookie is the
10
+ * persistent credential, so logout MUST clear it (and the `mol_auth` presence
11
+ * hint) — otherwise the app's cookie-based session restore (`GET /users/me` on
12
+ * init) would silently log the user back in on the next page load, and a
13
+ * client-side-only logout would be cosmetic (the cookie would still authenticate
14
+ * direct API calls). Cookie-authed via `auth` (no `:id`; the session identifies
15
+ * the user).
16
+ * @returns A request handler responding `{ statusCode: 200, body: { success: true } }`.
17
+ */
18
+ export const logout = () => {
19
+ return async (_req, res) => {
20
+ try {
21
+ const session = res.locals.session;
22
+ if (session?.deviceId) {
23
+ // Delete the device so verifyMiddleware rejects this JWT server-side.
24
+ await get('device')?.delete(session.deviceId);
25
+ }
26
+ }
27
+ catch (error) {
28
+ // Best-effort — the device row may already be gone; clear cookies regardless.
29
+ logger.debug('Device delete on logout failed (non-fatal)', { error });
30
+ }
31
+ const expressRes = res;
32
+ if (typeof expressRes.clearCookie === 'function') {
33
+ // The CLEAR must carry the SAME attributes the cookie was SET with
34
+ // (getAuthCookieOptions: Secure + Path=/ in prod; + SameSite=None +
35
+ // Partitioned in the sandbox live-preview) or the browser won't match &
36
+ // delete it — a mismatched clear leaves a stale (partitioned) credential
37
+ // cookie alive, so logout would be cosmetic. Single source of truth.
38
+ const cookieOptions = getAuthCookieOptions();
39
+ // Clear by BOTH the current name (prod `__Host-`) and the legacy plain name
40
+ // so a pre-rollout copy can't linger.
41
+ const clear = (base) => {
42
+ expressRes.clearCookie(getAuthCookieName(base), { ...cookieOptions });
43
+ expressRes.clearCookie(base, { path: '/' });
44
+ };
45
+ clear('token');
46
+ clear('sessionId');
47
+ // The session-presence hint is a plain (non-`__Host-`) cookie set with the
48
+ // same per-environment attributes, so clear it with them too.
49
+ expressRes.clearCookie('mol_auth', { ...cookieOptions });
50
+ }
51
+ // Drop any Authorization header so a same-response refresh can't hand a
52
+ // fresh credential back to a client that just logged out.
53
+ expressRes.removeHeader?.('Authorization');
54
+ return { statusCode: 200, body: { success: true } };
55
+ };
56
+ };
57
+ //# sourceMappingURL=logout.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logout.js","sourceRoot":"","sources":["../../src/handlers/logout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAGnD,OAAO,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAE7E,MAAM,MAAM,GAAG,SAAS,EAAE,CAAA;AAE1B;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,GAAG,EAAE;IACzB,OAAO,KAAK,EAAE,IAAqB,EAAE,GAAqB,EAAE,EAAE;QAC5D,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,OAA4C,CAAA;YACvE,IAAI,OAAO,EAAE,QAAQ,EAAE,CAAC;gBACtB,sEAAsE;gBACtE,MAAM,GAAG,CAA8C,QAAQ,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAA;YAC5F,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,8EAA8E;YAC9E,MAAM,CAAC,KAAK,CAAC,4CAA4C,EAAE,EAAE,KAAK,EAAE,CAAC,CAAA;QACvE,CAAC;QAED,MAAM,UAAU,GAAG,GAGlB,CAAA;QACD,IAAI,OAAO,UAAU,CAAC,WAAW,KAAK,UAAU,EAAE,CAAC;YACjD,mEAAmE;YACnE,oEAAoE;YACpE,wEAAwE;YACxE,yEAAyE;YACzE,qEAAqE;YACrE,MAAM,aAAa,GAAG,oBAAoB,EAAE,CAAA;YAC5C,4EAA4E;YAC5E,sCAAsC;YACtC,MAAM,KAAK,GAAG,CAAC,IAAY,EAAQ,EAAE;gBACnC,UAAU,CAAC,WAAY,CAAC,iBAAiB,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,aAAa,EAAE,CAAC,CAAA;gBACtE,UAAU,CAAC,WAAY,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAA;YAC9C,CAAC,CAAA;YACD,KAAK,CAAC,OAAO,CAAC,CAAA;YACd,KAAK,CAAC,WAAW,CAAC,CAAA;YAClB,2EAA2E;YAC3E,8DAA8D;YAC9D,UAAU,CAAC,WAAY,CAAC,UAAU,EAAE,EAAE,GAAG,aAAa,EAAE,CAAC,CAAA;QAC3D,CAAC;QACD,wEAAwE;QACxE,0DAA0D;QAC1D,UAAU,CAAC,YAAY,EAAE,CAAC,eAAe,CAAC,CAAA;QAE1C,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE,CAAA;IACrD,CAAC,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,44 @@
1
+ import type { MoleculeRequest, MoleculeResponse } from '@molecule/api-resource';
2
+ /**
3
+ * OAuth initiation — `GET /users/oauth/:provider`.
4
+ *
5
+ * The missing first half of the OAuth flow whose second half is
6
+ * `logInOAuth` (`POST /users/log-in/oauth`): generates the CSRF `state` and
7
+ * a per-session PKCE S256 verifier, stores BOTH in httpOnly cookies (the
8
+ * exact names + attributes `logInOAuth` reads and clears —
9
+ * `getAuthCookieName('oauth_state')` / `('oauth_verifier')` with
10
+ * `getAuthCookieOptions()`), then 302-redirects the browser to the
11
+ * provider's authorization URL.
12
+ *
13
+ * Provider knowledge stays in the bond: the handler asks the bonded OAuth
14
+ * provider (`get('oauth', :provider)`) for its authorize URL via its
15
+ * optional `getAuthorizeUrl` builder (see `@molecule/api-oauth`). A
16
+ * provider that is not bonded, lacks the builder, or is unconfigured
17
+ * (builder returns `null`, e.g. missing client id) yields a clean 404/503 —
18
+ * never a crash.
19
+ *
20
+ * The `redirect_uri` sent to the provider is `APP_ORIGIN` plus an optional
21
+ * validated same-origin `redirect_to` path from the query string (the page
22
+ * that initiated the flow, e.g. `/login`, where the app's OAuth callback
23
+ * handling is mounted). The path is sanitized so this can never become an
24
+ * open redirect; the state cookie — not `redirect_uri` — is the CSRF
25
+ * control.
26
+ *
27
+ * @returns A request handler that responds `302` with a `Location` header
28
+ * pointing at the provider's authorization URL.
29
+ */
30
+ export declare const oauthAuthorize: () => (req: MoleculeRequest, res: MoleculeResponse) => Promise<{
31
+ statusCode: number;
32
+ body: {
33
+ error: string;
34
+ errorKey: string;
35
+ };
36
+ headers?: undefined;
37
+ } | {
38
+ statusCode: number;
39
+ headers: {
40
+ Location: string;
41
+ };
42
+ body: undefined;
43
+ }>;
44
+ //# sourceMappingURL=oauthAuthorize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauthAuthorize.d.ts","sourceRoot":"","sources":["../../src/handlers/oauthAuthorize.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAA;AA2B/E;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,cAAc,SACX,KAAK,eAAe,EAAE,KAAK,gBAAgB;;;;;;;;;;;;;EAsG1D,CAAA"}