@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,231 @@
1
+ import { type LookupAddress, lookup } from 'node:dns'
2
+ import { request } from 'node:https'
3
+ import { BlockList, isIP } from 'node:net'
4
+ import { z } from 'zod'
5
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
6
+ import type {
7
+ ClientMetadata,
8
+ ClientMetadataSource,
9
+ } from '@/domain/oauth/ports/client-metadata.port'
10
+
11
+ // The draft's recommended ceiling: a description of a client has no reason to be larger.
12
+ const MAX_BYTES = 5 * 1024
13
+ const TIMEOUT_MS = 5000
14
+ const MAX_CACHE_MS = 60 * 60 * 1000
15
+
16
+ // Anything that is not the public internet. The client chooses the URL, so without this list it could make the API
17
+ // read its own network.
18
+ const PRIVATE = new BlockList()
19
+
20
+ for (const [network, prefix] of [
21
+ ['0.0.0.0', 8],
22
+ ['10.0.0.0', 8],
23
+ ['100.64.0.0', 10],
24
+ ['127.0.0.0', 8],
25
+ ['169.254.0.0', 16],
26
+ ['172.16.0.0', 12],
27
+ ['192.0.0.0', 24],
28
+ ['192.0.2.0', 24],
29
+ ['192.168.0.0', 16],
30
+ ['198.18.0.0', 15],
31
+ ['198.51.100.0', 24],
32
+ ['203.0.113.0', 24],
33
+ ['224.0.0.0', 4],
34
+ ['240.0.0.0', 4],
35
+ ] as const) {
36
+ PRIVATE.addSubnet(network, prefix, 'ipv4')
37
+ }
38
+
39
+ // An IPv4-mapped address (::ffff:127.0.0.1) is checked against the IPv4 rules by BlockList itself; a rule for the
40
+ // whole mapped range would match every IPv4 address, public ones included.
41
+ for (const [network, prefix] of [
42
+ ['::', 127],
43
+ ['64:ff9b::', 96],
44
+ ['100::', 64],
45
+ ['2001:db8::', 32],
46
+ ['fc00::', 7],
47
+ ['fe80::', 10],
48
+ ['ff00::', 8],
49
+ ] as const) {
50
+ PRIVATE.addSubnet(network, prefix, 'ipv6')
51
+ }
52
+
53
+ export function isPrivateAddress(address: string): boolean {
54
+ return PRIVATE.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')
55
+ }
56
+
57
+ const documentSchema = z.object({
58
+ client_id: z.string(),
59
+ client_name: z.string().trim().min(1).max(200),
60
+ redirect_uris: z.array(z.string()).min(1).max(20),
61
+ // A document is public by nature, so it cannot hold a secret, and a key-based method is not supported here.
62
+ token_endpoint_auth_method: z.literal('none').optional(),
63
+ client_secret: z.never().optional(),
64
+ client_secret_expires_at: z.never().optional(),
65
+ })
66
+
67
+ function parseJson(text: string): unknown {
68
+ try {
69
+ return JSON.parse(text)
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ function fail(message: string): never {
76
+ throw new InvalidClientError(message)
77
+ }
78
+
79
+ // https, a path, no credentials, no query, no fragment, no dot segments: and written exactly as the URL parser
80
+ // would write it, since the document's client_id is compared to it character by character.
81
+ function clientUrl(clientId: string): URL {
82
+ const url = URL.canParse(clientId) ? new URL(clientId) : fail('client_id is not a URL')
83
+
84
+ if (
85
+ url.protocol !== 'https:' ||
86
+ url.username !== '' ||
87
+ url.password !== '' ||
88
+ url.search !== '' ||
89
+ url.hash !== '' ||
90
+ url.pathname === '/' ||
91
+ url.href !== clientId
92
+ ) {
93
+ fail('client_id must be a canonical https URL with a path, and nothing else')
94
+ }
95
+
96
+ return url
97
+ }
98
+
99
+ // HTTPS anywhere, or plain HTTP back to this device, as MCP allows for native clients.
100
+ function isAllowedRedirect(uri: string): boolean {
101
+ if (!URL.canParse(uri)) {
102
+ return false
103
+ }
104
+
105
+ const url = new URL(uri)
106
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
107
+
108
+ return url.hash === '' && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
109
+ }
110
+
111
+ // The connection goes to the address checked here, so a name that resolves differently a second time cannot reach
112
+ // the private network after passing.
113
+ function guardedLookup(
114
+ hostname: string,
115
+ options: { all?: boolean },
116
+ callback: (error: Error | null, address: string | LookupAddress[], family?: number) => void,
117
+ ): void {
118
+ lookup(hostname, { all: true }, (error, addresses) => {
119
+ if (error !== null) {
120
+ callback(error, [])
121
+ return
122
+ }
123
+
124
+ if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) {
125
+ callback(new Error(`${hostname} resolves to a private address`), [])
126
+ return
127
+ }
128
+
129
+ const [first] = addresses as [LookupAddress]
130
+
131
+ if (options.all === true) {
132
+ callback(null, addresses)
133
+ } else {
134
+ callback(null, first.address, first.family)
135
+ }
136
+ })
137
+ }
138
+
139
+ function download(url: URL): Promise<{ body: string; maxAgeMs: number }> {
140
+ return new Promise((resolve, reject) => {
141
+ const req = request(
142
+ url,
143
+ { method: 'GET', headers: { accept: 'application/json' }, lookup: guardedLookup as never },
144
+ (res) => {
145
+ if (res.statusCode !== 200) {
146
+ res.destroy()
147
+ reject(new Error(`answered ${res.statusCode}`))
148
+ return
149
+ }
150
+
151
+ const chunks: Buffer[] = []
152
+ let size = 0
153
+
154
+ res.on('data', (chunk: Buffer) => {
155
+ size += chunk.length
156
+
157
+ if (size > MAX_BYTES) {
158
+ res.destroy(new Error(`is larger than ${MAX_BYTES} bytes`))
159
+ } else {
160
+ chunks.push(chunk)
161
+ }
162
+ })
163
+ res.on('error', reject)
164
+ res.on('end', () => {
165
+ const maxAge = /max-age=(\d+)/.exec(res.headers['cache-control'] ?? '')?.[1]
166
+ const cacheable = !/no-store|no-cache/.test(res.headers['cache-control'] ?? '')
167
+
168
+ resolve({
169
+ body: Buffer.concat(chunks).toString('utf8'),
170
+ maxAgeMs:
171
+ cacheable && maxAge !== undefined ? Math.min(Number(maxAge) * 1000, MAX_CACHE_MS) : 0,
172
+ })
173
+ })
174
+ },
175
+ )
176
+
177
+ req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('timed out')))
178
+ req.on('error', reject)
179
+ req.end()
180
+ })
181
+ }
182
+
183
+ export class HttpsClientMetadata implements ClientMetadataSource {
184
+ private readonly cache = new Map<string, { metadata: ClientMetadata; until: number }>()
185
+
186
+ async read(clientId: string): Promise<ClientMetadata> {
187
+ const cached = this.cache.get(clientId)
188
+
189
+ if (cached !== undefined && cached.until > Date.now()) {
190
+ return cached.metadata
191
+ }
192
+
193
+ const url = clientUrl(clientId)
194
+ const host = url.hostname.replace(/^\[|\]$/g, '')
195
+
196
+ // A literal address is connected to directly, without the lookup that guards a name.
197
+ if (isIP(host) !== 0 && isPrivateAddress(host)) {
198
+ fail('client_id points at a private address')
199
+ }
200
+
201
+ const { body, maxAgeMs } = await download(url).catch((error: Error) =>
202
+ fail(`The client metadata document could not be read: ${error.message}`),
203
+ )
204
+ const parsed = documentSchema.safeParse(parseJson(body))
205
+
206
+ if (!parsed.success) {
207
+ fail('The client metadata document is not a valid public client description')
208
+ }
209
+
210
+ if (parsed.data.client_id !== clientId) {
211
+ fail('The client metadata document names another client_id')
212
+ }
213
+
214
+ if (!parsed.data.redirect_uris.every(isAllowedRedirect)) {
215
+ fail('Every redirect URI must be https, or http to localhost')
216
+ }
217
+
218
+ const metadata = {
219
+ clientId,
220
+ clientName: parsed.data.client_name,
221
+ redirectUris: parsed.data.redirect_uris,
222
+ }
223
+
224
+ // Only a valid document is kept, and only as long as its server says it may be.
225
+ if (maxAgeMs > 0) {
226
+ this.cache.set(clientId, { metadata, until: Date.now() + maxAgeMs })
227
+ }
228
+
229
+ return metadata
230
+ }
231
+ }
@@ -0,0 +1,199 @@
1
+ import { importPKCS8, SignJWT } from 'jose'
2
+ import * as oidc from 'openid-client'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
+ import type {
5
+ IdentityProviders,
6
+ ProviderCallback,
7
+ ProviderName,
8
+ ProviderProfile,
9
+ ProviderRedirect,
10
+ } from '@/domain/auth/ports/identity-providers.port'
11
+ import { DomainError } from '@/domain/shared/errors/domain-error'
12
+ import type { Env } from '@/infra/config/env'
13
+
14
+ type Settings = {
15
+ issuer: URL
16
+ clientId: string
17
+ scope: string
18
+ // Apple answers with a POST when the email is asked for; Google redirects back with a GET.
19
+ formPost: boolean
20
+ secret: () => Promise<string>
21
+ }
22
+
23
+ type Options = {
24
+ // A test points a provider at a local issuer, over plain HTTP.
25
+ issuers?: Partial<Record<ProviderName, URL>>
26
+ insecure?: boolean
27
+ }
28
+
29
+ // Apple accepts a client secret valid for up to six months; one hour keeps a leaked one short-lived.
30
+ const SECRET_LIFETIME_MS = 60 * 60 * 1000
31
+
32
+ export class OpenIdIdentityProviders implements IdentityProviders {
33
+ private readonly configurations = new Map<
34
+ ProviderName,
35
+ { configuration: oidc.Configuration; until: number }
36
+ >()
37
+
38
+ constructor(
39
+ private readonly env: Env,
40
+ private readonly options: Options = {},
41
+ ) {}
42
+
43
+ private settings(provider: ProviderName): Settings | null {
44
+ const { env } = this
45
+ const issuer = (fallback: string) => this.options.issuers?.[provider] ?? new URL(fallback)
46
+
47
+ switch (provider) {
48
+ // prumo:google
49
+ case 'google': {
50
+ const secret = env.GOOGLE_CLIENT_SECRET
51
+
52
+ return env.GOOGLE_CLIENT_ID === undefined || secret === undefined
53
+ ? null
54
+ : {
55
+ issuer: issuer('https://accounts.google.com'),
56
+ clientId: env.GOOGLE_CLIENT_ID,
57
+ scope: 'openid email profile',
58
+ formPost: false,
59
+ secret: async () => secret,
60
+ }
61
+ }
62
+ // prumo:end-google
63
+ // prumo:apple
64
+ case 'apple': {
65
+ const { APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY } = env
66
+
67
+ if (
68
+ APPLE_CLIENT_ID === undefined ||
69
+ APPLE_TEAM_ID === undefined ||
70
+ APPLE_KEY_ID === undefined ||
71
+ APPLE_PRIVATE_KEY === undefined
72
+ ) {
73
+ return null
74
+ }
75
+
76
+ return {
77
+ issuer: issuer('https://appleid.apple.com'),
78
+ clientId: APPLE_CLIENT_ID,
79
+ scope: 'openid email name',
80
+ formPost: true,
81
+ // Apple's client secret is a JWT signed with the team's .p8 key, not a string it hands out.
82
+ secret: async () =>
83
+ new SignJWT({})
84
+ .setProtectedHeader({ alg: 'ES256', kid: APPLE_KEY_ID })
85
+ .setIssuer(APPLE_TEAM_ID)
86
+ .setSubject(APPLE_CLIENT_ID)
87
+ .setAudience('https://appleid.apple.com')
88
+ .setIssuedAt()
89
+ .setExpirationTime(Math.floor((Date.now() + SECRET_LIFETIME_MS) / 1000))
90
+ .sign(await importPKCS8(APPLE_PRIVATE_KEY.replaceAll('\\n', '\n'), 'ES256')),
91
+ }
92
+ }
93
+ // prumo:end-apple
94
+ }
95
+
96
+ return null
97
+ }
98
+
99
+ private redirectUri(provider: ProviderName): string {
100
+ return `${this.env.API_URL}/api/auth/social/${provider}/callback`
101
+ }
102
+
103
+ private async configuration(provider: ProviderName, settings: Settings) {
104
+ const cached = this.configurations.get(provider)
105
+
106
+ if (cached !== undefined && cached.until > Date.now()) {
107
+ return cached.configuration
108
+ }
109
+
110
+ const configuration = await oidc.discovery(
111
+ settings.issuer,
112
+ settings.clientId,
113
+ undefined,
114
+ oidc.ClientSecretPost(await settings.secret()),
115
+ { execute: this.options.insecure === true ? [oidc.allowInsecureRequests] : [] },
116
+ )
117
+
118
+ // Renewed before the secret it was built with expires.
119
+ this.configurations.set(provider, {
120
+ configuration,
121
+ until: Date.now() + SECRET_LIFETIME_MS / 2,
122
+ })
123
+
124
+ return configuration
125
+ }
126
+
127
+ isConfigured(provider: ProviderName): boolean {
128
+ return this.settings(provider) !== null
129
+ }
130
+
131
+ async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
132
+ const settings = this.settings(provider)
133
+
134
+ if (settings === null) {
135
+ throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
136
+ }
137
+
138
+ const configuration = await this.configuration(provider, settings)
139
+ const nonce = oidc.randomNonce()
140
+ // PKCE where the provider says it takes it: Google does, Apple's metadata names none, and relies on state and nonce.
141
+ const codeVerifier = configuration.serverMetadata().supportsPKCE()
142
+ ? oidc.randomPKCECodeVerifier()
143
+ : null
144
+ const url = oidc.buildAuthorizationUrl(configuration, {
145
+ redirect_uri: this.redirectUri(provider),
146
+ scope: settings.scope,
147
+ state,
148
+ nonce,
149
+ ...(codeVerifier !== null && {
150
+ code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
151
+ code_challenge_method: 'S256',
152
+ }),
153
+ ...(settings.formPost && { response_mode: 'form_post' }),
154
+ })
155
+
156
+ return { url: url.href, codeVerifier, nonce }
157
+ }
158
+
159
+ async profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
160
+ const settings = this.settings(provider)
161
+
162
+ if (settings === null) {
163
+ throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
164
+ }
165
+
166
+ try {
167
+ const tokens = await oidc.authorizationCodeGrant(
168
+ await this.configuration(provider, settings),
169
+ callback.callbackUrl,
170
+ {
171
+ expectedState: callback.state,
172
+ expectedNonce: callback.nonce,
173
+ idTokenExpected: true,
174
+ ...(callback.codeVerifier !== null && { pkceCodeVerifier: callback.codeVerifier }),
175
+ },
176
+ )
177
+ // Read only after openid-client checked the ID token's signature, issuer, audience, expiry and nonce.
178
+ const claims = tokens.claims()
179
+
180
+ if (claims === undefined || typeof claims.email !== 'string') {
181
+ throw new SocialSignInFailedError('The provider did not share an email address')
182
+ }
183
+
184
+ return {
185
+ subject: claims.sub,
186
+ email: claims.email,
187
+ // Apple writes it as the string "true".
188
+ emailVerified: claims.email_verified === true || claims.email_verified === 'true',
189
+ name: typeof claims.name === 'string' ? claims.name : callback.name,
190
+ }
191
+ } catch (error) {
192
+ if (error instanceof DomainError) {
193
+ throw error
194
+ }
195
+
196
+ throw new SocialSignInFailedError()
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ROTATION_GRACE_MS, Session } from '@/domain/auth/entities/session.entity'
3
+
4
+ const NOW = new Date('2026-10-02T12:00:00Z')
5
+ const later = (ms: number) => new Date(NOW.getTime() + ms)
6
+
7
+ describe('Session', () => {
8
+ it('accepts the current token, and after a rotation only the new one', () => {
9
+ const session = new Session('user-1', 'first', NOW)
10
+
11
+ expect(session.check('first', NOW)).toBe('valid')
12
+
13
+ session.rotate('second', later(1000))
14
+
15
+ expect(session.check('second', later(2000))).toBe('valid')
16
+ })
17
+
18
+ it('tells a token rotated a moment ago from one kept and replayed', () => {
19
+ const session = new Session('user-1', 'first', NOW)
20
+
21
+ session.rotate('second', NOW)
22
+
23
+ expect(session.check('first', later(ROTATION_GRACE_MS - 1))).toBe('superseded')
24
+ expect(session.check('first', later(ROTATION_GRACE_MS))).toBe('reused')
25
+ expect(session.check('never-issued', NOW)).toBe('reused')
26
+ })
27
+
28
+ it('refuses every token once revoked or expired', () => {
29
+ const revoked = new Session('user-1', 'first', NOW)
30
+ revoked.revoke(NOW)
31
+
32
+ expect(revoked.check('first', NOW)).toBe('expired')
33
+ expect(new Session('user-1', 'first', NOW).check('first', later(31 * 86_400_000))).toBe(
34
+ 'expired',
35
+ )
36
+ })
37
+
38
+ it('reads back the refresh token it writes, and nothing malformed', () => {
39
+ const session = new Session('user-1', 'hash', NOW)
40
+ session.id = '0199a7e2-0000-7000-8000-000000000000'
41
+
42
+ expect(Session.parseRefreshToken(session.refreshToken('s3cret'))).toEqual({
43
+ sessionId: session.id,
44
+ secret: 's3cret',
45
+ })
46
+ for (const token of ['', 'no-dot', 'not-a-uuid.secret', `${session.id}.`]) {
47
+ expect(Session.parseRefreshToken(token), token).toBeNull()
48
+ }
49
+ })
50
+ })
@@ -0,0 +1,87 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { EMAIL_CODE_ATTEMPTS } from '@/domain/auth/entities/email-code.entity'
4
+ import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error'
5
+ import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+
9
+ describe('email verification', () => {
10
+ it('refuses to sign in an unconfirmed account, and lets it in once confirmed', async () => {
11
+ const { signUp, signIn, verifyEmail, mailer } = authFakes()
12
+ await signUp.execute(ANA)
13
+
14
+ await expect(signIn.execute(ANA)).rejects.toBeInstanceOf(EmailNotVerifiedError)
15
+
16
+ await verifyEmail.execute({ email: ANA.email, code: mailer.lastCode(ANA.email) })
17
+
18
+ await expect(signIn.execute(ANA)).resolves.toBeDefined()
19
+ })
20
+
21
+ it('kills a code after five wrong guesses, even if the right one comes next', async () => {
22
+ const { signUp, verifyEmail, mailer } = authFakes()
23
+ await signUp.execute(ANA)
24
+ const code = mailer.lastCode(ANA.email)
25
+
26
+ for (let i = 0; i < EMAIL_CODE_ATTEMPTS; i += 1) {
27
+ await expect(
28
+ verifyEmail.execute({ email: ANA.email, code: '000000' }),
29
+ ).rejects.toBeInstanceOf(InvalidCodeError)
30
+ }
31
+
32
+ await expect(verifyEmail.execute({ email: ANA.email, code })).rejects.toBeInstanceOf(
33
+ InvalidCodeError,
34
+ )
35
+ })
36
+
37
+ it('answers an unknown email as it answers a wrong code', async () => {
38
+ const { verifyEmail } = authFakes()
39
+
40
+ await expect(
41
+ verifyEmail.execute({ email: 'nobody@example.com', code: '123456' }),
42
+ ).rejects.toBeInstanceOf(InvalidCodeError)
43
+ })
44
+ })
45
+
46
+ describe('password reset', () => {
47
+ it('sets the new password, ends every old session, and confirms the email it reached', async () => {
48
+ const { register, signIn, sessions, users, requestPasswordReset, resetPassword, mailer } =
49
+ authFakes()
50
+ await register(ANA)
51
+ await signIn.execute(ANA)
52
+
53
+ await requestPasswordReset.execute(ANA.email)
54
+ await resetPassword.execute({
55
+ email: ANA.email,
56
+ code: mailer.lastCode(ANA.email),
57
+ password: 'another-good-password',
58
+ })
59
+
60
+ const revoked = [...sessions.rows.values()].filter((session) => session.revokedAt !== null)
61
+ const [user] = [...users.rows.values()]
62
+
63
+ expect(revoked).toHaveLength(2)
64
+ expect(user?.passwordHash).toBe('hashed:another-good-password')
65
+ await expect(signIn.execute(ANA)).rejects.toThrow()
66
+ })
67
+
68
+ it('sends nothing for an unknown email, and says nothing about it either', async () => {
69
+ const { requestPasswordReset, mailer } = authFakes()
70
+
71
+ await expect(requestPasswordReset.execute('nobody@example.com')).resolves.toBeUndefined()
72
+ expect(mailer.sent).toEqual([])
73
+ })
74
+
75
+ it('voids the previous code when a new one is sent', async () => {
76
+ const { register, requestPasswordReset, resetPassword, mailer } = authFakes()
77
+ await register(ANA)
78
+
79
+ await requestPasswordReset.execute(ANA.email)
80
+ const first = mailer.lastCode(ANA.email)
81
+ await requestPasswordReset.execute(ANA.email)
82
+
83
+ await expect(
84
+ resetPassword.execute({ email: ANA.email, code: first, password: 'another-good-password' }),
85
+ ).rejects.toBeInstanceOf(InvalidCodeError)
86
+ })
87
+ })
@@ -0,0 +1,66 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { ROTATION_GRACE_MS } from '@/domain/auth/entities/session.entity'
4
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
5
+ import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers()
11
+ })
12
+
13
+ describe('RefreshSessionUseCase', () => {
14
+ it('rotates the refresh token on every use', async () => {
15
+ const { register, refresh } = authFakes()
16
+ const first = await register(ANA)
17
+
18
+ const second = await refresh.execute(first.refreshToken)
19
+ const third = await refresh.execute(second.refreshToken)
20
+
21
+ expect(new Set([first.refreshToken, second.refreshToken, third.refreshToken]).size).toBe(3)
22
+ })
23
+
24
+ it('asks a client that lost a race to retry, and revokes nothing', async () => {
25
+ const { register, refresh, sessions } = authFakes()
26
+ const first = await register(ANA)
27
+
28
+ const second = await refresh.execute(first.refreshToken)
29
+
30
+ await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(SupersededTokenError)
31
+ expect([...sessions.rows.values()][0]?.revokedAt).toBeNull()
32
+ await expect(refresh.execute(second.refreshToken)).resolves.toBeDefined()
33
+ })
34
+
35
+ it('ends the session when a token comes back after the grace period', async () => {
36
+ vi.useFakeTimers()
37
+ const { register, refresh, sessions } = authFakes()
38
+ const first = await register(ANA)
39
+ const second = await refresh.execute(first.refreshToken)
40
+
41
+ vi.advanceTimersByTime(ROTATION_GRACE_MS)
42
+
43
+ await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
44
+ expect([...sessions.rows.values()][0]?.revokedAt).not.toBeNull()
45
+ // The thief's replay also locked out the rightful client: that is the point.
46
+ await expect(refresh.execute(second.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
47
+ })
48
+
49
+ it('refuses a token that names no session or is not one at all', async () => {
50
+ const { refresh } = authFakes()
51
+
52
+ for (const token of ['garbage', '0199a7e2-0000-7000-8000-000000000000.secret']) {
53
+ await expect(refresh.execute(token), token).rejects.toBeInstanceOf(InvalidSessionError)
54
+ }
55
+ })
56
+
57
+ it('refuses every token of a session signed out of', async () => {
58
+ const { register, signOut, refresh, sessions } = authFakes()
59
+ const tokens = await register(ANA)
60
+ const session = [...sessions.rows.values()][0]
61
+
62
+ await signOut.execute(session?.userId ?? '', session?.id ?? '')
63
+
64
+ await expect(refresh.execute(tokens.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
65
+ })
66
+ })
@@ -0,0 +1,29 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
4
+
5
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
6
+
7
+ describe('SignInUseCase', () => {
8
+ it('starts a session for the right password, whatever the case of the email', async () => {
9
+ const { register, signIn, sessions } = authFakes()
10
+ await register(ANA)
11
+
12
+ await signIn.execute({ email: 'ANA@example.com', password: ANA.password })
13
+
14
+ expect(sessions.rows.size).toBe(2)
15
+ })
16
+
17
+ it('answers a wrong password and an unknown email alike, after checking a password both times', async () => {
18
+ const { register, signIn, hasher } = authFakes()
19
+ await register(ANA)
20
+
21
+ await expect(signIn.execute({ email: ANA.email, password: 'wrong' })).rejects.toBeInstanceOf(
22
+ InvalidCredentialsError,
23
+ )
24
+ await expect(
25
+ signIn.execute({ email: 'nobody@example.com', password: ANA.password }),
26
+ ).rejects.toBeInstanceOf(InvalidCredentialsError)
27
+ expect(hasher.verifications).toBe(2)
28
+ })
29
+ })