@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,55 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { type Grant, Session } from '@/domain/auth/entities/session.entity'
3
+ import {
4
+ ACCESS_TOKEN_LIFETIME_MS,
5
+ ACCESS_TOKENS,
6
+ type AccessTokens,
7
+ type IssuedToken,
8
+ } from '@/domain/auth/ports/access-tokens.port'
9
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
10
+ import {
11
+ SESSION_REPOSITORY,
12
+ type SessionRepository,
13
+ } from '@/domain/auth/repositories/session.repository'
14
+
15
+ export type SessionTokens = {
16
+ sessionId: string
17
+ accessToken: IssuedToken
18
+ refreshToken: string
19
+ refreshExpiresAt: Date
20
+ // What an OAuth client's tokens may do; null for the application's own sessions, which may do everything.
21
+ scope: string | null
22
+ }
23
+
24
+ @injectable()
25
+ export class StartSessionUseCase {
26
+ constructor(
27
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
28
+ @inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
29
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
30
+ ) {}
31
+
32
+ async execute(userId: string, grant?: Grant): Promise<SessionTokens> {
33
+ const now = new Date()
34
+ const secret = this.opaque.create()
35
+ const session = new Session(userId, this.opaque.digest(secret), now, grant)
36
+
37
+ await this.sessions.save(session)
38
+
39
+ return {
40
+ sessionId: session.id,
41
+ accessToken: await this.tokens.issue(
42
+ {
43
+ userId,
44
+ sessionId: session.id,
45
+ ...(grant && { clientId: grant.clientId, scope: grant.scope }),
46
+ },
47
+ new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
48
+ grant?.resource,
49
+ ),
50
+ refreshToken: session.refreshToken(secret),
51
+ refreshExpiresAt: session.expiresAt,
52
+ scope: session.scope,
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,54 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { type SocialClient, SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.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
+
15
+ export type SocialStart = { provider: ProviderName; client: SocialClient; returnTo: string }
16
+
17
+ @injectable()
18
+ export class StartSocialSignInUseCase {
19
+ constructor(
20
+ @inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
21
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
22
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
23
+ ) {}
24
+
25
+ // Returns where to send the browser, and the token the browser keeps to prove, on the way back, that it started.
26
+ async execute({
27
+ provider,
28
+ client,
29
+ returnTo,
30
+ }: SocialStart): Promise<{ url: string; browserToken: string }> {
31
+ if (!this.providers.isConfigured(provider)) {
32
+ throw new ProviderNotConfiguredError(provider)
33
+ }
34
+
35
+ const secret = this.opaque.create()
36
+ const signIn = new SocialSignIn(
37
+ provider,
38
+ client,
39
+ returnTo,
40
+ this.opaque.digest(secret),
41
+ new Date(),
42
+ )
43
+
44
+ // Saved first: its id is the state the provider sends back.
45
+ await this.signIns.save(signIn)
46
+
47
+ const redirect = await this.providers.redirect(provider, signIn.id)
48
+
49
+ signIn.prepare(redirect.codeVerifier, redirect.nonce)
50
+ await this.signIns.save(signIn)
51
+
52
+ return { url: redirect.url, browserToken: signIn.token(secret) }
53
+ }
54
+ }
@@ -0,0 +1,62 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { VerifyEmailDto } from '@/domain/auth/dto/verify-email.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 {
7
+ EMAIL_CODE_REPOSITORY,
8
+ type EmailCodeRepository,
9
+ } from '@/domain/auth/repositories/email-code.repository'
10
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
11
+ import {
12
+ type SessionTokens,
13
+ StartSessionUseCase,
14
+ } from '@/domain/auth/use-cases/start-session.use-case'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+
20
+ @injectable()
21
+ export class VerifyEmailUseCase {
22
+ constructor(
23
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
24
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
25
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
26
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
27
+ private readonly startSession: StartSessionUseCase,
28
+ ) {}
29
+
30
+ // A confirmed address signs the user in: they proved the inbox and already chose a password.
31
+ async execute({ email, code }: VerifyEmailDto): Promise<SessionTokens> {
32
+ // The spent attempt is committed before the error is thrown, or a wrong guess would cost nothing.
33
+ const verified = await this.transactions.run(async () => {
34
+ const now = new Date()
35
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
36
+ const stored = user === null ? null : await this.codes.lockFor(user.id, 'verify-email')
37
+
38
+ if (user === null || stored === null) {
39
+ return null
40
+ }
41
+
42
+ const outcome = stored.check(this.oneTimeCodes.digest(code), now)
43
+
44
+ await this.codes.save(stored)
45
+
46
+ if (outcome !== 'valid') {
47
+ return null
48
+ }
49
+
50
+ user.verifyEmail(now)
51
+ await this.users.save(user)
52
+
53
+ return user
54
+ })
55
+
56
+ if (verified === null) {
57
+ throw new InvalidCodeError()
58
+ }
59
+
60
+ return this.startSession.execute(verified.id)
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod'
2
+
3
+ export const authorizationDecisionSchema = z.strictObject({ accept: z.boolean() })
4
+
5
+ export type AuthorizationDecisionDto = z.infer<typeof authorizationDecisionSchema>
6
+
7
+ export const authorizationRedirectSchema = z.object({ redirectTo: z.string() })
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod'
2
+
3
+ // What the consent page shows. The hosts come first: a name is whatever the client chose to write.
4
+ export const authorizationViewSchema = z.object({
5
+ clientId: z.string(),
6
+ clientName: z.string(),
7
+ clientHost: z.string(),
8
+ redirectHost: z.string(),
9
+ // A localhost redirect cannot prove which program is listening there, so the page warns about it.
10
+ redirectsToThisDevice: z.boolean(),
11
+ scopes: z.array(z.object({ scope: z.string(), description: z.string() })),
12
+ })
13
+
14
+ export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
@@ -0,0 +1,87 @@
1
+ // Long enough to sign in and read the consent page; the code that follows is spent within a minute.
2
+ export const AUTHORIZATION_REQUEST_LIFETIME_MS = 10 * 60 * 1000
3
+ export const AUTHORIZATION_CODE_LIFETIME_MS = 60 * 1000
4
+
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
+
7
+ export type AuthorizationStatus = 'pending' | 'approved' | 'denied' | 'exchanged'
8
+
9
+ export type AuthorizationRequest = {
10
+ clientId: string
11
+ clientName: string
12
+ redirectUri: string
13
+ codeChallenge: string
14
+ clientState: string | null
15
+ resource: string
16
+ // The scopes the client asked for and the user is shown, space-separated.
17
+ scope: string
18
+ }
19
+
20
+ // One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
21
+ export class Authorization {
22
+ id: string
23
+ clientId: string
24
+ clientName: string
25
+ redirectUri: string
26
+ codeChallenge: string
27
+ clientState: string | null
28
+ resource: string
29
+ scope: string
30
+ status: AuthorizationStatus = 'pending'
31
+ userId: string | null = null
32
+ codeHash: string | null = null
33
+ sessionId: string | null = null
34
+ expiresAt: Date
35
+ createdAt: Date
36
+ updatedAt: Date
37
+
38
+ constructor(request: AuthorizationRequest, now: Date) {
39
+ this.clientId = request.clientId
40
+ this.clientName = request.clientName
41
+ this.redirectUri = request.redirectUri
42
+ this.codeChallenge = request.codeChallenge
43
+ this.clientState = request.clientState
44
+ this.resource = request.resource
45
+ this.scope = request.scope
46
+ this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
47
+ }
48
+
49
+ // The code names its authorization, as a refresh token names its session.
50
+ static parseCode(code: string): { authorizationId: string; secret: string } | null {
51
+ const dot = code.indexOf('.')
52
+ const authorizationId = code.slice(0, dot)
53
+ const secret = code.slice(dot + 1)
54
+
55
+ return dot === -1 || !UUID.test(authorizationId) || secret === ''
56
+ ? null
57
+ : { authorizationId, secret }
58
+ }
59
+
60
+ code(secret: string): string {
61
+ return `${this.id}.${secret}`
62
+ }
63
+
64
+ isPending(now: Date): boolean {
65
+ return this.status === 'pending' && this.expiresAt > now
66
+ }
67
+
68
+ approve(userId: string, codeHash: string, now: Date): void {
69
+ this.status = 'approved'
70
+ this.userId = userId
71
+ this.codeHash = codeHash
72
+ this.expiresAt = new Date(now.getTime() + AUTHORIZATION_CODE_LIFETIME_MS)
73
+ }
74
+
75
+ deny(): void {
76
+ this.status = 'denied'
77
+ }
78
+
79
+ isRedeemable(now: Date): boolean {
80
+ return this.status === 'approved' && this.expiresAt > now
81
+ }
82
+
83
+ exchange(sessionId: string): void {
84
+ this.status = 'exchanged'
85
+ this.sessionId = sessionId
86
+ }
87
+ }
@@ -0,0 +1,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class AuthorizationNotFoundError extends DomainError {
4
+ readonly kind = 'not_found'
5
+
6
+ constructor() {
7
+ super('This authorization request has expired or was already answered')
8
+ }
9
+ }
@@ -0,0 +1,18 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // A request from a client already known to be who it says, with a redirect URI it registered: the error goes back
4
+ // to that URI, in OAuth's own terms.
5
+ export class AuthorizationRequestError extends DomainError {
6
+ readonly kind = 'invalid'
7
+
8
+ constructor(
9
+ readonly code:
10
+ | 'invalid_request'
11
+ | 'unsupported_response_type'
12
+ | 'invalid_target'
13
+ | 'invalid_scope',
14
+ message: string,
15
+ ) {
16
+ super(message)
17
+ }
18
+ }
@@ -0,0 +1,6 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // The client or its redirect URI cannot be trusted, so the error is shown here and never sent to that URI.
4
+ export class InvalidClientError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ }
@@ -0,0 +1,6 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // OAuth's invalid_grant: the code or refresh token is wrong, spent, expired, or not this client's.
4
+ export class InvalidGrantError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ }
@@ -0,0 +1,8 @@
1
+ export interface AuthorizationCodes {
2
+ create(): string
3
+ digest(code: string): string
4
+ // PKCE's S256: whether the verifier the client kept hashes to the challenge it sent first.
5
+ matchesChallenge(verifier: string, challenge: string): boolean
6
+ }
7
+
8
+ export const AUTHORIZATION_CODES = Symbol('AuthorizationCodes')
@@ -0,0 +1,9 @@
1
+ export type ClientMetadata = { clientId: string; clientName: string; redirectUris: string[] }
2
+
3
+ // A client identifies itself by the HTTPS URL of a document describing it. Reading one that is missing, malformed,
4
+ // or not a public client's throws InvalidClientError.
5
+ export interface ClientMetadataSource {
6
+ read(clientId: string): Promise<ClientMetadata>
7
+ }
8
+
9
+ export const CLIENT_METADATA = Symbol('ClientMetadataSource')
@@ -0,0 +1,10 @@
1
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
2
+
3
+ export interface AuthorizationRepository {
4
+ findById(id: string): Promise<Authorization | null>
5
+ // Reads the row and holds it until the surrounding transaction ends, so a code is spent once.
6
+ lockById(id: string): Promise<Authorization | null>
7
+ save(authorization: Authorization): Promise<void>
8
+ }
9
+
10
+ export const AUTHORIZATION_REPOSITORY = Symbol('AuthorizationRepository')
@@ -0,0 +1,53 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
4
+ import {
5
+ AUTHORIZATION_CODES,
6
+ type AuthorizationCodes,
7
+ } from '@/domain/oauth/ports/authorization-codes.port'
8
+ import {
9
+ AUTHORIZATION_REPOSITORY,
10
+ type AuthorizationRepository,
11
+ } from '@/domain/oauth/repositories/authorization.repository'
12
+ import {
13
+ TRANSACTION_MANAGER,
14
+ type TransactionManager,
15
+ } from '@/domain/shared/transactions/transaction-manager'
16
+
17
+ export type Decision = { authorization: Authorization; code: string | null }
18
+
19
+ @injectable()
20
+ export class DecideAuthorizationUseCase {
21
+ constructor(
22
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
23
+ @inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
24
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
25
+ ) {}
26
+
27
+ // The user who answers is the user the client will act for.
28
+ execute(id: string, userId: string, accept: boolean): Promise<Decision> {
29
+ return this.transactions.run(async () => {
30
+ const now = new Date()
31
+ const authorization = await this.authorizations.lockById(id)
32
+
33
+ if (authorization === null || !authorization.isPending(now)) {
34
+ throw new AuthorizationNotFoundError()
35
+ }
36
+
37
+ let code: string | null = null
38
+
39
+ if (accept) {
40
+ const secret = this.codes.create()
41
+
42
+ authorization.approve(userId, this.codes.digest(secret), now)
43
+ code = authorization.code(secret)
44
+ } else {
45
+ authorization.deny()
46
+ }
47
+
48
+ await this.authorizations.save(authorization)
49
+
50
+ return { authorization, code }
51
+ })
52
+ }
53
+ }
@@ -0,0 +1,97 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
3
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
4
+ import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
5
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
6
+ import { InvalidGrantError } from '@/domain/oauth/errors/invalid-grant.error'
7
+ import {
8
+ AUTHORIZATION_CODES,
9
+ type AuthorizationCodes,
10
+ } from '@/domain/oauth/ports/authorization-codes.port'
11
+ import {
12
+ AUTHORIZATION_REPOSITORY,
13
+ type AuthorizationRepository,
14
+ } from '@/domain/oauth/repositories/authorization.repository'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+
20
+ export type CodeExchange = {
21
+ code: string
22
+ clientId: string
23
+ redirectUri: string
24
+ codeVerifier: string
25
+ resource: string
26
+ }
27
+
28
+ @injectable()
29
+ export class ExchangeCodeUseCase {
30
+ constructor(
31
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
32
+ @inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
33
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
34
+ private readonly startSession: StartSessionUseCase,
35
+ private readonly signOut: SignOutUseCase,
36
+ ) {}
37
+
38
+ async execute(request: CodeExchange): Promise<SessionTokens> {
39
+ const parsed = Authorization.parseCode(request.code)
40
+
41
+ if (parsed === null) {
42
+ throw new InvalidGrantError('Unknown authorization code')
43
+ }
44
+
45
+ const outcome = await this.transactions.run(async () => {
46
+ const now = new Date()
47
+ const authorization = await this.authorizations.lockById(parsed.authorizationId)
48
+
49
+ if (authorization === null || authorization.codeHash !== this.codes.digest(parsed.secret)) {
50
+ return { error: 'Unknown authorization code' }
51
+ }
52
+
53
+ // A code presented twice was copied: the tokens the first use obtained are no longer trusted either.
54
+ if (authorization.status === 'exchanged' && authorization.userId !== null) {
55
+ if (authorization.sessionId !== null) {
56
+ await this.signOut.execute(authorization.userId, authorization.sessionId)
57
+ }
58
+
59
+ return { error: 'Authorization code already used' }
60
+ }
61
+
62
+ if (!authorization.isRedeemable(now) || authorization.userId === null) {
63
+ return { error: 'Authorization code expired' }
64
+ }
65
+
66
+ if (
67
+ authorization.clientId !== request.clientId ||
68
+ authorization.redirectUri !== request.redirectUri ||
69
+ authorization.resource !== request.resource
70
+ ) {
71
+ return { error: 'The code was issued for another client, redirect URI or resource' }
72
+ }
73
+
74
+ if (!this.codes.matchesChallenge(request.codeVerifier, authorization.codeChallenge)) {
75
+ return { error: 'code_verifier does not match the code_challenge' }
76
+ }
77
+
78
+ const tokens = await this.startSession.execute(authorization.userId, {
79
+ clientId: authorization.clientId,
80
+ resource: authorization.resource,
81
+ scope: authorization.scope,
82
+ })
83
+
84
+ authorization.exchange(tokens.sessionId)
85
+ await this.authorizations.save(authorization)
86
+
87
+ return { tokens }
88
+ })
89
+
90
+ // Thrown after the transaction, so revoking a copied code's session is committed and not rolled back.
91
+ if ('error' in outcome) {
92
+ throw new InvalidGrantError(outcome.error)
93
+ }
94
+
95
+ return outcome.tokens
96
+ }
97
+ }
@@ -0,0 +1,24 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
4
+ import {
5
+ AUTHORIZATION_REPOSITORY,
6
+ type AuthorizationRepository,
7
+ } from '@/domain/oauth/repositories/authorization.repository'
8
+
9
+ @injectable()
10
+ export class FindAuthorizationUseCase {
11
+ constructor(
12
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
13
+ ) {}
14
+
15
+ async execute(id: string): Promise<Authorization> {
16
+ const authorization = await this.authorizations.findById(id)
17
+
18
+ if (authorization === null || !authorization.isPending(new Date())) {
19
+ throw new AuthorizationNotFoundError()
20
+ }
21
+
22
+ return authorization
23
+ }
24
+ }
@@ -0,0 +1,89 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationRequestError } from '@/domain/oauth/errors/authorization-request.error'
4
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
5
+ import {
6
+ CLIENT_METADATA,
7
+ type ClientMetadataSource,
8
+ } from '@/domain/oauth/ports/client-metadata.port'
9
+ import {
10
+ AUTHORIZATION_REPOSITORY,
11
+ type AuthorizationRepository,
12
+ } from '@/domain/oauth/repositories/authorization.repository'
13
+
14
+ // RFC 7636: 43 to 128 characters from the unreserved set.
15
+ const CODE_CHALLENGE = /^[A-Za-z0-9\-._~]{43,128}$/
16
+
17
+ export type AuthorizeRequest = {
18
+ responseType: string | undefined
19
+ clientId: string
20
+ redirectUri: string
21
+ codeChallenge: string | undefined
22
+ codeChallengeMethod: string | undefined
23
+ clientState: string | null
24
+ resource: string | undefined
25
+ scope: string | undefined
26
+ }
27
+
28
+ @injectable()
29
+ export class StartAuthorizationUseCase {
30
+ constructor(
31
+ @inject(CLIENT_METADATA) private readonly clients: ClientMetadataSource,
32
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
33
+ ) {}
34
+
35
+ // The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
36
+ async execute(
37
+ request: AuthorizeRequest,
38
+ expectedResource: string,
39
+ supportedScopes: readonly string[],
40
+ ): Promise<Authorization> {
41
+ const client = await this.clients.read(request.clientId)
42
+
43
+ // Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
44
+ if (!client.redirectUris.includes(request.redirectUri)) {
45
+ throw new InvalidClientError('redirect_uri is not one the client registered')
46
+ }
47
+
48
+ if (request.responseType !== 'code') {
49
+ throw new AuthorizationRequestError('unsupported_response_type', 'Only response_type=code')
50
+ }
51
+
52
+ if (
53
+ request.codeChallengeMethod !== 'S256' ||
54
+ !CODE_CHALLENGE.test(request.codeChallenge ?? '')
55
+ ) {
56
+ throw new AuthorizationRequestError('invalid_request', 'PKCE with S256 is required')
57
+ }
58
+
59
+ // RFC 8707: the token is for one resource, and this server has one.
60
+ if (request.resource !== expectedResource) {
61
+ throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
62
+ }
63
+
64
+ // A client that asks for nothing gets everything this server offers, and the consent page says so.
65
+ const requested = (request.scope ?? '').split(' ').filter(Boolean)
66
+ const unknown = requested.filter((scope) => !supportedScopes.includes(scope))
67
+
68
+ if (unknown.length > 0) {
69
+ throw new AuthorizationRequestError('invalid_scope', `Unknown scope: ${unknown.join(' ')}`)
70
+ }
71
+
72
+ const authorization = new Authorization(
73
+ {
74
+ clientId: request.clientId,
75
+ clientName: client.clientName,
76
+ redirectUri: request.redirectUri,
77
+ codeChallenge: request.codeChallenge as string,
78
+ clientState: request.clientState,
79
+ resource: expectedResource,
80
+ scope: (requested.length > 0 ? requested : supportedScopes).join(' '),
81
+ },
82
+ new Date(),
83
+ )
84
+
85
+ await this.authorizations.save(authorization)
86
+
87
+ return authorization
88
+ }
89
+ }
@@ -2,4 +2,6 @@ export type DomainErrorKind = 'not_found' | 'conflict' | 'invalid' | 'forbidden'
2
2
 
3
3
  export abstract class DomainError extends Error {
4
4
  abstract readonly kind: DomainErrorKind
5
+ // A stable name a client can act on, when the status alone does not say what to do next.
6
+ readonly code?: string
5
7
  }
@@ -0,0 +1,21 @@
1
+ import { hash, verify } from '@node-rs/argon2'
2
+ import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
3
+
4
+ // The library's defaults are Argon2id with 19 MiB, two passes and one lane: OWASP's minimum, so none is restated.
5
+ export class Argon2PasswordHasher implements PasswordHasher {
6
+ // A real hash of nothing in particular, so an unknown email costs one verification like any other.
7
+ private readonly decoy = hash('decoy password that nobody holds')
8
+
9
+ hash(password: string): Promise<string> {
10
+ return hash(password)
11
+ }
12
+
13
+ async verify(stored: string | undefined, password: string): Promise<boolean> {
14
+ if (stored === undefined) {
15
+ await verify(await this.decoy, password)
16
+ return false
17
+ }
18
+
19
+ return verify(stored, password)
20
+ }
21
+ }
@@ -0,0 +1,14 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
3
+
4
+ // 256 random bits need no slow hash: nothing can be guessed, so a plain SHA-256 is enough to keep a stolen
5
+ // database from handing out working tokens.
6
+ export class CryptoOpaqueTokens implements OpaqueTokens {
7
+ create(): string {
8
+ return randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ digest(token: string): string {
12
+ return createHash('sha256').update(token).digest('base64url')
13
+ }
14
+ }