@nauth-toolkit/core 0.1.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 (778) hide show
  1. package/dist/adapters/database-columns.d.ts +10 -0
  2. package/dist/adapters/database-columns.d.ts.map +1 -0
  3. package/dist/adapters/database-columns.js +85 -0
  4. package/dist/adapters/database-columns.js.map +1 -0
  5. package/dist/adapters/express.adapter.d.ts +41 -0
  6. package/dist/adapters/express.adapter.d.ts.map +1 -0
  7. package/dist/adapters/express.adapter.js +188 -0
  8. package/dist/adapters/express.adapter.js.map +1 -0
  9. package/dist/adapters/fastify.adapter.d.ts +33 -0
  10. package/dist/adapters/fastify.adapter.d.ts.map +1 -0
  11. package/dist/adapters/fastify.adapter.js +223 -0
  12. package/dist/adapters/fastify.adapter.js.map +1 -0
  13. package/dist/adapters/index.d.ts +5 -0
  14. package/dist/adapters/index.d.ts.map +1 -0
  15. package/dist/adapters/index.js +25 -0
  16. package/dist/adapters/index.js.map +1 -0
  17. package/dist/adapters/storage.factory.d.ts +7 -0
  18. package/dist/adapters/storage.factory.d.ts.map +1 -0
  19. package/dist/adapters/storage.factory.js +24 -0
  20. package/dist/adapters/storage.factory.js.map +1 -0
  21. package/dist/bootstrap.d.ts +41 -0
  22. package/dist/bootstrap.d.ts.map +1 -0
  23. package/dist/bootstrap.js +113 -0
  24. package/dist/bootstrap.js.map +1 -0
  25. package/dist/dto/auth-challenge.dto.d.ts +19 -0
  26. package/dist/dto/auth-challenge.dto.d.ts.map +1 -0
  27. package/dist/dto/auth-challenge.dto.js +86 -0
  28. package/dist/dto/auth-challenge.dto.js.map +1 -0
  29. package/dist/dto/auth-response.dto.d.ts +31 -0
  30. package/dist/dto/auth-response.dto.d.ts.map +1 -0
  31. package/dist/dto/auth-response.dto.js +18 -0
  32. package/dist/dto/auth-response.dto.js.map +1 -0
  33. package/dist/dto/challenge-response.dto.d.ts +36 -0
  34. package/dist/dto/challenge-response.dto.d.ts.map +1 -0
  35. package/dist/dto/challenge-response.dto.js +3 -0
  36. package/dist/dto/challenge-response.dto.js.map +1 -0
  37. package/dist/dto/change-password-request.dto.d.ts +5 -0
  38. package/dist/dto/change-password-request.dto.d.ts.map +1 -0
  39. package/dist/dto/change-password-request.dto.js +30 -0
  40. package/dist/dto/change-password-request.dto.js.map +1 -0
  41. package/dist/dto/change-password-response.dto.d.ts +4 -0
  42. package/dist/dto/change-password-response.dto.d.ts.map +1 -0
  43. package/dist/dto/change-password-response.dto.js +8 -0
  44. package/dist/dto/change-password-response.dto.js.map +1 -0
  45. package/dist/dto/change-password.dto.d.ts +5 -0
  46. package/dist/dto/change-password.dto.d.ts.map +1 -0
  47. package/dist/dto/change-password.dto.js +29 -0
  48. package/dist/dto/change-password.dto.js.map +1 -0
  49. package/dist/dto/error-response.dto.d.ts +9 -0
  50. package/dist/dto/error-response.dto.d.ts.map +1 -0
  51. package/dist/dto/error-response.dto.js +59 -0
  52. package/dist/dto/error-response.dto.js.map +1 -0
  53. package/dist/dto/get-available-methods.dto.d.ts +7 -0
  54. package/dist/dto/get-available-methods.dto.d.ts.map +1 -0
  55. package/dist/dto/get-available-methods.dto.js +33 -0
  56. package/dist/dto/get-available-methods.dto.js.map +1 -0
  57. package/dist/dto/get-challenge-data-response.dto.d.ts +4 -0
  58. package/dist/dto/get-challenge-data-response.dto.d.ts.map +1 -0
  59. package/dist/dto/get-challenge-data-response.dto.js +8 -0
  60. package/dist/dto/get-challenge-data-response.dto.js.map +1 -0
  61. package/dist/dto/get-challenge-data.dto.d.ts +8 -0
  62. package/dist/dto/get-challenge-data.dto.d.ts.map +1 -0
  63. package/dist/dto/get-challenge-data.dto.js +40 -0
  64. package/dist/dto/get-challenge-data.dto.js.map +1 -0
  65. package/dist/dto/get-client-info.dto.d.ts +17 -0
  66. package/dist/dto/get-client-info.dto.d.ts.map +1 -0
  67. package/dist/dto/get-client-info.dto.js +20 -0
  68. package/dist/dto/get-client-info.dto.js.map +1 -0
  69. package/dist/dto/get-device-token-response.dto.d.ts +4 -0
  70. package/dist/dto/get-device-token-response.dto.d.ts.map +1 -0
  71. package/dist/dto/get-device-token-response.dto.js +8 -0
  72. package/dist/dto/get-device-token-response.dto.js.map +1 -0
  73. package/dist/dto/get-events-by-type.dto.d.ts +17 -0
  74. package/dist/dto/get-events-by-type.dto.d.ts.map +1 -0
  75. package/dist/dto/get-events-by-type.dto.js +20 -0
  76. package/dist/dto/get-events-by-type.dto.js.map +1 -0
  77. package/dist/dto/get-ip-address-response.dto.d.ts +4 -0
  78. package/dist/dto/get-ip-address-response.dto.d.ts.map +1 -0
  79. package/dist/dto/get-ip-address-response.dto.js +8 -0
  80. package/dist/dto/get-ip-address-response.dto.js.map +1 -0
  81. package/dist/dto/get-mfa-status.dto.d.ts +16 -0
  82. package/dist/dto/get-mfa-status.dto.d.ts.map +1 -0
  83. package/dist/dto/get-mfa-status.dto.js +41 -0
  84. package/dist/dto/get-mfa-status.dto.js.map +1 -0
  85. package/dist/dto/get-risk-assessment-history.dto.d.ts +9 -0
  86. package/dist/dto/get-risk-assessment-history.dto.d.ts.map +1 -0
  87. package/dist/dto/get-risk-assessment-history.dto.js +13 -0
  88. package/dist/dto/get-risk-assessment-history.dto.js.map +1 -0
  89. package/dist/dto/get-session-id-response.dto.d.ts +4 -0
  90. package/dist/dto/get-session-id-response.dto.d.ts.map +1 -0
  91. package/dist/dto/get-session-id-response.dto.js +8 -0
  92. package/dist/dto/get-session-id-response.dto.js.map +1 -0
  93. package/dist/dto/get-setup-data-response.dto.d.ts +4 -0
  94. package/dist/dto/get-setup-data-response.dto.d.ts.map +1 -0
  95. package/dist/dto/get-setup-data-response.dto.js +8 -0
  96. package/dist/dto/get-setup-data-response.dto.js.map +1 -0
  97. package/dist/dto/get-setup-data.dto.d.ts +7 -0
  98. package/dist/dto/get-setup-data.dto.d.ts.map +1 -0
  99. package/dist/dto/get-setup-data.dto.js +43 -0
  100. package/dist/dto/get-setup-data.dto.js.map +1 -0
  101. package/dist/dto/get-suspicious-activity.dto.d.ts +9 -0
  102. package/dist/dto/get-suspicious-activity.dto.d.ts.map +1 -0
  103. package/dist/dto/get-suspicious-activity.dto.js +13 -0
  104. package/dist/dto/get-suspicious-activity.dto.js.map +1 -0
  105. package/dist/dto/get-user-agent-response.dto.d.ts +4 -0
  106. package/dist/dto/get-user-agent-response.dto.d.ts.map +1 -0
  107. package/dist/dto/get-user-agent-response.dto.js +8 -0
  108. package/dist/dto/get-user-agent-response.dto.js.map +1 -0
  109. package/dist/dto/get-user-auth-history.dto.d.ts +20 -0
  110. package/dist/dto/get-user-auth-history.dto.d.ts.map +1 -0
  111. package/dist/dto/get-user-auth-history.dto.js +22 -0
  112. package/dist/dto/get-user-auth-history.dto.js.map +1 -0
  113. package/dist/dto/get-user-by-email.dto.d.ts +5 -0
  114. package/dist/dto/get-user-by-email.dto.d.ts.map +1 -0
  115. package/dist/dto/get-user-by-email.dto.js +36 -0
  116. package/dist/dto/get-user-by-email.dto.js.map +1 -0
  117. package/dist/dto/get-user-by-id.dto.d.ts +4 -0
  118. package/dist/dto/get-user-by-id.dto.d.ts.map +1 -0
  119. package/dist/dto/get-user-by-id.dto.js +29 -0
  120. package/dist/dto/get-user-by-id.dto.js.map +1 -0
  121. package/dist/dto/get-user-devices.dto.d.ts +8 -0
  122. package/dist/dto/get-user-devices.dto.d.ts.map +1 -0
  123. package/dist/dto/get-user-devices.dto.js +33 -0
  124. package/dist/dto/get-user-devices.dto.js.map +1 -0
  125. package/dist/dto/get-user-response.dto.d.ts +2 -0
  126. package/dist/dto/get-user-response.dto.d.ts.map +1 -0
  127. package/dist/dto/get-user-response.dto.js +6 -0
  128. package/dist/dto/get-user-response.dto.js.map +1 -0
  129. package/dist/dto/has-provider.dto.d.ts +7 -0
  130. package/dist/dto/has-provider.dto.d.ts.map +1 -0
  131. package/dist/dto/has-provider.dto.js +38 -0
  132. package/dist/dto/has-provider.dto.js.map +1 -0
  133. package/dist/dto/index.d.ts +51 -0
  134. package/dist/dto/index.d.ts.map +1 -0
  135. package/dist/dto/index.js +67 -0
  136. package/dist/dto/index.js.map +1 -0
  137. package/dist/dto/is-trusted-device-response.dto.d.ts +4 -0
  138. package/dist/dto/is-trusted-device-response.dto.d.ts.map +1 -0
  139. package/dist/dto/is-trusted-device-response.dto.js +8 -0
  140. package/dist/dto/is-trusted-device-response.dto.js.map +1 -0
  141. package/dist/dto/list-providers-response.dto.d.ts +4 -0
  142. package/dist/dto/list-providers-response.dto.d.ts.map +1 -0
  143. package/dist/dto/list-providers-response.dto.js +8 -0
  144. package/dist/dto/list-providers-response.dto.js.map +1 -0
  145. package/dist/dto/login.dto.d.ts +7 -0
  146. package/dist/dto/login.dto.d.ts.map +1 -0
  147. package/dist/dto/login.dto.js +68 -0
  148. package/dist/dto/login.dto.js.map +1 -0
  149. package/dist/dto/logout-all-response.dto.d.ts +4 -0
  150. package/dist/dto/logout-all-response.dto.d.ts.map +1 -0
  151. package/dist/dto/logout-all-response.dto.js +8 -0
  152. package/dist/dto/logout-all-response.dto.js.map +1 -0
  153. package/dist/dto/logout-all.dto.d.ts +5 -0
  154. package/dist/dto/logout-all.dto.d.ts.map +1 -0
  155. package/dist/dto/logout-all.dto.js +42 -0
  156. package/dist/dto/logout-all.dto.js.map +1 -0
  157. package/dist/dto/logout-response.dto.d.ts +4 -0
  158. package/dist/dto/logout-response.dto.d.ts.map +1 -0
  159. package/dist/dto/logout-response.dto.js +8 -0
  160. package/dist/dto/logout-response.dto.js.map +1 -0
  161. package/dist/dto/logout.dto.d.ts +5 -0
  162. package/dist/dto/logout.dto.d.ts.map +1 -0
  163. package/dist/dto/logout.dto.js +36 -0
  164. package/dist/dto/logout.dto.js.map +1 -0
  165. package/dist/dto/refresh-token.dto.d.ts +4 -0
  166. package/dist/dto/refresh-token.dto.d.ts.map +1 -0
  167. package/dist/dto/refresh-token.dto.js +24 -0
  168. package/dist/dto/refresh-token.dto.js.map +1 -0
  169. package/dist/dto/remove-devices.dto.d.ts +9 -0
  170. package/dist/dto/remove-devices.dto.d.ts.map +1 -0
  171. package/dist/dto/remove-devices.dto.js +50 -0
  172. package/dist/dto/remove-devices.dto.js.map +1 -0
  173. package/dist/dto/resend-code-response.dto.d.ts +4 -0
  174. package/dist/dto/resend-code-response.dto.d.ts.map +1 -0
  175. package/dist/dto/resend-code-response.dto.js +8 -0
  176. package/dist/dto/resend-code-response.dto.js.map +1 -0
  177. package/dist/dto/resend-code.dto.d.ts +4 -0
  178. package/dist/dto/resend-code.dto.d.ts.map +1 -0
  179. package/dist/dto/resend-code.dto.js +29 -0
  180. package/dist/dto/resend-code.dto.js.map +1 -0
  181. package/dist/dto/reset-password.dto.d.ts +8 -0
  182. package/dist/dto/reset-password.dto.d.ts.map +1 -0
  183. package/dist/dto/reset-password.dto.js +61 -0
  184. package/dist/dto/reset-password.dto.js.map +1 -0
  185. package/dist/dto/respond-challenge.dto.d.ts +33 -0
  186. package/dist/dto/respond-challenge.dto.d.ts.map +1 -0
  187. package/dist/dto/respond-challenge.dto.js +131 -0
  188. package/dist/dto/respond-challenge.dto.js.map +1 -0
  189. package/dist/dto/set-mfa-exemption.dto.d.ts +12 -0
  190. package/dist/dto/set-mfa-exemption.dto.d.ts.map +1 -0
  191. package/dist/dto/set-mfa-exemption.dto.js +66 -0
  192. package/dist/dto/set-mfa-exemption.dto.js.map +1 -0
  193. package/dist/dto/set-must-change-password-response.dto.d.ts +4 -0
  194. package/dist/dto/set-must-change-password-response.dto.d.ts.map +1 -0
  195. package/dist/dto/set-must-change-password-response.dto.js +8 -0
  196. package/dist/dto/set-must-change-password-response.dto.js.map +1 -0
  197. package/dist/dto/set-must-change-password.dto.d.ts +4 -0
  198. package/dist/dto/set-must-change-password.dto.d.ts.map +1 -0
  199. package/dist/dto/set-must-change-password.dto.js +29 -0
  200. package/dist/dto/set-must-change-password.dto.js.map +1 -0
  201. package/dist/dto/set-preferred-method.dto.d.ts +8 -0
  202. package/dist/dto/set-preferred-method.dto.d.ts.map +1 -0
  203. package/dist/dto/set-preferred-method.dto.js +49 -0
  204. package/dist/dto/set-preferred-method.dto.js.map +1 -0
  205. package/dist/dto/setup-mfa.dto.d.ts +9 -0
  206. package/dist/dto/setup-mfa.dto.d.ts.map +1 -0
  207. package/dist/dto/setup-mfa.dto.js +55 -0
  208. package/dist/dto/setup-mfa.dto.js.map +1 -0
  209. package/dist/dto/signup.dto.d.ts +10 -0
  210. package/dist/dto/signup.dto.d.ts.map +1 -0
  211. package/dist/dto/signup.dto.js +109 -0
  212. package/dist/dto/signup.dto.js.map +1 -0
  213. package/dist/dto/social-auth.dto.d.ts +54 -0
  214. package/dist/dto/social-auth.dto.d.ts.map +1 -0
  215. package/dist/dto/social-auth.dto.js +232 -0
  216. package/dist/dto/social-auth.dto.js.map +1 -0
  217. package/dist/dto/trust-device-response.dto.d.ts +4 -0
  218. package/dist/dto/trust-device-response.dto.d.ts.map +1 -0
  219. package/dist/dto/trust-device-response.dto.js +8 -0
  220. package/dist/dto/trust-device-response.dto.js.map +1 -0
  221. package/dist/dto/trust-device.dto.d.ts +1 -0
  222. package/dist/dto/trust-device.dto.d.ts.map +1 -0
  223. package/dist/dto/trust-device.dto.js +2 -0
  224. package/dist/dto/trust-device.dto.js.map +1 -0
  225. package/dist/dto/update-user-attributes-request.dto.d.ts +5 -0
  226. package/dist/dto/update-user-attributes-request.dto.d.ts.map +1 -0
  227. package/dist/dto/update-user-attributes-request.dto.js +30 -0
  228. package/dist/dto/update-user-attributes-request.dto.js.map +1 -0
  229. package/dist/dto/user-response.dto.d.ts +20 -0
  230. package/dist/dto/user-response.dto.d.ts.map +1 -0
  231. package/dist/dto/user-response.dto.js +42 -0
  232. package/dist/dto/user-response.dto.js.map +1 -0
  233. package/dist/dto/user-update.dto.d.ts +12 -0
  234. package/dist/dto/user-update.dto.d.ts.map +1 -0
  235. package/dist/dto/user-update.dto.js +119 -0
  236. package/dist/dto/user-update.dto.js.map +1 -0
  237. package/dist/dto/verify-email.dto.d.ts +29 -0
  238. package/dist/dto/verify-email.dto.d.ts.map +1 -0
  239. package/dist/dto/verify-email.dto.js +161 -0
  240. package/dist/dto/verify-email.dto.js.map +1 -0
  241. package/dist/dto/verify-mfa-code.dto.d.ts +10 -0
  242. package/dist/dto/verify-mfa-code.dto.d.ts.map +1 -0
  243. package/dist/dto/verify-mfa-code.dto.js +56 -0
  244. package/dist/dto/verify-mfa-code.dto.js.map +1 -0
  245. package/dist/dto/verify-phone-by-sub.dto.d.ts +6 -0
  246. package/dist/dto/verify-phone-by-sub.dto.d.ts.map +1 -0
  247. package/dist/dto/verify-phone-by-sub.dto.js +49 -0
  248. package/dist/dto/verify-phone-by-sub.dto.js.map +1 -0
  249. package/dist/dto/verify-phone.dto.d.ts +24 -0
  250. package/dist/dto/verify-phone.dto.d.ts.map +1 -0
  251. package/dist/dto/verify-phone.dto.js +124 -0
  252. package/dist/dto/verify-phone.dto.js.map +1 -0
  253. package/dist/entities/auth-audit.entity.d.ts +31 -0
  254. package/dist/entities/auth-audit.entity.d.ts.map +1 -0
  255. package/dist/entities/auth-audit.entity.js +33 -0
  256. package/dist/entities/auth-audit.entity.js.map +1 -0
  257. package/dist/entities/challenge-session.entity.d.ts +17 -0
  258. package/dist/entities/challenge-session.entity.d.ts.map +1 -0
  259. package/dist/entities/challenge-session.entity.js +21 -0
  260. package/dist/entities/challenge-session.entity.js.map +1 -0
  261. package/dist/entities/index.d.ts +12 -0
  262. package/dist/entities/index.d.ts.map +1 -0
  263. package/dist/entities/index.js +26 -0
  264. package/dist/entities/index.js.map +1 -0
  265. package/dist/entities/login-attempt.entity.d.ts +13 -0
  266. package/dist/entities/login-attempt.entity.d.ts.map +1 -0
  267. package/dist/entities/login-attempt.entity.js +17 -0
  268. package/dist/entities/login-attempt.entity.js.map +1 -0
  269. package/dist/entities/mfa-device.entity.d.ts +22 -0
  270. package/dist/entities/mfa-device.entity.d.ts.map +1 -0
  271. package/dist/entities/mfa-device.entity.js +25 -0
  272. package/dist/entities/mfa-device.entity.js.map +1 -0
  273. package/dist/entities/rate-limit.entity.d.ts +9 -0
  274. package/dist/entities/rate-limit.entity.d.ts.map +1 -0
  275. package/dist/entities/rate-limit.entity.js +13 -0
  276. package/dist/entities/rate-limit.entity.js.map +1 -0
  277. package/dist/entities/session.entity.d.ts +32 -0
  278. package/dist/entities/session.entity.d.ts.map +1 -0
  279. package/dist/entities/session.entity.js +36 -0
  280. package/dist/entities/session.entity.js.map +1 -0
  281. package/dist/entities/social-account.entity.d.ts +13 -0
  282. package/dist/entities/social-account.entity.d.ts.map +1 -0
  283. package/dist/entities/social-account.entity.js +17 -0
  284. package/dist/entities/social-account.entity.js.map +1 -0
  285. package/dist/entities/storage-lock.entity.d.ts +8 -0
  286. package/dist/entities/storage-lock.entity.d.ts.map +1 -0
  287. package/dist/entities/storage-lock.entity.js +12 -0
  288. package/dist/entities/storage-lock.entity.js.map +1 -0
  289. package/dist/entities/trusted-device.entity.d.ts +17 -0
  290. package/dist/entities/trusted-device.entity.d.ts.map +1 -0
  291. package/dist/entities/trusted-device.entity.js +21 -0
  292. package/dist/entities/trusted-device.entity.js.map +1 -0
  293. package/dist/entities/user.entity.d.ts +41 -0
  294. package/dist/entities/user.entity.d.ts.map +1 -0
  295. package/dist/entities/user.entity.js +45 -0
  296. package/dist/entities/user.entity.js.map +1 -0
  297. package/dist/entities/verification-token.entity.d.ts +19 -0
  298. package/dist/entities/verification-token.entity.d.ts.map +1 -0
  299. package/dist/entities/verification-token.entity.js +29 -0
  300. package/dist/entities/verification-token.entity.js.map +1 -0
  301. package/dist/enums/auth-audit-event-type.enum.d.ts +55 -0
  302. package/dist/enums/auth-audit-event-type.enum.d.ts.map +1 -0
  303. package/dist/enums/auth-audit-event-type.enum.js +59 -0
  304. package/dist/enums/auth-audit-event-type.enum.js.map +1 -0
  305. package/dist/enums/error-codes.enum.d.ts +53 -0
  306. package/dist/enums/error-codes.enum.d.ts.map +1 -0
  307. package/dist/enums/error-codes.enum.js +57 -0
  308. package/dist/enums/error-codes.enum.js.map +1 -0
  309. package/dist/enums/mfa-method.enum.d.ts +11 -0
  310. package/dist/enums/mfa-method.enum.d.ts.map +1 -0
  311. package/dist/enums/mfa-method.enum.js +18 -0
  312. package/dist/enums/mfa-method.enum.js.map +1 -0
  313. package/dist/enums/risk-factor.enum.d.ts +14 -0
  314. package/dist/enums/risk-factor.enum.d.ts.map +1 -0
  315. package/dist/enums/risk-factor.enum.js +18 -0
  316. package/dist/enums/risk-factor.enum.js.map +1 -0
  317. package/dist/exceptions/nauth.exception.d.ts +18 -0
  318. package/dist/exceptions/nauth.exception.d.ts.map +1 -0
  319. package/dist/exceptions/nauth.exception.js +64 -0
  320. package/dist/exceptions/nauth.exception.js.map +1 -0
  321. package/dist/handlers/auth.handler.d.ts +18 -0
  322. package/dist/handlers/auth.handler.d.ts.map +1 -0
  323. package/dist/handlers/auth.handler.js +173 -0
  324. package/dist/handlers/auth.handler.js.map +1 -0
  325. package/dist/handlers/client-info.handler.d.ts +12 -0
  326. package/dist/handlers/client-info.handler.d.ts.map +1 -0
  327. package/dist/handlers/client-info.handler.js +61 -0
  328. package/dist/handlers/client-info.handler.js.map +1 -0
  329. package/dist/handlers/csrf.handler.d.ts +13 -0
  330. package/dist/handlers/csrf.handler.d.ts.map +1 -0
  331. package/dist/handlers/csrf.handler.js +84 -0
  332. package/dist/handlers/csrf.handler.js.map +1 -0
  333. package/dist/handlers/token-delivery.handler.d.ts +12 -0
  334. package/dist/handlers/token-delivery.handler.d.ts.map +1 -0
  335. package/dist/handlers/token-delivery.handler.js +86 -0
  336. package/dist/handlers/token-delivery.handler.js.map +1 -0
  337. package/dist/index.d.ts +27 -0
  338. package/dist/index.d.ts.map +1 -0
  339. package/dist/index.js +51 -0
  340. package/dist/index.js.map +1 -0
  341. package/dist/interfaces/client-info.interface.d.ts +16 -0
  342. package/dist/interfaces/client-info.interface.d.ts.map +1 -0
  343. package/dist/interfaces/client-info.interface.js +3 -0
  344. package/dist/interfaces/client-info.interface.js.map +1 -0
  345. package/dist/interfaces/config.interface.d.ts +279 -0
  346. package/dist/interfaces/config.interface.d.ts.map +1 -0
  347. package/dist/interfaces/config.interface.js +3 -0
  348. package/dist/interfaces/config.interface.js.map +1 -0
  349. package/dist/interfaces/entities.interface.d.ts +169 -0
  350. package/dist/interfaces/entities.interface.d.ts.map +1 -0
  351. package/dist/interfaces/entities.interface.js +3 -0
  352. package/dist/interfaces/entities.interface.js.map +1 -0
  353. package/dist/interfaces/index.d.ts +11 -0
  354. package/dist/interfaces/index.d.ts.map +1 -0
  355. package/dist/interfaces/index.js +27 -0
  356. package/dist/interfaces/index.js.map +1 -0
  357. package/dist/interfaces/logger.interface.d.ts +43 -0
  358. package/dist/interfaces/logger.interface.d.ts.map +1 -0
  359. package/dist/interfaces/logger.interface.js +12 -0
  360. package/dist/interfaces/logger.interface.js.map +1 -0
  361. package/dist/interfaces/mfa-provider.interface.d.ts +12 -0
  362. package/dist/interfaces/mfa-provider.interface.d.ts.map +1 -0
  363. package/dist/interfaces/mfa-provider.interface.js +3 -0
  364. package/dist/interfaces/mfa-provider.interface.js.map +1 -0
  365. package/dist/interfaces/oauth.interface.d.ts +24 -0
  366. package/dist/interfaces/oauth.interface.d.ts.map +1 -0
  367. package/dist/interfaces/oauth.interface.js +3 -0
  368. package/dist/interfaces/oauth.interface.js.map +1 -0
  369. package/dist/interfaces/provider.interface.d.ts +12 -0
  370. package/dist/interfaces/provider.interface.d.ts.map +1 -0
  371. package/dist/interfaces/provider.interface.js +3 -0
  372. package/dist/interfaces/provider.interface.js.map +1 -0
  373. package/dist/interfaces/social-auth-provider.interface.d.ts +13 -0
  374. package/dist/interfaces/social-auth-provider.interface.d.ts.map +1 -0
  375. package/dist/interfaces/social-auth-provider.interface.js +3 -0
  376. package/dist/interfaces/social-auth-provider.interface.js.map +1 -0
  377. package/dist/interfaces/storage-adapter.interface.d.ts +39 -0
  378. package/dist/interfaces/storage-adapter.interface.d.ts.map +1 -0
  379. package/dist/interfaces/storage-adapter.interface.js +3 -0
  380. package/dist/interfaces/storage-adapter.interface.js.map +1 -0
  381. package/dist/interfaces/template.interface.d.ts +99 -0
  382. package/dist/interfaces/template.interface.d.ts.map +1 -0
  383. package/dist/interfaces/template.interface.js +15 -0
  384. package/dist/interfaces/template.interface.js.map +1 -0
  385. package/dist/interfaces/token-verifier.interface.d.ts +7 -0
  386. package/dist/interfaces/token-verifier.interface.d.ts.map +1 -0
  387. package/dist/interfaces/token-verifier.interface.js +3 -0
  388. package/dist/interfaces/token-verifier.interface.js.map +1 -0
  389. package/dist/internal.d.ts +20 -0
  390. package/dist/internal.d.ts.map +1 -0
  391. package/dist/internal.js +53 -0
  392. package/dist/internal.js.map +1 -0
  393. package/dist/platform/interfaces.d.ts +56 -0
  394. package/dist/platform/interfaces.d.ts.map +1 -0
  395. package/dist/platform/interfaces.js +3 -0
  396. package/dist/platform/interfaces.js.map +1 -0
  397. package/dist/schemas/auth-config.schema.d.ts +3411 -0
  398. package/dist/schemas/auth-config.schema.d.ts.map +1 -0
  399. package/dist/schemas/auth-config.schema.js +428 -0
  400. package/dist/schemas/auth-config.schema.js.map +1 -0
  401. package/dist/services/adaptive-mfa-decision.service.d.ts +39 -0
  402. package/dist/services/adaptive-mfa-decision.service.d.ts.map +1 -0
  403. package/dist/services/adaptive-mfa-decision.service.js +223 -0
  404. package/dist/services/adaptive-mfa-decision.service.js.map +1 -0
  405. package/dist/services/auth-audit.service.d.ts +44 -0
  406. package/dist/services/auth-audit.service.d.ts.map +1 -0
  407. package/dist/services/auth-audit.service.js +241 -0
  408. package/dist/services/auth-audit.service.js.map +1 -0
  409. package/dist/services/auth-challenge-helper.service.d.ts +48 -0
  410. package/dist/services/auth-challenge-helper.service.d.ts.map +1 -0
  411. package/dist/services/auth-challenge-helper.service.js +425 -0
  412. package/dist/services/auth-challenge-helper.service.js.map +1 -0
  413. package/dist/services/auth-flow-context-builder.service.d.ts +31 -0
  414. package/dist/services/auth-flow-context-builder.service.d.ts.map +1 -0
  415. package/dist/services/auth-flow-context-builder.service.js +253 -0
  416. package/dist/services/auth-flow-context-builder.service.js.map +1 -0
  417. package/dist/services/auth-flow-rules.d.ts +18 -0
  418. package/dist/services/auth-flow-rules.d.ts.map +1 -0
  419. package/dist/services/auth-flow-rules.js +55 -0
  420. package/dist/services/auth-flow-rules.js.map +1 -0
  421. package/dist/services/auth-flow-state-definitions.d.ts +5 -0
  422. package/dist/services/auth-flow-state-definitions.d.ts.map +1 -0
  423. package/dist/services/auth-flow-state-definitions.js +87 -0
  424. package/dist/services/auth-flow-state-definitions.js.map +1 -0
  425. package/dist/services/auth-flow-state-machine.service.d.ts +17 -0
  426. package/dist/services/auth-flow-state-machine.service.d.ts.map +1 -0
  427. package/dist/services/auth-flow-state-machine.service.js +91 -0
  428. package/dist/services/auth-flow-state-machine.service.js.map +1 -0
  429. package/dist/services/auth-flow-state-machine.types.d.ts +55 -0
  430. package/dist/services/auth-flow-state-machine.types.d.ts.map +1 -0
  431. package/dist/services/auth-flow-state-machine.types.js +16 -0
  432. package/dist/services/auth-flow-state-machine.types.js.map +1 -0
  433. package/dist/services/auth.service.d.ts +87 -0
  434. package/dist/services/auth.service.d.ts.map +1 -0
  435. package/dist/services/auth.service.js +2356 -0
  436. package/dist/services/auth.service.js.map +1 -0
  437. package/dist/services/challenge.service.d.ts +32 -0
  438. package/dist/services/challenge.service.d.ts.map +1 -0
  439. package/dist/services/challenge.service.js +293 -0
  440. package/dist/services/challenge.service.js.map +1 -0
  441. package/dist/services/client-info.service.d.ts +20 -0
  442. package/dist/services/client-info.service.d.ts.map +1 -0
  443. package/dist/services/client-info.service.js +202 -0
  444. package/dist/services/client-info.service.js.map +1 -0
  445. package/dist/services/csrf.service.d.ts +13 -0
  446. package/dist/services/csrf.service.d.ts.map +1 -0
  447. package/dist/services/csrf.service.js +67 -0
  448. package/dist/services/csrf.service.js.map +1 -0
  449. package/dist/services/email-verification.service.d.ts +30 -0
  450. package/dist/services/email-verification.service.d.ts.map +1 -0
  451. package/dist/services/email-verification.service.js +373 -0
  452. package/dist/services/email-verification.service.js.map +1 -0
  453. package/dist/services/geo-location.service.d.ts +85 -0
  454. package/dist/services/geo-location.service.d.ts.map +1 -0
  455. package/dist/services/geo-location.service.js +338 -0
  456. package/dist/services/geo-location.service.js.map +1 -0
  457. package/dist/services/index.d.ts +14 -0
  458. package/dist/services/index.d.ts.map +1 -0
  459. package/dist/services/index.js +30 -0
  460. package/dist/services/index.js.map +1 -0
  461. package/dist/services/jwt.service.d.ts +62 -0
  462. package/dist/services/jwt.service.d.ts.map +1 -0
  463. package/dist/services/jwt.service.js +261 -0
  464. package/dist/services/jwt.service.js.map +1 -0
  465. package/dist/services/mfa-base.service.d.ts +37 -0
  466. package/dist/services/mfa-base.service.d.ts.map +1 -0
  467. package/dist/services/mfa-base.service.js +297 -0
  468. package/dist/services/mfa-base.service.js.map +1 -0
  469. package/dist/services/mfa.service.d.ts +35 -0
  470. package/dist/services/mfa.service.d.ts.map +1 -0
  471. package/dist/services/mfa.service.js +449 -0
  472. package/dist/services/mfa.service.js.map +1 -0
  473. package/dist/services/password.service.d.ts +19 -0
  474. package/dist/services/password.service.d.ts.map +1 -0
  475. package/dist/services/password.service.js +150 -0
  476. package/dist/services/password.service.js.map +1 -0
  477. package/dist/services/phone-verification.service.d.ts +32 -0
  478. package/dist/services/phone-verification.service.d.ts.map +1 -0
  479. package/dist/services/phone-verification.service.js +474 -0
  480. package/dist/services/phone-verification.service.js.map +1 -0
  481. package/dist/services/risk-detection.service.d.ts +30 -0
  482. package/dist/services/risk-detection.service.d.ts.map +1 -0
  483. package/dist/services/risk-detection.service.js +518 -0
  484. package/dist/services/risk-detection.service.js.map +1 -0
  485. package/dist/services/risk-scoring.service.d.ts +12 -0
  486. package/dist/services/risk-scoring.service.d.ts.map +1 -0
  487. package/dist/services/risk-scoring.service.js +44 -0
  488. package/dist/services/risk-scoring.service.js.map +1 -0
  489. package/dist/services/session.service.d.ts +64 -0
  490. package/dist/services/session.service.d.ts.map +1 -0
  491. package/dist/services/session.service.js +455 -0
  492. package/dist/services/session.service.js.map +1 -0
  493. package/dist/services/social-auth-base.service.d.ts +57 -0
  494. package/dist/services/social-auth-base.service.d.ts.map +1 -0
  495. package/dist/services/social-auth-base.service.js +340 -0
  496. package/dist/services/social-auth-base.service.js.map +1 -0
  497. package/dist/services/social-auth.service.d.ts +31 -0
  498. package/dist/services/social-auth.service.d.ts.map +1 -0
  499. package/dist/services/social-auth.service.js +172 -0
  500. package/dist/services/social-auth.service.js.map +1 -0
  501. package/dist/services/social-provider-registry.service.d.ts +9 -0
  502. package/dist/services/social-provider-registry.service.d.ts.map +1 -0
  503. package/dist/services/social-provider-registry.service.js +30 -0
  504. package/dist/services/social-provider-registry.service.js.map +1 -0
  505. package/dist/services/trusted-device.service.d.ts +29 -0
  506. package/dist/services/trusted-device.service.d.ts.map +1 -0
  507. package/dist/services/trusted-device.service.js +190 -0
  508. package/dist/services/trusted-device.service.js.map +1 -0
  509. package/dist/storage/account-lockout-storage.service.d.ts +16 -0
  510. package/dist/storage/account-lockout-storage.service.d.ts.map +1 -0
  511. package/dist/storage/account-lockout-storage.service.js +50 -0
  512. package/dist/storage/account-lockout-storage.service.js.map +1 -0
  513. package/dist/storage/index.d.ts +4 -0
  514. package/dist/storage/index.d.ts.map +1 -0
  515. package/dist/storage/index.js +20 -0
  516. package/dist/storage/index.js.map +1 -0
  517. package/dist/storage/memory-storage.adapter.d.ts +33 -0
  518. package/dist/storage/memory-storage.adapter.d.ts.map +1 -0
  519. package/dist/storage/memory-storage.adapter.js +195 -0
  520. package/dist/storage/memory-storage.adapter.js.map +1 -0
  521. package/dist/storage/rate-limit-storage.service.d.ts +11 -0
  522. package/dist/storage/rate-limit-storage.service.d.ts.map +1 -0
  523. package/dist/storage/rate-limit-storage.service.js +33 -0
  524. package/dist/storage/rate-limit-storage.service.js.map +1 -0
  525. package/dist/templates/html-template.engine.d.ts +16 -0
  526. package/dist/templates/html-template.engine.d.ts.map +1 -0
  527. package/dist/templates/html-template.engine.js +502 -0
  528. package/dist/templates/html-template.engine.js.map +1 -0
  529. package/dist/templates/index.d.ts +2 -0
  530. package/dist/templates/index.d.ts.map +1 -0
  531. package/dist/templates/index.js +18 -0
  532. package/dist/templates/index.js.map +1 -0
  533. package/dist/utils/common-passwords.d.ts +4 -0
  534. package/dist/utils/common-passwords.d.ts.map +1 -0
  535. package/dist/utils/common-passwords.js +108 -0
  536. package/dist/utils/common-passwords.js.map +1 -0
  537. package/dist/utils/context-storage.d.ts +13 -0
  538. package/dist/utils/context-storage.d.ts.map +1 -0
  539. package/dist/utils/context-storage.js +54 -0
  540. package/dist/utils/context-storage.js.map +1 -0
  541. package/dist/utils/cookie-names.util.d.ts +7 -0
  542. package/dist/utils/cookie-names.util.d.ts.map +1 -0
  543. package/dist/utils/cookie-names.util.js +30 -0
  544. package/dist/utils/cookie-names.util.js.map +1 -0
  545. package/dist/utils/cookies.util.d.ts +12 -0
  546. package/dist/utils/cookies.util.d.ts.map +1 -0
  547. package/dist/utils/cookies.util.js +48 -0
  548. package/dist/utils/cookies.util.js.map +1 -0
  549. package/dist/utils/index.d.ts +8 -0
  550. package/dist/utils/index.d.ts.map +1 -0
  551. package/dist/utils/index.js +24 -0
  552. package/dist/utils/index.js.map +1 -0
  553. package/dist/utils/ip-extractor.d.ts +12 -0
  554. package/dist/utils/ip-extractor.d.ts.map +1 -0
  555. package/dist/utils/ip-extractor.js +88 -0
  556. package/dist/utils/ip-extractor.js.map +1 -0
  557. package/dist/utils/nauth-logger.d.ts +20 -0
  558. package/dist/utils/nauth-logger.d.ts.map +1 -0
  559. package/dist/utils/nauth-logger.js +129 -0
  560. package/dist/utils/nauth-logger.js.map +1 -0
  561. package/dist/utils/pii-redactor.d.ts +16 -0
  562. package/dist/utils/pii-redactor.d.ts.map +1 -0
  563. package/dist/utils/pii-redactor.js +147 -0
  564. package/dist/utils/pii-redactor.js.map +1 -0
  565. package/dist/utils/setup/get-repositories.d.ts +16 -0
  566. package/dist/utils/setup/get-repositories.d.ts.map +1 -0
  567. package/dist/utils/setup/get-repositories.js +36 -0
  568. package/dist/utils/setup/get-repositories.js.map +1 -0
  569. package/dist/utils/setup/init-services.d.ts +41 -0
  570. package/dist/utils/setup/init-services.d.ts.map +1 -0
  571. package/dist/utils/setup/init-services.js +107 -0
  572. package/dist/utils/setup/init-services.js.map +1 -0
  573. package/dist/utils/setup/init-social.d.ts +13 -0
  574. package/dist/utils/setup/init-social.d.ts.map +1 -0
  575. package/dist/utils/setup/init-social.js +77 -0
  576. package/dist/utils/setup/init-social.js.map +1 -0
  577. package/dist/utils/setup/init-storage.d.ts +4 -0
  578. package/dist/utils/setup/init-storage.d.ts.map +1 -0
  579. package/dist/utils/setup/init-storage.js +79 -0
  580. package/dist/utils/setup/init-storage.js.map +1 -0
  581. package/dist/utils/setup/register-mfa.d.ts +5 -0
  582. package/dist/utils/setup/register-mfa.d.ts.map +1 -0
  583. package/dist/utils/setup/register-mfa.js +85 -0
  584. package/dist/utils/setup/register-mfa.js.map +1 -0
  585. package/dist/utils/setup/run-nauth-migrations.d.ts +5 -0
  586. package/dist/utils/setup/run-nauth-migrations.d.ts.map +1 -0
  587. package/dist/utils/setup/run-nauth-migrations.js +67 -0
  588. package/dist/utils/setup/run-nauth-migrations.js.map +1 -0
  589. package/dist/utils/token-delivery-policy.d.ts +6 -0
  590. package/dist/utils/token-delivery-policy.d.ts.map +1 -0
  591. package/dist/utils/token-delivery-policy.js +15 -0
  592. package/dist/utils/token-delivery-policy.js.map +1 -0
  593. package/dist/validators/template.validator.d.ts +7 -0
  594. package/dist/validators/template.validator.d.ts.map +1 -0
  595. package/dist/validators/template.validator.js +95 -0
  596. package/dist/validators/template.validator.js.map +1 -0
  597. package/jest.config.js +15 -0
  598. package/jest.setup.ts +6 -0
  599. package/package.json +73 -0
  600. package/src/adapters/database-columns.ts +165 -0
  601. package/src/adapters/express.adapter.ts +385 -0
  602. package/src/adapters/fastify.adapter.ts +416 -0
  603. package/src/adapters/index.ts +16 -0
  604. package/src/adapters/storage.factory.ts +143 -0
  605. package/src/bootstrap.ts +374 -0
  606. package/src/dto/auth-challenge.dto.ts +231 -0
  607. package/src/dto/auth-response.dto.ts +253 -0
  608. package/src/dto/challenge-response.dto.ts +234 -0
  609. package/src/dto/change-password-request.dto.ts +50 -0
  610. package/src/dto/change-password-response.dto.ts +29 -0
  611. package/src/dto/change-password.dto.ts +57 -0
  612. package/src/dto/error-response.dto.ts +136 -0
  613. package/src/dto/get-available-methods.dto.ts +55 -0
  614. package/src/dto/get-challenge-data-response.dto.ts +28 -0
  615. package/src/dto/get-challenge-data.dto.ts +69 -0
  616. package/src/dto/get-client-info.dto.ts +104 -0
  617. package/src/dto/get-device-token-response.dto.ts +25 -0
  618. package/src/dto/get-events-by-type.dto.ts +76 -0
  619. package/src/dto/get-ip-address-response.dto.ts +24 -0
  620. package/src/dto/get-mfa-status.dto.ts +94 -0
  621. package/src/dto/get-risk-assessment-history.dto.ts +39 -0
  622. package/src/dto/get-session-id-response.dto.ts +25 -0
  623. package/src/dto/get-setup-data-response.dto.ts +31 -0
  624. package/src/dto/get-setup-data.dto.ts +75 -0
  625. package/src/dto/get-suspicious-activity.dto.ts +42 -0
  626. package/src/dto/get-user-agent-response.dto.ts +23 -0
  627. package/src/dto/get-user-auth-history.dto.ts +95 -0
  628. package/src/dto/get-user-by-email.dto.ts +61 -0
  629. package/src/dto/get-user-by-id.dto.ts +46 -0
  630. package/src/dto/get-user-devices.dto.ts +53 -0
  631. package/src/dto/get-user-response.dto.ts +17 -0
  632. package/src/dto/has-provider.dto.ts +56 -0
  633. package/src/dto/index.ts +57 -0
  634. package/src/dto/is-trusted-device-response.dto.ts +34 -0
  635. package/src/dto/list-providers-response.dto.ts +23 -0
  636. package/src/dto/login.dto.ts +95 -0
  637. package/src/dto/logout-all-response.dto.ts +24 -0
  638. package/src/dto/logout-all.dto.ts +65 -0
  639. package/src/dto/logout-response.dto.ts +25 -0
  640. package/src/dto/logout.dto.ts +64 -0
  641. package/src/dto/refresh-token.dto.ts +36 -0
  642. package/src/dto/remove-devices.dto.ts +85 -0
  643. package/src/dto/resend-code-response.dto.ts +32 -0
  644. package/src/dto/resend-code.dto.ts +51 -0
  645. package/src/dto/reset-password.dto.ts +115 -0
  646. package/src/dto/respond-challenge.dto.ts +272 -0
  647. package/src/dto/set-mfa-exemption.dto.ts +112 -0
  648. package/src/dto/set-must-change-password-response.dto.ts +27 -0
  649. package/src/dto/set-must-change-password.dto.ts +46 -0
  650. package/src/dto/set-preferred-method.dto.ts +80 -0
  651. package/src/dto/setup-mfa.dto.ts +98 -0
  652. package/src/dto/signup.dto.ts +174 -0
  653. package/src/dto/social-auth.dto.ts +422 -0
  654. package/src/dto/trust-device-response.dto.ts +30 -0
  655. package/src/dto/trust-device.dto.ts +9 -0
  656. package/src/dto/update-user-attributes-request.dto.ts +51 -0
  657. package/src/dto/user-response.dto.ts +138 -0
  658. package/src/dto/user-update.dto.ts +222 -0
  659. package/src/dto/verify-email.dto.ts +313 -0
  660. package/src/dto/verify-mfa-code.dto.ts +103 -0
  661. package/src/dto/verify-phone-by-sub.dto.ts +78 -0
  662. package/src/dto/verify-phone.dto.ts +245 -0
  663. package/src/entities/auth-audit.entity.ts +232 -0
  664. package/src/entities/challenge-session.entity.ts +116 -0
  665. package/src/entities/index.ts +29 -0
  666. package/src/entities/login-attempt.entity.ts +64 -0
  667. package/src/entities/mfa-device.entity.ts +151 -0
  668. package/src/entities/rate-limit.entity.ts +44 -0
  669. package/src/entities/session.entity.ts +180 -0
  670. package/src/entities/social-account.entity.ts +96 -0
  671. package/src/entities/storage-lock.entity.ts +39 -0
  672. package/src/entities/trusted-device.entity.ts +112 -0
  673. package/src/entities/user.entity.ts +243 -0
  674. package/src/entities/verification-token.entity.ts +141 -0
  675. package/src/enums/auth-audit-event-type.enum.ts +360 -0
  676. package/src/enums/error-codes.enum.ts +420 -0
  677. package/src/enums/mfa-method.enum.ts +97 -0
  678. package/src/enums/risk-factor.enum.ts +111 -0
  679. package/src/exceptions/nauth.exception.ts +231 -0
  680. package/src/handlers/auth.handler.ts +260 -0
  681. package/src/handlers/client-info.handler.ts +101 -0
  682. package/src/handlers/csrf.handler.ts +156 -0
  683. package/src/handlers/token-delivery.handler.ts +118 -0
  684. package/src/index.ts +118 -0
  685. package/src/interfaces/client-info.interface.ts +85 -0
  686. package/src/interfaces/config.interface.ts +2135 -0
  687. package/src/interfaces/entities.interface.ts +226 -0
  688. package/src/interfaces/index.ts +15 -0
  689. package/src/interfaces/logger.interface.ts +283 -0
  690. package/src/interfaces/mfa-provider.interface.ts +154 -0
  691. package/src/interfaces/oauth.interface.ts +148 -0
  692. package/src/interfaces/provider.interface.ts +47 -0
  693. package/src/interfaces/social-auth-provider.interface.ts +131 -0
  694. package/src/interfaces/storage-adapter.interface.ts +82 -0
  695. package/src/interfaces/template.interface.ts +510 -0
  696. package/src/interfaces/token-verifier.interface.ts +110 -0
  697. package/src/internal.ts +178 -0
  698. package/src/platform/interfaces.ts +299 -0
  699. package/src/schemas/auth-config.schema.ts +646 -0
  700. package/src/services/adaptive-mfa-decision.service.spec.ts +1058 -0
  701. package/src/services/adaptive-mfa-decision.service.ts +457 -0
  702. package/src/services/auth-audit.service.spec.ts +675 -0
  703. package/src/services/auth-audit.service.ts +558 -0
  704. package/src/services/auth-challenge-helper.service.spec.ts +3227 -0
  705. package/src/services/auth-challenge-helper.service.ts +825 -0
  706. package/src/services/auth-flow-context-builder.service.ts +520 -0
  707. package/src/services/auth-flow-rules.ts +202 -0
  708. package/src/services/auth-flow-state-definitions.ts +190 -0
  709. package/src/services/auth-flow-state-machine.service.ts +207 -0
  710. package/src/services/auth-flow-state-machine.types.ts +316 -0
  711. package/src/services/auth.service.spec.ts +4195 -0
  712. package/src/services/auth.service.ts +3727 -0
  713. package/src/services/challenge.service.spec.ts +1363 -0
  714. package/src/services/challenge.service.ts +696 -0
  715. package/src/services/client-info.service.spec.ts +572 -0
  716. package/src/services/client-info.service.ts +374 -0
  717. package/src/services/csrf.service.ts +54 -0
  718. package/src/services/email-verification.service.spec.ts +1229 -0
  719. package/src/services/email-verification.service.ts +578 -0
  720. package/src/services/geo-location.service.spec.ts +603 -0
  721. package/src/services/geo-location.service.ts +599 -0
  722. package/src/services/index.ts +13 -0
  723. package/src/services/jwt.service.spec.ts +882 -0
  724. package/src/services/jwt.service.ts +621 -0
  725. package/src/services/mfa-base.service.spec.ts +246 -0
  726. package/src/services/mfa-base.service.ts +611 -0
  727. package/src/services/mfa.service.spec.ts +693 -0
  728. package/src/services/mfa.service.ts +960 -0
  729. package/src/services/password.service.spec.ts +166 -0
  730. package/src/services/password.service.ts +309 -0
  731. package/src/services/phone-verification.service.spec.ts +1120 -0
  732. package/src/services/phone-verification.service.ts +751 -0
  733. package/src/services/risk-detection.service.spec.ts +1292 -0
  734. package/src/services/risk-detection.service.ts +1012 -0
  735. package/src/services/risk-scoring.service.spec.ts +204 -0
  736. package/src/services/risk-scoring.service.ts +131 -0
  737. package/src/services/session.service.spec.ts +1293 -0
  738. package/src/services/session.service.ts +803 -0
  739. package/src/services/social-account.service.spec.ts +725 -0
  740. package/src/services/social-auth-base.service.spec.ts +418 -0
  741. package/src/services/social-auth-base.service.ts +581 -0
  742. package/src/services/social-auth.service.spec.ts +238 -0
  743. package/src/services/social-auth.service.ts +436 -0
  744. package/src/services/social-provider-registry.service.spec.ts +238 -0
  745. package/src/services/social-provider-registry.service.ts +122 -0
  746. package/src/services/trusted-device.service.spec.ts +505 -0
  747. package/src/services/trusted-device.service.ts +339 -0
  748. package/src/storage/account-lockout-storage.service.spec.ts +310 -0
  749. package/src/storage/account-lockout-storage.service.ts +89 -0
  750. package/src/storage/index.ts +3 -0
  751. package/src/storage/memory-storage.adapter.ts +443 -0
  752. package/src/storage/rate-limit-storage.service.spec.ts +247 -0
  753. package/src/storage/rate-limit-storage.service.ts +38 -0
  754. package/src/templates/html-template.engine.spec.ts +161 -0
  755. package/src/templates/html-template.engine.ts +688 -0
  756. package/src/templates/index.ts +7 -0
  757. package/src/utils/common-passwords.spec.ts +230 -0
  758. package/src/utils/common-passwords.ts +170 -0
  759. package/src/utils/context-storage.ts +188 -0
  760. package/src/utils/cookie-names.util.ts +67 -0
  761. package/src/utils/cookies.util.ts +94 -0
  762. package/src/utils/index.ts +12 -0
  763. package/src/utils/ip-extractor.spec.ts +330 -0
  764. package/src/utils/ip-extractor.ts +220 -0
  765. package/src/utils/nauth-logger.spec.ts +388 -0
  766. package/src/utils/nauth-logger.ts +215 -0
  767. package/src/utils/pii-redactor.spec.ts +130 -0
  768. package/src/utils/pii-redactor.ts +288 -0
  769. package/src/utils/setup/get-repositories.ts +140 -0
  770. package/src/utils/setup/init-services.ts +422 -0
  771. package/src/utils/setup/init-social.ts +189 -0
  772. package/src/utils/setup/init-storage.ts +94 -0
  773. package/src/utils/setup/register-mfa.ts +165 -0
  774. package/src/utils/setup/run-nauth-migrations.ts +61 -0
  775. package/src/utils/token-delivery-policy.ts +38 -0
  776. package/src/validators/template.validator.ts +219 -0
  777. package/tsconfig.json +37 -0
  778. package/tsconfig.lint.json +6 -0
@@ -0,0 +1,3727 @@
1
+ import { Repository } from 'typeorm';
2
+ import { IUser, ISession } from '../interfaces/entities.interface';
3
+ import { BaseUser, BaseLoginAttempt, BaseMFADevice } from '../entities';
4
+ import { PasswordService } from './password.service';
5
+ import { JwtService } from './jwt.service';
6
+ import { SessionService } from './session.service';
7
+ import { EmailVerificationService } from './email-verification.service';
8
+ import { PhoneVerificationService } from './phone-verification.service';
9
+ import { ClientInfoService } from './client-info.service';
10
+ import { ChallengeService } from './challenge.service';
11
+ import { AuthChallengeHelperService } from './auth-challenge-helper.service';
12
+ import { AccountLockoutStorageService } from '../storage/account-lockout-storage.service';
13
+ import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
14
+ import { TrustedDeviceService } from './trusted-device.service';
15
+ import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
16
+ import { RiskFactor } from '../enums/risk-factor.enum';
17
+ import { MFAService } from './mfa.service';
18
+ import { ContextStorage } from '../utils/context-storage';
19
+ import { SignupDTO } from '../dto/signup.dto';
20
+ import { LoginDTO } from '../dto/login.dto';
21
+ import { ChangePasswordRequestDTO } from '../dto/change-password-request.dto';
22
+ import { ChangePasswordResponseDTO } from '../dto/change-password-response.dto';
23
+ import { UpdateUserAttributesRequestDTO } from '../dto/update-user-attributes-request.dto';
24
+ import { UserResponseDto } from '../dto/user-response.dto';
25
+ import { AuthResponseDTO, TokenResponse } from '../dto/auth-response.dto';
26
+ import { AuthChallenge } from '../dto/auth-challenge.dto';
27
+ import {
28
+ ChallengeResponseData,
29
+ VerifyEmailResponse,
30
+ CollectPhoneResponse,
31
+ VerifyPhoneResponse,
32
+ VerifyMFACodeResponse,
33
+ VerifyMFAPasskeyResponse,
34
+ ForceChangePasswordResponse,
35
+ MFASetupResponse,
36
+ } from '../dto/challenge-response.dto';
37
+ import { RespondChallengeDTO } from '../dto/respond-challenge.dto';
38
+ import { GetUserByEmailDTO } from '../dto/get-user-by-email.dto';
39
+ import { GetUserByIdDTO } from '../dto/get-user-by-id.dto';
40
+ import { LogoutDTO } from '../dto/logout.dto';
41
+ import { LogoutResponseDTO } from '../dto/logout-response.dto';
42
+ import { LogoutAllDTO } from '../dto/logout-all.dto';
43
+ import { LogoutAllResponseDTO } from '../dto/logout-all-response.dto';
44
+ import { RefreshTokenDTO } from '../dto/refresh-token.dto';
45
+ import { ResendCodeDTO } from '../dto/resend-code.dto';
46
+ import { ResendCodeResponseDTO } from '../dto/resend-code-response.dto';
47
+ import { SetMustChangePasswordDTO } from '../dto/set-must-change-password.dto';
48
+ import { SetMustChangePasswordResponseDTO } from '../dto/set-must-change-password-response.dto';
49
+ import { TrustDeviceResponseDTO } from '../dto/trust-device-response.dto';
50
+ import { IsTrustedDeviceResponseDTO } from '../dto/is-trusted-device-response.dto';
51
+ import { VerifyEmailWithCodeDTO, ResendVerificationEmailDTO } from '../dto/verify-email.dto';
52
+ import { SendVerificationSMSDTO, ResendVerificationSMSDTO } from '../dto/verify-phone.dto';
53
+ import { VerifyPhoneWithCodeBySubDTO } from '../dto/verify-phone-by-sub.dto';
54
+
55
+ import { NAuthConfig } from '../interfaces/config.interface';
56
+ import { NAuthLogger } from '../utils/nauth-logger';
57
+ import { NAuthException } from '../exceptions/nauth.exception';
58
+ import { AuthErrorCode } from '../enums/error-codes.enum';
59
+ import { MFAMethod } from '../enums/mfa-method.enum';
60
+ import * as crypto from 'crypto';
61
+
62
+ /**
63
+ * Dummy Argon2 hash for constant-time response
64
+ *
65
+ * ⚠️ SECURITY CRITICAL: Used when user doesn't exist to prevent timing attacks
66
+ * This dummy hash has same format/cost as real Argon2id hashes but verifies against nothing.
67
+ *
68
+ * Format: $argon2id$v=19$m=65536,t=3,p=4$salt$hash
69
+ */
70
+ const DUMMY_ARGON2_HASH =
71
+ '$argon2id$v=19$m=65536,t=3,p=4$RFVNTVlfU0FMVF9GT1JfVElNSU5H$dummyhashfordummyhashfordummyhash1234567890';
72
+
73
+ export class AuthService {
74
+ constructor(
75
+ private readonly userRepository: Repository<BaseUser>,
76
+ private readonly loginAttemptRepository: Repository<BaseLoginAttempt>,
77
+ private readonly passwordService: PasswordService,
78
+ private readonly jwtService: JwtService,
79
+ private readonly sessionService: SessionService,
80
+ private readonly challengeService: ChallengeService,
81
+ private readonly challengeHelper: AuthChallengeHelperService,
82
+ private readonly emailVerificationService: EmailVerificationService,
83
+ private readonly clientInfoService: ClientInfoService,
84
+ private readonly accountLockoutStorage: AccountLockoutStorageService,
85
+ private readonly config: NAuthConfig,
86
+ private readonly logger: NAuthLogger,
87
+ private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
88
+ private readonly phoneVerificationService?: PhoneVerificationService, // Optional - only available when SMS provider is configured
89
+ private readonly mfaService?: MFAService, // Optional - available when MFA modules are imported
90
+ private readonly mfaDeviceRepository?: Repository<BaseMFADevice>, // Optional - available when MFA modules are imported
91
+ private readonly trustedDeviceService?: TrustedDeviceService, // Optional - only available when rememberDevices is not 'never'
92
+ ) {
93
+ this.logger?.log?.('AuthService initialized');
94
+ }
95
+
96
+ // ============================================================================
97
+ // User Signup
98
+ // ============================================================================
99
+
100
+ /**
101
+ * Register a new user.
102
+ *
103
+ * Checks for duplicates (email, username, phone), validates password, hashes it,
104
+ * creates the user, and returns tokens or a challenge if verification is required.
105
+ *
106
+ * @param dto - Signup payload
107
+ * @returns Auth response with tokens or a verification challenge
108
+ * @throws {NAuthException} If user exists, password is invalid, or signup is disabled
109
+ *
110
+ * @example
111
+ * ```typescript
112
+ * const result = await authService.signup({
113
+ * email: 'user@example.com',
114
+ * password: 'Password123!',
115
+ * username: 'johndoe',
116
+ * });
117
+ * ```
118
+ */
119
+ async signup(dto: SignupDTO): Promise<AuthResponseDTO> {
120
+ // Get client info from request context (transparent!)
121
+ const clientInfo = this.clientInfoService.get();
122
+
123
+ this.logger?.log?.(`Signup attempt for email: ${dto.email}`);
124
+ this.logger?.debug?.(
125
+ `Signup details: { email: ${dto.email}, username: ${dto.username || 'none'}, ip: ${clientInfo.ipAddress} }`,
126
+ );
127
+
128
+ // Check if signup is enabled
129
+ if (this.config.signup?.enabled === false) {
130
+ this.logger?.warn?.(`Signup blocked - signup is disabled`);
131
+ throw new NAuthException(AuthErrorCode.SIGNUP_DISABLED, 'Signups are currently disabled');
132
+ }
133
+
134
+ // Check if user already exists (email and username)
135
+ this.logger?.debug?.(`Checking if user exists: ${dto.email}`);
136
+ const existingUserByEmail = await this.userRepository.findOne({
137
+ where: { email: dto.email },
138
+ });
139
+
140
+ if (existingUserByEmail) {
141
+ this.logger?.warn?.(`Signup failed - user already exists: ${dto.email}`);
142
+ throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
143
+ }
144
+
145
+ // Check for duplicate username if provided
146
+ if (dto.username) {
147
+ this.logger?.debug?.(`Checking if username exists: ${dto.username}`);
148
+ const existingUserByUsername = await this.userRepository.findOne({
149
+ where: { username: dto.username },
150
+ });
151
+
152
+ if (existingUserByUsername) {
153
+ this.logger?.warn?.(`Signup failed - username already exists: ${dto.username}`);
154
+ throw new NAuthException(AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
155
+ }
156
+ }
157
+
158
+ // Check for duplicate phone if provided and duplicates not allowed
159
+ if (dto.phone && !this.config.signup?.allowDuplicatePhones) {
160
+ this.logger?.debug?.(`Checking if phone exists: ${dto.phone}`);
161
+ const existingUserByPhone = await this.userRepository.findOne({
162
+ where: { phone: dto.phone },
163
+ });
164
+
165
+ if (existingUserByPhone) {
166
+ this.logger?.warn?.(`Signup failed - phone already exists: ${dto.phone}`);
167
+ throw new NAuthException(AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
168
+ }
169
+ }
170
+
171
+ // Validate password policy
172
+ this.logger?.debug?.('Validating password against policy');
173
+ const passwordValidation = await this.passwordService.validatePassword(dto.password, {
174
+ email: dto.email,
175
+ username: dto.username,
176
+ });
177
+
178
+ if (!passwordValidation.valid) {
179
+ this.logger?.warn?.(`Password validation failed for ${dto.email}: ${passwordValidation.errors.join(', ')}`);
180
+ throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, passwordValidation.errors.join(', '), {
181
+ errors: passwordValidation.errors,
182
+ });
183
+ }
184
+
185
+ // Hash password
186
+ const passwordHash = await this.passwordService.hashPassword(dto.password);
187
+
188
+ // Determine verification requirements based on verification method
189
+ const verificationMethod = this.config.signup?.verificationMethod;
190
+
191
+ // Validate required fields based on verification method
192
+ if ((verificationMethod === 'phone' || verificationMethod === 'both') && !dto.phone) {
193
+ this.logger?.warn?.(`Signup failed - phone required for verification method: ${verificationMethod}`);
194
+ throw new NAuthException(
195
+ AuthErrorCode.PHONE_REQUIRED,
196
+ 'Phone number is required for the selected verification method',
197
+ { verificationMethod },
198
+ );
199
+ }
200
+
201
+ // Create user
202
+ // Users are always created as ACTIVE (so they can complete pending challenges)
203
+ // Verification status controls access via challenge system, not account activation
204
+ // Email and phone verification status is always false initially - must be explicitly verified
205
+ this.logger?.debug?.(`Creating user record for: ${dto.email} || ${dto.username} || ${dto.phone}`);
206
+ const user = this.userRepository.create({
207
+ email: dto.email,
208
+ username: dto.username,
209
+ firstName: dto.firstName,
210
+ lastName: dto.lastName,
211
+ phone: dto.phone,
212
+ passwordHash,
213
+ passwordChangedAt: new Date(),
214
+ isEmailVerified: false, // Always false initially - must be explicitly verified
215
+ isPhoneVerified: false, // Always false initially - must be verified via SMS
216
+ isActive: true, // Always active - challenges control access instead
217
+ metadata: dto.metadata,
218
+ });
219
+
220
+ let savedUser: IUser;
221
+ try {
222
+ savedUser = (await this.userRepository.save(user)) as unknown as IUser;
223
+ this.logger?.log?.(`User created successfully: ${dto.email} (sub: ${savedUser.sub})`);
224
+
225
+ // ============================================================================
226
+ // Audit: Record account creation
227
+ // ============================================================================
228
+ try {
229
+ await this.auditService?.recordEvent({
230
+ userId: savedUser.id,
231
+ eventType: AuthAuditEventType.ACCOUNT_CREATED,
232
+ eventStatus: 'INFO',
233
+ authMethod: 'password',
234
+ // Client info automatically included from context
235
+ metadata: {
236
+ email: savedUser.email,
237
+ username: savedUser.username || null,
238
+ verificationMethod,
239
+ },
240
+ });
241
+ } catch (auditError) {
242
+ // Non-blocking: Log but continue
243
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
244
+ this.logger?.error?.(`Failed to record ACCOUNT_CREATED audit event: ${errorMessage}`, {
245
+ error: auditError,
246
+ userId: savedUser.id,
247
+ });
248
+ }
249
+ } catch (error: unknown) {
250
+ // Handle database constraint violations gracefully
251
+ if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
252
+ // PostgreSQL unique constraint violation
253
+ const dbError = error as { code: string; detail?: string; message?: string };
254
+ if (dbError.detail?.includes('email')) {
255
+ this.logger?.warn?.(`Signup failed - email constraint violation: ${dto.email}`);
256
+ throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
257
+ } else if (dbError.detail?.includes('username')) {
258
+ this.logger?.warn?.(`Signup failed - username constraint violation: ${dto.username}`);
259
+ throw new NAuthException(AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
260
+ } else if (dbError.detail?.includes('phone')) {
261
+ this.logger?.warn?.(`Signup failed - phone constraint violation: ${dto.phone}`);
262
+ throw new NAuthException(AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
263
+ } else {
264
+ this.logger?.error?.(`Signup failed - database constraint violation: ${dbError.message}`);
265
+ throw new NAuthException(AuthErrorCode.EMAIL_EXISTS, 'User with this information already exists', {
266
+ conflictType: 'unknown',
267
+ });
268
+ }
269
+ }
270
+
271
+ // Re-throw other database errors
272
+ const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
273
+ this.logger?.error?.(`Signup failed - database error: ${errorMessage}`);
274
+ throw error;
275
+ }
276
+
277
+ // ============================================================================
278
+ // Verification Code Sending: Handled by challenge system (sequential flow)
279
+ // ============================================================================
280
+ // All verification codes are sent when challenges are created (in AuthChallengeHelperService.createChallengeResponse)
281
+ // This ensures proper sequential flow: email code first, then phone code after email is verified
282
+ // This prevents user confusion from receiving multiple codes at once
283
+
284
+ // Execute afterSignup hook if configured
285
+ if (this.config.hooks?.afterSignup) {
286
+ await this.config.hooks.afterSignup(savedUser, { requiresVerification: verificationMethod !== 'none' });
287
+ }
288
+
289
+ // ============================================================================
290
+ // Challenge System: Determine if user needs to complete challenges
291
+ // ============================================================================
292
+
293
+ const response = await this.challengeHelper.determineAuthResponse({
294
+ user: savedUser,
295
+ config: this.config,
296
+ deviceToken: clientInfo.deviceToken,
297
+ });
298
+
299
+ if (response.challengeName) {
300
+ this.logger?.log?.(`Challenge required for user ${savedUser.sub}: ${response.challengeName}`);
301
+ } else {
302
+ this.logger?.log?.(`Signup successful - tokens issued for: ${dto.email}`);
303
+ }
304
+
305
+ return response;
306
+ }
307
+
308
+ // ============================================================================
309
+ // User Login
310
+ // ============================================================================
311
+ /**
312
+ * Log in a user with identifier (email, username, or phone) and password.
313
+ *
314
+ * Handles client/device context, login hooks, lockout checks, audit logging, password verification,
315
+ * and challenge flow (MFA/verification) if required.
316
+ *
317
+ * @param dto - Login credentials (identifier and password)
318
+ * @returns Authentication response containing challenge details if required, or tokens on success
319
+ * @throws {NAuthException} On login failure, forbidden access, or account lockout
320
+ *
321
+ * @example
322
+ * ```typescript
323
+ * const res = await authService.login({ identifier: 'user@email.com', password: 'Pass123!' });
324
+ * if (res.challengeName) {
325
+ * // prompt user for verification code
326
+ * }
327
+ * ```
328
+ */
329
+ async login(dto: LoginDTO): Promise<AuthResponseDTO> {
330
+ // Get client info from request context (transparent!)
331
+ const clientInfo = this.clientInfoService.get();
332
+ const fireAndForget = this.config.auditLogs?.fireAndForget === true;
333
+
334
+ this.logger?.log?.(`Login attempt for: ${dto.identifier}`);
335
+ this.logger?.debug?.(
336
+ `Login details: { identifier: ${dto.identifier}, ip: ${clientInfo.ipAddress}, deviceToken: ${clientInfo.deviceToken ? 'present' : 'none'} }`,
337
+ );
338
+
339
+ // Check IP-based account lockout
340
+ if (this.config.lockout?.enabled) {
341
+ const clientInfo = this.clientInfoService.get();
342
+ const ipAddress = clientInfo.ipAddress;
343
+
344
+ if (ipAddress) {
345
+ this.logger?.debug?.(`Checking IP lockout status for: ${ipAddress}`);
346
+ const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
347
+ if (isLocked) {
348
+ this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
349
+ await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
350
+
351
+ // ============================================================================
352
+ // Audit: Record blocked login (IP locked)
353
+ // ============================================================================
354
+ if (fireAndForget) {
355
+ this.auditService
356
+ ?.recordEvent({
357
+ userSub: dto.identifier,
358
+ eventType: AuthAuditEventType.LOGIN_BLOCKED,
359
+ eventStatus: 'FAILURE',
360
+ authMethod: 'password',
361
+ reason: 'ip_locked',
362
+ description: 'Login blocked - IP address locked due to too many failed attempts',
363
+ })
364
+ .catch((err) => {
365
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
366
+ this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (fire-and-forget): ${errorMessage}`, {
367
+ error: err,
368
+ identifier: dto.identifier,
369
+ });
370
+ });
371
+ } else {
372
+ try {
373
+ await this.auditService?.recordEvent({
374
+ userSub: dto.identifier,
375
+ eventType: AuthAuditEventType.LOGIN_BLOCKED,
376
+ eventStatus: 'FAILURE',
377
+ authMethod: 'password',
378
+ reason: 'ip_locked',
379
+ description: 'Login blocked - IP address locked due to too many failed attempts',
380
+ });
381
+ } catch (auditError) {
382
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
383
+ this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (IP locked): ${errorMessage}`, {
384
+ error: auditError,
385
+ });
386
+ }
387
+ }
388
+
389
+ throw new NAuthException(
390
+ AuthErrorCode.RATE_LIMIT_LOGIN,
391
+ 'Too many failed attempts from this IP. Please try again later.',
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ // ============================================================================
398
+ // Validate identifier type based on configuration
399
+ // ============================================================================
400
+ const identifierType = this.config.login?.identifierType;
401
+ if (identifierType) {
402
+ this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
403
+ const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
404
+ if (!isValidIdentifier) {
405
+ this.logger?.warn?.(
406
+ `Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`,
407
+ );
408
+ await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
409
+ throw new NAuthException(
410
+ AuthErrorCode.INVALID_CREDENTIALS,
411
+ `Login with this identifier type is not allowed. Expected: ${identifierType}`,
412
+ );
413
+ }
414
+ }
415
+
416
+ // Find user by email, username, or phone (filtered by identifierType config)
417
+ this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
418
+ const user = await this.findUserByIdentifier(dto.identifier, identifierType);
419
+
420
+ // ⚠️ SECURITY CRITICAL: Always hash password even when user doesn't exist
421
+ // This ensures constant-time response to prevent user enumeration via timing attacks
422
+ const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
423
+
424
+ // Verify password (takes ~200-300ms regardless of user existence)
425
+ this.logger?.debug?.('Verifying password');
426
+ const isPasswordValid = await this.passwordService.verifyPassword(dto.password, hashToVerify);
427
+
428
+ // Now check all conditions AFTER password verification (constant time achieved)
429
+ if (!user || !user.passwordHash || !isPasswordValid) {
430
+ this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
431
+ await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
432
+
433
+ // ============================================================================
434
+ // Audit: Record failed login
435
+ // ============================================================================
436
+ if (user) {
437
+ if (fireAndForget) {
438
+ this.auditService
439
+ ?.recordEvent({
440
+ userId: user.id,
441
+ eventType: AuthAuditEventType.LOGIN_FAILED,
442
+ eventStatus: 'FAILURE',
443
+ authMethod: 'password',
444
+ reason: 'invalid_credentials',
445
+ description: 'Invalid password or user not found',
446
+ })
447
+ .catch((err) => {
448
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
449
+ this.logger?.error?.(`Failed to record LOGIN_FAILED audit event (fire-and-forget): ${errorMessage}`, {
450
+ error: err,
451
+ userId: user.id,
452
+ userSub: user.sub,
453
+ });
454
+ });
455
+ } else {
456
+ try {
457
+ await this.auditService?.recordEvent({
458
+ userId: user.id,
459
+ eventType: AuthAuditEventType.LOGIN_FAILED,
460
+ eventStatus: 'FAILURE',
461
+ authMethod: 'password',
462
+ reason: 'invalid_credentials',
463
+ description: 'Invalid password or user not found',
464
+ });
465
+ } catch (auditError) {
466
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
467
+ this.logger?.error?.(`Failed to record LOGIN_FAILED audit event: ${errorMessage}`, {
468
+ error: auditError,
469
+ userId: user?.id,
470
+ });
471
+ }
472
+ }
473
+ }
474
+
475
+ // Provide helpful error if user exists but has no password (social-only account)
476
+ if (user && !user.passwordHash && user.socialProviders && user.socialProviders.length > 0) {
477
+ const provider = user.socialProviders[0];
478
+ const providerName = provider.charAt(0).toUpperCase() + provider.slice(1);
479
+ throw new NAuthException(
480
+ AuthErrorCode.INVALID_CREDENTIALS,
481
+ `Invalid credentials - use your ${providerName} account`,
482
+ {
483
+ suggestedProvider: providerName,
484
+ },
485
+ );
486
+ }
487
+
488
+ throw new NAuthException(AuthErrorCode.INVALID_CREDENTIALS, 'Invalid credentials');
489
+ }
490
+
491
+ // ============================================================================
492
+ // Password Expiry Check
493
+ // ============================================================================
494
+ const expiryDays = this.config.password?.expiryDays;
495
+ if (expiryDays && expiryDays > 0 && user.passwordChangedAt) {
496
+ const expiryDate = new Date(user.passwordChangedAt);
497
+ expiryDate.setDate(expiryDate.getDate() + expiryDays);
498
+ const now = new Date();
499
+
500
+ if (now > expiryDate) {
501
+ this.logger?.warn?.(
502
+ `Password expired for user: ${user.sub}. Changed: ${user.passwordChangedAt}, Expiry: ${expiryDate}`,
503
+ );
504
+
505
+ // Force password change by setting mustChangePassword flag
506
+ await this.userRepository.update(user.id, {
507
+ mustChangePassword: true,
508
+ });
509
+ // Update in-memory user reference to include mustChangePassword
510
+ user.mustChangePassword = true;
511
+
512
+ // Check challenges - FORCE_CHANGE_PASSWORD will be included
513
+ const response = await this.challengeHelper.determineAuthResponse({
514
+ user,
515
+ config: this.config,
516
+ deviceToken: clientInfo.deviceToken,
517
+ isSocialLogin: false,
518
+ });
519
+
520
+ if (response.challengeName) {
521
+ this.logger?.warn?.(
522
+ `Login blocked - password expired, challenge: ${response.challengeName} for ${dto.identifier}`,
523
+ );
524
+ return response;
525
+ }
526
+ }
527
+ }
528
+
529
+ // ============================================================================
530
+ // Audit: Record login attempt for successful password verification
531
+ // ============================================================================
532
+ // Record LOGIN_ATTEMPT for all successful password verifications
533
+ // IMPORTANT: Always await this to ensure correct chronological order before risk assessment
534
+ try {
535
+ await this.auditService?.recordEvent({
536
+ userId: user.id,
537
+ eventType: AuthAuditEventType.LOGIN_ATTEMPT,
538
+ eventStatus: 'INFO',
539
+ authMethod: 'password',
540
+ description: 'Password verification successful',
541
+ });
542
+ } catch (auditError) {
543
+ // Non-blocking: Log but continue even if audit fails
544
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
545
+ this.logger?.error?.(`Failed to record LOGIN_ATTEMPT audit event: ${errorMessage}`, {
546
+ error: auditError,
547
+ userId: user.id,
548
+ });
549
+ }
550
+
551
+ // ============================================================================
552
+ // Challenge System: Determine authentication response using state machine
553
+ // ============================================================================
554
+ // All challenge determination is now handled by state machine in determineAuthResponse
555
+ // This replaces the old determinePendingChallenges and checkMFARequirement methods
556
+
557
+ const response = await this.challengeHelper.determineAuthResponse({
558
+ user,
559
+ config: this.config,
560
+ deviceToken: clientInfo.deviceToken,
561
+ isSocialLogin: false,
562
+ });
563
+
564
+ // If challenge is required, record login attempt and return challenge
565
+ if (response.challengeName) {
566
+ const reasonMap: Record<AuthChallenge, string> = {
567
+ [AuthChallenge.VERIFY_EMAIL]: 'verification_required',
568
+ [AuthChallenge.VERIFY_PHONE]: 'verification_required',
569
+ [AuthChallenge.MFA_SETUP_REQUIRED]: 'mfa_setup_required',
570
+ [AuthChallenge.FORCE_CHANGE_PASSWORD]: 'password_change_required',
571
+ [AuthChallenge.MFA_REQUIRED]: 'mfa_required',
572
+ };
573
+
574
+ this.logger?.warn?.(
575
+ `Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`,
576
+ );
577
+ await this.recordLoginAttempt(
578
+ dto.identifier,
579
+ false,
580
+ reasonMap[response.challengeName] || 'challenge_required',
581
+ user.id,
582
+ );
583
+
584
+ return response;
585
+ }
586
+
587
+ // If response already has tokens (session was created by challenge helper), return it
588
+ // This prevents duplicate session creation
589
+ if (response.accessToken && response.refreshToken) {
590
+ this.logger?.debug?.(
591
+ `Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`,
592
+ );
593
+
594
+ // Record successful login attempt
595
+ await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
596
+ this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
597
+
598
+ // Update user last login info
599
+ await this.userRepository.update(user.id, {
600
+ lastLoginAt: new Date(),
601
+ lastLoginIp: clientInfo.ipAddress,
602
+ failedLoginAttempts: 0,
603
+ });
604
+
605
+ // Reset IP-based failed attempts on successful login
606
+ if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
607
+ const ipAddress = clientInfo.ipAddress;
608
+ if (ipAddress) {
609
+ this.logger?.debug?.(`Resetting failed login attempts for IP: ${ipAddress}`);
610
+ await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
611
+ }
612
+ }
613
+
614
+ // Extract session ID and device info from token to record audit event
615
+ let sessionId: number | undefined;
616
+ let deviceId: string | undefined;
617
+ try {
618
+ const tokenPayload = this.jwtService.decodeToken(response.accessToken);
619
+ if (tokenPayload?.sessionId) {
620
+ sessionId = parseInt(String(tokenPayload.sessionId), 10);
621
+ }
622
+ // Get deviceId from session if available
623
+ if (sessionId) {
624
+ const session = await this.sessionService.findById(sessionId);
625
+ if (session && session.deviceId) {
626
+ deviceId = session.deviceId;
627
+ }
628
+ }
629
+ } catch (error) {
630
+ // Non-blocking: Continue without sessionId/deviceId
631
+ this.logger?.debug?.('Failed to extract sessionId/deviceId from token for audit');
632
+ }
633
+
634
+ // Determine trusted device and MFA bypass status from response
635
+ const isTrustedDevice = response.trusted || false;
636
+ const mfaBypassed = false; // Challenge helper handles MFA, so if we get here, MFA was not bypassed
637
+ const mfaBypassReason: 'trusted_device' | 'mfa_exempt' | null = null;
638
+
639
+ // Record successful login audit event
640
+ if (fireAndForget) {
641
+ this.auditService
642
+ ?.recordEvent({
643
+ userId: user.id,
644
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
645
+ eventStatus: 'SUCCESS',
646
+ sessionId: sessionId || undefined,
647
+ deviceId: deviceId || undefined,
648
+ authMethod: 'password',
649
+ metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
650
+ })
651
+ .catch((err) => {
652
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
653
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
654
+ error: err,
655
+ userId: user.id,
656
+ userSub: user.sub,
657
+ });
658
+ });
659
+ } else {
660
+ try {
661
+ await this.auditService?.recordEvent({
662
+ userId: user.id,
663
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
664
+ eventStatus: 'SUCCESS',
665
+ sessionId: sessionId || undefined,
666
+ deviceId: deviceId || undefined,
667
+ authMethod: 'password',
668
+ metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
669
+ });
670
+ } catch (auditError) {
671
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
672
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event: ${errorMessage}`, {
673
+ error: auditError,
674
+ userId: user.id,
675
+ });
676
+ }
677
+ }
678
+
679
+ return response;
680
+ }
681
+
682
+ // ============================================================================
683
+ // Trusted Device Status Check (for audit metadata)
684
+ // ============================================================================
685
+ let isTrustedDevice = false;
686
+ let mfaBypassed = false;
687
+ let mfaBypassReason: 'trusted_device' | 'mfa_exempt' | null = null;
688
+
689
+ if (
690
+ this.config.mfa?.rememberDevices &&
691
+ this.config.mfa?.rememberDevices !== 'never' &&
692
+ this.trustedDeviceService &&
693
+ clientInfo.deviceToken
694
+ ) {
695
+ isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
696
+ }
697
+
698
+ // Check if user is exempt from MFA
699
+ const userEntityDebug = user as unknown as Record<string, unknown>;
700
+ const userMfaExempt = userEntityDebug.mfaExempt === true || userEntityDebug.mfaExempt === 'true';
701
+
702
+ // Determine if MFA was bypassed
703
+ // MFA is bypassed if:
704
+ // 1. No challenge was returned (meaning MFA was skipped)
705
+ // 2. MFA would have been required otherwise
706
+ // 3. Either:
707
+ // a. Device is trusted AND bypassMFAForTrustedDevices is enabled (trusted device bypass)
708
+ // b. User has mfaExempt = true (MFA exemption bypass)
709
+ if (!response.challengeName && this.config.mfa) {
710
+ const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
711
+ // MFA would be required if:
712
+ // - OPTIONAL enforcement AND user has MFA enabled, OR
713
+ // - REQUIRED/ADAPTIVE enforcement (regardless of user.mfaEnabled for REQUIRED)
714
+ const wouldRequireMFA =
715
+ (enforcement === 'OPTIONAL' && user.mfaEnabled) || enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE';
716
+
717
+ if (wouldRequireMFA) {
718
+ // Check if bypassed due to trusted device
719
+ if (
720
+ isTrustedDevice &&
721
+ this.config.mfa.bypassMFAForTrustedDevices === true &&
722
+ enforcement !== 'ADAPTIVE' && // Adaptive MFA could bypass it anyway if device is trusted but requires different logging
723
+ !userMfaExempt
724
+ ) {
725
+ mfaBypassed = true;
726
+ mfaBypassReason = 'trusted_device';
727
+ this.logger?.debug?.(`MFA bypassed for trusted device - user ${user.sub}`);
728
+ }
729
+ // Check if bypassed due to MFA exemption
730
+ else if (userMfaExempt) {
731
+ mfaBypassed = true;
732
+ mfaBypassReason = 'mfa_exempt';
733
+ this.logger?.debug?.(`MFA bypassed due to exemption - user ${user.sub}`);
734
+ }
735
+ }
736
+ }
737
+
738
+ // MFA challenge is already handled by determineAuthResponse above
739
+ // If response.challengeName is set, it was already returned
740
+
741
+ // Check if user is active (should never happen with new signups, but keep for legacy accounts)
742
+ if (!user.isActive) {
743
+ this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
744
+ await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
745
+
746
+ // ============================================================================
747
+ // Audit: Record blocked login (account inactive)
748
+ // ============================================================================
749
+ try {
750
+ await this.auditService?.recordEvent({
751
+ userId: user.id,
752
+ eventType: AuthAuditEventType.LOGIN_BLOCKED,
753
+ eventStatus: 'FAILURE',
754
+ authMethod: 'password',
755
+ reason: 'account_inactive',
756
+ description: 'Login blocked - account is inactive',
757
+ // Client info automatically included from context
758
+ });
759
+ } catch (auditError) {
760
+ // Non-blocking: Log but continue
761
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
762
+ this.logger?.error?.(`Failed to record LOGIN_BLOCKED audit event (account inactive): ${errorMessage}`, {
763
+ error: auditError,
764
+ userId: user.id,
765
+ });
766
+ }
767
+
768
+ throw new NAuthException(AuthErrorCode.ACCOUNT_INACTIVE, 'Account is inactive. Please contact support.');
769
+ }
770
+
771
+ // Reset IP-based failed attempts on successful login
772
+ if (this.config.lockout?.enabled && this.config.lockout.resetOnSuccess) {
773
+ const ipAddress = clientInfo.ipAddress;
774
+
775
+ if (ipAddress) {
776
+ this.logger?.debug?.(`Resetting failed login attempts for IP: ${ipAddress}`);
777
+ await this.accountLockoutStorage.resetFailedAttempts(ipAddress);
778
+ }
779
+ }
780
+
781
+ // ============================================================================
782
+ // Generate Device ID Server-Side (Security: Never accept from client)
783
+ // ============================================================================
784
+
785
+ // Always generate device ID server-side (no client input accepted)
786
+ // This device ID is used for session tracking, not for trusted device feature
787
+ // Trusted devices use separate deviceToken (generated after MFA verification)
788
+ const validatedDeviceId = crypto.randomUUID();
789
+ this.logger?.debug?.(`Generated server-side deviceId: ${validatedDeviceId}`);
790
+
791
+ // Generate token family for rotation tracking
792
+ const tokenFamily = this.jwtService.generateTokenFamily();
793
+
794
+ // ============================================================================
795
+ // Single Session Mode: Revoke other sessions if disallowMultipleSessions is enabled
796
+ // ============================================================================
797
+ if (this.config.session?.disallowMultipleSessions) {
798
+ this.logger?.debug?.(`Single session mode enabled - revoking other sessions for user: ${user.sub}`);
799
+ const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Login from new session');
800
+ if (revokedCount > 0) {
801
+ this.logger?.log?.(`Revoked ${revokedCount} other active session(s) for user: ${user.sub}`);
802
+ }
803
+ }
804
+
805
+ // Atomically create session and persist token hashes
806
+ this.logger?.debug?.(`Creating login session for user: ${user.sub}`);
807
+ const atomic = await this.sessionService.createSessionAtomic(
808
+ {
809
+ userId: user.id,
810
+ tokenFamily,
811
+ deviceId: validatedDeviceId,
812
+ deviceName: dto.deviceName,
813
+ deviceType: dto.deviceType,
814
+ // Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
815
+ isRemembered: false,
816
+ expiresAt: this.sessionService.getSessionExpirationDate(),
817
+ authMethod: 'password',
818
+ },
819
+ async (sessionId) => {
820
+ const pair = await this.jwtService.generateTokenPair({
821
+ userId: user.sub,
822
+ email: user.email,
823
+ sessionId: sessionId.toString(),
824
+ tokenFamily,
825
+ });
826
+ return {
827
+ accessTokenHash: this.jwtService.hashToken(pair.accessToken),
828
+ refreshTokenHash: this.jwtService.hashToken(pair.refreshToken),
829
+ extra: pair,
830
+ };
831
+ },
832
+ );
833
+ const session = atomic.session;
834
+ const tokens = atomic.extra!;
835
+ this.logger?.debug?.(`Session created: ${session.id}`);
836
+
837
+ // Update user last login info - use internal id for update
838
+ await this.userRepository.update(user.id, {
839
+ lastLoginAt: new Date(),
840
+ lastLoginIp: clientInfo.ipAddress,
841
+ failedLoginAttempts: 0,
842
+ });
843
+
844
+ // Record successful login attempt - use internal id
845
+ await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
846
+ this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
847
+
848
+ // ============================================================================
849
+ // Audit: Record successful login with trusted device and MFA bypass metadata
850
+ // ============================================================================
851
+ if (fireAndForget) {
852
+ this.auditService
853
+ ?.recordEvent({
854
+ userId: user.id,
855
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
856
+ eventStatus: 'SUCCESS',
857
+ sessionId: session.id,
858
+ deviceId: validatedDeviceId || undefined,
859
+ authMethod: 'password',
860
+ metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
861
+ })
862
+ .catch((err) => {
863
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
864
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
865
+ error: err,
866
+ userId: user.id,
867
+ userSub: user.sub,
868
+ });
869
+ });
870
+ } else {
871
+ try {
872
+ await this.auditService?.recordEvent({
873
+ userId: user.id,
874
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
875
+ eventStatus: 'SUCCESS',
876
+ sessionId: session.id,
877
+ deviceId: validatedDeviceId || undefined,
878
+ authMethod: 'password',
879
+ metadata: { trustedDevice: isTrustedDevice, mfaBypassed, mfaBypassReason },
880
+ });
881
+ } catch (auditError) {
882
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
883
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event: ${errorMessage}`, {
884
+ error: auditError,
885
+ userId: user.id,
886
+ });
887
+ }
888
+ }
889
+
890
+ // // Execute afterLogin hook
891
+ // if (this.config.hooks?.afterLogin) {
892
+ // await this.config.hooks.afterLogin(user, session);
893
+ // }
894
+
895
+ // ============================================================================
896
+ // Trusted Device Token Management (Remember Device Feature)
897
+ // ============================================================================
898
+ let deviceToken: string | undefined;
899
+ let isTrusted = false;
900
+
901
+ if (this.config.mfa?.rememberDevices && this.config.mfa?.rememberDevices !== 'never' && this.trustedDeviceService) {
902
+ const rememberDevicesMode = this.config.mfa.rememberDevices;
903
+
904
+ // Check if device is already trusted
905
+ if (clientInfo.deviceToken) {
906
+ isTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, user.id);
907
+ if (isTrusted) {
908
+ deviceToken = clientInfo.deviceToken; // Reuse existing token
909
+ this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
910
+ }
911
+ }
912
+
913
+ // Auto-trust mode: Create device token automatically if not already trusted
914
+ if (rememberDevicesMode === 'always' && !isTrusted) {
915
+ try {
916
+ deviceToken = await this.trustedDeviceService.createTrustedDevice(
917
+ user.id,
918
+ dto.deviceName || clientInfo.deviceName,
919
+ dto.deviceType || clientInfo.deviceType,
920
+ clientInfo.ipAddress,
921
+ clientInfo.userAgent,
922
+ clientInfo.platform,
923
+ clientInfo.browser,
924
+ );
925
+ isTrusted = true;
926
+ this.logger?.debug?.(`Auto-created trusted device token for user ${user.sub} (always mode)`);
927
+ } catch (error) {
928
+ // Non-blocking: Log but continue without device token
929
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
930
+ this.logger?.warn?.(`Failed to create trusted device token: ${errorMessage}`, { error });
931
+ }
932
+ }
933
+ // user_opt_in mode: Don't create token here - user must call trust-device endpoint
934
+ // isTrusted flag is already set above if device token exists and is valid
935
+ }
936
+
937
+ // Decode tokens to get expiry times
938
+ const accessTokenValidation = await this.jwtService.validateAccessToken(tokens.accessToken);
939
+ const refreshTokenValidation = await this.jwtService.validateRefreshToken(tokens.refreshToken);
940
+
941
+ // Return sanitized user object with expiry timestamps
942
+ // Note: deviceToken inclusion in response body is handled by CookieTokenInterceptor
943
+ // which checks route-level @TokenDelivery decorator and global config
944
+ // to decide whether to set as cookie and/or strip from body
945
+ const userDto = UserResponseDto.fromEntity(user);
946
+ const authResponse: AuthResponseDTO = {
947
+ user: {
948
+ sub: userDto.sub,
949
+ email: userDto.email,
950
+ firstName: userDto.firstName,
951
+ lastName: userDto.lastName,
952
+ phone: userDto.phone ?? undefined,
953
+ isEmailVerified: userDto.isEmailVerified,
954
+ isPhoneVerified: userDto.isPhoneVerified ?? undefined,
955
+ socialProviders:
956
+ userDto.socialProviders && userDto.socialProviders.length > 0 ? userDto.socialProviders : undefined,
957
+ },
958
+ accessToken: tokens.accessToken,
959
+ refreshToken: tokens.refreshToken,
960
+ accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
961
+ refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
962
+ trusted: isTrusted, // Include trusted flag so frontend knows if device is already trusted
963
+ // Include deviceToken - CookieTokenInterceptor will handle cookie/stripping based on @TokenDelivery decorator
964
+ deviceToken,
965
+ };
966
+ return authResponse;
967
+ }
968
+
969
+ /**
970
+ * Complete an authentication challenge using the provided response data.
971
+ *
972
+ * Handles all challenge types (email verification, phone verification, MFA, password change, MFA setup).
973
+ * Validates the session, challenge type, and parameters, and returns the result (tokens or next challenge).
974
+ *
975
+ * @param responseData - Data for responding to the challenge
976
+ * @returns The authentication response (tokens or next challenge requirement)
977
+ * @throws {NAuthException} If validation fails or the challenge type is unknown
978
+ *
979
+ * @example
980
+ * ```typescript
981
+ * // Example for email verification:
982
+ * const dto = Object.assign(new RespondChallengeDTO(), {
983
+ * session: 'session-token',
984
+ * type: 'VERIFY_EMAIL',
985
+ * code: '123456',
986
+ * });
987
+ * await authService.respondToChallenge(dto);
988
+ * ```
989
+ */
990
+ async respondToChallenge(dto: RespondChallengeDTO): Promise<AuthResponseDTO> {
991
+ const responseData = dto as ChallengeResponseData;
992
+ const { session, type } = responseData;
993
+ const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
994
+
995
+ this.logger?.log?.(
996
+ `[${requestTrace}] Challenge response received: type=${type}, session=${session?.substring(0, 8)}...`,
997
+ );
998
+
999
+ // Validate session and get challenge type
1000
+ const challengeSession = await this.challengeService.validateSession(session);
1001
+
1002
+ // Validate response matches expected challenge
1003
+ this.validateChallengeTypeMatch(challengeSession.challengeName, type);
1004
+
1005
+ // Validate parameters for this challenge type
1006
+ // TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
1007
+ this.validateChallengeParams(type, responseData);
1008
+
1009
+ // Handle challenge based on type
1010
+ switch (type) {
1011
+ case 'VERIFY_EMAIL':
1012
+ return await this.handleVerifyEmail(challengeSession, (responseData as VerifyEmailResponse).code);
1013
+
1014
+ case 'VERIFY_PHONE':
1015
+ return await this.handleVerifyPhone(
1016
+ challengeSession,
1017
+ responseData as VerifyPhoneResponse | CollectPhoneResponse,
1018
+ );
1019
+
1020
+ case 'MFA_REQUIRED':
1021
+ return await this.handleMFAVerification(
1022
+ challengeSession,
1023
+ responseData as VerifyMFACodeResponse | VerifyMFAPasskeyResponse,
1024
+ );
1025
+
1026
+ case 'FORCE_CHANGE_PASSWORD':
1027
+ return await this.handleForceChangePassword(
1028
+ challengeSession,
1029
+ (responseData as ForceChangePasswordResponse).newPassword,
1030
+ );
1031
+
1032
+ case 'MFA_SETUP_REQUIRED':
1033
+ return await this.handleMFASetup(challengeSession, responseData as MFASetupResponse);
1034
+
1035
+ default:
1036
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Validate that response type matches expected challenge type
1042
+ */
1043
+ private validateChallengeTypeMatch(expected: string, provided: string): void {
1044
+ if (expected !== provided) {
1045
+ throw new NAuthException(
1046
+ AuthErrorCode.VALIDATION_FAILED,
1047
+ `Challenge type mismatch: expected ${expected}, got ${provided}`,
1048
+ );
1049
+ }
1050
+ }
1051
+
1052
+ /**
1053
+ * Validate parameters for challenge type
1054
+ *
1055
+ * Service-level validation ensures Express/other frameworks get same validation as NestJS.
1056
+ * This is critical for non-DTO-based applications.
1057
+ */
1058
+ private validateChallengeParams(type: string, data: ChallengeResponseData): void {
1059
+ switch (type) {
1060
+ case 'VERIFY_EMAIL': {
1061
+ const response = data as VerifyEmailResponse;
1062
+ if (!response.code || typeof response.code !== 'string') {
1063
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
1064
+ }
1065
+ break;
1066
+ }
1067
+
1068
+ case 'VERIFY_PHONE': {
1069
+ const response = data as VerifyPhoneResponse | CollectPhoneResponse;
1070
+ const hasCode = 'code' in response && response.code;
1071
+ const hasPhone = 'phone' in response && response.phone;
1072
+
1073
+ if (!hasCode && !hasPhone) {
1074
+ throw new NAuthException(
1075
+ AuthErrorCode.VALIDATION_FAILED,
1076
+ 'Either phone number or verification code is required',
1077
+ { fields: ['phone', 'code'] },
1078
+ );
1079
+ }
1080
+ break;
1081
+ }
1082
+
1083
+ case 'MFA_REQUIRED': {
1084
+ const response = data as VerifyMFACodeResponse | VerifyMFAPasskeyResponse;
1085
+ if (!response.method) {
1086
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA method is required', { field: 'method' });
1087
+ }
1088
+
1089
+ if (response.method === 'passkey') {
1090
+ const passkeyResponse = response as VerifyMFAPasskeyResponse;
1091
+ if (!passkeyResponse.credential) {
1092
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
1093
+ field: 'credential',
1094
+ });
1095
+ }
1096
+ } else {
1097
+ const codeResponse = response as VerifyMFACodeResponse;
1098
+ if (!codeResponse.code || typeof codeResponse.code !== 'string') {
1099
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA code is required', { field: 'code' });
1100
+ }
1101
+ }
1102
+ break;
1103
+ }
1104
+
1105
+ case 'FORCE_CHANGE_PASSWORD': {
1106
+ const response = data as ForceChangePasswordResponse;
1107
+ if (!response.newPassword || typeof response.newPassword !== 'string') {
1108
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
1109
+ field: 'newPassword',
1110
+ });
1111
+ }
1112
+ break;
1113
+ }
1114
+
1115
+ case 'MFA_SETUP_REQUIRED': {
1116
+ const response = data as MFASetupResponse;
1117
+ if (!response.method) {
1118
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
1119
+ field: 'method',
1120
+ });
1121
+ }
1122
+ if (!response.setupData || typeof response.setupData !== 'object') {
1123
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
1124
+ field: 'setupData',
1125
+ });
1126
+ }
1127
+ break;
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Handle VERIFY_EMAIL challenge
1134
+ */
1135
+ private async handleVerifyEmail(challengeSession: any, code: string): Promise<AuthResponseDTO> {
1136
+ const user = challengeSession.user;
1137
+ if (!user) {
1138
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
1139
+ }
1140
+
1141
+ this.logger?.log?.(`Verifying email for user: ${user.sub}`);
1142
+
1143
+ // Verify email with code, ensuring it belongs to this specific challenge session
1144
+ const verifyDto = Object.assign(new VerifyEmailWithCodeDTO(), {
1145
+ email: user.email,
1146
+ code,
1147
+ challengeSessionId: challengeSession.id, // Link verification to this specific session
1148
+ });
1149
+ const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
1150
+ const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
1151
+
1152
+ if (!isVerified) {
1153
+ // Increment attempts but don't consume session
1154
+ await this.challengeService.incrementAttempts(challengeSession);
1155
+ throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
1156
+ }
1157
+
1158
+ // Consume challenge session
1159
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.VERIFY_EMAIL);
1160
+
1161
+ // Reload user to get updated emailVerified flag
1162
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
1163
+ if (!updatedUser) {
1164
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after email verification');
1165
+ }
1166
+
1167
+ // Get client info
1168
+ const clientInfo = this.clientInfoService.get();
1169
+
1170
+ // Read auth context from challenge session metadata
1171
+ const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
1172
+ const authProvider = challengeSession.metadata?.authProvider as string | undefined;
1173
+ const isSocialLogin = authMethod === 'social';
1174
+
1175
+ // Check for next challenges
1176
+ const response = await this.challengeHelper.determineAuthResponse({
1177
+ user: updatedUser as unknown as IUser,
1178
+ config: this.config,
1179
+ deviceToken: clientInfo.deviceToken,
1180
+ isSocialLogin,
1181
+ skipMFAVerification: false,
1182
+ authProvider,
1183
+ });
1184
+
1185
+ if (response.challengeName) {
1186
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
1187
+ } else {
1188
+ this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
1189
+ }
1190
+
1191
+ return response;
1192
+ }
1193
+
1194
+ /**
1195
+ * Handle VERIFY_PHONE challenge
1196
+ */
1197
+ private async handleVerifyPhone(
1198
+ challengeSession: any,
1199
+ data: VerifyPhoneResponse | CollectPhoneResponse,
1200
+ ): Promise<AuthResponseDTO> {
1201
+ const user = challengeSession.user;
1202
+ if (!user) {
1203
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
1204
+ }
1205
+
1206
+ // Check if this is phone collection (first step) or verification (second step)
1207
+ if ('phone' in data && data.phone) {
1208
+ // Phone collection step
1209
+ const phone = data.phone;
1210
+
1211
+ this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
1212
+
1213
+ // Validate phone format (E.164 format: +[country][number])
1214
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
1215
+ if (!phoneRegex.test(phone)) {
1216
+ throw new NAuthException(
1217
+ AuthErrorCode.INVALID_PHONE_FORMAT,
1218
+ 'Invalid phone number format. Use E.164 format (e.g., +1234567890)',
1219
+ );
1220
+ }
1221
+
1222
+ // Update user phone number
1223
+ await this.userRepository.update({ sub: user.sub }, { phone });
1224
+
1225
+ this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
1226
+
1227
+ // Send verification SMS to the newly added phone
1228
+ let smsError: string | undefined;
1229
+ if (this.phoneVerificationService) {
1230
+ this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
1231
+ try {
1232
+ const smsDto = Object.assign(new SendVerificationSMSDTO(), {
1233
+ sub: user.sub,
1234
+ challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
1235
+ });
1236
+ await this.phoneVerificationService.sendVerificationSMS(smsDto);
1237
+ this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
1238
+ } catch (error: unknown) {
1239
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1240
+ this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
1241
+ smsError = errorMessage;
1242
+ }
1243
+ } else {
1244
+ this.logger?.warn?.(
1245
+ `Phone verification SMS not sent - PhoneVerificationService not available. ` +
1246
+ 'Phone verification requires an SMS provider to be configured.',
1247
+ );
1248
+ }
1249
+
1250
+ // DO NOT consume the challenge session yet - user still needs to verify the code
1251
+ // Preserve auth context from original challenge session
1252
+ const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
1253
+ const authProvider = challengeSession.metadata?.authProvider as string | undefined;
1254
+
1255
+ // Return same challenge with updated phone in parameters
1256
+ // Skip auto-send since SMS was already sent above during phone collection
1257
+ const challengeResponse = await this.challengeHelper.createChallengeResponse(
1258
+ { ...user, phone },
1259
+ AuthChallenge.VERIFY_PHONE,
1260
+ this.config,
1261
+ authMethod as 'password' | 'social',
1262
+ authProvider,
1263
+ true, // skipAutoSend = true (SMS already sent during phone collection)
1264
+ );
1265
+
1266
+ // Include SMS error in challenge parameters if SMS failed
1267
+ if (smsError) {
1268
+ challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
1269
+ challengeResponse.challengeParameters.smsError = smsError;
1270
+ }
1271
+
1272
+ return challengeResponse;
1273
+ } else {
1274
+ // Phone verification step (code provided)
1275
+ const code = (data as VerifyPhoneResponse).code;
1276
+
1277
+ this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
1278
+
1279
+ // Check if phone is set
1280
+ if (!user.phone) {
1281
+ throw new NAuthException(
1282
+ AuthErrorCode.VALIDATION_FAILED,
1283
+ 'Phone number not yet provided. Submit phone number first.',
1284
+ );
1285
+ }
1286
+
1287
+ // Verify phone with code, ensuring it belongs to this specific challenge session
1288
+ const verifyDto = Object.assign(new VerifyPhoneWithCodeBySubDTO(), {
1289
+ sub: user.sub,
1290
+ code,
1291
+ challengeSessionId: challengeSession.id, // Link verification to this specific session
1292
+ });
1293
+ const result = await this.phoneVerificationService!.verifyPhoneWithCodeBySub(verifyDto);
1294
+ const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
1295
+
1296
+ if (!isVerified) {
1297
+ // Increment attempts but don't consume session
1298
+ await this.challengeService.incrementAttempts(challengeSession);
1299
+ throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
1300
+ }
1301
+
1302
+ // Consume challenge session
1303
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.VERIFY_PHONE);
1304
+
1305
+ // Reload user to get updated phoneVerified flag
1306
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
1307
+ if (!updatedUser) {
1308
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
1309
+ }
1310
+
1311
+ // Get client info
1312
+ const clientInfo = this.clientInfoService.get();
1313
+
1314
+ // Read auth context from challenge session metadata
1315
+ const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
1316
+ const authProvider = challengeSession.metadata?.authProvider as string | undefined;
1317
+ const isSocialLogin = authMethod === 'social';
1318
+
1319
+ // Check for next challenges
1320
+ const response = await this.challengeHelper.determineAuthResponse({
1321
+ user: updatedUser as unknown as IUser,
1322
+ config: this.config,
1323
+ deviceToken: clientInfo.deviceToken,
1324
+ isSocialLogin,
1325
+ skipMFAVerification: false,
1326
+ authProvider,
1327
+ });
1328
+
1329
+ if (response.challengeName) {
1330
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
1331
+ } else {
1332
+ this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
1333
+
1334
+ // ============================================================================
1335
+ // Audit: Record successful login after phone verification
1336
+ // ============================================================================
1337
+ const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
1338
+ if (fireAndForget) {
1339
+ this.auditService
1340
+ ?.recordEvent({
1341
+ userId: user.id,
1342
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1343
+ eventStatus: 'SUCCESS',
1344
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1345
+ metadata: {
1346
+ completedAfterPhoneVerification: true,
1347
+ },
1348
+ })
1349
+ .catch((err) => {
1350
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1351
+ this.logger?.error?.(
1352
+ `Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`,
1353
+ {
1354
+ error: err,
1355
+ userId: user.id,
1356
+ userSub: user.sub,
1357
+ },
1358
+ );
1359
+ });
1360
+ } else {
1361
+ try {
1362
+ await this.auditService?.recordEvent({
1363
+ userId: user.id,
1364
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1365
+ eventStatus: 'SUCCESS',
1366
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1367
+ metadata: {
1368
+ completedAfterPhoneVerification: true,
1369
+ },
1370
+ });
1371
+ } catch (auditError) {
1372
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1373
+ this.logger?.error?.(
1374
+ `Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`,
1375
+ {
1376
+ error: auditError,
1377
+ userId: user.id,
1378
+ },
1379
+ );
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ return response;
1385
+ }
1386
+ }
1387
+
1388
+ /**
1389
+ * Handle MFA_REQUIRED challenge
1390
+ */
1391
+ private async handleMFAVerification(
1392
+ challengeSession: any,
1393
+ data: VerifyMFACodeResponse | VerifyMFAPasskeyResponse,
1394
+ ): Promise<AuthResponseDTO> {
1395
+ const user = challengeSession.user;
1396
+ if (!user) {
1397
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
1398
+ }
1399
+
1400
+ const method = data.method;
1401
+
1402
+ this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
1403
+
1404
+ // Check if MFAService is available
1405
+ if (!this.mfaService) {
1406
+ throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
1407
+ }
1408
+
1409
+ // Get client info
1410
+ const clientInfo = this.clientInfoService.get();
1411
+
1412
+ // Verify MFA based on method
1413
+ let isValid = false;
1414
+
1415
+ if (method === 'passkey') {
1416
+ const passkeyData = data as VerifyMFAPasskeyResponse;
1417
+ const credential = passkeyData.credential;
1418
+
1419
+ // Get expected challenge from session metadata
1420
+ const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
1421
+ if (!expectedChallenge) {
1422
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
1423
+ }
1424
+
1425
+ // Verify passkey via MFAService
1426
+ const wrappedCredential = { credential, expectedChallenge };
1427
+ const verifyResult = await this.mfaService.verifyCode({
1428
+ sub: user.sub,
1429
+ methodName: MFAMethod.PASSKEY,
1430
+ code: wrappedCredential,
1431
+ });
1432
+ isValid = verifyResult.valid;
1433
+ } else {
1434
+ const codeData = data as VerifyMFACodeResponse;
1435
+ const code = codeData.code;
1436
+
1437
+ // Verify code via MFAService (handles totp, sms, and backup)
1438
+ const verifyResult = await this.mfaService.verifyCode({
1439
+ sub: user.sub,
1440
+ methodName: method,
1441
+ code,
1442
+ });
1443
+ isValid = verifyResult.valid;
1444
+ }
1445
+
1446
+ if (!isValid) {
1447
+ this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
1448
+
1449
+ // Audit: Record MFA verification failure
1450
+ if (this.config.auditLogs?.fireAndForget) {
1451
+ this.auditService
1452
+ ?.recordEvent({
1453
+ userId: user.id,
1454
+ eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
1455
+ eventStatus: 'FAILURE',
1456
+ challengeSessionId: challengeSession.id,
1457
+ authMethod: method,
1458
+ metadata: { mfaMethod: method },
1459
+ })
1460
+ .catch((err) => {
1461
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1462
+ this.logger?.error?.(
1463
+ `Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`,
1464
+ {
1465
+ error: err,
1466
+ userId: user.id,
1467
+ userSub: user.sub,
1468
+ },
1469
+ );
1470
+ });
1471
+ } else {
1472
+ try {
1473
+ await this.auditService?.recordEvent({
1474
+ userId: user.id,
1475
+ eventType: AuthAuditEventType.MFA_VERIFICATION_FAILED,
1476
+ eventStatus: 'FAILURE',
1477
+ challengeSessionId: challengeSession.id,
1478
+ authMethod: method,
1479
+ metadata: { mfaMethod: method },
1480
+ });
1481
+ } catch (auditError) {
1482
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1483
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
1484
+ error: auditError,
1485
+ userId: user.id,
1486
+ });
1487
+ }
1488
+ }
1489
+
1490
+ // Increment challenge attempts (session not consumed, so user can retry)
1491
+ await this.challengeService.incrementAttempts(challengeSession);
1492
+
1493
+ throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
1494
+ }
1495
+
1496
+ this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
1497
+
1498
+ // Audit: Record MFA verification success
1499
+ if (this.config.auditLogs?.fireAndForget) {
1500
+ this.auditService
1501
+ ?.recordEvent({
1502
+ userId: user.id,
1503
+ eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
1504
+ eventStatus: 'SUCCESS',
1505
+ challengeSessionId: challengeSession.id,
1506
+ authMethod: method,
1507
+ metadata: { mfaMethod: method },
1508
+ })
1509
+ .catch((err) => {
1510
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1511
+ this.logger?.error?.(
1512
+ `Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`,
1513
+ {
1514
+ error: err,
1515
+ userId: user.id,
1516
+ userSub: user.sub,
1517
+ },
1518
+ );
1519
+ });
1520
+ } else {
1521
+ try {
1522
+ await this.auditService?.recordEvent({
1523
+ userId: user.id,
1524
+ eventType: AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
1525
+ eventStatus: 'SUCCESS',
1526
+ challengeSessionId: challengeSession.id,
1527
+ authMethod: method,
1528
+ metadata: { mfaMethod: method },
1529
+ });
1530
+ } catch (auditError) {
1531
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1532
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
1533
+ error: auditError,
1534
+ userId: user.id,
1535
+ });
1536
+ }
1537
+ }
1538
+
1539
+ // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
1540
+ await this.challengeService.updateMetadata(challengeSession.sessionToken, {
1541
+ mfaMethod: method,
1542
+ });
1543
+
1544
+ // Only consume the session AFTER successful verification
1545
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, AuthChallenge.MFA_REQUIRED);
1546
+
1547
+ // Read auth context from challenge session metadata
1548
+ const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
1549
+ const authProvider = challengeSession.metadata?.authProvider as string | undefined;
1550
+ const isSocialLogin = authMethod === 'social';
1551
+
1552
+ // ============================================================================
1553
+ // Trusted Device Token Management (Remember Device Feature)
1554
+ // ============================================================================
1555
+ // NOTE:
1556
+ // - We only create / update trusted device tokens AFTER MFA has been successfully
1557
+ // verified to avoid trusting devices that haven't completed full auth.
1558
+ // - For 'always' mode, this mirrors the behavior in the primary login flow.
1559
+ let deviceToken = clientInfo.deviceToken as string | undefined;
1560
+ let isTrustedDevice = false;
1561
+
1562
+ if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
1563
+ const rememberMode = this.config.mfa.rememberDevices;
1564
+
1565
+ // If a device token is already present, check if it's trusted
1566
+ if (deviceToken) {
1567
+ try {
1568
+ isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
1569
+ if (isTrustedDevice) {
1570
+ this.logger?.debug?.(
1571
+ `MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`,
1572
+ );
1573
+ }
1574
+ } catch (error) {
1575
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1576
+ this.logger?.warn?.(
1577
+ `MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`,
1578
+ { error },
1579
+ );
1580
+ }
1581
+ }
1582
+
1583
+ // Auto-trust mode: create device token automatically if not already trusted
1584
+ if (rememberMode === 'always' && !isTrustedDevice) {
1585
+ try {
1586
+ deviceToken = await this.trustedDeviceService.createTrustedDevice(
1587
+ user.id,
1588
+ clientInfo.deviceName,
1589
+ clientInfo.deviceType,
1590
+ clientInfo.ipAddress,
1591
+ clientInfo.userAgent,
1592
+ clientInfo.platform,
1593
+ clientInfo.browser,
1594
+ );
1595
+ isTrustedDevice = true;
1596
+ this.logger?.debug?.(
1597
+ `MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`,
1598
+ );
1599
+ } catch (error) {
1600
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1601
+ this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
1602
+ error,
1603
+ });
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ // Check for next challenges (MFA is usually the last challenge)
1609
+ const response = await this.challengeHelper.determineAuthResponse({
1610
+ user,
1611
+ config: this.config,
1612
+ deviceToken,
1613
+ isSocialLogin,
1614
+ skipMFAVerification: true, // Already verified
1615
+ authProvider,
1616
+ });
1617
+
1618
+ // Propagate trusted device metadata into response so that:
1619
+ // - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
1620
+ // - Mobile clients in JSON mode can store the device token securely
1621
+ if (isTrustedDevice) {
1622
+ response.trusted = response.trusted ?? true;
1623
+ }
1624
+ if (deviceToken && !response.deviceToken) {
1625
+ response.deviceToken = deviceToken;
1626
+ }
1627
+
1628
+ if (response.challengeName) {
1629
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
1630
+ } else {
1631
+ this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
1632
+
1633
+ // ============================================================================
1634
+ // Audit: Record successful login after MFA completion
1635
+ // ============================================================================
1636
+ const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
1637
+ if (fireAndForget) {
1638
+ this.auditService
1639
+ ?.recordEvent({
1640
+ userId: user.id,
1641
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1642
+ eventStatus: 'SUCCESS',
1643
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1644
+ metadata: {
1645
+ completedAfterMFA: true,
1646
+ },
1647
+ })
1648
+ .catch((err) => {
1649
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1650
+ this.logger?.error?.(
1651
+ `Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`,
1652
+ {
1653
+ error: err,
1654
+ userId: user.id,
1655
+ userSub: user.sub,
1656
+ },
1657
+ );
1658
+ });
1659
+ } else {
1660
+ try {
1661
+ await this.auditService?.recordEvent({
1662
+ userId: user.id,
1663
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1664
+ eventStatus: 'SUCCESS',
1665
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1666
+ metadata: {
1667
+ completedAfterMFA: true,
1668
+ },
1669
+ });
1670
+ } catch (auditError) {
1671
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1672
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
1673
+ error: auditError,
1674
+ userId: user.id,
1675
+ });
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ return response;
1681
+ }
1682
+
1683
+ /**
1684
+ * Handle FORCE_CHANGE_PASSWORD challenge
1685
+ */
1686
+ private async handleForceChangePassword(challengeSession: any, newPassword: string): Promise<AuthResponseDTO> {
1687
+ const user = challengeSession.user;
1688
+ if (!user) {
1689
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
1690
+ }
1691
+
1692
+ this.logger?.log?.(`Changing password for user: ${user.sub}`);
1693
+
1694
+ // Validate new password
1695
+ const validation = await this.passwordService.validatePassword(newPassword, {
1696
+ email: user.email,
1697
+ username: user.username || undefined,
1698
+ });
1699
+
1700
+ if (!validation.valid) {
1701
+ throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
1702
+ errors: validation.errors,
1703
+ });
1704
+ }
1705
+
1706
+ // Check password history
1707
+ if (this.config.password?.historyCount) {
1708
+ const historyToCheck = user.passwordHistory || [];
1709
+ const allPreviousPasswords = user.passwordHash ? [user.passwordHash, ...historyToCheck] : historyToCheck;
1710
+
1711
+ const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
1712
+
1713
+ if (isReused) {
1714
+ throw new NAuthException(
1715
+ AuthErrorCode.PASSWORD_REUSED,
1716
+ 'You have used this password recently. Please choose a different password.',
1717
+ );
1718
+ }
1719
+ }
1720
+
1721
+ // Hash new password
1722
+ const newHash = await this.passwordService.hashPassword(newPassword);
1723
+
1724
+ // Update password history
1725
+ const newHistory = this.passwordService.addToHistory(user.passwordHistory || [], user.passwordHash);
1726
+
1727
+ // Update user password and clear mustChangePassword flag - use save() for array fields
1728
+ user.passwordHash = newHash;
1729
+ user.passwordChangedAt = new Date();
1730
+ user.passwordHistory = newHistory;
1731
+ user.mustChangePassword = false;
1732
+ await this.userRepository.save(user);
1733
+
1734
+ this.logger?.log?.(`Password changed successfully for user: ${user.sub}`);
1735
+
1736
+ // Consume challenge session
1737
+ await this.challengeService.validateAndConsumeSession(
1738
+ challengeSession.sessionToken,
1739
+ AuthChallenge.FORCE_CHANGE_PASSWORD,
1740
+ );
1741
+
1742
+ // Reload user from database to get updated mustChangePassword flag
1743
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
1744
+ if (!updatedUser) {
1745
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after password update');
1746
+ }
1747
+
1748
+ // Get client info
1749
+ const clientInfo = this.clientInfoService.get();
1750
+
1751
+ // Read auth context from challenge session metadata
1752
+ const authMethod = (challengeSession.metadata?.authMethod as string) || 'password';
1753
+ const authProvider = challengeSession.metadata?.authProvider as string | undefined;
1754
+ const isSocialLogin = authMethod === 'social';
1755
+
1756
+ // Check for next challenges
1757
+ const response = await this.challengeHelper.determineAuthResponse({
1758
+ user: updatedUser as unknown as IUser,
1759
+ config: this.config,
1760
+ deviceToken: clientInfo.deviceToken,
1761
+ isSocialLogin,
1762
+ skipMFAVerification: false,
1763
+ authProvider,
1764
+ });
1765
+
1766
+ if (response.challengeName) {
1767
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
1768
+ } else {
1769
+ this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
1770
+
1771
+ // ============================================================================
1772
+ // Audit: Record successful login after password change
1773
+ // ============================================================================
1774
+ const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
1775
+ if (fireAndForget) {
1776
+ this.auditService
1777
+ ?.recordEvent({
1778
+ userId: user.id,
1779
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1780
+ eventStatus: 'SUCCESS',
1781
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1782
+ metadata: {
1783
+ completedAfterPasswordChange: true,
1784
+ },
1785
+ })
1786
+ .catch((err) => {
1787
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1788
+ this.logger?.error?.(
1789
+ `Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`,
1790
+ {
1791
+ error: err,
1792
+ userId: user.id,
1793
+ userSub: user.sub,
1794
+ },
1795
+ );
1796
+ });
1797
+ } else {
1798
+ try {
1799
+ await this.auditService?.recordEvent({
1800
+ userId: user.id,
1801
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1802
+ eventStatus: 'SUCCESS',
1803
+ authMethod: isSocialLogin ? authProvider || 'social' : 'password',
1804
+ metadata: {
1805
+ completedAfterPasswordChange: true,
1806
+ },
1807
+ });
1808
+ } catch (auditError) {
1809
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1810
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
1811
+ error: auditError,
1812
+ userId: user.id,
1813
+ });
1814
+ }
1815
+ }
1816
+ }
1817
+
1818
+ return response;
1819
+ }
1820
+
1821
+ /**
1822
+ * Handle MFA_SETUP_REQUIRED challenge
1823
+ */
1824
+ private async handleMFASetup(challengeSession: any, data: MFASetupResponse): Promise<AuthResponseDTO> {
1825
+ const user = challengeSession.user;
1826
+ if (!user) {
1827
+ throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
1828
+ }
1829
+
1830
+ const method = data.method;
1831
+ const setupData = data.setupData;
1832
+
1833
+ const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
1834
+ this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
1835
+
1836
+ // Check if MFAService is available
1837
+ if (!this.mfaService) {
1838
+ throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
1839
+ }
1840
+
1841
+ // Get provider
1842
+ const provider = this.mfaService.getProvider(method);
1843
+
1844
+ // Verify setup based on method
1845
+ let deviceId: number;
1846
+
1847
+ try {
1848
+ deviceId = await provider.verifySetup(user, setupData);
1849
+ this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
1850
+ } catch (error) {
1851
+ this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
1852
+
1853
+ // Increment attempts but don't consume session
1854
+ await this.challengeService.incrementAttempts(challengeSession);
1855
+
1856
+ // Re-throw the error
1857
+ throw error;
1858
+ }
1859
+
1860
+ // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
1861
+ await this.challengeService.updateMetadata(challengeSession.sessionToken, {
1862
+ mfaMethod: method,
1863
+ });
1864
+
1865
+ // Consume challenge session
1866
+ await this.challengeService.validateAndConsumeSession(
1867
+ challengeSession.sessionToken,
1868
+ AuthChallenge.MFA_SETUP_REQUIRED,
1869
+ );
1870
+
1871
+ // Reload user from database to get updated mfaEnabled flag
1872
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
1873
+ if (!updatedUser) {
1874
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
1875
+ }
1876
+
1877
+ // Get client info
1878
+ const clientInfo = this.clientInfoService.get();
1879
+
1880
+ // Check for next challenges with updated user data
1881
+ // Skip MFA verification because device was already verified during setup
1882
+ const response = await this.challengeHelper.determineAuthResponse({
1883
+ user: updatedUser as unknown as IUser,
1884
+ config: this.config,
1885
+ deviceToken: clientInfo.deviceToken,
1886
+ isSocialLogin: false,
1887
+ skipMFAVerification: true, // Device already verified during setup
1888
+ });
1889
+
1890
+ if (response.challengeName) {
1891
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
1892
+ } else {
1893
+ this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
1894
+
1895
+ // ============================================================================
1896
+ // Audit: Record successful login after MFA setup
1897
+ // ============================================================================
1898
+ const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
1899
+ if (fireAndForget) {
1900
+ this.auditService
1901
+ ?.recordEvent({
1902
+ userId: user.id,
1903
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1904
+ eventStatus: 'SUCCESS',
1905
+ authMethod: 'password',
1906
+ metadata: {
1907
+ completedAfterMFASetup: true,
1908
+ },
1909
+ })
1910
+ .catch((err) => {
1911
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
1912
+ this.logger?.error?.(
1913
+ `Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`,
1914
+ {
1915
+ error: err,
1916
+ userId: user.id,
1917
+ userSub: user.sub,
1918
+ },
1919
+ );
1920
+ });
1921
+ } else {
1922
+ try {
1923
+ await this.auditService?.recordEvent({
1924
+ userId: user.id,
1925
+ eventType: AuthAuditEventType.LOGIN_SUCCESS,
1926
+ eventStatus: 'SUCCESS',
1927
+ authMethod: 'password',
1928
+ metadata: {
1929
+ completedAfterMFASetup: true,
1930
+ },
1931
+ });
1932
+ } catch (auditError) {
1933
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1934
+ this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
1935
+ error: auditError,
1936
+ userId: user.id,
1937
+ });
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ return response;
1943
+ }
1944
+
1945
+ // ============================================================================
1946
+ // Challenge Helper Methods
1947
+ // ============================================================================
1948
+
1949
+ /**
1950
+ * Resend verification code for current challenge
1951
+ *
1952
+ * Determines the challenge type from the session and resends the appropriate code:
1953
+ * - VERIFY_EMAIL: Resends email verification code
1954
+ * - VERIFY_PHONE: Resends SMS verification code
1955
+ * - MFA_REQUIRED: Resends MFA code (for SMS MFA)
1956
+ *
1957
+ * Rate limits are enforced internally by the verification services.
1958
+ *
1959
+ * @param session - Challenge session token
1960
+ * @returns Destination info (masked email/phone)
1961
+ * @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
1962
+ *
1963
+ * @example
1964
+ * ```typescript
1965
+ * const result = await authService.resendCode(session);
1966
+ * // Returns: { destination: 'u***r@example.com' }
1967
+ * ```
1968
+ */
1969
+ async resendCode(dto: ResendCodeDTO): Promise<ResendCodeResponseDTO> {
1970
+ this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
1971
+
1972
+ // Validate session (session must be valid to resend)
1973
+ const challengeSession = await this.challengeService.validateSession(dto.session);
1974
+
1975
+ // Get user from session
1976
+ const user = challengeSession.user;
1977
+ if (!user) {
1978
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
1979
+ }
1980
+
1981
+ // Handle based on challenge type
1982
+ switch (challengeSession.challengeName) {
1983
+ case AuthChallenge.VERIFY_EMAIL: {
1984
+ // Resend email verification
1985
+ const resendDto = Object.assign(new ResendVerificationEmailDTO(), { sub: user.sub });
1986
+ await this.emailVerificationService.resendVerificationEmail(resendDto);
1987
+ const maskedEmail = this.maskEmail(user.email);
1988
+ this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
1989
+ return { destination: maskedEmail };
1990
+ }
1991
+
1992
+ case AuthChallenge.VERIFY_PHONE: {
1993
+ // Check if phone already collected
1994
+ if (!user.phone) {
1995
+ throw new NAuthException(
1996
+ AuthErrorCode.VALIDATION_FAILED,
1997
+ 'Phone number not yet provided. Submit phone number first.',
1998
+ );
1999
+ }
2000
+
2001
+ if (!this.phoneVerificationService) {
2002
+ throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
2003
+ }
2004
+
2005
+ // Resend SMS verification
2006
+ const resendDto = Object.assign(new ResendVerificationSMSDTO(), { sub: user.sub });
2007
+ await this.phoneVerificationService.resendVerificationSMS(resendDto);
2008
+ const maskedPhone = this.maskPhone(user.phone);
2009
+ this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
2010
+ return { destination: maskedPhone };
2011
+ }
2012
+
2013
+ case AuthChallenge.MFA_REQUIRED: {
2014
+ // For MFA, we need to know which method is being used
2015
+ // Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
2016
+ // Note: challengeParameters is never populated - only metadata is used
2017
+ const metadata = challengeSession.metadata as { method?: string };
2018
+ const method = metadata?.method;
2019
+
2020
+ if (!method) {
2021
+ throw new NAuthException(
2022
+ AuthErrorCode.VALIDATION_FAILED,
2023
+ 'Cannot resend MFA code: method not specified in session',
2024
+ );
2025
+ }
2026
+
2027
+ // SMS and Email MFA support resending codes
2028
+ if (method === 'sms' || method === 'email') {
2029
+ // For SMS, use phone verification service directly to pass challengeSessionId
2030
+ if (method === 'sms' && this.phoneVerificationService) {
2031
+ const smsDto = Object.assign(new SendVerificationSMSDTO(), {
2032
+ sub: user.sub,
2033
+ skipAlreadyVerifiedCheck: true,
2034
+ challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2035
+ });
2036
+ await this.phoneVerificationService.sendVerificationSMS(smsDto);
2037
+ this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
2038
+ // Get masked phone from user or device
2039
+ const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
2040
+ return { destination: maskedPhone };
2041
+ }
2042
+
2043
+ // For Email, use email verification service directly to pass challengeSessionId
2044
+ if (method === 'email' && this.emailVerificationService) {
2045
+ const emailDto = Object.assign(new ResendVerificationEmailDTO(), {
2046
+ sub: user.sub,
2047
+ challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2048
+ });
2049
+ await this.emailVerificationService.resendVerificationEmail(emailDto);
2050
+ this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
2051
+ const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
2052
+ return { destination: maskedEmail };
2053
+ }
2054
+
2055
+ // Fallback to provider if services not available (shouldn't happen)
2056
+ if (!this.mfaService) {
2057
+ throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2058
+ }
2059
+
2060
+ const provider = this.mfaService.getProvider(method);
2061
+
2062
+ if (!provider.sendChallenge) {
2063
+ throw new NAuthException(
2064
+ AuthErrorCode.VALIDATION_FAILED,
2065
+ `${method.toUpperCase()} MFA provider does not support sending challenges`,
2066
+ );
2067
+ }
2068
+
2069
+ const result = await provider.sendChallenge(user);
2070
+ this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
2071
+
2072
+ // Provider returns masked phone or email
2073
+ return { destination: result as string };
2074
+ }
2075
+
2076
+ throw new NAuthException(
2077
+ AuthErrorCode.VALIDATION_FAILED,
2078
+ `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`,
2079
+ );
2080
+ }
2081
+
2082
+ default:
2083
+ throw new NAuthException(
2084
+ AuthErrorCode.VALIDATION_FAILED,
2085
+ `Cannot resend code for challenge type '${challengeSession.challengeName}'`,
2086
+ );
2087
+ }
2088
+ }
2089
+
2090
+ /**
2091
+ * Mask email for display (helper method)
2092
+ */
2093
+ private maskEmail(email: string): string {
2094
+ const [localPart, domain] = email.split('@');
2095
+ if (localPart.length <= 2) {
2096
+ return `${localPart[0]}***@${domain}`;
2097
+ }
2098
+ return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
2099
+ }
2100
+
2101
+ /**
2102
+ * Mask phone number for display (helper method)
2103
+ */
2104
+ private maskPhone(phone: string): string {
2105
+ const digits = phone.replace(/\D/g, '');
2106
+ const lastFour = digits.slice(-4);
2107
+ return `***-***-${lastFour}`;
2108
+ }
2109
+
2110
+ /**
2111
+ * Registers the current device as trusted for the user (opt-in).
2112
+ *
2113
+ * Only available when rememberDevices is set to 'user_opt_in'. Generates and returns a trusted device token for the device associated with the current authenticated session.
2114
+ *
2115
+ * Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
2116
+ *
2117
+ * @returns Object containing the new device token
2118
+ * @throws {NAuthException} If the feature is unavailable, service is not enabled, or session ID is not available
2119
+ *
2120
+ * @example
2121
+ * ```typescript
2122
+ * const result = await authService.trustDevice();
2123
+ * // { deviceToken: 'abc123' }
2124
+ * ```
2125
+ */
2126
+ async trustDevice(): Promise<TrustDeviceResponseDTO> {
2127
+ if (this.config.mfa?.rememberDevices !== 'user_opt_in') {
2128
+ throw new NAuthException(AuthErrorCode.FORBIDDEN, 'Trust device feature is only available in user_opt_in mode');
2129
+ }
2130
+
2131
+ if (!this.trustedDeviceService) {
2132
+ throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'Trusted device service not available');
2133
+ }
2134
+
2135
+ // Get sessionId from context (automatically extracted from JWT token)
2136
+ const clientInfo = this.clientInfoService.get();
2137
+ const sessionId = clientInfo.sessionId;
2138
+
2139
+ if (!sessionId) {
2140
+ throw new NAuthException(
2141
+ AuthErrorCode.SESSION_NOT_FOUND,
2142
+ 'Session ID not found in request context. Ensure the request is authenticated.',
2143
+ );
2144
+ }
2145
+
2146
+ // Get session to extract device info
2147
+ const session = await this.sessionService.findById(sessionId);
2148
+ if (!session || session.isRevoked) {
2149
+ throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
2150
+ }
2151
+
2152
+ // Get user
2153
+ const user = await this.userRepository.findOne({ where: { id: session.userId } });
2154
+ if (!user) {
2155
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2156
+ }
2157
+
2158
+ // Check if device is already trusted
2159
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2160
+ if (clientInfo.deviceToken) {
2161
+ const isAlreadyTrusted = await this.trustedDeviceService.isDeviceTrusted(clientInfo.deviceToken, userId);
2162
+ if (isAlreadyTrusted) {
2163
+ this.logger?.debug?.(`Device already trusted for user ${user.sub}`);
2164
+ return { deviceToken: clientInfo.deviceToken };
2165
+ }
2166
+ // If device token exists but not trusted, revoke it first (may be expired/invalid)
2167
+ try {
2168
+ await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, userId);
2169
+ this.logger?.debug?.(`Revoked existing untrusted device token for user ${user.sub}`);
2170
+ } catch {
2171
+ // Non-blocking - may not exist
2172
+ }
2173
+ }
2174
+
2175
+ // Create trusted device token using session device info
2176
+ const deviceToken = await this.trustedDeviceService.createTrustedDevice(
2177
+ userId,
2178
+ session.deviceName || clientInfo.deviceName,
2179
+ session.deviceType || clientInfo.deviceType,
2180
+ session.ipAddress || clientInfo.ipAddress,
2181
+ session.userAgent || clientInfo.userAgent,
2182
+ clientInfo.platform,
2183
+ clientInfo.browser,
2184
+ );
2185
+
2186
+ this.logger?.log?.(`Device trusted for user ${user.sub} (user opt-in)`);
2187
+
2188
+ // ============================================================================
2189
+ // Audit: Record device trust event
2190
+ // ============================================================================
2191
+ try {
2192
+ // Ensure userId is a number for audit
2193
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2194
+
2195
+ await this.auditService?.recordEvent({
2196
+ userId,
2197
+ eventType: AuthAuditEventType.DEVICE_TRUSTED,
2198
+ eventStatus: 'SUCCESS',
2199
+ // Override deviceId with the newly created device token
2200
+ deviceId: deviceToken,
2201
+ sessionId: session.id,
2202
+ description: `Device trusted by user (opt-in) - ${session.deviceName || 'Unknown device'}`,
2203
+ // Client info (deviceName, deviceType, etc.) automatically included from context
2204
+ metadata: {
2205
+ rememberDeviceDays: this.config.mfa?.rememberDeviceDays || 30,
2206
+ trustedUntil: new Date(
2207
+ Date.now() + (this.config.mfa?.rememberDeviceDays || 30) * 24 * 60 * 60 * 1000,
2208
+ ).toISOString(),
2209
+ },
2210
+ });
2211
+ } catch (auditError) {
2212
+ // Non-blocking: Log but continue
2213
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2214
+ this.logger?.error?.(`Failed to record DEVICE_TRUSTED audit event: ${errorMessage}`, {
2215
+ error: auditError,
2216
+ userId: user.id,
2217
+ });
2218
+ }
2219
+
2220
+ return { deviceToken };
2221
+ }
2222
+
2223
+ /**
2224
+ * Check if the current device is trusted
2225
+ *
2226
+ * Returns whether the device associated with the current authenticated session
2227
+ * is trusted. Works for both cookies mode (reads from httpOnly cookie) and
2228
+ * JSON mode (reads from X-Device-Token header).
2229
+ *
2230
+ * This endpoint validates the device token on the server side and checks:
2231
+ * - Device token exists and is valid
2232
+ * - Device token matches a trusted device record in the database
2233
+ * - Trust has not expired
2234
+ *
2235
+ * @returns Object containing the trusted status
2236
+ * @throws {NAuthException} If the session is not found or user is not authenticated
2237
+ *
2238
+ * @example
2239
+ * ```typescript
2240
+ * const result = await authService.isTrustedDevice();
2241
+ * // { trusted: true }
2242
+ * ```
2243
+ */
2244
+ async isTrustedDevice(): Promise<IsTrustedDeviceResponseDTO> {
2245
+ if (!this.trustedDeviceService) {
2246
+ // If trusted device service is not available, device is not trusted
2247
+ return { trusted: false };
2248
+ }
2249
+
2250
+ // Get sessionId from context (automatically extracted from JWT token)
2251
+ const clientInfo = this.clientInfoService.get();
2252
+ const sessionId = clientInfo.sessionId;
2253
+
2254
+ if (!sessionId) {
2255
+ throw new NAuthException(
2256
+ AuthErrorCode.SESSION_NOT_FOUND,
2257
+ 'Session ID not found in request context. Ensure the request is authenticated.',
2258
+ );
2259
+ }
2260
+
2261
+ // Get session to extract user
2262
+ const session = await this.sessionService.findById(sessionId);
2263
+ if (!session || session.isRevoked) {
2264
+ throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
2265
+ }
2266
+
2267
+ // Get user
2268
+ const user = await this.userRepository.findOne({ where: { id: session.userId } });
2269
+ if (!user) {
2270
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2271
+ }
2272
+
2273
+ // Check if device is trusted
2274
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2275
+ const deviceToken = clientInfo.deviceToken;
2276
+
2277
+ if (!deviceToken) {
2278
+ return { trusted: false };
2279
+ }
2280
+
2281
+ const isTrusted = await this.trustedDeviceService.isDeviceTrusted(deviceToken, userId);
2282
+ return { trusted: isTrusted };
2283
+ }
2284
+
2285
+ /**
2286
+ * Refresh the access token using a refresh token.
2287
+ *
2288
+ * Handles secure token rotation with distributed locking, reuse detection,
2289
+ * and family revocation to prevent race conditions and replay attacks.
2290
+ *
2291
+ * @param refreshToken - The refresh token issued to the client
2292
+ * @returns Newly generated access and refresh tokens
2293
+ * @throws {NAuthException} If the session is not found, revoked, or refresh is abused
2294
+ *
2295
+ * @example
2296
+ * ```typescript
2297
+ * const tokens = await authService.refreshToken(refreshToken);
2298
+ * ```
2299
+ */
2300
+ async refreshToken(dto: RefreshTokenDTO): Promise<TokenResponse> {
2301
+ const tokenHash = this.jwtService.hashToken(dto.refreshToken);
2302
+
2303
+ // ============================================================================
2304
+ // CRITICAL SECURITY FIX #1 & #2: Distributed Lock + Reuse Detection
2305
+ // ============================================================================
2306
+
2307
+ // CRITICAL: We need to get session ID for locking, but we must lock BEFORE validation
2308
+ // to prevent race conditions. So we do a quick, lightweight lookup first.
2309
+ // Find session by refresh token hash - this is fast and allows us to get session ID
2310
+ const session = await this.sessionService.findByRefreshToken(tokenHash);
2311
+
2312
+ if (!session || session.isRevoked) {
2313
+ // Validate token to get user info for error message
2314
+ const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
2315
+ const userId = validation.payload?.sub || 'unknown';
2316
+ this.logger?.debug?.(
2317
+ `Session not found or revoked for user ${userId}. Possible issue where token are not cleared on logout`,
2318
+ );
2319
+ throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
2320
+ }
2321
+
2322
+ // Acquire distributed lock using SESSION ID (not token hash)
2323
+ // THIS MUST HAPPEN BEFORE VALIDATION to prevent race conditions
2324
+ // where multiple requests validate the same token before any lock is acquired
2325
+ const lockKey = `session-refresh:${session.id}`;
2326
+ this.logger?.debug?.(
2327
+ `[REFRESH DEBUG] Attempting to acquire lock ${lockKey} for token hash ${tokenHash.substring(0, 16)}...`,
2328
+ );
2329
+ let lockAcquired = false;
2330
+ try {
2331
+ const lockStartTime = Date.now();
2332
+ lockAcquired = await this.sessionService.acquireRefreshLock(lockKey, 10000);
2333
+ const lockDuration = Date.now() - lockStartTime;
2334
+
2335
+ if (!lockAcquired) {
2336
+ this.logger?.warn?.(
2337
+ `[REFRESH DEBUG] Lock ${lockKey} NOT acquired - refresh already in progress for session ${session.id}`,
2338
+ );
2339
+ throw new NAuthException(AuthErrorCode.RATE_LIMIT_LOGIN, 'Token refresh already in progress', {
2340
+ retryAfter: 5,
2341
+ });
2342
+ }
2343
+
2344
+ this.logger?.debug?.(
2345
+ `[REFRESH DEBUG] Lock ${lockKey} acquired successfully in ${lockDuration}ms for token hash ${tokenHash.substring(0, 16)}...`,
2346
+ );
2347
+
2348
+ // CRITICAL: Check for token reuse IMMEDIATELY after acquiring lock
2349
+ // If same session + cookie race → return current tokens (don't reissue)
2350
+ // If different session → invalidate that session and reject (attack)
2351
+ if (this.config.jwt.refreshToken.reuseDetection) {
2352
+ const isAlreadyUsed = await this.sessionService.isRefreshTokenUsed(tokenHash);
2353
+ if (isAlreadyUsed) {
2354
+ // Decode token to get sessionId from JWT payload (without full validation)
2355
+ // This allows us to check if the token belongs to the session we found
2356
+ const tokenPayload = this.jwtService.decodeToken(dto.refreshToken);
2357
+ const tokenSessionId = tokenPayload?.sessionId;
2358
+
2359
+ // Get current session state to ensure it's still valid
2360
+ const currentSession = (await this.sessionService.findByIdLight(session.id)) as unknown as ISession | null;
2361
+ if (!currentSession || currentSession.isRevoked) {
2362
+ throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
2363
+ }
2364
+
2365
+ // Check if token's sessionId matches the session we found
2366
+ // If they match → cookie race (same session)
2367
+ // If they don't match → attack (token stolen from different session)
2368
+ if (tokenSessionId && tokenSessionId === session.id.toString()) {
2369
+ // Same session - this is a cookie race condition
2370
+ // Return the current valid tokens (user already has them from first request)
2371
+
2372
+ this.logger?.debug?.(
2373
+ `[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for same session ${session.id} - cookie race detected, returning current tokens`,
2374
+ );
2375
+
2376
+ // Get user info
2377
+ const user = (await this.userRepository.findOne({
2378
+ where: { id: currentSession.userId },
2379
+ })) as IUser | null;
2380
+
2381
+ if (!user) {
2382
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2383
+ }
2384
+
2385
+ // Generate tokens from current session state (same as what the first request returned)
2386
+ // These will match what the user already has, so no change needed
2387
+ // Note: deviceId not included in token - session.deviceId is source of truth
2388
+ const newTokens = await this.jwtService.generateTokenPair({
2389
+ userId: user.sub,
2390
+ email: user.email,
2391
+ sessionId: currentSession.id.toString(),
2392
+ tokenFamily: currentSession.tokenFamily,
2393
+ });
2394
+
2395
+ // Update session with these tokens (they're already there, but ensures consistency)
2396
+ await this.sessionService.updateTokens(
2397
+ currentSession.id,
2398
+ this.jwtService.hashToken(newTokens.accessToken),
2399
+ this.jwtService.hashToken(newTokens.refreshToken),
2400
+ );
2401
+
2402
+ // Decode tokens to get expiry times
2403
+ const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
2404
+ const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
2405
+
2406
+ // Return success with current tokens
2407
+ return {
2408
+ accessToken: newTokens.accessToken,
2409
+ refreshToken: newTokens.refreshToken,
2410
+ accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
2411
+ refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
2412
+ };
2413
+ } else {
2414
+ // Different session - this is an attack!
2415
+ // A refresh token from one session cannot be used by another session
2416
+ this.logger?.error?.(
2417
+ `[REFRESH DEBUG] Token hash ${tokenHash.substring(0, 16)}... already used for different session - ATTACK DETECTED! Token sessionId: ${tokenSessionId}, Found session: ${session.id}. Revoking session ${session.id}`,
2418
+ );
2419
+
2420
+ // Revoke the session that's trying to use a stolen token
2421
+ await this.sessionService.revokeSession(session.id, 'Token reuse detected - possible token theft');
2422
+
2423
+ // Audit the attack
2424
+ let userForAudit: IUser | null = null;
2425
+ try {
2426
+ userForAudit = (await this.userRepository.findOne({
2427
+ where: { id: session.userId },
2428
+ })) as IUser | null;
2429
+ if (userForAudit) {
2430
+ await this.auditService?.recordEvent({
2431
+ userId: userForAudit.id,
2432
+ eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2433
+ eventStatus: 'SUSPICIOUS',
2434
+ riskFactor: 90,
2435
+ riskFactors: [RiskFactor.TOKEN_THEFT_ATTEMPT, RiskFactor.REFRESH_TOKEN_REUSE_DIFFERENT_SESSION],
2436
+ reason: 'Refresh token reuse from different session',
2437
+ // Client info automatically included from context
2438
+ description:
2439
+ 'Refresh token from another session attempted to be used. Session revoked as security measure.',
2440
+ metadata: {
2441
+ sessionId: session.id,
2442
+ tokenSessionId,
2443
+ tokenHash: `${tokenHash.substring(0, 16)}...`,
2444
+ detectedAt: new Date().toISOString(),
2445
+ action: 'session_revoked',
2446
+ },
2447
+ });
2448
+ }
2449
+ } catch (auditError) {
2450
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2451
+ this.logger?.error?.(`Failed to record SUSPICIOUS_ACTIVITY audit event (token reuse): ${errorMessage}`, {
2452
+ error: auditError,
2453
+ userId: userForAudit?.id || session.userId,
2454
+ });
2455
+ }
2456
+
2457
+ throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Refresh token has already been used');
2458
+ }
2459
+ }
2460
+ }
2461
+
2462
+ // NOW validate the refresh token (after lock is acquired and reuse check)
2463
+ // This ensures only one request can validate at a time per session
2464
+ const validation = await this.jwtService.validateRefreshToken(dto.refreshToken);
2465
+
2466
+ if (!validation.valid || !validation.payload) {
2467
+ throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Invalid refresh token');
2468
+ }
2469
+
2470
+ const payload = validation.payload;
2471
+
2472
+ // Re-check session after acquiring lock (it might have been revoked/updated)
2473
+ // Since we have the lock, no other request can modify this session, but it might have been revoked
2474
+ // We already have currentSession from the early reuse check, but re-fetch to ensure it's still valid
2475
+ const lockedSession = (await this.sessionService.findByIdLight(session.id)) as unknown as ISession | null;
2476
+ if (!lockedSession || lockedSession.isRevoked || lockedSession.id !== session.id) {
2477
+ this.logger?.debug?.(
2478
+ `Session changed after lock acquisition for user ${payload.sub}. Session may have been revoked.`,
2479
+ );
2480
+ throw new NAuthException(AuthErrorCode.SESSION_NOT_FOUND, 'Session not found or revoked');
2481
+ }
2482
+
2483
+ // ============================================================================
2484
+ // NOTE: We still do the atomic mark operation below as a double-check
2485
+ // The early check above handles cookie race conditions where old tokens
2486
+ // are sent before new cookies are received
2487
+ // ============================================================================
2488
+
2489
+ // Mark token as used BEFORE generating new tokens (prevents reuse)
2490
+ if (this.config.jwt.refreshToken.reuseDetection) {
2491
+ const refreshTokenTTL = this.jwtService.getRefreshTokenTTL();
2492
+ const marked = await this.sessionService.markRefreshTokenAsUsed(tokenHash, refreshTokenTTL);
2493
+
2494
+ if (!marked) {
2495
+ // Token was already marked as used - reuse detected!
2496
+ this.logger?.error?.(
2497
+ `Token reuse detected for user ${payload.sub} - atomic mark failed, revoking entire token family ${payload.tokenFamily}`,
2498
+ );
2499
+
2500
+ // Audit the reuse attempt
2501
+ try {
2502
+ const userForAudit = (await this.userRepository.findOne({
2503
+ where: { sub: payload.sub },
2504
+ })) as IUser | null;
2505
+ if (userForAudit) {
2506
+ await this.auditService?.recordEvent({
2507
+ userId: userForAudit.id,
2508
+ eventType: AuthAuditEventType.SUSPICIOUS_ACTIVITY,
2509
+ eventStatus: 'SUSPICIOUS',
2510
+ riskFactor: 75,
2511
+ riskFactors: [RiskFactor.TOKEN_REUSE_ATTEMPT],
2512
+ reason: 'Token reuse attempt blocked',
2513
+ // Client info automatically included from context
2514
+ description:
2515
+ 'Refresh token reuse attempt detected via atomic operation. Legitimate user session preserved.',
2516
+ metadata: {
2517
+ tokenFamily: payload.tokenFamily,
2518
+ detectedAt: new Date().toISOString(),
2519
+ action: 'reuse_blocked_atomic',
2520
+ },
2521
+ });
2522
+ }
2523
+ } catch (auditError) {
2524
+ this.logger?.warn?.('Failed to record SUSPICIOUS_ACTIVITY audit event', { error: auditError });
2525
+ }
2526
+
2527
+ throw new NAuthException(AuthErrorCode.TOKEN_INVALID, 'Refresh token has already been used');
2528
+ }
2529
+
2530
+ this.logger?.debug?.(`Marked refresh token as used for session ${lockedSession.id}`);
2531
+ }
2532
+
2533
+ // Generate new token pair with same family
2534
+ // Note: deviceId not included in token - session.deviceId is source of truth
2535
+ const newTokens = await this.jwtService.generateTokenPair({
2536
+ userId: payload.sub,
2537
+ email: payload.email,
2538
+ sessionId: lockedSession.id.toString(), // Convert integer to string for JWT
2539
+ tokenFamily: payload.tokenFamily,
2540
+ });
2541
+
2542
+ // Update session with new token hashes (token rotation)
2543
+ // This automatically invalidates the old tokens as they won't match the session
2544
+ await this.sessionService.updateTokens(
2545
+ lockedSession.id,
2546
+ this.jwtService.hashToken(newTokens.accessToken),
2547
+ this.jwtService.hashToken(newTokens.refreshToken),
2548
+ );
2549
+
2550
+ this.logger?.log?.(`Token refreshed successfully for user ${payload.sub}`);
2551
+
2552
+ // Decode new tokens to get expiry times
2553
+ const accessTokenValidation = await this.jwtService.validateAccessToken(newTokens.accessToken);
2554
+ const refreshTokenValidation = await this.jwtService.validateRefreshToken(newTokens.refreshToken);
2555
+
2556
+ return {
2557
+ accessToken: newTokens.accessToken,
2558
+ refreshToken: newTokens.refreshToken,
2559
+ accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
2560
+ refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
2561
+ };
2562
+ } finally {
2563
+ // Always release lock, even if error occurs
2564
+ // Only release if we successfully acquired it
2565
+ if (lockAcquired) {
2566
+ await this.sessionService.releaseRefreshLock(lockKey);
2567
+ this.logger?.debug?.(`[REFRESH DEBUG] Released lock ${lockKey}`);
2568
+ }
2569
+ }
2570
+ }
2571
+
2572
+ // ============================================================================
2573
+ // Logout
2574
+ // ============================================================================
2575
+
2576
+ /**
2577
+ * Logout user (revoke session)
2578
+ *
2579
+ * Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
2580
+ *
2581
+ * @param dto - Logout options (forgetMe flag)
2582
+ * @returns Success status
2583
+ * @throws {NAuthException} If session ID is not available in request context
2584
+ */
2585
+ async logout(dto: LogoutDTO): Promise<LogoutResponseDTO> {
2586
+ // Get sessionId from context (automatically extracted from JWT token)
2587
+ const clientInfo = this.clientInfoService.get();
2588
+ let sessionId = clientInfo.sessionId;
2589
+
2590
+ // Fallback: Try to get sessionId from JWT payload in context
2591
+ if (!sessionId) {
2592
+ const jwtPayload = ContextStorage.get<any>('JWT_PAYLOAD');
2593
+ if (jwtPayload?.sessionId) {
2594
+ // Parse sessionId to number (JWT payload has it as string)
2595
+ const sessionIdStr = String(jwtPayload.sessionId);
2596
+ const sessionIdNumber = parseInt(sessionIdStr, 10);
2597
+ if (!isNaN(sessionIdNumber) && sessionIdNumber > 0) {
2598
+ sessionId = sessionIdNumber;
2599
+ // Update CLIENT_INFO in context for future use
2600
+ const clientInfoInContext = ContextStorage.get<any>('CLIENT_INFO');
2601
+ if (clientInfoInContext) {
2602
+ clientInfoInContext.sessionId = sessionIdNumber;
2603
+ ContextStorage.set('CLIENT_INFO', clientInfoInContext);
2604
+ }
2605
+ }
2606
+ }
2607
+ }
2608
+
2609
+ if (!sessionId) {
2610
+ throw new NAuthException(
2611
+ AuthErrorCode.SESSION_NOT_FOUND,
2612
+ 'Session ID not found in request context. Ensure the request is authenticated.',
2613
+ );
2614
+ }
2615
+
2616
+ // Prepare metadata for audit trail
2617
+ const auditMetadata: Record<string, unknown> | undefined = dto.forgetMe
2618
+ ? {
2619
+ deviceForgotten: true,
2620
+ reason: 'User requested device to be forgotten on logout',
2621
+ }
2622
+ : undefined;
2623
+
2624
+ await this.sessionService.revokeSession(sessionId, 'User logout', auditMetadata);
2625
+
2626
+ // If forgetMe is true, revoke trusted device
2627
+ if (
2628
+ dto.forgetMe &&
2629
+ this.config.mfa?.rememberDevices &&
2630
+ this.config.mfa?.rememberDevices !== 'never' &&
2631
+ this.trustedDeviceService
2632
+ ) {
2633
+ if (clientInfo.deviceToken) {
2634
+ try {
2635
+ // Get session to get userId
2636
+ const session = await this.sessionService.findById(sessionId);
2637
+ if (session) {
2638
+ await this.trustedDeviceService.revokeTrustedDevice(clientInfo.deviceToken, session.userId);
2639
+ this.logger?.log?.(`Revoked trusted device token for user (forgetMe=true)`);
2640
+
2641
+ // Get user for audit
2642
+ const user = await this.userRepository.findOne({ where: { id: session.userId } });
2643
+ if (user) {
2644
+ // ============================================================================
2645
+ // Audit: Record device untrust event
2646
+ // ============================================================================
2647
+ try {
2648
+ // Ensure userId is a number for audit
2649
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2650
+
2651
+ await this.auditService?.recordEvent({
2652
+ userId,
2653
+ eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
2654
+ eventStatus: 'SUCCESS',
2655
+ sessionId: session.id,
2656
+ description: `Device untrusted by user (forgetMe=true) - ${session.deviceName || 'Unknown device'}`,
2657
+ // Client info (deviceId, deviceName, deviceType, etc.) automatically included from context
2658
+ metadata: {
2659
+ reason: 'user_logout_forget_me',
2660
+ },
2661
+ });
2662
+ } catch (auditError) {
2663
+ // Non-blocking: Log but continue
2664
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2665
+ this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
2666
+ error: auditError,
2667
+ userId: session.userId,
2668
+ });
2669
+ }
2670
+ }
2671
+ }
2672
+ } catch (error) {
2673
+ // Non-blocking: Log but continue
2674
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2675
+ this.logger?.debug?.(`Failed to revoke trusted device token on logout: ${errorMessage}`, { error });
2676
+ }
2677
+ }
2678
+ }
2679
+
2680
+ // ============================================================================
2681
+ // Automatically Clear Auth Cookies (if using cookie-based token delivery)
2682
+ // ============================================================================
2683
+ const response = this.clientInfoService.getResponse();
2684
+ if (response && this.config.tokenDelivery?.method !== 'json') {
2685
+ this.clearAuthCookies(response, dto.forgetMe ?? false);
2686
+ this.logger?.debug?.('Auth cookies cleared automatically on logout');
2687
+ }
2688
+
2689
+ return { success: true };
2690
+ }
2691
+
2692
+ /**
2693
+ * Clear authentication cookies from response
2694
+ *
2695
+ * @param response - HTTP response object with clearCookie method
2696
+ * @param forgetDevice - Whether to also clear device token cookie
2697
+ * @private
2698
+ */
2699
+ private clearAuthCookies(
2700
+ response: { clearCookie?: (name: string, options?: unknown) => void },
2701
+ forgetDevice: boolean,
2702
+ ): void {
2703
+ if (!response.clearCookie) {
2704
+ return; // Response doesn't support cookie clearing (shouldn't happen)
2705
+ }
2706
+
2707
+ const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
2708
+ const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
2709
+
2710
+ // Clear access and refresh tokens
2711
+ response.clearCookie(`${prefix}_access_token`, cookieOptions);
2712
+ response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
2713
+
2714
+ // Clear CSRF token cookie (httpOnly: false, so it can be cleared)
2715
+ // Use the same cookie options but with httpOnly: false to match how it was set
2716
+ const csrfCookieOptions = {
2717
+ ...cookieOptions,
2718
+ httpOnly: false, // CSRF token cookie is not httpOnly
2719
+ };
2720
+ const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
2721
+ response.clearCookie(csrfCookieName, csrfCookieOptions);
2722
+
2723
+ // Clear device token if forgetting device
2724
+ if (forgetDevice) {
2725
+ response.clearCookie(`${prefix}_device_token`, cookieOptions);
2726
+ }
2727
+ }
2728
+
2729
+ /**
2730
+ * Global signout (revoke all user sessions)
2731
+ * @param sub - External user identifier (sub/UUID)
2732
+ * @returns Number of sessions revoked
2733
+ */
2734
+ async logoutAll(dto: LogoutAllDTO): Promise<LogoutAllResponseDTO> {
2735
+ // Get user by sub to get internal id
2736
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
2737
+ if (!user) {
2738
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2739
+ }
2740
+
2741
+ // Use internal id for session queries
2742
+ const revokedCount = await this.sessionService.revokeAllUserSessions(user.id, 'Global signout');
2743
+
2744
+ // Revoke all trusted devices if forgetDevices flag is set
2745
+ let revokedDevicesCount = 0;
2746
+ let revokedDevices: Array<{
2747
+ id: number | string;
2748
+ deviceName: string | null;
2749
+ lastUsedAt: Date | null;
2750
+ trustedUntil: Date | null;
2751
+ }> = [];
2752
+ if (
2753
+ dto.forgetDevices &&
2754
+ this.config.mfa?.rememberDevices &&
2755
+ this.config.mfa?.rememberDevices !== 'never' &&
2756
+ this.trustedDeviceService
2757
+ ) {
2758
+ try {
2759
+ const deviceRevocationResult = await this.trustedDeviceService.revokeAllTrustedDevices(user.id);
2760
+ revokedDevicesCount = deviceRevocationResult.revokedCount;
2761
+ revokedDevices = deviceRevocationResult.devices;
2762
+ this.logger?.log?.(
2763
+ `Revoked ${revokedDevicesCount} trusted device(s) for user ${user.sub} (forgetDevices=true)`,
2764
+ );
2765
+
2766
+ // Record audit event for device revocation
2767
+ if (revokedDevicesCount > 0 && this.auditService) {
2768
+ try {
2769
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2770
+ await this.auditService.recordEvent({
2771
+ userId,
2772
+ eventType: AuthAuditEventType.DEVICE_UNTRUSTED,
2773
+ eventStatus: 'SUCCESS',
2774
+ description: `Global signout: All trusted devices revoked (${revokedDevicesCount} device(s))`,
2775
+ metadata: {
2776
+ reason: 'global_logout_forget_devices',
2777
+ revokedDevicesCount,
2778
+ devices: revokedDevices.map((d) => ({
2779
+ id: d.id,
2780
+ deviceName: d.deviceName,
2781
+ lastUsedAt: d.lastUsedAt?.toISOString() || null,
2782
+ trustedUntil: d.trustedUntil?.toISOString() || null,
2783
+ })),
2784
+ },
2785
+ });
2786
+ } catch (auditError) {
2787
+ // Non-blocking: Log but continue
2788
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2789
+ this.logger?.error?.(`Failed to record DEVICE_UNTRUSTED audit event: ${errorMessage}`, {
2790
+ error: auditError,
2791
+ userId: user.id,
2792
+ });
2793
+ }
2794
+ }
2795
+ } catch (error) {
2796
+ // Non-blocking: Log but continue
2797
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2798
+ this.logger?.debug?.(`Failed to revoke trusted devices on global logout: ${errorMessage}`, { error });
2799
+ }
2800
+ }
2801
+
2802
+ // ============================================================================
2803
+ // Audit: Record GLOBAL_SIGNOUT event (individual SESSION_REVOKED events recorded in SessionService)
2804
+ // ============================================================================
2805
+ if (this.auditService && revokedCount > 0) {
2806
+ try {
2807
+ const userId = typeof user.id === 'number' ? user.id : parseInt(String(user.id), 10);
2808
+ const description =
2809
+ dto.forgetDevices && revokedDevicesCount > 0
2810
+ ? `Global signout: ${revokedCount} session(s) revoked, ${revokedDevicesCount} trusted device(s) forgotten`
2811
+ : `Global signout: ${revokedCount} session(s) revoked`;
2812
+
2813
+ await this.auditService.recordEvent({
2814
+ userId,
2815
+ eventType: AuthAuditEventType.GLOBAL_SIGNOUT,
2816
+ eventStatus: 'INFO',
2817
+ reason: 'Global signout',
2818
+ description,
2819
+ metadata: {
2820
+ revokedCount,
2821
+ forgetDevices: dto.forgetDevices ?? false,
2822
+ ...(dto.forgetDevices && revokedDevicesCount > 0
2823
+ ? {
2824
+ revokedDevicesCount,
2825
+ devices: revokedDevices.map((d) => ({
2826
+ id: d.id,
2827
+ deviceName: d.deviceName,
2828
+ lastUsedAt: d.lastUsedAt?.toISOString() || null,
2829
+ trustedUntil: d.trustedUntil?.toISOString() || null,
2830
+ })),
2831
+ }
2832
+ : {}),
2833
+ },
2834
+ });
2835
+ } catch (auditError) {
2836
+ // Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
2837
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2838
+ this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
2839
+ error: auditError,
2840
+ userId: user.id,
2841
+ });
2842
+ }
2843
+ }
2844
+
2845
+ // ============================================================================
2846
+ // Automatically Clear Auth Cookies (if using cookie-based token delivery)
2847
+ // ============================================================================
2848
+ const response = this.clientInfoService.getResponse();
2849
+ if (response && this.config.tokenDelivery?.method !== 'json') {
2850
+ // Clear auth cookies
2851
+ // If forgetDevices is true, also clear device token cookie
2852
+ this.clearAuthCookies(response, dto.forgetDevices ?? false);
2853
+ this.logger?.debug?.('Auth cookies cleared automatically on global logout');
2854
+ }
2855
+
2856
+ return { revokedCount };
2857
+ }
2858
+
2859
+ // ============================================================================
2860
+ // Password Management
2861
+ // ============================================================================
2862
+
2863
+ /**
2864
+ * Change the password for an existing user.
2865
+ *
2866
+ * Verifies the current password, validates the new password,
2867
+ * checks password reuse policy, and updates the user's password hash and history.
2868
+ * Executes configured pre-change hooks if provided.
2869
+ *
2870
+ * @param sub - External user identifier (sub/UUID)
2871
+ * @param dto - ChangePasswordDTO containing old and new password
2872
+ * @returns void
2873
+ * @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
2874
+ *
2875
+ * @example
2876
+ * ```typescript
2877
+ * await authService.changePassword('user-uuid', {
2878
+ * oldPassword: 'currentPass123!',
2879
+ * newPassword: 'newStr0ngPass!@#',
2880
+ * });
2881
+ * ```
2882
+ */
2883
+ async changePassword(dto: ChangePasswordRequestDTO): Promise<ChangePasswordResponseDTO> {
2884
+ // Get user by sub
2885
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
2886
+
2887
+ if (!user || !user.passwordHash) {
2888
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2889
+ }
2890
+
2891
+ // Execute beforePasswordChange hook (use sub for external API)
2892
+ if (this.config.hooks?.beforePasswordChange) {
2893
+ const result = await this.config.hooks.beforePasswordChange(dto.sub, dto.oldPassword);
2894
+ if (result === false) {
2895
+ throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
2896
+ }
2897
+ }
2898
+
2899
+ // Verify old password
2900
+ const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
2901
+
2902
+ if (!isValid) {
2903
+ throw new NAuthException(AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
2904
+ }
2905
+
2906
+ // Validate new password
2907
+ const validation = await this.passwordService.validatePassword(dto.newPassword, {
2908
+ email: user.email,
2909
+ username: user.username || undefined,
2910
+ });
2911
+
2912
+ if (!validation.valid) {
2913
+ throw new NAuthException(AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
2914
+ errors: validation.errors,
2915
+ });
2916
+ }
2917
+
2918
+ // Check password history
2919
+ if (this.config.password?.historyCount) {
2920
+ // Include current password hash in the check to prevent immediate reuse
2921
+ const historyToCheck = user.passwordHistory || [];
2922
+ const allPreviousPasswords = user.passwordHash ? [user.passwordHash, ...historyToCheck] : historyToCheck;
2923
+
2924
+ const isReused = await this.passwordService.isPasswordInHistory(dto.newPassword, allPreviousPasswords);
2925
+
2926
+ if (isReused) {
2927
+ throw new NAuthException(
2928
+ AuthErrorCode.PASSWORD_REUSED,
2929
+ 'You have used this password recently. Please choose a different password.',
2930
+ );
2931
+ }
2932
+ }
2933
+
2934
+ // Hash new password
2935
+ const newHash = await this.passwordService.hashPassword(dto.newPassword);
2936
+
2937
+ // Update password history
2938
+ const newHistory = this.passwordService.addToHistory(user.passwordHistory || [], user.passwordHash);
2939
+
2940
+ // Update user - use save() instead of update() to ensure TypeORM properly serializes simple-array fields
2941
+ user.passwordHash = newHash;
2942
+ user.passwordChangedAt = new Date();
2943
+ user.passwordHistory = newHistory;
2944
+ await this.userRepository.save(user);
2945
+
2946
+ // Execute afterPasswordChange hook (use sub for external API)
2947
+ if (this.config.hooks?.afterPasswordChange) {
2948
+ await this.config.hooks.afterPasswordChange(dto.sub);
2949
+ }
2950
+
2951
+ // Optionally revoke all sessions (force re-login) - use internal id
2952
+ await this.sessionService.revokeAllUserSessions(user.id, 'Password changed');
2953
+
2954
+ // ============================================================================
2955
+ // Audit: Record password change
2956
+ // ============================================================================
2957
+ try {
2958
+ await this.auditService?.recordEvent({
2959
+ userId: user.id,
2960
+ eventType: AuthAuditEventType.PASSWORD_CHANGED,
2961
+ eventStatus: 'SUCCESS',
2962
+ // Client info automatically included from context
2963
+ });
2964
+ } catch (auditError) {
2965
+ // Non-blocking: Log but continue
2966
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2967
+ this.logger?.error?.(`Failed to record PASSWORD_CHANGED audit event: ${errorMessage}`, {
2968
+ error: auditError,
2969
+ userId: user.id,
2970
+ });
2971
+ }
2972
+
2973
+ return { success: true };
2974
+ }
2975
+
2976
+ /**
2977
+ * Update user profile attributes.
2978
+ *
2979
+ * Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
2980
+ *
2981
+ * @param sub - User sub/UUID
2982
+ * @param updateData - User fields to update
2983
+ * @returns Updated user object
2984
+ * @throws {NAuthException} If user not found or unique constraint violated
2985
+ *
2986
+ * @example
2987
+ * await authService.updateUserAttributes(sub, { email: 'test@example.com' });
2988
+ */
2989
+ async updateUserAttributes(dto: UpdateUserAttributesRequestDTO): Promise<UserResponseDto> {
2990
+ // Find user by sub (external identifier)
2991
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
2992
+ if (!user) {
2993
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
2994
+ }
2995
+
2996
+ // Check for uniqueness constraints - use internal id
2997
+ await this.validateUniquenessConstraints(user.id, dto);
2998
+
2999
+ // Prepare update object
3000
+ const updateFields: Partial<IUser> = {};
3001
+
3002
+ // Update basic fields if provided
3003
+ if (dto.firstName !== undefined) {
3004
+ updateFields.firstName = dto.firstName;
3005
+ }
3006
+ if (dto.lastName !== undefined) {
3007
+ updateFields.lastName = dto.lastName;
3008
+ }
3009
+ if (dto.username !== undefined) {
3010
+ updateFields.username = dto.username;
3011
+ }
3012
+ if (dto.email !== undefined) {
3013
+ const oldEmail = user.email;
3014
+ updateFields.email = dto.email;
3015
+ // Reset email verification if email changed (unless retainVerification is true)
3016
+ if (dto.email !== user.email) {
3017
+ if (!dto.retainVerification) {
3018
+ updateFields.isEmailVerified = false;
3019
+ } else {
3020
+ // Explicitly retain current verification status
3021
+ updateFields.isEmailVerified = user.isEmailVerified;
3022
+ }
3023
+
3024
+ // ============================================================================
3025
+ // MFA Device Management: Handle Email MFA devices when email changes
3026
+ // ============================================================================
3027
+ // When email address changes, Email MFA devices become invalid.
3028
+ // We deactivate them and check if user has any other active MFA devices.
3029
+ // If Email was the only MFA method, user will need to set up MFA again.
3030
+ // This happens automatically via challenge system at next login.
3031
+ if (oldEmail && this.mfaDeviceRepository) {
3032
+ try {
3033
+ // Find all Email MFA devices (email field may be null in legacy devices)
3034
+ const emailDevices = (await this.mfaDeviceRepository.find({
3035
+ where: {
3036
+ userId: user.id,
3037
+ type: MFAMethod.EMAIL,
3038
+ isActive: true,
3039
+ },
3040
+ } as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
3041
+
3042
+ if (emailDevices.length > 0) {
3043
+ this.logger?.log?.(
3044
+ `Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`,
3045
+ );
3046
+
3047
+ // Delete all Email devices (can't be reactivated with old email)
3048
+ for (const device of emailDevices) {
3049
+ const deviceId = (device as Record<string, unknown>).id as number;
3050
+ await this.mfaDeviceRepository.delete(deviceId);
3051
+ }
3052
+
3053
+ // Record audit event for removed Email MFA devices
3054
+ if (this.auditService) {
3055
+ try {
3056
+ await this.auditService.recordEvent({
3057
+ userId: user.id,
3058
+ eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
3059
+ eventStatus: 'INFO',
3060
+ reason: 'email_changed',
3061
+ description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
3062
+ metadata: {
3063
+ method: MFAMethod.EMAIL,
3064
+ deletedCount: emailDevices.length,
3065
+ oldEmail,
3066
+ newEmail: dto.email,
3067
+ reason: 'email_address_changed_requires_reverification',
3068
+ },
3069
+ });
3070
+ } catch (auditError) {
3071
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3072
+ this.logger?.error?.(
3073
+ `Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`,
3074
+ { error: auditError, userId: user.id },
3075
+ );
3076
+ }
3077
+ }
3078
+
3079
+ // Check if user has any other active MFA devices
3080
+ const allActiveDevices = (await this.mfaDeviceRepository.find({
3081
+ where: {
3082
+ userId: user.id,
3083
+ isActive: true,
3084
+ },
3085
+ } as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
3086
+
3087
+ // If no active devices remain and user had MFA enabled, disable MFA
3088
+ if (allActiveDevices.length === 0 && user.mfaEnabled) {
3089
+ updateFields.mfaEnabled = false;
3090
+ updateFields.mfaMethods = [];
3091
+ updateFields.preferredMfaMethod = null;
3092
+ this.logger?.log?.(
3093
+ `MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`,
3094
+ );
3095
+ } else {
3096
+ this.logger?.log?.(
3097
+ `User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`,
3098
+ );
3099
+ }
3100
+ }
3101
+ } catch (error: unknown) {
3102
+ // Log error but don't fail the email update
3103
+ // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3104
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3105
+ this.logger?.warn?.(
3106
+ `Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`,
3107
+ );
3108
+ }
3109
+ }
3110
+ }
3111
+ }
3112
+ if (dto.phone !== undefined) {
3113
+ const oldPhone = user.phone;
3114
+ updateFields.phone = dto.phone;
3115
+ // Reset phone verification if phone changed (unless retainVerification is true)
3116
+ if (dto.phone !== user.phone) {
3117
+ if (!dto.retainVerification) {
3118
+ updateFields.isPhoneVerified = false;
3119
+ } else {
3120
+ // Explicitly retain current verification status
3121
+ updateFields.isPhoneVerified = user.isPhoneVerified;
3122
+ }
3123
+
3124
+ // ============================================================================
3125
+ // MFA Device Management: Handle SMS MFA devices when phone changes
3126
+ // ============================================================================
3127
+ // When phone number changes, SMS MFA devices become invalid.
3128
+ // We delete them and check if user has any other active MFA devices.
3129
+ // If SMS was the only MFA method, user will need to set up MFA again.
3130
+ // This happens automatically via challenge system at next login.
3131
+ if (oldPhone && this.mfaDeviceRepository) {
3132
+ try {
3133
+ // Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
3134
+ const smsDevices = (await this.mfaDeviceRepository.find({
3135
+ where: {
3136
+ userId: user.id,
3137
+ type: MFAMethod.SMS,
3138
+ isActive: true,
3139
+ },
3140
+ } as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
3141
+
3142
+ if (smsDevices.length > 0) {
3143
+ this.logger?.log?.(
3144
+ `Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`,
3145
+ );
3146
+
3147
+ // Delete all SMS devices (can't be reactivated with old phone number)
3148
+ for (const device of smsDevices) {
3149
+ const deviceId = (device as Record<string, unknown>).id as number;
3150
+ await this.mfaDeviceRepository.delete(deviceId);
3151
+ }
3152
+
3153
+ // Record audit event for removed SMS MFA devices
3154
+ if (this.auditService) {
3155
+ try {
3156
+ await this.auditService.recordEvent({
3157
+ userId: user.id,
3158
+ eventType: AuthAuditEventType.MFA_DEVICE_REMOVED,
3159
+ eventStatus: 'INFO',
3160
+ reason: 'phone_changed',
3161
+ description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
3162
+ metadata: {
3163
+ method: MFAMethod.SMS,
3164
+ deletedCount: smsDevices.length,
3165
+ oldPhone,
3166
+ newPhone: dto.phone,
3167
+ reason: 'phone_number_changed_requires_reverification',
3168
+ },
3169
+ });
3170
+ } catch (auditError) {
3171
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3172
+ this.logger?.error?.(
3173
+ `Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`,
3174
+ { error: auditError, userId: user.id },
3175
+ );
3176
+ }
3177
+ }
3178
+
3179
+ // Check if user has any other active MFA devices
3180
+ const allActiveDevices = (await this.mfaDeviceRepository.find({
3181
+ where: {
3182
+ userId: user.id,
3183
+ isActive: true,
3184
+ },
3185
+ } as Record<string, unknown>)) as unknown as Array<Record<string, unknown>>;
3186
+
3187
+ // If no active devices remain and user had MFA enabled, disable MFA
3188
+ if (allActiveDevices.length === 0 && user.mfaEnabled) {
3189
+ updateFields.mfaEnabled = false;
3190
+ updateFields.mfaMethods = [];
3191
+ updateFields.preferredMfaMethod = null;
3192
+ this.logger?.log?.(
3193
+ `MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`,
3194
+ );
3195
+ } else {
3196
+ this.logger?.log?.(
3197
+ `User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`,
3198
+ );
3199
+ }
3200
+ }
3201
+ } catch (error: unknown) {
3202
+ // Log error but don't fail the phone update
3203
+ // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3204
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3205
+ this.logger?.warn?.(
3206
+ `Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`,
3207
+ );
3208
+ }
3209
+ }
3210
+ }
3211
+ }
3212
+
3213
+ // Handle preferred MFA method
3214
+ if (dto.preferredMfaMethod !== undefined) {
3215
+ updateFields.preferredMfaMethod = dto.preferredMfaMethod as string | null;
3216
+ }
3217
+
3218
+ // Handle metadata merge
3219
+ if (dto.metadata !== undefined) {
3220
+ const existingMetadata = user.metadata || {};
3221
+ updateFields.metadata = { ...existingMetadata, ...dto.metadata };
3222
+ }
3223
+
3224
+ // Update user in database - use internal id for update query
3225
+ await this.userRepository.update(user.id, updateFields as Record<string, unknown>);
3226
+
3227
+ // Fetch updated user - use internal id
3228
+ const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } })) as IUser | null;
3229
+ if (!updatedUser) {
3230
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found after update');
3231
+ }
3232
+
3233
+ // ============================================================================
3234
+ // Audit: Record profile and attribute updates
3235
+ // ============================================================================
3236
+ try {
3237
+ // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
3238
+ // Note: ClientInfoService is used transparently by SessionService and AuditService
3239
+ const updatedFieldNames = Object.keys(updateFields);
3240
+
3241
+ // Build field changes map with before/after values
3242
+ const fieldChanges: Record<string, unknown> = {};
3243
+
3244
+ // Capture before/after values for each updated field
3245
+ if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
3246
+ fieldChanges.firstName = {
3247
+ before: user.firstName ?? null,
3248
+ after: dto.firstName ?? null,
3249
+ };
3250
+ }
3251
+
3252
+ if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
3253
+ fieldChanges.lastName = {
3254
+ before: user.lastName ?? null,
3255
+ after: dto.lastName ?? null,
3256
+ };
3257
+ }
3258
+
3259
+ if (dto.username !== undefined && dto.username !== user.username) {
3260
+ fieldChanges.username = {
3261
+ before: user.username ?? null,
3262
+ after: dto.username ?? null,
3263
+ };
3264
+ }
3265
+
3266
+ // Note: email and phone are tracked separately with specific audit events,
3267
+ // but we include them in fieldChanges for completeness
3268
+ if (dto.email !== undefined && dto.email !== user.email) {
3269
+ fieldChanges.email = {
3270
+ before: user.email ?? null,
3271
+ after: dto.email ?? null,
3272
+ };
3273
+ }
3274
+
3275
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
3276
+ fieldChanges.phone = {
3277
+ before: user.phone ?? null,
3278
+ after: dto.phone ?? null,
3279
+ };
3280
+ }
3281
+
3282
+ if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
3283
+ fieldChanges.preferredMfaMethod = {
3284
+ before: user.preferredMfaMethod ?? null,
3285
+ after: dto.preferredMfaMethod ?? null,
3286
+ };
3287
+ }
3288
+
3289
+ // Handle metadata changes (merged, so track what was added/changed)
3290
+ if (dto.metadata !== undefined) {
3291
+ const oldMetadata = user.metadata || {};
3292
+ const newMetadata = { ...oldMetadata, ...dto.metadata };
3293
+ const metadataChanges: Record<string, { before: unknown; after: unknown }> = {};
3294
+
3295
+ // Track all keys in new metadata
3296
+ const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
3297
+
3298
+ for (const key of allKeys) {
3299
+ const oldValue = oldMetadata[key];
3300
+ const newValue = newMetadata[key];
3301
+
3302
+ // Only track if value actually changed
3303
+ if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
3304
+ metadataChanges[key] = {
3305
+ before: oldValue ?? null,
3306
+ after: newValue ?? null,
3307
+ };
3308
+ }
3309
+ }
3310
+
3311
+ if (Object.keys(metadataChanges).length > 0) {
3312
+ fieldChanges.metadata = metadataChanges;
3313
+ }
3314
+ }
3315
+
3316
+ // Track verification status changes if email/phone changed
3317
+ if (dto.email !== undefined && dto.email !== user.email) {
3318
+ const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
3319
+ if (emailVerificationChanged) {
3320
+ fieldChanges.isEmailVerified = {
3321
+ before: user.isEmailVerified,
3322
+ after: false,
3323
+ };
3324
+ }
3325
+ }
3326
+
3327
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
3328
+ const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
3329
+ if (phoneVerificationChanged) {
3330
+ fieldChanges.isPhoneVerified = {
3331
+ before: user.isPhoneVerified,
3332
+ after: false,
3333
+ };
3334
+ }
3335
+ }
3336
+
3337
+ // Record general profile update with field changes
3338
+ await this.auditService?.recordEvent({
3339
+ userId: user.id,
3340
+ eventType: AuthAuditEventType.PROFILE_UPDATED,
3341
+ eventStatus: 'INFO',
3342
+ metadata: {
3343
+ // Client info automatically included from context
3344
+ updatedFields: updatedFieldNames,
3345
+ fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
3346
+ },
3347
+ });
3348
+
3349
+ // Record specific field changes
3350
+ if (dto.email !== undefined && dto.email !== user.email) {
3351
+ await this.auditService?.recordEvent({
3352
+ userId: user.id,
3353
+ eventType: AuthAuditEventType.EMAIL_CHANGED,
3354
+ eventStatus: 'INFO',
3355
+ metadata: {
3356
+ // Client info automatically included from context
3357
+ oldEmail: user.email,
3358
+ newEmail: dto.email,
3359
+ retainVerification: dto.retainVerification || false,
3360
+ },
3361
+ });
3362
+ }
3363
+
3364
+ if (dto.phone !== undefined && dto.phone !== user.phone) {
3365
+ await this.auditService?.recordEvent({
3366
+ userId: user.id,
3367
+ eventType: AuthAuditEventType.PHONE_CHANGED,
3368
+ eventStatus: 'INFO',
3369
+ metadata: {
3370
+ // Client info automatically included from context
3371
+ oldPhone: user.phone,
3372
+ newPhone: dto.phone,
3373
+ retainVerification: dto.retainVerification || false,
3374
+ },
3375
+ });
3376
+ }
3377
+
3378
+ if (dto.username !== undefined && dto.username !== user.username) {
3379
+ await this.auditService?.recordEvent({
3380
+ userId: user.id,
3381
+ eventType: AuthAuditEventType.USERNAME_CHANGED,
3382
+ eventStatus: 'INFO',
3383
+ metadata: {
3384
+ // Client info automatically included from context
3385
+ oldUsername: user.username,
3386
+ newUsername: dto.username,
3387
+ },
3388
+ });
3389
+ }
3390
+ } catch (auditError) {
3391
+ // Non-blocking: Log but continue
3392
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3393
+ this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
3394
+ error: auditError,
3395
+ userId: user.id,
3396
+ });
3397
+ }
3398
+
3399
+ // Return user response DTO
3400
+ return UserResponseDto.fromEntity(updatedUser);
3401
+ }
3402
+
3403
+ /**
3404
+ * Ensures email, phone, and username are unique for other users before update.
3405
+ *
3406
+ * Throws if another user already has the specified email, phone, or username.
3407
+ *
3408
+ * @param userId - Internal numeric user ID (excluded from check)
3409
+ * @param updateData - User fields to check for uniqueness
3410
+ * @throws {NAuthException} If a unique constraint is violated for email, phone, or username
3411
+ *
3412
+ * @example
3413
+ * ```typescript
3414
+ * await authService.validateUniquenessConstraints(1, { email: "test@example.com" });
3415
+ * ```
3416
+ */
3417
+ private async validateUniquenessConstraints(
3418
+ userId: number,
3419
+ updateData: UpdateUserAttributesRequestDTO,
3420
+ ): Promise<void> {
3421
+ const conflicts: string[] = [];
3422
+
3423
+ // Check email uniqueness
3424
+ if (updateData.email) {
3425
+ const existingUser = await this.userRepository.findOne({
3426
+ where: { email: updateData.email },
3427
+ });
3428
+ if (existingUser && existingUser.id !== userId) {
3429
+ conflicts.push('Email already exists');
3430
+ }
3431
+ }
3432
+
3433
+ // Check phone uniqueness
3434
+ if (updateData.phone) {
3435
+ const existingUser = await this.userRepository.findOne({
3436
+ where: { phone: updateData.phone },
3437
+ });
3438
+ if (existingUser && existingUser.id !== userId) {
3439
+ conflicts.push('Phone number already exists');
3440
+ }
3441
+ }
3442
+
3443
+ // Check username uniqueness
3444
+ if (updateData.username) {
3445
+ const existingUser = await this.userRepository.findOne({
3446
+ where: { username: updateData.username },
3447
+ });
3448
+ if (existingUser && existingUser.id !== userId) {
3449
+ conflicts.push('Username already exists');
3450
+ }
3451
+ }
3452
+
3453
+ if (conflicts.length > 0) {
3454
+ throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
3455
+ conflicts,
3456
+ });
3457
+ }
3458
+ }
3459
+
3460
+ // ============================================================================
3461
+ // Helper Methods
3462
+ // ============================================================================
3463
+
3464
+ /**
3465
+ * Checks if the login identifier matches the specified allowed type.
3466
+ *
3467
+ * Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
3468
+ * according to the configured identifier type restriction.
3469
+ *
3470
+ * @param identifier - The login identifier to check (email, username, or phone)
3471
+ * @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
3472
+ * @returns True if the identifier conforms to the allowed type, otherwise false
3473
+ *
3474
+ * @example
3475
+ * ```typescript
3476
+ * // Email check
3477
+ * const valid = this.validateIdentifierType('user@example.com', 'email'); // true
3478
+ *
3479
+ * // Username check
3480
+ * const valid = this.validateIdentifierType('johndoe', 'username'); // true
3481
+ * ```
3482
+ */
3483
+ private validateIdentifierType(
3484
+ identifier: string,
3485
+ allowedType: 'email' | 'username' | 'phone' | 'email_or_username',
3486
+ ): boolean {
3487
+ // Check if identifier is an email (contains @)
3488
+ const isEmail = identifier.includes('@');
3489
+ // Check if identifier looks like a phone (starts with + and contains digits)
3490
+ const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
3491
+ // If not email or phone, assume it's a username
3492
+ const isUsername = !isEmail && !isPhone;
3493
+
3494
+ switch (allowedType) {
3495
+ case 'email':
3496
+ return isEmail;
3497
+ case 'username':
3498
+ return isUsername;
3499
+ case 'phone':
3500
+ return isPhone;
3501
+ case 'email_or_username':
3502
+ return isEmail || isUsername;
3503
+ default:
3504
+ return true; // No restriction
3505
+ }
3506
+ }
3507
+
3508
+ /**
3509
+ * Retrieves a user entity by login identifier.
3510
+ *
3511
+ * Performs a lookup for a user by email, username, or phone number.
3512
+ * The search respects the identifierType restriction when provided, limiting which fields are queried.
3513
+ *
3514
+ * @param identifier - Login credential (email, username, or phone)
3515
+ * @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
3516
+ * @returns The user entity if found, otherwise null
3517
+ *
3518
+ * @example
3519
+ * ```typescript
3520
+ * const user = await this.findUserByIdentifier('user@example.com');
3521
+ * const user2 = await this.findUserByIdentifier('johndoe', 'username');
3522
+ * ```
3523
+ */
3524
+ private async findUserByIdentifier(
3525
+ identifier: string,
3526
+ identifierType?: 'email' | 'username' | 'phone' | 'email_or_username',
3527
+ ): Promise<IUser | null> {
3528
+ const queryBuilder = this.userRepository.createQueryBuilder('user');
3529
+
3530
+ // Build query based on identifier type restriction
3531
+ if (!identifierType) {
3532
+ // No restriction - search all fields
3533
+ queryBuilder
3534
+ .where('user.email = :identifier', { identifier })
3535
+ .orWhere('user.username = :identifier', { identifier })
3536
+ .orWhere('user.phone = :identifier', { identifier });
3537
+ } else {
3538
+ // Apply restriction based on identifier type
3539
+ switch (identifierType) {
3540
+ case 'email':
3541
+ queryBuilder.where('user.email = :identifier', { identifier });
3542
+ break;
3543
+ case 'username':
3544
+ queryBuilder.where('user.username = :identifier', { identifier });
3545
+ break;
3546
+ case 'phone':
3547
+ queryBuilder.where('user.phone = :identifier', { identifier });
3548
+ break;
3549
+ case 'email_or_username':
3550
+ queryBuilder
3551
+ .where('user.email = :identifier', { identifier })
3552
+ .orWhere('user.username = :identifier', { identifier });
3553
+ break;
3554
+ }
3555
+ }
3556
+
3557
+ // Select only columns required for login checks and response shaping to reduce row size
3558
+ queryBuilder.select([
3559
+ 'user.id',
3560
+ 'user.sub',
3561
+ 'user.email',
3562
+ 'user.firstName',
3563
+ 'user.lastName',
3564
+ 'user.username',
3565
+ 'user.phone',
3566
+ 'user.passwordHash',
3567
+ 'user.passwordChangedAt',
3568
+ 'user.mustChangePassword',
3569
+ 'user.isActive',
3570
+ 'user.mfaEnabled',
3571
+ 'user.preferredMfaMethod',
3572
+ 'user.isEmailVerified',
3573
+ 'user.isPhoneVerified',
3574
+ 'user.mfaExempt', // Required for MFA exemption check in challenge flow
3575
+ // The following are used for messaging/challenge determination when needed
3576
+ 'user.socialProviders',
3577
+ 'user.backupCodes',
3578
+ ]);
3579
+
3580
+ return (await queryBuilder.getOne()) as IUser | null;
3581
+ }
3582
+
3583
+ /**
3584
+ * Handles a failed login by recording the attempt, applying IP-based lockout policy,
3585
+ * and invoking relevant hooks.
3586
+ *
3587
+ * @param identifier - User identifier (email/username/phone)
3588
+ * @param reason - Optional reason for failure
3589
+ * @returns Promise<void>
3590
+ *
3591
+ * @example
3592
+ * ```typescript
3593
+ * await authService.handleFailedLogin('user@example.com', 'invalid_credentials');
3594
+ * ```
3595
+ */
3596
+ private async handleFailedLogin(identifier: string, reason?: string): Promise<void> {
3597
+ // Get client IP address for lockout tracking
3598
+ const clientInfo = this.clientInfoService.get();
3599
+ const ipAddress = clientInfo.ipAddress;
3600
+
3601
+ // Record failed attempt
3602
+ await this.recordLoginAttempt(identifier, false, reason);
3603
+
3604
+ // Increment IP-based lockout counter if enabled
3605
+ if (this.config.lockout?.enabled && ipAddress) {
3606
+ const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
3607
+
3608
+ // Lock IP if max attempts reached
3609
+ if (attempts >= (this.config.lockout.maxAttempts || 5)) {
3610
+ await this.accountLockoutStorage.blockIpAdresss(
3611
+ ipAddress,
3612
+ this.config.lockout.duration || 900, // 15 minutes default
3613
+ 'Too many failed login attempts from this IP',
3614
+ );
3615
+
3616
+ // // Execute hook with IP address
3617
+ // if (this.config.hooks?.afterAccountLock) {
3618
+ // await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
3619
+ // }
3620
+ }
3621
+ }
3622
+
3623
+ // // Execute hook
3624
+ // if (this.config.hooks?.afterLoginFailed) {
3625
+ // await this.config.hooks.afterLoginFailed(identifier, reason || 'unknown');
3626
+ // }
3627
+ }
3628
+
3629
+ /**
3630
+ * Records a login attempt with client context.
3631
+ *
3632
+ * @param email - User's email address
3633
+ * @param success - True if login succeeded, false if failed
3634
+ * @param failureReason - Optional reason for failure
3635
+ * @param userId - Optional internal user ID (only for successful logins)
3636
+ * @returns Promise<void>
3637
+ */
3638
+ private async recordLoginAttempt(
3639
+ email: string,
3640
+ success: boolean,
3641
+ failureReason?: string,
3642
+ userId?: number,
3643
+ ): Promise<void> {
3644
+ // Get client info from context
3645
+ const clientInfo = this.clientInfoService.get();
3646
+
3647
+ const attempt = this.loginAttemptRepository.create({
3648
+ email,
3649
+ userId, // Internal user ID (integer)
3650
+ ipAddress: clientInfo.ipAddress,
3651
+ userAgent: clientInfo.userAgent,
3652
+ success,
3653
+ failureReason,
3654
+ });
3655
+
3656
+ await this.loginAttemptRepository.save(attempt);
3657
+ }
3658
+
3659
+ /**
3660
+ * Get user by ID (sub)
3661
+ * @param sub - User sub (external identifier)
3662
+ * @returns User entity or null
3663
+ */
3664
+ async getUserById(dto: GetUserByIdDTO): Promise<UserResponseDto | null> {
3665
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } })) as IUser | null;
3666
+ return user ? UserResponseDto.fromEntity(user) : null;
3667
+ }
3668
+
3669
+ /**
3670
+ * Get user by email address.
3671
+ *
3672
+ * @param email - User email
3673
+ * @param requireEmailVerified - Only return user if email is verified (default: false)
3674
+ * @returns User entity or null
3675
+ * @internal - For use by social auth providers
3676
+ *
3677
+ * @example
3678
+ * ```typescript
3679
+ * const user = await authService.getUserByEmail('user@example.com', true);
3680
+ * ```
3681
+ */
3682
+ async getUserByEmail(dto: GetUserByEmailDTO): Promise<UserResponseDto | null> {
3683
+ const where: Record<string, unknown> = dto.requireEmailVerified
3684
+ ? { email: dto.email, isEmailVerified: true }
3685
+ : { email: dto.email };
3686
+ const user = (await this.userRepository.findOne({ where })) as IUser | null;
3687
+ return user ? UserResponseDto.fromEntity(user) : null;
3688
+ }
3689
+
3690
+ /**
3691
+ * Require user to change password at next login.
3692
+ *
3693
+ * Throws if user not found or has no password set (e.g. social login only).
3694
+ *
3695
+ * @param userId - User's sub identifier
3696
+ * @returns Resolves when flag is set
3697
+ * @throws {NAuthException} If user is not found or cannot change password
3698
+ *
3699
+ * @example
3700
+ * await authService.setMustChangePassword('user-uuid-123');
3701
+ */
3702
+ async setMustChangePassword(dto: SetMustChangePasswordDTO): Promise<SetMustChangePasswordResponseDTO> {
3703
+ const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
3704
+
3705
+ if (!user) {
3706
+ throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
3707
+ }
3708
+
3709
+ // CRITICAL PROTECTION: Only allow for users with password authentication
3710
+ // Pure social users cannot be forced to change password
3711
+ if (!user.passwordHash) {
3712
+ this.logger?.warn?.(
3713
+ `Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`,
3714
+ );
3715
+ throw new NAuthException(
3716
+ AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED,
3717
+ 'Password change not available. This account uses social authentication only and has no password.',
3718
+ );
3719
+ }
3720
+
3721
+ await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
3722
+
3723
+ this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
3724
+
3725
+ return { success: true };
3726
+ }
3727
+ }