@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
@@ -25,7 +25,7 @@ const APPS = {
25
25
  // A port that moves takes these with it: the key to write, in which app, from which app's port.
26
26
  const FOLLOWERS = {
27
27
  api: [
28
- { app: 'api', key: 'BETTER_AUTH_URL' },
28
+ { app: 'api', key: 'API_URL' },
29
29
  { app: 'web', key: 'VITE_API_URL' },
30
30
  { app: 'mobile', key: 'EXPO_PUBLIC_API_URL' },
31
31
  ],
@@ -0,0 +1,5 @@
1
+ import { z } from 'zod'
2
+
3
+ export const emailRequestSchema = z.strictObject({ email: z.email().max(254) })
4
+
5
+ export type EmailRequestDto = z.infer<typeof emailRequestSchema>
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ // The web sends its refresh token as a cookie and no body, which Fastify hands over as null; a native client
4
+ // sends it here.
5
+ export const refreshSchema = z
6
+ .strictObject({ refreshToken: z.string().min(1).optional() })
7
+ .nullish()
8
+
9
+ export type RefreshDto = z.infer<typeof refreshSchema>
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const resetPasswordSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ code: z.string().regex(/^\d{6}$/),
6
+ password: z.string().min(8).max(128),
7
+ })
8
+
9
+ export type ResetPasswordDto = z.infer<typeof resetPasswordSchema>
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod'
2
+
3
+ export const sessionResponseSchema = z.object({
4
+ user: z.object({ id: z.uuid(), email: z.string() }),
5
+ })
6
+
7
+ export type SessionResponseDto = z.infer<typeof sessionResponseSchema>
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const signInSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ password: z.string().min(1).max(128),
6
+ })
7
+
8
+ export type SignInDto = z.infer<typeof signInSchema>
@@ -0,0 +1,9 @@
1
+ import { z } from 'zod'
2
+
3
+ export const signUpSchema = z.strictObject({
4
+ name: z.string().trim().min(1).max(80),
5
+ email: z.email().max(254),
6
+ password: z.string().min(8).max(128),
7
+ })
8
+
9
+ export type SignUpDto = z.infer<typeof signUpSchema>
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod'
2
+
3
+ // What a native client receives instead of cookies. `expiresIn` is in seconds, as OAuth writes it.
4
+ export const tokensResponseSchema = z.object({
5
+ accessToken: z.string(),
6
+ refreshToken: z.string(),
7
+ expiresIn: z.number().int(),
8
+ })
9
+
10
+ export type TokensResponseDto = z.infer<typeof tokensResponseSchema>
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod'
2
+
3
+ export const verifyEmailSchema = z.strictObject({
4
+ email: z.email().max(254),
5
+ code: z.string().regex(/^\d{6}$/),
6
+ })
7
+
8
+ export type VerifyEmailDto = z.infer<typeof verifyEmailSchema>
@@ -0,0 +1,46 @@
1
+ // Short enough that guessing is a race against the clock, long enough to switch to the mail app and back.
2
+ export const EMAIL_CODE_LIFETIME_MS = 15 * 60 * 1000
3
+ export const EMAIL_CODE_ATTEMPTS = 5
4
+
5
+ export type EmailCodePurpose = 'verify-email' | 'reset-password'
6
+
7
+ export type CodeOutcome = 'valid' | 'wrong' | 'expired'
8
+
9
+ // One live code per user and purpose: sending another replaces it, and the old one stops working.
10
+ export class EmailCode {
11
+ id: string
12
+ userId: string
13
+ purpose: EmailCodePurpose
14
+ codeHash: string
15
+ attemptsLeft: number
16
+ expiresAt: Date
17
+ createdAt: Date
18
+ updatedAt: Date
19
+
20
+ constructor(userId: string, purpose: EmailCodePurpose, codeHash: string, now: Date) {
21
+ this.userId = userId
22
+ this.purpose = purpose
23
+ this.renew(codeHash, now)
24
+ }
25
+
26
+ renew(codeHash: string, now: Date): void {
27
+ this.codeHash = codeHash
28
+ this.attemptsLeft = EMAIL_CODE_ATTEMPTS
29
+ this.expiresAt = new Date(now.getTime() + EMAIL_CODE_LIFETIME_MS)
30
+ }
31
+
32
+ // Five wrong guesses out of a million leave nothing to brute-force: the code dies, and a new one must be sent.
33
+ check(codeHash: string, now: Date): CodeOutcome {
34
+ if (this.attemptsLeft <= 0 || this.expiresAt <= now) {
35
+ return 'expired'
36
+ }
37
+
38
+ if (codeHash === this.codeHash) {
39
+ this.attemptsLeft = 0
40
+ return 'valid'
41
+ }
42
+
43
+ this.attemptsLeft -= 1
44
+ return this.attemptsLeft === 0 ? 'expired' : 'wrong'
45
+ }
46
+ }
@@ -0,0 +1,18 @@
1
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
2
+
3
+ // A user as a provider knows them. The provider's subject, not the email, is what recognises them next time: an
4
+ // email can change at the provider, a subject cannot.
5
+ export class Identity {
6
+ id: string
7
+ userId: string
8
+ provider: ProviderName
9
+ subject: string
10
+ createdAt: Date
11
+ updatedAt: Date
12
+
13
+ constructor(userId: string, provider: ProviderName, subject: string) {
14
+ this.userId = userId
15
+ this.provider = provider
16
+ this.subject = subject
17
+ }
18
+ }
@@ -0,0 +1,78 @@
1
+ // A refresh token is long-lived, so it rotates on every use and is kept only as a hash.
2
+ export const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000
3
+
4
+ // Two tabs refreshing at once both present the same token. The second, arriving within this window, is told to
5
+ // retry instead of being treated as a stolen token.
6
+ export const ROTATION_GRACE_MS = 10 * 1000
7
+
8
+ export type RefreshOutcome = 'valid' | 'superseded' | 'reused' | 'expired'
9
+
10
+ // A session the user started in this application has none of these. One an OAuth client obtained names the client,
11
+ // the resource its access tokens are for, and the scopes the user granted it, space-separated as OAuth writes them.
12
+ export type Grant = { clientId: string; resource: string; scope: string }
13
+
14
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
15
+
16
+ export class Session {
17
+ id: string
18
+ userId: string
19
+ tokenHash: string
20
+ clientId: string | null = null
21
+ resource: string | null = null
22
+ scope: string | null = null
23
+ previousTokenHash: string | null = null
24
+ rotatedAt: Date | null = null
25
+ expiresAt: Date
26
+ revokedAt: Date | null = null
27
+ createdAt: Date
28
+ updatedAt: Date
29
+
30
+ constructor(userId: string, tokenHash: string, now: Date, grant?: Grant) {
31
+ this.userId = userId
32
+ this.tokenHash = tokenHash
33
+ this.clientId = grant?.clientId ?? null
34
+ this.resource = grant?.resource ?? null
35
+ this.scope = grant?.scope ?? null
36
+ this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
37
+ }
38
+
39
+ // The refresh token names its session, so a token from any earlier rotation is recognised as reuse.
40
+ static parseRefreshToken(token: string): { sessionId: string; secret: string } | null {
41
+ const dot = token.indexOf('.')
42
+ const sessionId = token.slice(0, dot)
43
+ const secret = token.slice(dot + 1)
44
+
45
+ return dot === -1 || !UUID.test(sessionId) || secret === '' ? null : { sessionId, secret }
46
+ }
47
+
48
+ refreshToken(secret: string): string {
49
+ return `${this.id}.${secret}`
50
+ }
51
+
52
+ // A token that is neither the current one nor the one just replaced was replaced long ago: someone kept a copy.
53
+ check(tokenHash: string, now: Date): RefreshOutcome {
54
+ if (this.revokedAt !== null || this.expiresAt <= now) {
55
+ return 'expired'
56
+ }
57
+
58
+ if (tokenHash === this.tokenHash) {
59
+ return 'valid'
60
+ }
61
+
62
+ const justRotated =
63
+ this.rotatedAt !== null && now.getTime() - this.rotatedAt.getTime() < ROTATION_GRACE_MS
64
+
65
+ return tokenHash === this.previousTokenHash && justRotated ? 'superseded' : 'reused'
66
+ }
67
+
68
+ rotate(tokenHash: string, now: Date): void {
69
+ this.previousTokenHash = this.tokenHash
70
+ this.tokenHash = tokenHash
71
+ this.rotatedAt = now
72
+ this.expiresAt = new Date(now.getTime() + SESSION_LIFETIME_MS)
73
+ }
74
+
75
+ revoke(now: Date): void {
76
+ this.revokedAt ??= now
77
+ }
78
+ }
@@ -0,0 +1,82 @@
1
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
2
+
3
+ // Long enough to sign in at the provider; the code a mobile app exchanges afterwards is spent within two minutes.
4
+ export const SOCIAL_SIGN_IN_LIFETIME_MS = 10 * 60 * 1000
5
+ export const SOCIAL_EXCHANGE_LIFETIME_MS = 2 * 60 * 1000
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+
9
+ export type SocialClient = 'web' | 'mobile'
10
+
11
+ export type SocialSignInStatus = 'started' | 'signed-in' | 'exchanged'
12
+
13
+ // One trip to a provider and back. Its id travels as OAuth's state; the browser that started it holds a secret the
14
+ // callback must present, so a callback carrying someone else's code cannot sign this browser into their account.
15
+ export class SocialSignIn {
16
+ id: string
17
+ provider: ProviderName
18
+ client: SocialClient
19
+ returnTo: string
20
+ browserHash: string
21
+ codeVerifier: string | null = null
22
+ nonce = ''
23
+ status: SocialSignInStatus = 'started'
24
+ userId: string | null = null
25
+ exchangeHash: string | null = null
26
+ expiresAt: Date
27
+ createdAt: Date
28
+ updatedAt: Date
29
+
30
+ constructor(
31
+ provider: ProviderName,
32
+ client: SocialClient,
33
+ returnTo: string,
34
+ browserHash: string,
35
+ now: Date,
36
+ ) {
37
+ this.provider = provider
38
+ this.client = client
39
+ this.returnTo = returnTo
40
+ this.browserHash = browserHash
41
+ this.expiresAt = new Date(now.getTime() + SOCIAL_SIGN_IN_LIFETIME_MS)
42
+ }
43
+
44
+ // `<id>.<secret>`, as refresh tokens and authorization codes are written.
45
+ static parseToken(token: string): { id: string; secret: string } | null {
46
+ const dot = token.indexOf('.')
47
+ const id = token.slice(0, dot)
48
+ const secret = token.slice(dot + 1)
49
+
50
+ return dot === -1 || !UUID.test(id) || secret === '' ? null : { id, secret }
51
+ }
52
+
53
+ token(secret: string): string {
54
+ return `${this.id}.${secret}`
55
+ }
56
+
57
+ // What the provider's redirect requires back: the PKCE verifier, when the provider takes PKCE, and the nonce.
58
+ prepare(codeVerifier: string | null, nonce: string): void {
59
+ this.codeVerifier = codeVerifier
60
+ this.nonce = nonce
61
+ }
62
+
63
+ isStarted(now: Date): boolean {
64
+ return this.status === 'started' && this.expiresAt > now
65
+ }
66
+
67
+ // The web is signed in at once; a mobile app gets a code to exchange, so its tokens never travel in a URL.
68
+ signIn(userId: string, exchangeHash: string | null, now: Date): void {
69
+ this.status = 'signed-in'
70
+ this.userId = userId
71
+ this.exchangeHash = exchangeHash
72
+ this.expiresAt = new Date(now.getTime() + SOCIAL_EXCHANGE_LIFETIME_MS)
73
+ }
74
+
75
+ isExchangeable(now: Date): boolean {
76
+ return this.status === 'signed-in' && this.exchangeHash !== null && this.expiresAt > now
77
+ }
78
+
79
+ exchange(): void {
80
+ this.status = 'exchanged'
81
+ }
82
+ }
@@ -0,0 +1,34 @@
1
+ export class User {
2
+ id: string
3
+ email: string
4
+ // Null for an account that only ever signed in through a provider, or whose password a provider's proof voided.
5
+ passwordHash: string | null
6
+ emailVerifiedAt: Date | null = null
7
+ createdAt: Date
8
+ updatedAt: Date
9
+
10
+ constructor(email: string, passwordHash: string | null) {
11
+ this.email = User.normalizeEmail(email)
12
+ this.passwordHash = passwordHash
13
+ }
14
+
15
+ // One address, one account: `Ana@Example.com ` and `ana@example.com` are the same person.
16
+ static normalizeEmail(email: string): string {
17
+ return email.trim().toLowerCase()
18
+ }
19
+
20
+ verifyEmail(now: Date): void {
21
+ this.emailVerifiedAt ??= now
22
+ }
23
+
24
+ changePassword(passwordHash: string): void {
25
+ this.passwordHash = passwordHash
26
+ }
27
+
28
+ // prumo:social
29
+ // A provider proved the address belongs to someone else than whoever chose this password.
30
+ removePassword(): void {
31
+ this.passwordHash = null
32
+ }
33
+ // prumo:end-social
34
+ }
@@ -0,0 +1,10 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class EmailNotVerifiedError extends DomainError {
4
+ readonly kind = 'forbidden'
5
+ override readonly code = 'email_not_verified'
6
+
7
+ constructor() {
8
+ super('Confirm your email before signing in')
9
+ }
10
+ }
@@ -0,0 +1,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class EmailTakenError extends DomainError {
4
+ readonly kind = 'conflict'
5
+
6
+ constructor() {
7
+ super('An account with this email already exists')
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // One answer for a wrong code, a dead one and an unknown email, so the form does not say which accounts exist.
4
+ export class InvalidCodeError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ override readonly code = 'invalid_code'
7
+
8
+ constructor() {
9
+ super('The code is wrong or has expired')
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // One message for an unknown email and a wrong password, so the answer does not say which accounts exist.
4
+ export class InvalidCredentialsError extends DomainError {
5
+ readonly kind = 'unauthorized'
6
+
7
+ constructor() {
8
+ super('Invalid email or password')
9
+ }
10
+ }
@@ -0,0 +1,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class InvalidSessionError extends DomainError {
4
+ readonly kind = 'unauthorized'
5
+
6
+ constructor(message = 'Session expired or revoked') {
7
+ super(message)
8
+ }
9
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // Without the provider's word that the address is theirs, an email cannot be trusted to name an account.
4
+ export class ProviderEmailUnverifiedError extends DomainError {
5
+ readonly kind = 'forbidden'
6
+ override readonly code = 'provider_email_unverified'
7
+
8
+ constructor() {
9
+ super('The provider has not verified this email address')
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class ProviderNotConfiguredError extends DomainError {
4
+ readonly kind = 'invalid'
5
+ override readonly code = 'provider_not_configured'
6
+
7
+ constructor(provider: string) {
8
+ super(`Sign-in with ${provider} is not configured: set its variables in .env`)
9
+ }
10
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // Expired, already used, started in another browser, or refused by the provider: the user starts again.
4
+ export class SocialSignInFailedError extends DomainError {
5
+ readonly kind = 'unauthorized'
6
+ override readonly code = 'social_sign_in_failed'
7
+
8
+ constructor(message = 'The sign-in did not complete. Start again.') {
9
+ super(message)
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // Another request rotated this token a moment ago. The client already holds the new one, so it retries instead of
4
+ // signing out.
5
+ export class SupersededTokenError extends DomainError {
6
+ readonly kind = 'conflict'
7
+
8
+ constructor() {
9
+ super('Refresh token was just rotated; retry with the current one')
10
+ }
11
+ }
@@ -0,0 +1,15 @@
1
+ export type AccessClaims = { userId: string; sessionId: string; clientId?: string; scope?: string }
2
+
3
+ export type IssuedToken = { token: string; expiresAt: Date }
4
+
5
+ // Short-lived and checked by signature alone: nothing is looked up to accept one, so nothing can revoke one.
6
+ export const ACCESS_TOKEN_LIFETIME_MS = 15 * 60 * 1000
7
+
8
+ // Without an audience a token is for this API's own routes; with one, for that resource alone. Neither is accepted
9
+ // where the other is expected.
10
+ export interface AccessTokens {
11
+ issue(claims: AccessClaims, expiresAt: Date, audience?: string): Promise<IssuedToken>
12
+ verify(token: string, audience?: string): Promise<AccessClaims | null>
13
+ }
14
+
15
+ export const ACCESS_TOKENS = Symbol('AccessTokens')
@@ -0,0 +1,29 @@
1
+ export type ProviderName = 'google' | 'apple'
2
+
3
+ export type ProviderProfile = {
4
+ subject: string
5
+ email: string
6
+ emailVerified: boolean
7
+ name: string | null
8
+ }
9
+
10
+ export type ProviderRedirect = { url: string; codeVerifier: string | null; nonce: string }
11
+
12
+ export type ProviderCallback = {
13
+ callbackUrl: URL
14
+ state: string
15
+ codeVerifier: string | null
16
+ nonce: string
17
+ // Apple sends the user's name once, beside the code, and never inside the ID token.
18
+ name: string | null
19
+ }
20
+
21
+ // Google and Apple speak OpenID Connect: the adapter builds the provider's URL and, on the way back, validates the
22
+ // ID token (signature, issuer, audience, nonce) before anything here reads it.
23
+ export interface IdentityProviders {
24
+ isConfigured(provider: ProviderName): boolean
25
+ redirect(provider: ProviderName, state: string): Promise<ProviderRedirect>
26
+ profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile>
27
+ }
28
+
29
+ export const IDENTITY_PROVIDERS = Symbol('IdentityProviders')
@@ -0,0 +1,8 @@
1
+ export type Mail = { to: string; subject: string; text: string }
2
+
3
+ // The template writes mail to the log; a project that sends real mail replaces the adapter, not the callers.
4
+ export interface Mailer {
5
+ send(mail: Mail): Promise<void>
6
+ }
7
+
8
+ export const MAILER = Symbol('Mailer')
@@ -0,0 +1,7 @@
1
+ // Six digits a person can type. The digest is keyed, so a copy of the table alone cannot try the million codes.
2
+ export interface OneTimeCodes {
3
+ create(): string
4
+ digest(code: string): string
5
+ }
6
+
7
+ export const ONE_TIME_CODES = Symbol('OneTimeCodes')
@@ -0,0 +1,7 @@
1
+ // Random values that are handed out once and stored only as a digest.
2
+ export interface OpaqueTokens {
3
+ create(): string
4
+ digest(token: string): string
5
+ }
6
+
7
+ export const OPAQUE_TOKENS = Symbol('OpaqueTokens')
@@ -0,0 +1,8 @@
1
+ export interface PasswordHasher {
2
+ hash(password: string): Promise<string>
3
+ // Without a hash it still spends the time of a real check and answers false, so an unknown email and a wrong
4
+ // password take as long as each other.
5
+ verify(hash: string | undefined, password: string): Promise<boolean>
6
+ }
7
+
8
+ export const PASSWORD_HASHER = Symbol('PasswordHasher')
@@ -0,0 +1,9 @@
1
+ import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
2
+
3
+ export interface EmailCodeRepository {
4
+ // Reads the row and holds it until the surrounding transaction ends, so two guesses cannot both spend one attempt.
5
+ lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null>
6
+ save(code: EmailCode): Promise<void>
7
+ }
8
+
9
+ export const EMAIL_CODE_REPOSITORY = Symbol('EmailCodeRepository')
@@ -0,0 +1,9 @@
1
+ import type { Identity } from '@/domain/auth/entities/identity.entity'
2
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
3
+
4
+ export interface IdentityRepository {
5
+ findBySubject(provider: ProviderName, subject: string): Promise<Identity | null>
6
+ save(identity: Identity): Promise<void>
7
+ }
8
+
9
+ export const IDENTITY_REPOSITORY = Symbol('IdentityRepository')
@@ -0,0 +1,12 @@
1
+ import type { Session } from '@/domain/auth/entities/session.entity'
2
+
3
+ export interface SessionRepository {
4
+ findById(id: string): Promise<Session | null>
5
+ // Reads the row and holds it until the surrounding transaction ends, so two rotations cannot interleave.
6
+ lockById(id: string): Promise<Session | null>
7
+ save(session: Session): Promise<void>
8
+ // Every session of the user, at once: a new password ends whatever the old one opened.
9
+ revokeAllFor(userId: string, now: Date): Promise<void>
10
+ }
11
+
12
+ export const SESSION_REPOSITORY = Symbol('SessionRepository')
@@ -0,0 +1,9 @@
1
+ import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
2
+
3
+ export interface SocialSignInRepository {
4
+ // Reads the row and holds it until the surrounding transaction ends, so a callback or a code is used once.
5
+ lockById(id: string): Promise<SocialSignIn | null>
6
+ save(signIn: SocialSignIn): Promise<void>
7
+ }
8
+
9
+ export const SOCIAL_SIGN_IN_REPOSITORY = Symbol('SocialSignInRepository')
@@ -0,0 +1,9 @@
1
+ import type { User } from '@/domain/auth/entities/user.entity'
2
+
3
+ export interface UserRepository {
4
+ findById(id: string): Promise<User | null>
5
+ findByEmail(email: string): Promise<User | null>
6
+ save(user: User): Promise<void>
7
+ }
8
+
9
+ export const USER_REPOSITORY = Symbol('UserRepository')