@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,103 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
+ import {
5
+ IDENTITY_PROVIDERS,
6
+ type IdentityProviders,
7
+ type ProviderName,
8
+ } from '@/domain/auth/ports/identity-providers.port'
9
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
10
+ import {
11
+ SOCIAL_SIGN_IN_REPOSITORY,
12
+ type SocialSignInRepository,
13
+ } from '@/domain/auth/repositories/social-sign-in.repository'
14
+ import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
15
+ import {
16
+ type SessionTokens,
17
+ StartSessionUseCase,
18
+ } from '@/domain/auth/use-cases/start-session.use-case'
19
+ import {
20
+ TRANSACTION_MANAGER,
21
+ type TransactionManager,
22
+ } from '@/domain/shared/transactions/transaction-manager'
23
+
24
+ export type SocialCallback = {
25
+ provider: ProviderName
26
+ state: string
27
+ browserToken: string | undefined
28
+ callbackUrl: URL
29
+ name: string | null
30
+ }
31
+
32
+ export type SocialOutcome =
33
+ | { client: 'web'; returnTo: string; tokens: SessionTokens }
34
+ | { client: 'mobile'; code: string }
35
+
36
+ @injectable()
37
+ export class CompleteSocialSignInUseCase {
38
+ constructor(
39
+ @inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
40
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
41
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
42
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
43
+ private readonly linkIdentity: LinkIdentityUseCase,
44
+ private readonly startSession: StartSessionUseCase,
45
+ ) {}
46
+
47
+ async execute(callback: SocialCallback): Promise<SocialOutcome> {
48
+ const browser =
49
+ callback.browserToken === undefined ? null : SocialSignIn.parseToken(callback.browserToken)
50
+
51
+ // The browser that comes back must be the one that left: a callback forwarded from elsewhere carries no token.
52
+ if (browser === null || browser.id !== callback.state) {
53
+ throw new SocialSignInFailedError('The sign-in was started in another browser. Start again.')
54
+ }
55
+
56
+ const started = await this.transactions.run(async () => {
57
+ const signIn = await this.signIns.lockById(browser.id)
58
+
59
+ return signIn !== null &&
60
+ signIn.provider === callback.provider &&
61
+ signIn.isStarted(new Date()) &&
62
+ signIn.browserHash === this.opaque.digest(browser.secret)
63
+ ? signIn
64
+ : null
65
+ })
66
+
67
+ if (started === null) {
68
+ throw new SocialSignInFailedError()
69
+ }
70
+
71
+ const profile = await this.providers.profile(callback.provider, {
72
+ callbackUrl: callback.callbackUrl,
73
+ state: started.id,
74
+ codeVerifier: started.codeVerifier,
75
+ nonce: started.nonce,
76
+ name: callback.name,
77
+ })
78
+ const userId = await this.linkIdentity.execute(callback.provider, profile)
79
+ const exchange = started.client === 'mobile' ? this.opaque.create() : null
80
+
81
+ await this.transactions.run(async () => {
82
+ const signIn = await this.signIns.lockById(started.id)
83
+
84
+ // Another callback for the same trip got here first.
85
+ if (signIn === null || !signIn.isStarted(new Date())) {
86
+ throw new SocialSignInFailedError()
87
+ }
88
+
89
+ signIn.signIn(userId, exchange === null ? null : this.opaque.digest(exchange), new Date())
90
+ await this.signIns.save(signIn)
91
+ })
92
+
93
+ if (exchange !== null) {
94
+ return { client: 'mobile', code: started.token(exchange) }
95
+ }
96
+
97
+ return {
98
+ client: 'web',
99
+ returnTo: started.returnTo,
100
+ tokens: await this.startSession.execute(userId),
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,56 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
5
+ import {
6
+ SOCIAL_SIGN_IN_REPOSITORY,
7
+ type SocialSignInRepository,
8
+ } from '@/domain/auth/repositories/social-sign-in.repository'
9
+ import {
10
+ type SessionTokens,
11
+ StartSessionUseCase,
12
+ } from '@/domain/auth/use-cases/start-session.use-case'
13
+ import {
14
+ TRANSACTION_MANAGER,
15
+ type TransactionManager,
16
+ } from '@/domain/shared/transactions/transaction-manager'
17
+
18
+ // The mobile app's half: the browser brought back a code, the app trades it once for its own tokens.
19
+ @injectable()
20
+ export class ExchangeSocialCodeUseCase {
21
+ constructor(
22
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
23
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
24
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
25
+ private readonly startSession: StartSessionUseCase,
26
+ ) {}
27
+
28
+ async execute(code: string): Promise<SessionTokens> {
29
+ const parsed = SocialSignIn.parseToken(code)
30
+
31
+ const userId = await this.transactions.run(async () => {
32
+ const signIn = parsed === null ? null : await this.signIns.lockById(parsed.id)
33
+
34
+ if (
35
+ parsed === null ||
36
+ signIn === null ||
37
+ signIn.userId === null ||
38
+ !signIn.isExchangeable(new Date()) ||
39
+ signIn.exchangeHash !== this.opaque.digest(parsed.secret)
40
+ ) {
41
+ return null
42
+ }
43
+
44
+ signIn.exchange()
45
+ await this.signIns.save(signIn)
46
+
47
+ return signIn.userId
48
+ })
49
+
50
+ if (userId === null) {
51
+ throw new SocialSignInFailedError()
52
+ }
53
+
54
+ return this.startSession.execute(userId)
55
+ }
56
+ }
@@ -0,0 +1,20 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { User } from '@/domain/auth/entities/user.entity'
3
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
4
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
5
+
6
+ @injectable()
7
+ export class FindUserUseCase {
8
+ constructor(@inject(USER_REPOSITORY) private readonly users: UserRepository) {}
9
+
10
+ async execute(userId: string): Promise<User> {
11
+ const user = await this.users.findById(userId)
12
+
13
+ // A valid token for a user who no longer exists is a session that should have ended.
14
+ if (user === null) {
15
+ throw new InvalidSessionError()
16
+ }
17
+
18
+ return user
19
+ }
20
+ }
@@ -0,0 +1,55 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
3
+ import type { User } from '@/domain/auth/entities/user.entity'
4
+ import { MAILER, type Mail, type Mailer } from '@/domain/auth/ports/mailer.port'
5
+ import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
6
+ import {
7
+ EMAIL_CODE_REPOSITORY,
8
+ type EmailCodeRepository,
9
+ } from '@/domain/auth/repositories/email-code.repository'
10
+ import {
11
+ TRANSACTION_MANAGER,
12
+ type TransactionManager,
13
+ } from '@/domain/shared/transactions/transaction-manager'
14
+
15
+ const MAILS: Record<EmailCodePurpose, (to: string, code: string) => Mail> = {
16
+ 'verify-email': (to, code) => ({
17
+ to,
18
+ subject: `${code} is your verification code`,
19
+ text: `Enter ${code} to confirm this email address. The code expires in 15 minutes.`,
20
+ }),
21
+ 'reset-password': (to, code) => ({
22
+ to,
23
+ subject: `${code} is your password reset code`,
24
+ text: `Enter ${code} to choose a new password. The code expires in 15 minutes. If you did not ask for it, ignore this email: your password stays as it is.`,
25
+ }),
26
+ }
27
+
28
+ @injectable()
29
+ export class IssueEmailCodeUseCase {
30
+ constructor(
31
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
32
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
33
+ @inject(MAILER) private readonly mailer: Mailer,
34
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
35
+ ) {}
36
+
37
+ async execute(user: User, purpose: EmailCodePurpose): Promise<void> {
38
+ const code = this.oneTimeCodes.create()
39
+ const codeHash = this.oneTimeCodes.digest(code)
40
+
41
+ await this.transactions.run(async () => {
42
+ const now = new Date()
43
+ const existing = await this.codes.lockFor(user.id, purpose)
44
+
45
+ if (existing === null) {
46
+ await this.codes.save(new EmailCode(user.id, purpose, codeHash, now))
47
+ } else {
48
+ existing.renew(codeHash, now)
49
+ await this.codes.save(existing)
50
+ }
51
+ })
52
+
53
+ await this.mailer.send(MAILS[purpose](user.email, code))
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Identity } from '@/domain/auth/entities/identity.entity'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
5
+ import type { ProviderName, ProviderProfile } from '@/domain/auth/ports/identity-providers.port'
6
+ import {
7
+ IDENTITY_REPOSITORY,
8
+ type IdentityRepository,
9
+ } from '@/domain/auth/repositories/identity.repository'
10
+ import {
11
+ SESSION_REPOSITORY,
12
+ type SessionRepository,
13
+ } from '@/domain/auth/repositories/session.repository'
14
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
20
+
21
+ const DISPLAY_NAME_MAX = 80
22
+
23
+ @injectable()
24
+ export class LinkIdentityUseCase {
25
+ constructor(
26
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
27
+ @inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository,
28
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
29
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
30
+ private readonly createProfile: CreateProfileUseCase,
31
+ ) {}
32
+
33
+ // Returns the id of the user the provider's account belongs to, creating or linking it as the rules say.
34
+ execute(provider: ProviderName, profile: ProviderProfile): Promise<string> {
35
+ return this.transactions.run(async () => {
36
+ const known = await this.identities.findBySubject(provider, profile.subject)
37
+
38
+ if (known !== null) {
39
+ return known.userId
40
+ }
41
+
42
+ if (!profile.emailVerified) {
43
+ throw new ProviderEmailUnverifiedError()
44
+ }
45
+
46
+ const now = new Date()
47
+ const existing = await this.users.findByEmail(User.normalizeEmail(profile.email))
48
+
49
+ if (existing !== null) {
50
+ // Nobody had shown this inbox was theirs, and the provider just did: whoever chose the password, possibly
51
+ // to wait for the real owner, loses it and every session it opened.
52
+ if (existing.emailVerifiedAt === null) {
53
+ existing.removePassword()
54
+ existing.verifyEmail(now)
55
+ await this.users.save(existing)
56
+ await this.sessions.revokeAllFor(existing.id, now)
57
+ }
58
+
59
+ await this.identities.save(new Identity(existing.id, provider, profile.subject))
60
+
61
+ return existing.id
62
+ }
63
+
64
+ const user = new User(profile.email, null)
65
+
66
+ user.verifyEmail(now)
67
+ await this.users.save(user)
68
+ await this.createProfile.execute(
69
+ user.id,
70
+ (profile.name ?? profile.email.split('@')[0] ?? profile.email).slice(0, DISPLAY_NAME_MAX),
71
+ )
72
+ await this.identities.save(new Identity(user.id, provider, profile.subject))
73
+
74
+ return user.id
75
+ })
76
+ }
77
+ }
@@ -0,0 +1,94 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Session } from '@/domain/auth/entities/session.entity'
3
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
4
+ import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
5
+ import {
6
+ ACCESS_TOKEN_LIFETIME_MS,
7
+ ACCESS_TOKENS,
8
+ type AccessTokens,
9
+ } from '@/domain/auth/ports/access-tokens.port'
10
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
11
+ import {
12
+ SESSION_REPOSITORY,
13
+ type SessionRepository,
14
+ } from '@/domain/auth/repositories/session.repository'
15
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
16
+ import {
17
+ TRANSACTION_MANAGER,
18
+ type TransactionManager,
19
+ } from '@/domain/shared/transactions/transaction-manager'
20
+
21
+ @injectable()
22
+ export class RefreshSessionUseCase {
23
+ constructor(
24
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
25
+ @inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
26
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
27
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
28
+ ) {}
29
+
30
+ // A first-party refresh passes no client; an OAuth client passes its own id, and neither can use the other's token.
31
+ async execute(refreshToken: string, clientId: string | null = null): Promise<SessionTokens> {
32
+ const parsed = Session.parseRefreshToken(refreshToken)
33
+
34
+ if (parsed === null) {
35
+ throw new InvalidSessionError()
36
+ }
37
+
38
+ const { sessionId, secret } = parsed
39
+ const now = new Date()
40
+ const rotated = await this.transactions.run(async () => {
41
+ const session = await this.sessions.lockById(sessionId)
42
+
43
+ if (session === null || session.clientId !== clientId) {
44
+ return 'missing' as const
45
+ }
46
+
47
+ const outcome = session.check(this.opaque.digest(secret), now)
48
+
49
+ if (outcome === 'reused') {
50
+ // An old token came back: whoever holds it is not the client that rotated it. End the session for both.
51
+ session.revoke(now)
52
+ await this.sessions.save(session)
53
+ }
54
+
55
+ if (outcome !== 'valid') {
56
+ return outcome
57
+ }
58
+
59
+ const next = this.opaque.create()
60
+
61
+ session.rotate(this.opaque.digest(next), now)
62
+ await this.sessions.save(session)
63
+
64
+ return { session, next }
65
+ })
66
+
67
+ if (rotated === 'superseded') {
68
+ throw new SupersededTokenError()
69
+ }
70
+
71
+ if (typeof rotated === 'string') {
72
+ throw new InvalidSessionError()
73
+ }
74
+
75
+ const { session, next } = rotated
76
+
77
+ return {
78
+ sessionId: session.id,
79
+ accessToken: await this.tokens.issue(
80
+ {
81
+ userId: session.userId,
82
+ sessionId: session.id,
83
+ ...(session.clientId !== null && { clientId: session.clientId }),
84
+ ...(session.scope !== null && { scope: session.scope }),
85
+ },
86
+ new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
87
+ session.resource ?? undefined,
88
+ ),
89
+ refreshToken: session.refreshToken(next),
90
+ refreshExpiresAt: session.expiresAt,
91
+ scope: session.scope,
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,21 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
4
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
5
+
6
+ @injectable()
7
+ export class RequestEmailVerificationUseCase {
8
+ constructor(
9
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
10
+ private readonly issueEmailCode: IssueEmailCodeUseCase,
11
+ ) {}
12
+
13
+ // Answers the same whether the account exists, is verified, or neither: the caller learns nothing about who signed up.
14
+ async execute(email: string): Promise<void> {
15
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
16
+
17
+ if (user !== null && user.emailVerifiedAt === null) {
18
+ await this.issueEmailCode.execute(user, 'verify-email')
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
4
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
5
+
6
+ @injectable()
7
+ export class RequestPasswordResetUseCase {
8
+ constructor(
9
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
10
+ private readonly issueEmailCode: IssueEmailCodeUseCase,
11
+ ) {}
12
+
13
+ // Answers the same for an unknown email: the caller learns nothing about who has an account.
14
+ async execute(email: string): Promise<void> {
15
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
16
+
17
+ if (user !== null) {
18
+ await this.issueEmailCode.execute(user, 'reset-password')
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,73 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { ResetPasswordDto } from '@/domain/auth/dto/reset-password.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
5
+ import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
6
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
7
+ import {
8
+ EMAIL_CODE_REPOSITORY,
9
+ type EmailCodeRepository,
10
+ } from '@/domain/auth/repositories/email-code.repository'
11
+ import {
12
+ SESSION_REPOSITORY,
13
+ type SessionRepository,
14
+ } from '@/domain/auth/repositories/session.repository'
15
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
16
+ import {
17
+ type SessionTokens,
18
+ StartSessionUseCase,
19
+ } from '@/domain/auth/use-cases/start-session.use-case'
20
+ import {
21
+ TRANSACTION_MANAGER,
22
+ type TransactionManager,
23
+ } from '@/domain/shared/transactions/transaction-manager'
24
+
25
+ @injectable()
26
+ export class ResetPasswordUseCase {
27
+ constructor(
28
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
29
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
30
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
31
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
32
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
33
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
34
+ private readonly startSession: StartSessionUseCase,
35
+ ) {}
36
+
37
+ async execute({ email, code, password }: ResetPasswordDto): Promise<SessionTokens> {
38
+ const passwordHash = await this.hasher.hash(password)
39
+
40
+ const reset = await this.transactions.run(async () => {
41
+ const now = new Date()
42
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
43
+ const stored = user === null ? null : await this.codes.lockFor(user.id, 'reset-password')
44
+
45
+ if (user === null || stored === null) {
46
+ return null
47
+ }
48
+
49
+ const outcome = stored.check(this.oneTimeCodes.digest(code), now)
50
+
51
+ await this.codes.save(stored)
52
+
53
+ if (outcome !== 'valid') {
54
+ return null
55
+ }
56
+
57
+ // The code arrived in the inbox, which is the proof verification asks for.
58
+ user.changePassword(passwordHash)
59
+ user.verifyEmail(now)
60
+ await this.users.save(user)
61
+ // Whoever knew the old password may still hold a session it opened.
62
+ await this.sessions.revokeAllFor(user.id, now)
63
+
64
+ return user
65
+ })
66
+
67
+ if (reset === null) {
68
+ throw new InvalidCodeError()
69
+ }
70
+
71
+ return this.startSession.execute(reset.id)
72
+ }
73
+ }
@@ -0,0 +1,38 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { SignInDto } from '@/domain/auth/dto/sign-in.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error' // prumo:email
5
+ import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
6
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
7
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
8
+ import {
9
+ type SessionTokens,
10
+ StartSessionUseCase,
11
+ } from '@/domain/auth/use-cases/start-session.use-case'
12
+
13
+ @injectable()
14
+ export class SignInUseCase {
15
+ constructor(
16
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
17
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
18
+ private readonly startSession: StartSessionUseCase,
19
+ ) {}
20
+
21
+ async execute({ email, password }: SignInDto): Promise<SessionTokens> {
22
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
23
+ const valid = await this.hasher.verify(user?.passwordHash ?? undefined, password)
24
+
25
+ if (user === null || !valid) {
26
+ throw new InvalidCredentialsError()
27
+ }
28
+
29
+ // prumo:email
30
+ // Asked only after the password matched, so the answer says nothing to someone who does not know it.
31
+ if (user.emailVerifiedAt === null) {
32
+ throw new EmailNotVerifiedError()
33
+ }
34
+
35
+ // prumo:end-email
36
+ return this.startSession.execute(user.id)
37
+ }
38
+ }
@@ -0,0 +1,22 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import {
3
+ SESSION_REPOSITORY,
4
+ type SessionRepository,
5
+ } from '@/domain/auth/repositories/session.repository'
6
+
7
+ @injectable()
8
+ export class SignOutUseCase {
9
+ constructor(@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository) {}
10
+
11
+ // Signing out twice, or out of a session already gone, is not an error: the outcome is the same.
12
+ async execute(userId: string, sessionId: string): Promise<void> {
13
+ const session = await this.sessions.findById(sessionId)
14
+
15
+ if (session === null || session.userId !== userId) {
16
+ return
17
+ }
18
+
19
+ session.revoke(new Date())
20
+ await this.sessions.save(session)
21
+ }
22
+ }
@@ -0,0 +1,57 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
6
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
7
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
8
+ import {
9
+ type SessionTokens,
10
+ StartSessionUseCase,
11
+ } from '@/domain/auth/use-cases/start-session.use-case'
12
+ import {
13
+ TRANSACTION_MANAGER,
14
+ type TransactionManager,
15
+ } from '@/domain/shared/transactions/transaction-manager'
16
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
17
+
18
+ @injectable()
19
+ export class SignUpUseCase {
20
+ constructor(
21
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
22
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
23
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
24
+ private readonly createProfile: CreateProfileUseCase,
25
+ private readonly startSession: StartSessionUseCase,
26
+ private readonly issueEmailCode: IssueEmailCodeUseCase, // prumo:email
27
+ ) {}
28
+
29
+ // Null when the account must confirm its email before it may sign in.
30
+ async execute({ name, email, password }: SignUpDto): Promise<SessionTokens | null> {
31
+ const passwordHash = await this.hasher.hash(password)
32
+
33
+ // The account and its profile exist together or not at all.
34
+ const user = await this.transactions.run(async () => {
35
+ if ((await this.users.findByEmail(User.normalizeEmail(email))) !== null) {
36
+ throw new EmailTakenError()
37
+ }
38
+
39
+ const user = new User(email, passwordHash)
40
+
41
+ await this.users.save(user)
42
+ await this.createProfile.execute(user.id, name)
43
+
44
+ return user
45
+ })
46
+
47
+ // prumo:email
48
+ // Until the address is confirmed the account cannot sign in, so it opens no session either.
49
+ if (user.emailVerifiedAt === null) {
50
+ await this.issueEmailCode.execute(user, 'verify-email')
51
+ return null
52
+ }
53
+
54
+ // prumo:end-email
55
+ return this.startSession.execute(user.id)
56
+ }
57
+ }