@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,58 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
3
+ import { HttpsClientMetadata, isPrivateAddress } from '@/infra/oauth/https-client-metadata.adapter'
4
+
5
+ describe('HttpsClientMetadata', () => {
6
+ it('refuses a client_id that is not a canonical https URL with a path', async () => {
7
+ const source = new HttpsClientMetadata()
8
+
9
+ for (const clientId of [
10
+ 'not a url',
11
+ 'http://client.example.com/client.json',
12
+ 'https://client.example.com',
13
+ 'https://client.example.com/',
14
+ 'https://client.example.com/client.json?x=1',
15
+ 'https://client.example.com/client.json#top',
16
+ 'https://user:pass@client.example.com/client.json',
17
+ 'https://client.example.com/a/../client.json',
18
+ 'https://CLIENT.example.com/client.json',
19
+ ]) {
20
+ await expect(source.read(clientId), clientId).rejects.toBeInstanceOf(InvalidClientError)
21
+ }
22
+ })
23
+
24
+ it('refuses to fetch from this machine or its network, whatever the name says', async () => {
25
+ const source = new HttpsClientMetadata()
26
+
27
+ for (const clientId of [
28
+ 'https://localhost/client.json',
29
+ 'https://127.0.0.1/client.json',
30
+ 'https://10.0.0.1/client.json',
31
+ 'https://[::1]/client.json',
32
+ ]) {
33
+ await expect(source.read(clientId), clientId).rejects.toThrow(
34
+ /private address|could not be read/,
35
+ )
36
+ }
37
+ })
38
+
39
+ it('knows the private, loopback, link-local and mapped ranges from public ones', () => {
40
+ for (const address of [
41
+ '127.0.0.1',
42
+ '10.1.2.3',
43
+ '172.16.0.1',
44
+ '192.168.1.1',
45
+ '169.254.169.254',
46
+ '::1',
47
+ 'fe80::1',
48
+ 'fd00::1',
49
+ '::ffff:127.0.0.1',
50
+ ]) {
51
+ expect(isPrivateAddress(address), address).toBe(true)
52
+ }
53
+
54
+ for (const address of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) {
55
+ expect(isPrivateAddress(address), address).toBe(false)
56
+ }
57
+ })
58
+ })
@@ -0,0 +1,154 @@
1
+ import { fakeOidcServer } from '@test/support/fake-oidc-server'
2
+ import { testEnv } from '@test/support/setup'
3
+ // One line, so its marker takes all of it.
4
+ import * as jose from 'jose' // prumo:apple
5
+ import { afterEach, describe, expect, it } from 'vitest'
6
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error' // prumo:google
7
+ import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
8
+
9
+ let server: Awaited<ReturnType<typeof fakeOidcServer>>
10
+
11
+ afterEach(async () => {
12
+ await server.close()
13
+ })
14
+
15
+ function callback(provider: string, code: string, state: string): URL {
16
+ return new URL(
17
+ `http://localhost:3000/api/auth/social/${provider}/callback?code=${code}&state=${state}`,
18
+ )
19
+ }
20
+
21
+ // prumo:google
22
+ describe('OpenIdIdentityProviders with Google', () => {
23
+ async function google() {
24
+ server = await fakeOidcServer({ pkce: true })
25
+
26
+ return new OpenIdIdentityProviders(
27
+ { ...testEnv(), GOOGLE_CLIENT_ID: 'google-client', GOOGLE_CLIENT_SECRET: 'google-secret' },
28
+ { issuers: { google: server.issuer }, insecure: true },
29
+ )
30
+ }
31
+
32
+ it('sends PKCE, state and nonce, and reads the verified profile back', async () => {
33
+ const providers = await google()
34
+ const redirect = await providers.redirect('google', 'state-1')
35
+ const url = new URL(redirect.url)
36
+ const code = server.authorize(redirect.url, {
37
+ sub: 'g-1',
38
+ email: 'ana@example.com',
39
+ email_verified: true,
40
+ name: 'Ana',
41
+ })
42
+
43
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256')
44
+ expect(url.searchParams.get('redirect_uri')).toBe(
45
+ 'http://localhost:3000/api/auth/social/google/callback',
46
+ )
47
+
48
+ const profile = await providers.profile('google', {
49
+ callbackUrl: callback('google', code, 'state-1'),
50
+ state: 'state-1',
51
+ codeVerifier: redirect.codeVerifier,
52
+ nonce: redirect.nonce,
53
+ name: null,
54
+ })
55
+
56
+ expect(profile).toEqual({
57
+ subject: 'g-1',
58
+ email: 'ana@example.com',
59
+ emailVerified: true,
60
+ name: 'Ana',
61
+ })
62
+ expect(server.tokenRequests[0]?.get('client_secret')).toBe('google-secret')
63
+ })
64
+
65
+ it('refuses another state, another nonce and a code it did not issue', async () => {
66
+ const providers = await google()
67
+ const redirect = await providers.redirect('google', 'state-1')
68
+ const base = {
69
+ state: 'state-1',
70
+ codeVerifier: redirect.codeVerifier,
71
+ nonce: redirect.nonce,
72
+ name: null,
73
+ }
74
+ const claims = { sub: 'g-1', email: 'ana@example.com', email_verified: true }
75
+
76
+ await expect(
77
+ providers.profile('google', {
78
+ ...base,
79
+ callbackUrl: callback('google', server.authorize(redirect.url, claims), 'other-state'),
80
+ }),
81
+ ).rejects.toBeInstanceOf(SocialSignInFailedError)
82
+ await expect(
83
+ providers.profile('google', {
84
+ ...base,
85
+ nonce: 'other-nonce',
86
+ callbackUrl: callback('google', server.authorize(redirect.url, claims), 'state-1'),
87
+ }),
88
+ ).rejects.toBeInstanceOf(SocialSignInFailedError)
89
+ await expect(
90
+ providers.profile('google', {
91
+ ...base,
92
+ callbackUrl: callback('google', 'forged', 'state-1'),
93
+ }),
94
+ ).rejects.toBeInstanceOf(SocialSignInFailedError)
95
+ })
96
+
97
+ it('is not configured without its client id and secret', () => {
98
+ expect(new OpenIdIdentityProviders(testEnv()).isConfigured('google')).toBe(false)
99
+ })
100
+ })
101
+
102
+ // prumo:end-google
103
+ // prumo:apple
104
+ describe('OpenIdIdentityProviders with Apple', () => {
105
+ it('signs its client secret with the .p8 key, asks for a form post, and reads "true" as verified', async () => {
106
+ server = await fakeOidcServer({ pkce: false })
107
+ const { privateKey, publicKey } = await jose.generateKeyPair('ES256', { extractable: true })
108
+ const providers = new OpenIdIdentityProviders(
109
+ {
110
+ ...testEnv(),
111
+ APPLE_CLIENT_ID: 'com.example.web',
112
+ APPLE_TEAM_ID: 'TEAM123456',
113
+ APPLE_KEY_ID: 'KEY1234567',
114
+ // As it sits in .env: one line, its breaks written as \n.
115
+ APPLE_PRIVATE_KEY: (await jose.exportPKCS8(privateKey)).replaceAll('\n', '\\n'),
116
+ },
117
+ { issuers: { apple: server.issuer }, insecure: true },
118
+ )
119
+ const redirect = await providers.redirect('apple', 'state-1')
120
+ const url = new URL(redirect.url)
121
+ const code = server.authorize(redirect.url, {
122
+ sub: 'a-1',
123
+ email: 'ana@privaterelay.appleid.com',
124
+ email_verified: 'true',
125
+ })
126
+
127
+ expect(url.searchParams.get('response_mode')).toBe('form_post')
128
+ expect(url.searchParams.get('code_challenge')).toBeNull()
129
+ expect(redirect.codeVerifier).toBeNull()
130
+
131
+ const profile = await providers.profile('apple', {
132
+ callbackUrl: callback('apple', code, 'state-1'),
133
+ state: 'state-1',
134
+ codeVerifier: null,
135
+ nonce: redirect.nonce,
136
+ name: 'Ana Apple',
137
+ })
138
+ const secret = server.tokenRequests[0]?.get('client_secret') ?? ''
139
+ const { payload } = await jose.jwtVerify(
140
+ secret,
141
+ await jose.importSPKI(await jose.exportSPKI(publicKey), 'ES256'),
142
+ {
143
+ issuer: 'TEAM123456',
144
+ subject: 'com.example.web',
145
+ audience: 'https://appleid.apple.com',
146
+ },
147
+ )
148
+
149
+ expect(profile).toMatchObject({ subject: 'a-1', emailVerified: true, name: 'Ana Apple' })
150
+ expect(jose.decodeProtectedHeader(secret)).toMatchObject({ alg: 'ES256', kid: 'KEY1234567' })
151
+ expect(payload.exp).toBeGreaterThan(Date.now() / 1000)
152
+ })
153
+ })
154
+ // prumo:end-apple
@@ -1,21 +1,19 @@
1
- import { randomUUID } from 'node:crypto'
2
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
3
 
4
- type UserOverrides = Partial<{ name: string; email: string }>
4
+ type UserOverrides = Partial<{ email: string; passwordHash: string }>
5
5
 
6
+ // The hash is not a real one: a test that signs in goes through the sign-up route instead.
6
7
  export async function createUser(
7
8
  em: EntityManager,
8
9
  overrides: UserOverrides = {},
9
10
  ): Promise<string> {
10
- const id = randomUUID()
11
+ const user = new User(
12
+ overrides.email ?? `${crypto.randomUUID()}@example.com`,
13
+ overrides.passwordHash ?? 'not-a-real-hash',
14
+ )
11
15
 
12
- // auth.user belongs to Better Auth, and the ORM maps only its id, so the row is written in SQL.
13
- await em
14
- .getConnection()
15
- .execute(
16
- 'insert into auth."user" (id, name, email, "emailVerified", "createdAt", "updatedAt") values (?, ?, ?, false, now(), now())',
17
- [id, overrides.name ?? 'Test Person', overrides.email ?? `${id}@example.com`],
18
- )
16
+ await em.persist(user).flush()
19
17
 
20
- return id
18
+ return user.id
21
19
  }
@@ -0,0 +1,96 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createServer, type Server } from 'node:http'
3
+ import { exportJWK, generateKeyPair, SignJWT } from 'jose'
4
+
5
+ type Grant = { nonce: string; challenge: string | null; claims: Record<string, unknown> }
6
+
7
+ // A minimal OpenID Connect provider on localhost: discovery, keys, and a token endpoint that signs an ID token for
8
+ // the code it handed out. Enough to run the real adapter end to end, which no test can do against Google or Apple.
9
+ export async function fakeOidcServer({ pkce }: { pkce: boolean }) {
10
+ const { privateKey, publicKey } = await generateKeyPair('RS256')
11
+ const jwk = { ...(await exportJWK(publicKey)), kid: 'k1', alg: 'RS256', use: 'sig' }
12
+ const grants = new Map<string, Grant>()
13
+ const tokenRequests: URLSearchParams[] = []
14
+ let issuer = ''
15
+
16
+ const server: Server = createServer(async (request, response) => {
17
+ const url = new URL(request.url ?? '/', issuer)
18
+ const json = (status: number, body: unknown) => {
19
+ response.writeHead(status, { 'content-type': 'application/json' })
20
+ response.end(JSON.stringify(body))
21
+ }
22
+
23
+ if (url.pathname === '/.well-known/openid-configuration') {
24
+ return json(200, {
25
+ issuer,
26
+ authorization_endpoint: `${issuer}/authorize`,
27
+ token_endpoint: `${issuer}/token`,
28
+ jwks_uri: `${issuer}/jwks`,
29
+ response_types_supported: ['code'],
30
+ subject_types_supported: ['public'],
31
+ id_token_signing_alg_values_supported: ['RS256'],
32
+ token_endpoint_auth_methods_supported: ['client_secret_post'],
33
+ ...(pkce && { code_challenge_methods_supported: ['S256'] }),
34
+ })
35
+ }
36
+
37
+ if (url.pathname === '/jwks') {
38
+ return json(200, { keys: [jwk] })
39
+ }
40
+
41
+ if (url.pathname === '/token') {
42
+ let body = ''
43
+ for await (const chunk of request) body += chunk
44
+ const form = new URLSearchParams(body)
45
+ tokenRequests.push(form)
46
+
47
+ const grant = grants.get(form.get('code') ?? '')
48
+ const verifier = form.get('code_verifier')
49
+
50
+ if (
51
+ grant === undefined ||
52
+ (grant.challenge !== null &&
53
+ createHash('sha256')
54
+ .update(verifier ?? '')
55
+ .digest('base64url') !== grant.challenge)
56
+ ) {
57
+ return json(400, { error: 'invalid_grant' })
58
+ }
59
+
60
+ grants.delete(form.get('code') ?? '')
61
+
62
+ const idToken = await new SignJWT({ nonce: grant.nonce, ...grant.claims })
63
+ .setProtectedHeader({ alg: 'RS256', kid: 'k1' })
64
+ .setIssuer(issuer)
65
+ .setAudience(form.get('client_id') ?? '')
66
+ .setIssuedAt()
67
+ .setExpirationTime('5m')
68
+ .sign(privateKey)
69
+
70
+ return json(200, { access_token: 'access', token_type: 'Bearer', id_token: idToken })
71
+ }
72
+
73
+ json(404, {})
74
+ })
75
+
76
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
77
+ issuer = `http://127.0.0.1:${(server.address() as { port: number }).port}`
78
+
79
+ return {
80
+ issuer: new URL(issuer),
81
+ tokenRequests,
82
+ // What the provider would do after the user signs in: remember the request and hand back a code.
83
+ authorize(authorizationUrl: string, claims: Record<string, unknown>): string {
84
+ const params = new URL(authorizationUrl).searchParams
85
+ const code = crypto.randomUUID()
86
+
87
+ grants.set(code, {
88
+ nonce: params.get('nonce') ?? '',
89
+ challenge: params.get('code_challenge'),
90
+ claims,
91
+ })
92
+ return code
93
+ },
94
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
95
+ }
96
+ }
@@ -0,0 +1,94 @@
1
+ import { FakeAccessTokens } from '@test/support/fakes/fake-access-tokens'
2
+ import { FakeMailer } from '@test/support/fakes/fake-mailer' // prumo:email
3
+ import { FakeOneTimeCodes } from '@test/support/fakes/fake-one-time-codes' // prumo:email
4
+ import { FakeOpaqueTokens } from '@test/support/fakes/fake-opaque-tokens'
5
+ import { FakePasswordHasher } from '@test/support/fakes/fake-password-hasher'
6
+ import { ImmediateTransactionManager } from '@test/support/fakes/immediate-transaction-manager'
7
+ import { InMemoryEmailCodeRepository } from '@test/support/fakes/in-memory-email-code.repository' // prumo:email
8
+ import { InMemoryProfileRepository } from '@test/support/fakes/in-memory-profile.repository'
9
+ import { InMemorySessionRepository } from '@test/support/fakes/in-memory-session.repository'
10
+ import { InMemoryUserRepository } from '@test/support/fakes/in-memory-user.repository'
11
+ import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
12
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
13
+ import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
14
+ import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case' // prumo:email
15
+ import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case' // prumo:email
16
+ import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
17
+ import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
18
+ import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
19
+ import {
20
+ type SessionTokens,
21
+ StartSessionUseCase,
22
+ } from '@/domain/auth/use-cases/start-session.use-case'
23
+ import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case' // prumo:email
24
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
25
+
26
+ // Every auth use case wired to in-memory ports, sharing one set of them as the container would.
27
+ export function authFakes() {
28
+ const users = new InMemoryUserRepository()
29
+ const sessions = new InMemorySessionRepository()
30
+ const profiles = new InMemoryProfileRepository()
31
+ const hasher = new FakePasswordHasher()
32
+ const tokens = new FakeAccessTokens()
33
+ const opaque = new FakeOpaqueTokens()
34
+ const transactions = new ImmediateTransactionManager()
35
+ const startSession = new StartSessionUseCase(sessions, tokens, opaque)
36
+ // prumo:email
37
+ const mailer = new FakeMailer()
38
+ const codes = new InMemoryEmailCodeRepository()
39
+ const oneTimeCodes = new FakeOneTimeCodes()
40
+ const issueEmailCode = new IssueEmailCodeUseCase(codes, oneTimeCodes, mailer, transactions)
41
+ const verifyEmail = new VerifyEmailUseCase(users, codes, oneTimeCodes, transactions, startSession)
42
+ // prumo:end-email
43
+ const signUp = new SignUpUseCase(
44
+ users,
45
+ hasher,
46
+ transactions,
47
+ new CreateProfileUseCase(profiles),
48
+ startSession,
49
+ issueEmailCode, // prumo:email
50
+ )
51
+
52
+ // Signs up and, where the email must be confirmed first, confirms it: a session either way.
53
+ async function register(request: SignUpDto): Promise<SessionTokens> {
54
+ const started = await signUp.execute(request)
55
+
56
+ // prumo:email
57
+ if (started === null) {
58
+ return verifyEmail.execute({ email: request.email, code: mailer.lastCode(request.email) })
59
+ }
60
+
61
+ // prumo:end-email
62
+ if (started === null) {
63
+ throw new Error('Sign-up opened no session')
64
+ }
65
+
66
+ return started
67
+ }
68
+
69
+ return {
70
+ users,
71
+ sessions,
72
+ profiles,
73
+ hasher,
74
+ signUp,
75
+ register,
76
+ signIn: new SignInUseCase(users, hasher, startSession),
77
+ refresh: new RefreshSessionUseCase(sessions, tokens, opaque, transactions),
78
+ signOut: new SignOutUseCase(sessions),
79
+ // prumo:email
80
+ mailer,
81
+ verifyEmail,
82
+ requestPasswordReset: new RequestPasswordResetUseCase(users, issueEmailCode),
83
+ resetPassword: new ResetPasswordUseCase(
84
+ users,
85
+ codes,
86
+ sessions,
87
+ oneTimeCodes,
88
+ hasher,
89
+ transactions,
90
+ startSession,
91
+ ),
92
+ // prumo:end-email
93
+ }
94
+ }
@@ -0,0 +1,19 @@
1
+ import type {
2
+ AccessClaims,
3
+ AccessTokens,
4
+ IssuedToken,
5
+ } from '@/domain/auth/ports/access-tokens.port'
6
+
7
+ export class FakeAccessTokens implements AccessTokens {
8
+ async issue({ userId, sessionId }: AccessClaims, expiresAt: Date): Promise<IssuedToken> {
9
+ return { token: `access:${userId}:${sessionId}`, expiresAt }
10
+ }
11
+
12
+ async verify(token: string): Promise<AccessClaims | null> {
13
+ const [kind, userId, sessionId] = token.split(':')
14
+
15
+ return kind === 'access' && userId !== undefined && sessionId !== undefined
16
+ ? { userId, sessionId }
17
+ : null
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
2
+
3
+ // Keeps what would have been sent, so a test reads the code the way a person reads their inbox.
4
+ export class FakeMailer implements Mailer {
5
+ readonly sent: Mail[] = []
6
+
7
+ async send(mail: Mail): Promise<void> {
8
+ this.sent.push(mail)
9
+ }
10
+
11
+ lastCode(to: string): string {
12
+ const mail = this.sent.findLast((each) => each.to === to)
13
+ const code = mail === undefined ? undefined : /\b\d{6}\b/.exec(mail.text)?.[0]
14
+
15
+ if (code === undefined) {
16
+ throw new Error(`No code was sent to ${to}`)
17
+ }
18
+
19
+ return code
20
+ }
21
+ }
@@ -0,0 +1,14 @@
1
+ import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
2
+
3
+ export class FakeOneTimeCodes implements OneTimeCodes {
4
+ private next = 100000
5
+
6
+ create(): string {
7
+ this.next += 1
8
+ return String(this.next)
9
+ }
10
+
11
+ digest(code: string): string {
12
+ return `digest:${code}`
13
+ }
14
+ }
@@ -0,0 +1,14 @@
1
+ import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
2
+
3
+ export class FakeOpaqueTokens implements OpaqueTokens {
4
+ private next = 0
5
+
6
+ create(): string {
7
+ this.next += 1
8
+ return `secret-${this.next}`
9
+ }
10
+
11
+ digest(token: string): string {
12
+ return `digest:${token}`
13
+ }
14
+ }
@@ -0,0 +1,15 @@
1
+ import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
2
+
3
+ // Reversible on purpose, so a test can read what was stored. Argon2 itself is proved in its adapter's test.
4
+ export class FakePasswordHasher implements PasswordHasher {
5
+ verifications = 0
6
+
7
+ async hash(password: string): Promise<string> {
8
+ return `hashed:${password}`
9
+ }
10
+
11
+ async verify(hash: string | undefined, password: string): Promise<boolean> {
12
+ this.verifications += 1
13
+ return hash === `hashed:${password}`
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { TransactionManager } from '@/domain/shared/transactions/transaction-manager'
2
+
3
+ // Runs the work and nothing else: atomicity is the adapter's to prove, against a real database.
4
+ export class ImmediateTransactionManager implements TransactionManager {
5
+ run<T>(work: () => Promise<T>): Promise<T> {
6
+ return work()
7
+ }
8
+ }
@@ -0,0 +1,15 @@
1
+ import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
2
+ import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
3
+
4
+ export class InMemoryEmailCodeRepository implements EmailCodeRepository {
5
+ readonly rows = new Map<string, EmailCode>()
6
+
7
+ async lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
8
+ return this.rows.get(`${userId}:${purpose}`) ?? null
9
+ }
10
+
11
+ async save(code: EmailCode): Promise<void> {
12
+ code.id ??= crypto.randomUUID()
13
+ this.rows.set(`${code.userId}:${code.purpose}`, code)
14
+ }
15
+ }
@@ -0,0 +1,27 @@
1
+ import type { Session } from '@/domain/auth/entities/session.entity'
2
+ import type { SessionRepository } from '@/domain/auth/repositories/session.repository'
3
+
4
+ export class InMemorySessionRepository implements SessionRepository {
5
+ readonly rows = new Map<string, Session>()
6
+
7
+ async findById(id: string): Promise<Session | null> {
8
+ return this.rows.get(id) ?? null
9
+ }
10
+
11
+ lockById(id: string): Promise<Session | null> {
12
+ return this.findById(id)
13
+ }
14
+
15
+ async save(session: Session): Promise<void> {
16
+ session.id ??= crypto.randomUUID()
17
+ this.rows.set(session.id, session)
18
+ }
19
+
20
+ async revokeAllFor(userId: string, now: Date): Promise<void> {
21
+ for (const session of this.rows.values()) {
22
+ if (session.userId === userId) {
23
+ session.revoke(now)
24
+ }
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,20 @@
1
+ import type { User } from '@/domain/auth/entities/user.entity'
2
+ import type { UserRepository } from '@/domain/auth/repositories/user.repository'
3
+
4
+ export class InMemoryUserRepository implements UserRepository {
5
+ readonly rows = new Map<string, User>()
6
+
7
+ async findById(id: string): Promise<User | null> {
8
+ return this.rows.get(id) ?? null
9
+ }
10
+
11
+ async findByEmail(email: string): Promise<User | null> {
12
+ return [...this.rows.values()].find((user) => user.email === email) ?? null
13
+ }
14
+
15
+ async save(user: User): Promise<void> {
16
+ // The database assigns the id; here the first save does.
17
+ user.id ??= crypto.randomUUID()
18
+ this.rows.set(user.id, user)
19
+ }
20
+ }