@stjoseph/prumo 0.2.0 → 0.4.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 (252) hide show
  1. package/.prumo-templates/api/auth.md +51 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -4
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/email/codes.md +62 -0
  10. package/.prumo-templates/email/mailer.md +47 -0
  11. package/.prumo-templates/mcp/server.md +55 -14
  12. package/.prumo-templates/mobile/routing.md +1 -1
  13. package/.prumo-templates/mobile/storage.md +7 -6
  14. package/.prumo-templates/monorepo/layout.md +4 -4
  15. package/.prumo-templates/multi-tenancy/client.md +6 -6
  16. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  17. package/.prumo-templates/social/sign-in.md +77 -0
  18. package/README.md +8 -6
  19. package/dist/asker.js +13 -0
  20. package/dist/clean.js +2 -2
  21. package/dist/cli.js +16 -2
  22. package/dist/commands.js +7 -0
  23. package/dist/compose.js +3 -11
  24. package/dist/context.js +4 -0
  25. package/dist/features.js +230 -0
  26. package/dist/generate.js +20 -7
  27. package/dist/questions.js +64 -1
  28. package/package.json +1 -1
  29. package/templates/api/.env.example +21 -5
  30. package/templates/api/README.md +4 -5
  31. package/templates/api/docker-compose.yml +0 -1
  32. package/templates/api/migrations/Migration20261005111545_create_user_profile_session.ts +31 -0
  33. package/templates/api/migrations/Migration20261005111547_create_authorization.ts +18 -0
  34. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  35. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  36. package/templates/api/package.json +7 -8
  37. package/templates/api/scripts/database.mjs +3 -11
  38. package/templates/api/scripts/ports.mjs +1 -1
  39. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  40. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  41. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  42. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  43. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  44. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  45. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  46. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  47. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  48. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  49. package/templates/api/src/domain/auth/entities/session.entity.ts +78 -0
  50. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  51. package/templates/api/src/domain/auth/entities/user.entity.ts +34 -0
  52. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  53. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  54. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  55. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  56. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  57. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  58. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  59. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  60. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  61. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  62. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  63. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  64. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  65. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  66. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  67. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  68. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  69. package/templates/api/src/domain/auth/repositories/session.repository.ts +12 -0
  70. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  71. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  72. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  73. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  74. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  75. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  76. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  77. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +94 -0
  78. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  79. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  80. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  81. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +38 -0
  82. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  83. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +57 -0
  84. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +55 -0
  85. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  86. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  87. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  88. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +14 -0
  89. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +87 -0
  90. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  91. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +18 -0
  92. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  93. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  94. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  95. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  96. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  97. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  98. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +97 -0
  99. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  100. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +89 -0
  101. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  102. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  103. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  104. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +64 -0
  105. package/templates/api/src/infra/config/env.ts +17 -3
  106. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +38 -0
  107. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  108. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  109. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  110. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +34 -0
  111. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  112. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +19 -0
  113. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +15 -4
  114. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  115. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  116. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  117. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +25 -0
  118. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  119. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  120. package/templates/api/src/infra/di/auth.di.ts +21 -0
  121. package/templates/api/src/infra/di/email.di.ts +23 -0
  122. package/templates/api/src/infra/di/index.ts +8 -10
  123. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  124. package/templates/api/src/infra/di/social.di.ts +17 -0
  125. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  126. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  127. package/templates/api/src/infra/http/app.ts +15 -1
  128. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  129. package/templates/api/src/infra/http/controllers/auth.controller.ts +108 -29
  130. package/templates/api/src/infra/http/controllers/mcp.controller.ts +46 -27
  131. package/templates/api/src/infra/http/controllers/oauth.controller.ts +267 -0
  132. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  133. package/templates/api/src/infra/http/errors/error-handler.ts +5 -0
  134. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  135. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  136. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  137. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  138. package/templates/api/src/infra/http/server.ts +1 -4
  139. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  140. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  141. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  142. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  143. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  144. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  145. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  146. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  147. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  148. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  149. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  150. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +41 -0
  151. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  152. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +53 -0
  153. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  154. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  155. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  156. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  157. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  158. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  159. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +168 -0
  160. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +309 -67
  161. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  162. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +13 -35
  163. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  164. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  165. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  166. package/templates/api/test/support/factories/user.factory.ts +9 -11
  167. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  168. package/templates/api/test/support/fakes/auth-fakes.ts +94 -0
  169. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  170. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  171. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  172. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  173. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  174. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  175. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  176. package/templates/api/test/support/fakes/in-memory-session.repository.ts +27 -0
  177. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  178. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  179. package/templates/api/test/support/setup.ts +6 -28
  180. package/templates/api/test/support/test-app.ts +65 -0
  181. package/templates/mobile/README.md +1 -1
  182. package/templates/mobile/jest.config.js +0 -17
  183. package/templates/mobile/package.json +0 -4
  184. package/templates/mobile/scripts/ports.mjs +1 -1
  185. package/templates/mobile/src/api-contract/api-error.ts +4 -11
  186. package/templates/mobile/src/api-contract/auth.ts +221 -0
  187. package/templates/mobile/src/api-contract/index.ts +14 -0
  188. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  189. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  190. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  191. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  192. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  193. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  194. package/templates/mobile/src/app/_layout.tsx +5 -5
  195. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  196. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  197. package/templates/mobile/src/features/auth/sign-in-form.tsx +21 -7
  198. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  199. package/templates/mobile/src/features/auth/sign-up-form.tsx +24 -8
  200. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  201. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  202. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  203. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  204. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  205. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  206. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  207. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  208. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  209. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  210. package/templates/site/scripts/ports.mjs +1 -1
  211. package/templates/web/package.json +0 -3
  212. package/templates/web/scripts/ports.mjs +1 -1
  213. package/templates/web/src/api-contract/api-error.ts +4 -11
  214. package/templates/web/src/api-contract/auth.ts +221 -0
  215. package/templates/web/src/api-contract/index.ts +14 -0
  216. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  217. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  218. package/templates/web/src/features/auth/sign-in-form.tsx +24 -14
  219. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  220. package/templates/web/src/features/auth/sign-up-form.tsx +16 -8
  221. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  222. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  223. package/templates/web/src/features/oauth/consent-form.tsx +72 -35
  224. package/templates/web/src/main.tsx +6 -4
  225. package/templates/web/src/routeTree.gen.ts +73 -2
  226. package/templates/web/src/router.tsx +1 -2
  227. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  228. package/templates/web/src/routes/_authenticated.tsx +1 -1
  229. package/templates/web/src/routes/forgot-password.tsx +17 -0
  230. package/templates/web/src/routes/reset-password.tsx +20 -0
  231. package/templates/web/src/routes/sign-in.tsx +10 -3
  232. package/templates/web/src/routes/sign-up.tsx +4 -0
  233. package/templates/web/src/routes/verify-email.tsx +20 -0
  234. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  235. package/templates/web/test/app.spec.tsx +47 -22
  236. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  237. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  238. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  239. package/templates/web/test/features/oauth/consent-form.spec.tsx +90 -26
  240. package/templates/web/test/support/render-app.tsx +4 -4
  241. package/templates/workspace/scripts/ports.mjs +1 -1
  242. package/dist/mcp.js +0 -100
  243. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  244. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  245. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  246. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  247. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  248. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  249. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  250. package/templates/mobile/src/features/auth/session.ts +0 -17
  251. package/templates/web/src/features/auth/auth-client.ts +0 -17
  252. package/templates/web/src/features/auth/session.ts +0 -17
@@ -0,0 +1,41 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
4
+
5
+ const ANA = { name: 'Ana', email: ' Ana@Example.com', password: 'correct-horse-battery' }
6
+
7
+ describe('SignUpUseCase', () => {
8
+ it('creates the user and the profile together', async () => {
9
+ const { signUp, users, profiles } = authFakes()
10
+
11
+ await signUp.execute(ANA)
12
+ const [user] = [...users.rows.values()]
13
+
14
+ expect(user).toMatchObject({
15
+ email: 'ana@example.com',
16
+ passwordHash: 'hashed:correct-horse-battery',
17
+ })
18
+ expect(profiles.rows.get(user?.id ?? '')).toMatchObject({ displayName: 'Ana' })
19
+ })
20
+
21
+ it('refuses an email already taken, whatever its case', async () => {
22
+ const { signUp, profiles } = authFakes()
23
+
24
+ await signUp.execute(ANA)
25
+
26
+ await expect(signUp.execute({ ...ANA, email: 'ana@example.COM' })).rejects.toBeInstanceOf(
27
+ EmailTakenError,
28
+ )
29
+ expect(profiles.rows.size).toBe(1)
30
+ })
31
+
32
+ // prumo:email
33
+ it('sends a code and opens no session until the email is confirmed', async () => {
34
+ const { signUp, sessions, mailer } = authFakes()
35
+
36
+ expect(await signUp.execute(ANA)).toBeNull()
37
+ expect(sessions.rows.size).toBe(0)
38
+ expect(mailer.lastCode('ana@example.com')).toMatch(/^\d{6}$/)
39
+ })
40
+ // prumo:end-email
41
+ })
@@ -0,0 +1,148 @@
1
+ import { socialFakes } from '@test/support/fakes/social-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
4
+ import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.error'
5
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+ const GOOGLE = {
9
+ subject: 'google-sub-1',
10
+ email: 'Ana@Example.com',
11
+ emailVerified: true,
12
+ name: 'Ana G',
13
+ }
14
+
15
+ describe('LinkIdentityUseCase', () => {
16
+ it('creates a verified, password-less account with its profile for someone new', async () => {
17
+ const { linkIdentity, users, profiles, identities } = socialFakes()
18
+
19
+ const userId = await linkIdentity.execute('google', GOOGLE)
20
+ const user = users.rows.get(userId)
21
+
22
+ expect(user).toMatchObject({ email: 'ana@example.com', passwordHash: null })
23
+ expect(user?.emailVerifiedAt).not.toBeNull()
24
+ expect(profiles.rows.get(userId)).toMatchObject({ displayName: 'Ana G' })
25
+ expect(identities.rows).toEqual([expect.objectContaining({ provider: 'google', userId })])
26
+ })
27
+
28
+ it('recognises the provider account the next time, by subject even if the email changed', async () => {
29
+ const { linkIdentity } = socialFakes()
30
+
31
+ const first = await linkIdentity.execute('google', GOOGLE)
32
+ const again = await linkIdentity.execute('google', { ...GOOGLE, email: 'new@example.com' })
33
+
34
+ expect(again).toBe(first)
35
+ })
36
+
37
+ it('links to a confirmed account and leaves its password alone', async () => {
38
+ const { register, linkIdentity, users, signIn } = socialFakes()
39
+ await register(ANA)
40
+ // Confirmed by a code where the project verifies emails; marked here so the rule is proved either way.
41
+ for (const user of users.rows.values()) {
42
+ user.verifyEmail(new Date())
43
+ }
44
+
45
+ const userId = await linkIdentity.execute('google', GOOGLE)
46
+
47
+ expect(users.rows.get(userId)?.passwordHash).toBe('hashed:correct-horse-battery')
48
+ await expect(signIn.execute(ANA)).resolves.toBeDefined()
49
+ })
50
+
51
+ // The attack: someone signs up with another person's email first, and waits for them to arrive by Google.
52
+ it('takes an unconfirmed account from whoever chose its password, ending their sessions', async () => {
53
+ const { signUp, linkIdentity, users, sessions } = socialFakes()
54
+ await signUp.execute(ANA)
55
+ const squatter = [...users.rows.values()][0]
56
+
57
+ const userId = await linkIdentity.execute('google', GOOGLE)
58
+
59
+ expect(userId).toBe(squatter?.id)
60
+ expect(users.rows.get(userId)?.passwordHash).toBeNull()
61
+ expect([...sessions.rows.values()].every((session) => session.revokedAt !== null)).toBe(true)
62
+ })
63
+
64
+ it('refuses an email the provider has not verified', async () => {
65
+ const { linkIdentity } = socialFakes()
66
+
67
+ await expect(
68
+ linkIdentity.execute('google', { ...GOOGLE, emailVerified: false }),
69
+ ).rejects.toBeInstanceOf(ProviderEmailUnverifiedError)
70
+ })
71
+ })
72
+
73
+ describe('a social sign-in, start to session', () => {
74
+ it('signs the web in on return, and only the browser that started', async () => {
75
+ const { start, complete } = socialFakes()
76
+ const { url, browserToken } = await start.execute({
77
+ provider: 'google',
78
+ client: 'web',
79
+ returnTo: '/orders',
80
+ })
81
+ const state = new URL(url).searchParams.get('state') ?? ''
82
+ const callback = {
83
+ provider: 'google' as const,
84
+ state,
85
+ callbackUrl: new URL('http://localhost:3000/cb'),
86
+ name: null,
87
+ }
88
+
89
+ await expect(complete.execute({ ...callback, browserToken: undefined })).rejects.toBeInstanceOf(
90
+ SocialSignInFailedError,
91
+ )
92
+
93
+ const outcome = await complete.execute({ ...callback, browserToken })
94
+
95
+ expect(outcome).toMatchObject({ client: 'web', returnTo: '/orders' })
96
+ await expect(complete.execute({ ...callback, browserToken })).rejects.toBeInstanceOf(
97
+ SocialSignInFailedError,
98
+ )
99
+ })
100
+
101
+ it('gives a mobile app a code it can exchange once', async () => {
102
+ const { start, complete, exchange } = socialFakes()
103
+ const { url, browserToken } = await start.execute({
104
+ provider: 'apple',
105
+ client: 'mobile',
106
+ returnTo: '/',
107
+ })
108
+ const outcome = await complete.execute({
109
+ provider: 'apple',
110
+ state: new URL(url).searchParams.get('state') ?? '',
111
+ browserToken,
112
+ callbackUrl: new URL('http://localhost:3000/cb'),
113
+ name: 'Ana',
114
+ })
115
+
116
+ if (outcome.client !== 'mobile') {
117
+ throw new Error('expected a code for the mobile app')
118
+ }
119
+
120
+ await expect(exchange.execute(outcome.code)).resolves.toBeDefined()
121
+ await expect(exchange.execute(outcome.code)).rejects.toBeInstanceOf(SocialSignInFailedError)
122
+ })
123
+
124
+ it('refuses a provider without credentials, and a callback for another provider', async () => {
125
+ const { start, complete, providers } = socialFakes()
126
+ const { url, browserToken } = await start.execute({
127
+ provider: 'google',
128
+ client: 'web',
129
+ returnTo: '/',
130
+ })
131
+
132
+ await expect(
133
+ complete.execute({
134
+ provider: 'apple',
135
+ state: new URL(url).searchParams.get('state') ?? '',
136
+ browserToken,
137
+ callbackUrl: new URL('http://localhost:3000/cb'),
138
+ name: null,
139
+ }),
140
+ ).rejects.toBeInstanceOf(SocialSignInFailedError)
141
+
142
+ Object.assign(providers, { configured: [] })
143
+
144
+ await expect(
145
+ start.execute({ provider: 'google', client: 'web', returnTo: '/' }),
146
+ ).rejects.toBeInstanceOf(ProviderNotConfiguredError)
147
+ })
148
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ AUTHORIZATION_CODE_LIFETIME_MS,
4
+ AUTHORIZATION_REQUEST_LIFETIME_MS,
5
+ Authorization,
6
+ } from '@/domain/oauth/entities/authorization.entity'
7
+
8
+ const NOW = new Date('2026-10-02T12:00:00Z')
9
+ const later = (ms: number) => new Date(NOW.getTime() + ms)
10
+
11
+ const request = {
12
+ clientId: 'https://client.example.com/client.json',
13
+ clientName: 'Client',
14
+ redirectUri: 'https://client.example.com/callback',
15
+ codeChallenge: 'challenge',
16
+ clientState: null,
17
+ resource: 'http://localhost:3000/api/mcp',
18
+ scope: 'profile:read',
19
+ }
20
+
21
+ describe('Authorization', () => {
22
+ it('waits for an answer until the request expires', () => {
23
+ const authorization = new Authorization(request, NOW)
24
+
25
+ expect(authorization.isPending(later(AUTHORIZATION_REQUEST_LIFETIME_MS - 1))).toBe(true)
26
+ expect(authorization.isPending(later(AUTHORIZATION_REQUEST_LIFETIME_MS))).toBe(false)
27
+ })
28
+
29
+ it('gives an approved code one minute, and no second exchange', () => {
30
+ const authorization = new Authorization(request, NOW)
31
+
32
+ authorization.approve('user-1', 'hash', NOW)
33
+
34
+ expect(authorization.isPending(NOW)).toBe(false)
35
+ expect(authorization.isRedeemable(later(AUTHORIZATION_CODE_LIFETIME_MS - 1))).toBe(true)
36
+ expect(authorization.isRedeemable(later(AUTHORIZATION_CODE_LIFETIME_MS))).toBe(false)
37
+
38
+ authorization.exchange('session-1')
39
+
40
+ expect(authorization.isRedeemable(NOW)).toBe(false)
41
+ })
42
+
43
+ it('reads back the code it writes, and nothing malformed', () => {
44
+ const authorization = new Authorization(request, NOW)
45
+ authorization.id = '0199a7e2-0000-7000-8000-000000000000'
46
+
47
+ expect(Authorization.parseCode(authorization.code('s3cret'))).toEqual({
48
+ authorizationId: authorization.id,
49
+ secret: 's3cret',
50
+ })
51
+ expect(Authorization.parseCode('nonsense')).toBeNull()
52
+ })
53
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Argon2PasswordHasher } from '@/infra/auth/argon2-password-hasher.adapter'
3
+
4
+ describe('Argon2PasswordHasher', () => {
5
+ it('stores Argon2id with OWASP’s minimum, and verifies only the right password', async () => {
6
+ const hasher = new Argon2PasswordHasher()
7
+ const stored = await hasher.hash('correct-horse-battery')
8
+
9
+ expect(stored).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/)
10
+ expect(await hasher.verify(stored, 'correct-horse-battery')).toBe(true)
11
+ expect(await hasher.verify(stored, 'wrong')).toBe(false)
12
+ })
13
+
14
+ it('answers false without a hash, after doing the same work', async () => {
15
+ expect(await new Argon2PasswordHasher().verify(undefined, 'anything')).toBe(false)
16
+ })
17
+ })
@@ -0,0 +1,57 @@
1
+ import { SignJWT, UnsecuredJWT } from 'jose'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { JoseAccessTokens } from '@/infra/auth/jose-access-tokens.adapter'
4
+
5
+ const ENV = {
6
+ JWT_SECRET: 'a-secret-that-is-at-least-32-characters',
7
+ API_URL: 'http://localhost:3000',
8
+ }
9
+ const CLAIMS = { userId: '0199a7e2-0000-7000-8000-000000000001', sessionId: 'session-1' }
10
+ const inAMinute = () => new Date(Date.now() + 60_000)
11
+
12
+ describe('JoseAccessTokens', () => {
13
+ it('reads back the claims of a token it issued', async () => {
14
+ const tokens = new JoseAccessTokens(ENV)
15
+ const { token } = await tokens.issue(CLAIMS, inAMinute())
16
+
17
+ expect(await tokens.verify(token)).toEqual(CLAIMS)
18
+ })
19
+
20
+ it('refuses an expired token, another secret, another issuer and garbage', async () => {
21
+ const tokens = new JoseAccessTokens(ENV)
22
+ const expired = await tokens.issue(CLAIMS, new Date(Date.now() - 1000))
23
+ const otherSecret = await new JoseAccessTokens({ ...ENV, JWT_SECRET: 'x'.repeat(40) }).issue(
24
+ CLAIMS,
25
+ inAMinute(),
26
+ )
27
+ const otherIssuer = await new JoseAccessTokens({ ...ENV, API_URL: 'https://elsewhere' }).issue(
28
+ CLAIMS,
29
+ inAMinute(),
30
+ )
31
+
32
+ for (const token of [expired.token, otherSecret.token, otherIssuer.token, 'not.a.jwt']) {
33
+ expect(await tokens.verify(token)).toBeNull()
34
+ }
35
+ })
36
+
37
+ it('refuses a token that names no algorithm or another one', async () => {
38
+ const tokens = new JoseAccessTokens(ENV)
39
+ const claims = { sid: CLAIMS.sessionId }
40
+ const unsigned = new UnsecuredJWT(claims)
41
+ .setSubject(CLAIMS.userId)
42
+ .setIssuer(ENV.API_URL)
43
+ .setAudience(ENV.API_URL)
44
+ .setExpirationTime('1m')
45
+ .encode()
46
+ const hs512 = await new SignJWT(claims)
47
+ .setProtectedHeader({ alg: 'HS512' })
48
+ .setSubject(CLAIMS.userId)
49
+ .setIssuer(ENV.API_URL)
50
+ .setAudience(ENV.API_URL)
51
+ .setExpirationTime('1m')
52
+ .sign(new TextEncoder().encode(`${ENV.JWT_SECRET}${'-'.repeat(32)}`))
53
+
54
+ expect(await tokens.verify(unsigned)).toBeNull()
55
+ expect(await tokens.verify(hs512)).toBeNull()
56
+ })
57
+ })
@@ -2,7 +2,7 @@ import { testOrm } from '@test/support/setup'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  describe('createOrmConfig', () => {
5
- it('finds nothing to change once migrated, and leaves Better Auth’s schema alone', async () => {
5
+ it('finds nothing to change once migrated', async () => {
6
6
  await expect(testOrm().schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('')
7
7
  })
8
8
  })
@@ -0,0 +1,28 @@
1
+ import { testOrm } from '@test/support/setup'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import { MikroOrmUserRepository } from '@/infra/database/mikroorm/repositories/mikroorm-user.repository'
6
+
7
+ describe('MikroOrmUserRepository', () => {
8
+ it('finds a user by its normalized email', async () => {
9
+ const repository = new MikroOrmUserRepository(testOrm().em.fork())
10
+ const user = new User('Ana@Example.com', 'hash')
11
+
12
+ await repository.save(user)
13
+
14
+ expect(
15
+ await new MikroOrmUserRepository(testOrm().em.fork()).findByEmail('ana@example.com'),
16
+ ).toMatchObject({
17
+ id: user.id,
18
+ })
19
+ })
20
+
21
+ it('turns the unique index into EmailTakenError when two sign-ups race', async () => {
22
+ await new MikroOrmUserRepository(testOrm().em.fork()).save(new User('ana@example.com', 'hash'))
23
+
24
+ await expect(
25
+ new MikroOrmUserRepository(testOrm().em.fork()).save(new User('ana@example.com', 'hash')),
26
+ ).rejects.toBeInstanceOf(EmailTakenError)
27
+ })
28
+ })
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
3
+
4
+ describe('HmacOneTimeCodes', () => {
5
+ it('makes six digits, and a digest that depends on the secret', () => {
6
+ const codes = new HmacOneTimeCodes({ JWT_SECRET: 'a'.repeat(40) })
7
+ const other = new HmacOneTimeCodes({ JWT_SECRET: 'b'.repeat(40) })
8
+
9
+ for (let i = 0; i < 50; i += 1) {
10
+ expect(codes.create()).toMatch(/^\d{6}$/)
11
+ }
12
+ expect(codes.digest('123456')).toBe(codes.digest('123456'))
13
+ expect(codes.digest('123456')).not.toBe(other.digest('123456'))
14
+ })
15
+ })
@@ -0,0 +1,92 @@
1
+ import type { FakeMailer } from '@test/support/fakes/fake-mailer'
2
+ import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+
6
+ const BEARER = { 'x-auth-transport': 'bearer' }
7
+
8
+ let app: FastifyInstance
9
+ let mailer: FakeMailer
10
+
11
+ const post = (
12
+ url: string,
13
+ payload: Record<string, string>,
14
+ headers: Record<string, string> = BEARER,
15
+ ) => app.inject({ method: 'POST', url, headers, payload })
16
+
17
+ beforeEach(async () => {
18
+ ;({ app, mailer } = await testApp())
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await app.close()
23
+ })
24
+
25
+ describe('email verification over HTTP', () => {
26
+ it('opens no session at sign-up, refuses sign-in, and signs in once the code is entered', async () => {
27
+ const email = 'ana@example.com'
28
+ const signUp = await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD })
29
+ const refused = await post('/api/auth/sign-in', { email, password: PASSWORD })
30
+
31
+ expect(signUp.statusCode).toBe(201)
32
+ expect(signUp.json()).toEqual({ verificationRequired: true })
33
+ expect(refused.statusCode).toBe(403)
34
+ expect(refused.json()).toMatchObject({ code: 'email_not_verified' })
35
+
36
+ await post('/api/auth/email/verification', { email })
37
+ const verified = await post('/api/auth/email/verify', { email, code: mailer.lastCode(email) })
38
+
39
+ expect(verified.statusCode).toBe(200)
40
+ expect(verified.json().accessToken).toEqual(expect.any(String))
41
+ })
42
+
43
+ it('delivers the web its cookies once the code is entered', async () => {
44
+ const email = 'ana@example.com'
45
+ await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD }, { origin: ORIGIN })
46
+
47
+ const verified = await post(
48
+ '/api/auth/email/verify',
49
+ { email, code: mailer.lastCode(email) },
50
+ { origin: ORIGIN },
51
+ )
52
+
53
+ expect(verified.statusCode).toBe(204)
54
+ expect(verified.cookies.map((cookie) => cookie.name).sort()).toEqual([
55
+ 'access_token',
56
+ 'refresh_token',
57
+ ])
58
+ })
59
+
60
+ it('answers a wrong code with invalid_code, and an unknown email with 204 and no mail', async () => {
61
+ await post('/api/auth/sign-up', { name: 'Ana', email: 'ana@example.com', password: PASSWORD })
62
+
63
+ const wrong = await post('/api/auth/email/verify', { email: 'ana@example.com', code: '000000' })
64
+ const unknown = await post('/api/auth/email/verification', { email: 'nobody@example.com' })
65
+
66
+ expect(wrong.statusCode).toBe(422)
67
+ expect(wrong.json()).toMatchObject({ code: 'invalid_code' })
68
+ expect(unknown.statusCode).toBe(204)
69
+ expect(mailer.sent.map((mail) => mail.to)).not.toContain('nobody@example.com')
70
+ })
71
+ })
72
+
73
+ describe('password reset over HTTP', () => {
74
+ it('resets the password with the mailed code and signs in with the new one', async () => {
75
+ const email = 'ana@example.com'
76
+ await post('/api/auth/sign-up', { name: 'Ana', email, password: PASSWORD })
77
+
78
+ const forgot = await post('/api/auth/password/forgot', { email })
79
+ const reset = await post('/api/auth/password/reset', {
80
+ email,
81
+ code: mailer.lastCode(email),
82
+ password: 'another-good-password',
83
+ })
84
+ const old = await post('/api/auth/sign-in', { email, password: PASSWORD })
85
+ const fresh = await post('/api/auth/sign-in', { email, password: 'another-good-password' })
86
+
87
+ expect(forgot.statusCode).toBe(204)
88
+ expect(reset.statusCode).toBe(200)
89
+ expect(old.statusCode).toBe(401)
90
+ expect(fresh.statusCode).toBe(200)
91
+ })
92
+ })
@@ -0,0 +1,168 @@
1
+ import { ORIGIN, PASSWORD, testApp } from '@test/support/test-app'
2
+ import type { FastifyInstance, LightMyRequestResponse } from 'fastify'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+
5
+ const BEARER = { 'x-auth-transport': 'bearer' }
6
+
7
+ let app: FastifyInstance
8
+ let signUp: Awaited<ReturnType<typeof testApp>>['signUp']
9
+
10
+ function cookiesOf(response: LightMyRequestResponse): Record<string, string> {
11
+ return Object.fromEntries(response.cookies.map((cookie) => [cookie.name, cookie.value]))
12
+ }
13
+
14
+ function setCookie(response: LightMyRequestResponse, name: string) {
15
+ return response.cookies.find((cookie) => cookie.name === name)
16
+ }
17
+
18
+ beforeEach(async () => {
19
+ ;({ app, signUp } = await testApp())
20
+ })
21
+
22
+ afterEach(async () => {
23
+ await app.close()
24
+ })
25
+
26
+ describe('auth controller, on the web', () => {
27
+ it('starts a session in httpOnly cookies, the refresh one sent only to its route', async () => {
28
+ const { response } = await signUp()
29
+
30
+ expect(response.body).toBe('')
31
+ expect(setCookie(response, 'access_token')).toMatchObject({
32
+ httpOnly: true,
33
+ sameSite: 'Lax',
34
+ path: '/',
35
+ })
36
+ expect(setCookie(response, 'refresh_token')).toMatchObject({
37
+ httpOnly: true,
38
+ path: '/api/auth/refresh',
39
+ })
40
+ })
41
+
42
+ it('reads the session from the cookie, refreshes it, and signs out', async () => {
43
+ const { access_token, refresh_token } = cookiesOf((await signUp()).response)
44
+
45
+ const session = await app.inject({
46
+ url: '/api/auth/session',
47
+ headers: { cookie: `access_token=${access_token}` },
48
+ })
49
+ expect(session.json()).toMatchObject({ user: { id: expect.any(String) } })
50
+
51
+ const refreshed = await app.inject({
52
+ method: 'POST',
53
+ url: '/api/auth/refresh',
54
+ headers: { cookie: `refresh_token=${refresh_token}`, origin: ORIGIN },
55
+ })
56
+ expect(refreshed.statusCode).toBe(204)
57
+ expect(cookiesOf(refreshed).refresh_token).not.toBe(refresh_token)
58
+
59
+ const signOut = await app.inject({
60
+ method: 'POST',
61
+ url: '/api/auth/sign-out',
62
+ headers: { cookie: `access_token=${cookiesOf(refreshed).access_token}`, origin: ORIGIN },
63
+ })
64
+ expect(signOut.statusCode).toBe(204)
65
+
66
+ const afterSignOut = await app.inject({
67
+ method: 'POST',
68
+ url: '/api/auth/refresh',
69
+ headers: { cookie: `refresh_token=${cookiesOf(refreshed).refresh_token}`, origin: ORIGIN },
70
+ })
71
+ expect(afterSignOut.statusCode).toBe(401)
72
+ })
73
+
74
+ it('refuses a request that leans on a cookie from another origin, or from none', async () => {
75
+ const cookie = `access_token=${cookiesOf((await signUp()).response).access_token}`
76
+
77
+ for (const headers of [{ cookie, origin: 'https://evil.example' }, { cookie }]) {
78
+ const response = await app.inject({ method: 'POST', url: '/api/auth/sign-out', headers })
79
+
80
+ expect(response.statusCode).toBe(403)
81
+ expect(response.headers['content-type']).toContain('application/problem+json')
82
+ }
83
+ })
84
+ })
85
+
86
+ describe('auth controller, for a native client', () => {
87
+ it('hands the tokens over in the body, and accepts them as a bearer', async () => {
88
+ const { response } = await signUp({ bearer: true })
89
+ const tokens = response.json()
90
+
91
+ expect(response.cookies).toEqual([])
92
+ expect(tokens).toEqual({
93
+ accessToken: expect.any(String),
94
+ refreshToken: expect.any(String),
95
+ expiresIn: 900,
96
+ })
97
+
98
+ const session = await app.inject({
99
+ url: '/api/auth/session',
100
+ headers: { authorization: `Bearer ${tokens.accessToken}` },
101
+ })
102
+ expect(session.statusCode).toBe(200)
103
+
104
+ const refreshed = await app.inject({
105
+ method: 'POST',
106
+ url: '/api/auth/refresh',
107
+ headers: BEARER,
108
+ payload: { refreshToken: tokens.refreshToken },
109
+ })
110
+ expect(refreshed.statusCode).toBe(200)
111
+ expect(refreshed.json().refreshToken).not.toBe(tokens.refreshToken)
112
+ })
113
+ })
114
+
115
+ describe('auth controller, refusing', () => {
116
+ it('answers a wrong password with 401 and a taken email with 409, as problem+json', async () => {
117
+ const { email } = await signUp({ bearer: true })
118
+
119
+ const wrong = await app.inject({
120
+ method: 'POST',
121
+ url: '/api/auth/sign-in',
122
+ headers: BEARER,
123
+ payload: { email, password: 'wrong-password' },
124
+ })
125
+ const taken = await app.inject({
126
+ method: 'POST',
127
+ url: '/api/auth/sign-up',
128
+ headers: BEARER,
129
+ payload: { name: 'Ana', email, password: PASSWORD },
130
+ })
131
+
132
+ expect(wrong.statusCode).toBe(401)
133
+ expect(wrong.json()).toMatchObject({ detail: 'Invalid email or password' })
134
+ expect(taken.statusCode).toBe(409)
135
+ expect(taken.headers['content-type']).toContain('application/problem+json')
136
+ })
137
+
138
+ it('names the fields a sign-up gets wrong', async () => {
139
+ const response = await app.inject({
140
+ method: 'POST',
141
+ url: '/api/auth/sign-up',
142
+ headers: BEARER,
143
+ payload: { name: '', email: 'not-an-email', password: 'short' },
144
+ })
145
+
146
+ expect(response.statusCode).toBe(400)
147
+ expect(Object.keys(response.json().errors).sort()).toEqual(['email', 'name', 'password'])
148
+ })
149
+
150
+ it('slows down repeated sign-in attempts from one address', async () => {
151
+ const attempt = () =>
152
+ app.inject({
153
+ method: 'POST',
154
+ url: '/api/auth/sign-in',
155
+ headers: BEARER,
156
+ payload: { email: 'nobody@example.com', password: 'whatever' },
157
+ })
158
+
159
+ for (let i = 0; i < 10; i += 1) {
160
+ expect((await attempt()).statusCode).toBe(401)
161
+ }
162
+
163
+ const limited = await attempt()
164
+
165
+ expect(limited.statusCode).toBe(429)
166
+ expect(limited.headers['content-type']).toContain('application/problem+json')
167
+ })
168
+ })