@stjoseph/prumo 0.3.0 → 0.4.1

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 (155) hide show
  1. package/.prumo-templates/api/auth.md +3 -2
  2. package/.prumo-templates/core/testing.md +4 -3
  3. package/.prumo-templates/email/codes.md +62 -0
  4. package/.prumo-templates/email/mailer.md +47 -0
  5. package/.prumo-templates/mcp/server.md +19 -5
  6. package/.prumo-templates/monorepo/tasks.md +1 -1
  7. package/.prumo-templates/social/sign-in.md +77 -0
  8. package/README.md +9 -7
  9. package/dist/asker.js +13 -0
  10. package/dist/cli.js +25 -2
  11. package/dist/commands.js +7 -0
  12. package/dist/compose.js +6 -3
  13. package/dist/context.js +4 -0
  14. package/dist/features.js +234 -0
  15. package/dist/generate.js +17 -3
  16. package/dist/next-steps.js +24 -0
  17. package/dist/questions.js +64 -1
  18. package/package.json +1 -1
  19. package/templates/api/.env.example +18 -0
  20. package/templates/api/README.md +19 -0
  21. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  22. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  23. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  24. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  25. package/templates/api/package.json +1 -0
  26. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  27. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  28. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  29. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  30. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  31. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  32. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  33. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  34. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  36. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  37. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  39. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  40. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  41. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  42. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  43. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  46. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  47. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  48. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  49. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  50. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  52. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  53. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  54. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  55. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  56. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  57. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  58. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  59. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  60. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  61. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  62. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  63. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  65. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  66. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  67. package/templates/api/src/infra/config/env.ts +18 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  73. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  74. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  77. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  78. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  79. package/templates/api/src/infra/di/email.di.ts +23 -0
  80. package/templates/api/src/infra/di/index.ts +4 -0
  81. package/templates/api/src/infra/di/social.di.ts +17 -0
  82. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  83. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  84. package/templates/api/src/infra/http/app.ts +4 -0
  85. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  89. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  90. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  91. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  92. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  93. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  94. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  95. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  96. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  97. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  98. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  99. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  100. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  101. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  102. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  103. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  104. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  105. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  106. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  107. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  108. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  109. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  110. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  111. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  112. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  114. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  115. package/templates/api/test/support/test-app.ts +65 -0
  116. package/templates/mobile/README.md +16 -3
  117. package/templates/mobile/package.json +3 -2
  118. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  119. package/templates/mobile/src/api-contract/auth.ts +63 -3
  120. package/templates/mobile/src/api-contract/index.ts +4 -0
  121. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  122. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  125. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  126. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  127. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  128. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  129. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  130. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  131. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  132. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  133. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  134. package/templates/web/README.md +5 -0
  135. package/templates/web/src/api-contract/api-error.ts +4 -0
  136. package/templates/web/src/api-contract/auth.ts +63 -3
  137. package/templates/web/src/api-contract/index.ts +4 -0
  138. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  139. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  141. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  142. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  143. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  144. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  145. package/templates/web/src/routeTree.gen.ts +73 -2
  146. package/templates/web/src/routes/forgot-password.tsx +17 -0
  147. package/templates/web/src/routes/reset-password.tsx +20 -0
  148. package/templates/web/src/routes/sign-in.tsx +10 -3
  149. package/templates/web/src/routes/sign-up.tsx +4 -0
  150. package/templates/web/src/routes/verify-email.tsx +20 -0
  151. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  152. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  153. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  154. package/templates/workspace/README.md +10 -0
  155. package/dist/mcp.js +0 -107
@@ -0,0 +1,23 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { MAILER } from '@/domain/auth/ports/mailer.port'
3
+ import { ONE_TIME_CODES } from '@/domain/auth/ports/one-time-codes.port'
4
+ import { EMAIL_CODE_REPOSITORY } from '@/domain/auth/repositories/email-code.repository'
5
+ import { ENV, type Env } from '@/infra/config/env'
6
+ import { MikroOrmEmailCodeRepository } from '@/infra/database/mikroorm/repositories/mikroorm-email-code.repository'
7
+ import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
8
+ import { LogMailer } from '@/infra/email/log-mailer.adapter'
9
+
10
+ export function registerEmail(container: DependencyContainer): void {
11
+ const env = container.resolve<Env>(ENV)
12
+
13
+ container.register(EMAIL_CODE_REPOSITORY, { useClass: MikroOrmEmailCodeRepository })
14
+ container.register(ONE_TIME_CODES, { useValue: new HmacOneTimeCodes(env) })
15
+ // Silent under test, where a test registers its own Mailer to read what was sent.
16
+ container.register(MAILER, {
17
+ useValue: new LogMailer((line) => {
18
+ if (env.NODE_ENV !== 'test') {
19
+ console.info(line)
20
+ }
21
+ }),
22
+ })
23
+ }
@@ -4,7 +4,9 @@ import { TRANSACTION_MANAGER } from '@/domain/shared/transactions/transaction-ma
4
4
  import { ENV, type Env } from '@/infra/config/env'
5
5
  import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
6
6
  import { registerAuth } from './auth.di'
7
+ import { registerEmail } from './email.di' // prumo:email
7
8
  import { registerOauth } from './oauth.di' // prumo:mcp
9
+ import { registerSocial } from './social.di' // prumo:social
8
10
  import { registerUsers } from './users.di'
9
11
 
10
12
  export type Dependencies = { env: Env; orm: MikroORM }
@@ -21,6 +23,8 @@ export function createContainer({ env, orm }: Dependencies): DependencyContainer
21
23
  registerAuth(container)
22
24
  registerUsers(container)
23
25
  registerOauth(container) // prumo:mcp
26
+ registerEmail(container) // prumo:email
27
+ registerSocial(container) // prumo:social
24
28
 
25
29
  return container
26
30
  }
@@ -0,0 +1,17 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { IDENTITY_PROVIDERS } from '@/domain/auth/ports/identity-providers.port'
3
+ import { IDENTITY_REPOSITORY } from '@/domain/auth/repositories/identity.repository'
4
+ import { SOCIAL_SIGN_IN_REPOSITORY } from '@/domain/auth/repositories/social-sign-in.repository'
5
+ import { ENV, type Env } from '@/infra/config/env'
6
+ import { MikroOrmIdentityRepository } from '@/infra/database/mikroorm/repositories/mikroorm-identity.repository'
7
+ import { MikroOrmSocialSignInRepository } from '@/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository'
8
+ import { OpenIdIdentityProviders } from '@/infra/social/openid-identity-providers.adapter'
9
+
10
+ export function registerSocial(container: DependencyContainer): void {
11
+ container.register(IDENTITY_REPOSITORY, { useClass: MikroOrmIdentityRepository })
12
+ container.register(SOCIAL_SIGN_IN_REPOSITORY, { useClass: MikroOrmSocialSignInRepository })
13
+ // One instance, so each provider's discovery document is fetched once.
14
+ container.register(IDENTITY_PROVIDERS, {
15
+ useValue: new OpenIdIdentityProviders(container.resolve<Env>(ENV)),
16
+ })
17
+ }
@@ -0,0 +1,16 @@
1
+ import { createHmac, randomInt } from 'node:crypto'
2
+ import type { OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
3
+ import type { Env } from '@/infra/config/env'
4
+
5
+ export class HmacOneTimeCodes implements OneTimeCodes {
6
+ constructor(private readonly env: Pick<Env, 'JWT_SECRET'>) {}
7
+
8
+ create(): string {
9
+ return randomInt(0, 1_000_000).toString().padStart(6, '0')
10
+ }
11
+
12
+ // A million codes hash in a moment, so the hash is keyed by a secret the database does not hold.
13
+ digest(code: string): string {
14
+ return createHmac('sha256', this.env.JWT_SECRET).update(code).digest('base64url')
15
+ }
16
+ }
@@ -0,0 +1,11 @@
1
+ import type { Mail, Mailer } from '@/domain/auth/ports/mailer.port'
2
+
3
+ // Writes each mail to the log instead of sending it: enough to develop against, and nothing to sign up for. A
4
+ // project that sends real mail gives the Mailer port another adapter; see .prumo/email/.
5
+ export class LogMailer implements Mailer {
6
+ constructor(private readonly write: (line: string) => void) {}
7
+
8
+ async send(mail: Mail): Promise<void> {
9
+ this.write(`[mail] to ${mail.to}: ${mail.subject}\n${mail.text}`)
10
+ }
11
+ }
@@ -14,9 +14,11 @@ import {
14
14
  import type { DependencyContainer } from 'tsyringe'
15
15
  import { ENV, type Env } from '@/infra/config/env'
16
16
  import { authController } from '@/infra/http/controllers/auth.controller'
17
+ import { authEmailController } from '@/infra/http/controllers/auth-email.controller' // prumo:email
17
18
  import { healthController } from '@/infra/http/controllers/health.controller'
18
19
  import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
19
20
  import { oauthController } from '@/infra/http/controllers/oauth.controller' // prumo:mcp
21
+ import { socialController } from '@/infra/http/controllers/social.controller' // prumo:social
20
22
  import { usersController } from '@/infra/http/controllers/users.controller'
21
23
  import { registerErrorHandler } from '@/infra/http/errors/error-handler'
22
24
  import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
@@ -98,6 +100,8 @@ export async function buildApp(container: DependencyContainer): Promise<FastifyI
98
100
  registerAuthHook(app)
99
101
 
100
102
  await app.register(authController, { prefix: '/api/auth' })
103
+ await app.register(authEmailController, { prefix: '/api/auth' }) // prumo:email
104
+ await app.register(socialController, { prefix: '/api/auth/social' }) // prumo:social
101
105
  await app.register(healthController, { prefix: '/api/health' })
102
106
  await app.register(usersController, { prefix: '/api/v1/users' })
103
107
  // prumo:mcp
@@ -0,0 +1,73 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { emailRequestSchema } from '@/domain/auth/dto/email-request.dto'
4
+ import { resetPasswordSchema } from '@/domain/auth/dto/reset-password.dto'
5
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
6
+ import { verifyEmailSchema } from '@/domain/auth/dto/verify-email.dto'
7
+ import { RequestEmailVerificationUseCase } from '@/domain/auth/use-cases/request-email-verification.use-case'
8
+ import { RequestPasswordResetUseCase } from '@/domain/auth/use-cases/request-password-reset.use-case'
9
+ import { ResetPasswordUseCase } from '@/domain/auth/use-cases/reset-password.use-case'
10
+ import { VerifyEmailUseCase } from '@/domain/auth/use-cases/verify-email.use-case'
11
+ import { ENV, type Env } from '@/infra/config/env'
12
+ import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
13
+ import { sessionDelivery } from '@/infra/http/session-delivery'
14
+
15
+ // Every route answers alike whether or not the email has an account: none of them can be used to find out.
16
+ export async function authEmailController(fastify: FastifyInstance): Promise<void> {
17
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
18
+ const { deliver } = sessionDelivery(app.container.resolve<Env>(ENV))
19
+
20
+ app.post(
21
+ '/email/verification',
22
+ { config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
23
+ async (request, reply) => {
24
+ await app.container.resolve(RequestEmailVerificationUseCase).execute(request.body.email)
25
+
26
+ return reply.code(204).send()
27
+ },
28
+ )
29
+
30
+ app.post(
31
+ '/email/verify',
32
+ {
33
+ config: { public: true, ...ATTEMPTS },
34
+ schema: { tags: ['auth'], body: verifyEmailSchema, response: { 200: tokensResponseSchema } },
35
+ },
36
+ async (request, reply) =>
37
+ deliver(
38
+ request,
39
+ reply,
40
+ await app.container.resolve(VerifyEmailUseCase).execute(request.body),
41
+ 200,
42
+ ),
43
+ )
44
+
45
+ app.post(
46
+ '/password/forgot',
47
+ { config: { public: true, ...ATTEMPTS }, schema: { tags: ['auth'], body: emailRequestSchema } },
48
+ async (request, reply) => {
49
+ await app.container.resolve(RequestPasswordResetUseCase).execute(request.body.email)
50
+
51
+ return reply.code(204).send()
52
+ },
53
+ )
54
+
55
+ app.post(
56
+ '/password/reset',
57
+ {
58
+ config: { public: true, ...ATTEMPTS },
59
+ schema: {
60
+ tags: ['auth'],
61
+ body: resetPasswordSchema,
62
+ response: { 200: tokensResponseSchema },
63
+ },
64
+ },
65
+ async (request, reply) =>
66
+ deliver(
67
+ request,
68
+ reply,
69
+ await app.container.resolve(ResetPasswordUseCase).execute(request.body),
70
+ 200,
71
+ ),
72
+ )
73
+ }
@@ -1,95 +1,51 @@
1
- import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
1
+ import type { FastifyInstance } from 'fastify'
2
2
  import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { z } from 'zod'
3
4
  import { refreshSchema } from '@/domain/auth/dto/refresh.dto'
4
5
  import { sessionResponseSchema } from '@/domain/auth/dto/session-response.dto'
5
6
  import { signInSchema } from '@/domain/auth/dto/sign-in.dto'
6
7
  import { signUpSchema } from '@/domain/auth/dto/sign-up.dto'
7
- import { type TokensResponseDto, tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
8
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
8
9
  import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
9
10
  import { FindUserUseCase } from '@/domain/auth/use-cases/find-user.use-case'
10
11
  import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
11
12
  import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
12
13
  import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
13
14
  import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
14
- import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
15
15
  import { ENV, type Env } from '@/infra/config/env'
16
- import { ACCESS_COOKIE, currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
17
-
18
- const REFRESH_COOKIE = 'refresh_token'
19
-
20
- // The refresh cookie travels only to the one route that reads it, never with an ordinary request.
21
- const REFRESH_PATH = '/api/auth/refresh'
16
+ import { currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
17
+ import { REFRESH_COOKIE, sessionDelivery, wantsBearer } from '@/infra/http/session-delivery'
22
18
 
23
19
  // Repeated guessing is slowed down per address; a refresh happens every quarter hour per tab, so it gets more room.
24
- const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
20
+ export const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
25
21
  const REFRESHES = { rateLimit: { max: 60, timeWindow: '1 minute' } }
26
22
 
27
- // A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
28
- // injected script can take.
29
- function wantsBearer(request: FastifyRequest): boolean {
30
- return request.headers['x-auth-transport'] === 'bearer'
31
- }
32
-
33
- function asBody(tokens: SessionTokens): TokensResponseDto {
34
- return {
35
- accessToken: tokens.accessToken.token,
36
- refreshToken: tokens.refreshToken,
37
- expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
38
- }
39
- }
23
+ const verificationRequiredSchema = z.object({ verificationRequired: z.literal(true) })
40
24
 
41
25
  export async function authController(fastify: FastifyInstance): Promise<void> {
42
26
  const app = fastify.withTypeProvider<ZodTypeProvider>()
43
- const env = app.container.resolve<Env>(ENV)
44
- const secure = env.API_URL.startsWith('https://')
45
- const cookie = { httpOnly: true, sameSite: 'lax', secure } as const
46
-
47
- function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
48
- reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
49
- ...cookie,
50
- path: '/',
51
- expires: tokens.accessToken.expiresAt,
52
- })
53
- reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
54
- ...cookie,
55
- path: REFRESH_PATH,
56
- expires: tokens.refreshExpiresAt,
57
- })
58
- }
59
-
60
- function clearCookies(reply: FastifyReply): void {
61
- reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
62
- reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
63
- }
64
-
65
- // The web gets cookies and an empty body; a native client gets the tokens and no cookie.
66
- function deliver(
67
- request: FastifyRequest,
68
- reply: FastifyReply,
69
- tokens: SessionTokens,
70
- status: number,
71
- ) {
72
- if (wantsBearer(request)) {
73
- return reply.code(status).send(asBody(tokens))
74
- }
75
-
76
- setCookies(reply, tokens)
77
- return reply.code(status === 201 ? 201 : 204).send()
78
- }
27
+ const { deliver, clearCookies } = sessionDelivery(app.container.resolve<Env>(ENV))
79
28
 
80
29
  app.post(
81
30
  '/sign-up',
82
31
  {
83
32
  config: { public: true, ...ATTEMPTS },
84
- schema: { tags: ['auth'], body: signUpSchema, response: { 201: tokensResponseSchema } },
33
+ schema: {
34
+ tags: ['auth'],
35
+ body: signUpSchema,
36
+ response: { 201: z.union([tokensResponseSchema, verificationRequiredSchema]) },
37
+ },
38
+ },
39
+ async (request, reply) => {
40
+ const tokens = await app.container.resolve(SignUpUseCase).execute(request.body)
41
+
42
+ // No session until the address is confirmed: the client asks for the code next.
43
+ if (tokens === null) {
44
+ return reply.code(201).send({ verificationRequired: true })
45
+ }
46
+
47
+ return deliver(request, reply, tokens, 201)
85
48
  },
86
- async (request, reply) =>
87
- deliver(
88
- request,
89
- reply,
90
- await app.container.resolve(SignUpUseCase).execute(request.body),
91
- 201,
92
- ),
93
49
  )
94
50
 
95
51
  app.post(
@@ -4,6 +4,7 @@ import { ACCESS_TOKENS, type AccessTokens } from '@/domain/auth/ports/access-tok
4
4
  import { ENV, type Env } from '@/infra/config/env'
5
5
  import { HttpError } from '@/infra/http/errors/http-error'
6
6
  import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
7
+ import { missingScopes, SCOPES } from '@/infra/mcp/mcp.scopes'
7
8
  import { createMcpServerHandler, mcpResource, mcpResourceMetadataUrl } from '@/infra/mcp/mcp.server'
8
9
 
9
10
  // The route answers an OAuth access token issued for it, never a session cookie or a first-party token: the
@@ -24,18 +25,31 @@ export async function mcpController(fastify: FastifyInstance): Promise<void> {
24
25
  const claims = token === undefined ? null : await tokens.verify(token, resource)
25
26
 
26
27
  if (token === undefined || claims === null || claims.clientId === undefined) {
27
- // RFC 9728: the 401 names the document that says where to obtain a token.
28
+ // RFC 9728: the 401 names the document that says where to obtain a token, and the scopes worth asking for.
28
29
  reply.header(
29
30
  'www-authenticate',
30
- `Bearer resource_metadata="${mcpResourceMetadataUrl(env)}"${token === undefined ? '' : ', error="invalid_token"'}`,
31
+ `Bearer resource_metadata="${mcpResourceMetadataUrl(env)}", scope="${Object.keys(SCOPES).join(' ')}"${token === undefined ? '' : ', error="invalid_token"'}`,
31
32
  )
32
33
  throw new HttpError(401, 'Unauthorized')
33
34
  }
34
35
 
36
+ const scopes = (claims.scope ?? '').split(' ').filter(Boolean)
37
+ const missing = missingScopes(request.body, scopes)
38
+
39
+ // Checked before the server runs the call: a tool the user did not grant is refused with the scopes it needs,
40
+ // so the client can ask the user for them and come back.
41
+ if (missing.length > 0) {
42
+ reply.header(
43
+ 'www-authenticate',
44
+ `Bearer error="insufficient_scope", scope="${missing.join(' ')}", resource_metadata="${mcpResourceMetadataUrl(env)}"`,
45
+ )
46
+ throw new HttpError(403, `This token lacks ${missing.join(' ')}`)
47
+ }
48
+
35
49
  const authInfo: AuthInfo = {
36
50
  token,
37
51
  clientId: claims.clientId,
38
- scopes: [],
52
+ scopes,
39
53
  extra: { userId: claims.userId },
40
54
  }
41
55
 
@@ -18,6 +18,7 @@ import { FindAuthorizationUseCase } from '@/domain/oauth/use-cases/find-authoriz
18
18
  import { StartAuthorizationUseCase } from '@/domain/oauth/use-cases/start-authorization.use-case'
19
19
  import { ENV, type Env } from '@/infra/config/env'
20
20
  import { currentUser } from '@/infra/http/hooks/auth.hook'
21
+ import { SCOPES } from '@/infra/mcp/mcp.scopes'
21
22
  import { mcpResource } from '@/infra/mcp/mcp.server'
22
23
 
23
24
  const PUBLIC = { public: true, rateLimit: { max: 30, timeWindow: '1 minute' } }
@@ -54,6 +55,7 @@ function tokenResponse(tokens: SessionTokens) {
54
55
  token_type: 'Bearer',
55
56
  expires_in: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
56
57
  refresh_token: tokens.refreshToken,
58
+ scope: tokens.scope ?? '',
57
59
  }
58
60
  }
59
61
 
@@ -72,10 +74,12 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
72
74
  (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
73
75
  )
74
76
 
77
+ const scopesSupported = Object.keys(SCOPES)
75
78
  const resourceMetadata = {
76
79
  resource,
77
80
  authorization_servers: [issuer],
78
81
  bearer_methods_supported: ['header'],
82
+ scopes_supported: scopesSupported,
79
83
  }
80
84
 
81
85
  // At the path of the resource first, then at the root: the two places an MCP client looks.
@@ -98,6 +102,7 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
98
102
  code_challenge_methods_supported: ['S256'],
99
103
  token_endpoint_auth_methods_supported: ['none'],
100
104
  client_id_metadata_document_supported: true,
105
+ scopes_supported: scopesSupported,
101
106
  authorization_response_iss_parameter_supported: true,
102
107
  }),
103
108
  )
@@ -118,8 +123,10 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
118
123
  codeChallengeMethod: query.code_challenge_method,
119
124
  clientState: query.state ?? null,
120
125
  resource: query.resource,
126
+ scope: query.scope,
121
127
  },
122
128
  resource,
129
+ scopesSupported,
123
130
  )
124
131
 
125
132
  // Signing in, if needed, and the consent itself happen on the web app.
@@ -162,6 +169,9 @@ export async function oauthController(fastify: FastifyInstance): Promise<void> {
162
169
  clientHost: new URL(authorization.clientId).host,
163
170
  redirectHost: redirect.host,
164
171
  redirectsToThisDevice: LOOPBACK.has(redirect.hostname),
172
+ scopes: authorization.scope
173
+ .split(' ')
174
+ .map((scope) => ({ scope, description: SCOPES[scope] ?? scope })),
165
175
  }
166
176
  },
167
177
  )
@@ -0,0 +1,192 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { z } from 'zod'
4
+ import { tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
5
+ import type { SocialClient } from '@/domain/auth/entities/social-sign-in.entity'
6
+ import { CompleteSocialSignInUseCase } from '@/domain/auth/use-cases/complete-social-sign-in.use-case'
7
+ import { ExchangeSocialCodeUseCase } from '@/domain/auth/use-cases/exchange-social-code.use-case'
8
+ import { StartSocialSignInUseCase } from '@/domain/auth/use-cases/start-social-sign-in.use-case'
9
+ import { DomainError } from '@/domain/shared/errors/domain-error'
10
+ import { ENV, type Env } from '@/infra/config/env'
11
+ import { ATTEMPTS } from '@/infra/http/controllers/auth.controller'
12
+ import { HttpError } from '@/infra/http/errors/http-error'
13
+ import { sessionDelivery } from '@/infra/http/session-delivery'
14
+
15
+ const BROWSER_COOKIE = 'social_sign_in'
16
+ const BROWSER_COOKIE_MAX_AGE_S = 10 * 60
17
+
18
+ const PROVIDERS = [
19
+ 'google', // prumo:google
20
+ 'apple', // prumo:apple
21
+ ] as const
22
+
23
+ const providerParams = z.object({ provider: z.enum(PROVIDERS) })
24
+
25
+ // Only a path on the web app: an absolute URL here would turn sign-in into an open redirect.
26
+ function safePath(path: string | undefined): string {
27
+ return path?.startsWith('/') && !path.startsWith('//') ? path : '/'
28
+ }
29
+
30
+ // Apple sends the name it shares once, as JSON beside the code.
31
+ function appleName(user: string | undefined): string | null {
32
+ try {
33
+ const name = (JSON.parse(user ?? '') as { name?: { firstName?: string; lastName?: string } })
34
+ .name
35
+ const full = [name?.firstName, name?.lastName].filter(Boolean).join(' ')
36
+
37
+ return full === '' ? null : full
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ export async function socialController(fastify: FastifyInstance): Promise<void> {
44
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
45
+ const env = app.container.resolve<Env>(ENV)
46
+ const { deliver, setCookies } = sessionDelivery(env)
47
+ const cookie = {
48
+ httpOnly: true,
49
+ sameSite: 'lax',
50
+ secure: env.API_URL.startsWith('https://'),
51
+ path: '/api/auth/social',
52
+ } as const
53
+
54
+ const back = (client: SocialClient, query: Record<string, string>) =>
55
+ client === 'mobile'
56
+ ? `${env.MOBILE_APP_SCHEME}://social?${new URLSearchParams(query)}`
57
+ : `${env.WEB_ORIGIN}/sign-in?${new URLSearchParams(query)}`
58
+
59
+ // Apple's answer is a form.
60
+ app.addContentTypeParser(
61
+ 'application/x-www-form-urlencoded',
62
+ { parseAs: 'string' },
63
+ (_request, body, done) => done(null, Object.fromEntries(new URLSearchParams(body as string))),
64
+ )
65
+
66
+ app.get(
67
+ '/:provider',
68
+ {
69
+ config: { public: true, ...ATTEMPTS },
70
+ schema: {
71
+ tags: ['auth'],
72
+ params: providerParams,
73
+ querystring: z.object({
74
+ client: z.enum(['web', 'mobile']).default('web'),
75
+ returnTo: z.string().max(512).optional(),
76
+ }),
77
+ },
78
+ },
79
+ async (request, reply) => {
80
+ const { client, returnTo } = request.query
81
+
82
+ if (client === 'mobile' && env.MOBILE_APP_SCHEME === undefined) {
83
+ throw new HttpError(
84
+ 422,
85
+ 'MOBILE_APP_SCHEME is not set, so a mobile app cannot be sent back',
86
+ )
87
+ }
88
+
89
+ const started = await app.container.resolve(StartSocialSignInUseCase).execute({
90
+ provider: request.params.provider,
91
+ client,
92
+ returnTo: safePath(returnTo),
93
+ })
94
+
95
+ // The client rides beside the token, so an error found before the flow is read still goes to the right place.
96
+ reply.setCookie(BROWSER_COOKIE, `${client}~${started.browserToken}`, {
97
+ ...cookie,
98
+ maxAge: BROWSER_COOKIE_MAX_AGE_S,
99
+ })
100
+
101
+ return reply.redirect(started.url)
102
+ },
103
+ )
104
+
105
+ // A cross-site POST carries no SameSite=Lax cookie, and a top-level GET does: Apple's form is turned into a
106
+ // redirect to the same address, so the browser's token comes with it.
107
+ app.post(
108
+ '/:provider/callback',
109
+ { config: { public: true }, schema: { hide: true, params: providerParams } },
110
+ async (request, reply) => {
111
+ const form = new URLSearchParams(request.body as Record<string, string>)
112
+
113
+ return reply
114
+ .code(303)
115
+ .redirect(`${env.API_URL}/api/auth/social/${request.params.provider}/callback?${form}`)
116
+ },
117
+ )
118
+
119
+ app.get(
120
+ '/:provider/callback',
121
+ {
122
+ config: { public: true },
123
+ schema: {
124
+ hide: true,
125
+ params: providerParams,
126
+ querystring: z.object({
127
+ state: z.string().optional(),
128
+ code: z.string().optional(),
129
+ error: z.string().optional(),
130
+ user: z.string().optional(),
131
+ }),
132
+ },
133
+ },
134
+ async (request, reply) => {
135
+ const [client, browserToken] = (request.cookies[BROWSER_COOKIE] ?? 'web~').split('~') as [
136
+ SocialClient,
137
+ string,
138
+ ]
139
+ const { state, code, error } = request.query
140
+
141
+ reply.clearCookie(BROWSER_COOKIE, cookie)
142
+
143
+ if (error !== undefined || state === undefined || code === undefined) {
144
+ return reply.redirect(back(client, { error: 'social_sign_in_failed' }))
145
+ }
146
+
147
+ try {
148
+ const outcome = await app.container.resolve(CompleteSocialSignInUseCase).execute({
149
+ provider: request.params.provider,
150
+ state,
151
+ browserToken: browserToken === '' ? undefined : browserToken,
152
+ callbackUrl: new URL(request.url, env.API_URL),
153
+ name: appleName(request.query.user),
154
+ })
155
+
156
+ if (outcome.client === 'mobile') {
157
+ return reply.redirect(back('mobile', { code: outcome.code }))
158
+ }
159
+
160
+ setCookies(reply, outcome.tokens)
161
+
162
+ return reply.redirect(`${env.WEB_ORIGIN}${outcome.returnTo}`)
163
+ } catch (caught) {
164
+ if (caught instanceof DomainError) {
165
+ return reply.redirect(back(client, { error: caught.code ?? 'social_sign_in_failed' }))
166
+ }
167
+
168
+ throw caught
169
+ }
170
+ },
171
+ )
172
+
173
+ // The mobile app trades the code it was sent back with for its tokens, once.
174
+ app.post(
175
+ '/exchange',
176
+ {
177
+ config: { public: true, ...ATTEMPTS },
178
+ schema: {
179
+ tags: ['auth'],
180
+ body: z.strictObject({ code: z.string().min(1).max(512) }),
181
+ response: { 200: tokensResponseSchema },
182
+ },
183
+ },
184
+ async (request, reply) =>
185
+ deliver(
186
+ request,
187
+ reply,
188
+ await app.container.resolve(ExchangeSocialCodeUseCase).execute(request.body.code),
189
+ 200,
190
+ ),
191
+ )
192
+ }
@@ -11,6 +11,7 @@ type ProblemDocument = {
11
11
  detail: string
12
12
  instance: string
13
13
  requestId: string
14
+ code?: string
14
15
  errors?: FieldErrors
15
16
  }
16
17
 
@@ -104,9 +105,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
104
105
  request.log.error(error)
105
106
  }
106
107
 
108
+ const code = error instanceof DomainError ? error.code : undefined
109
+
107
110
  return send(request, reply, {
108
111
  status,
109
112
  detail: detailOf(error, status, errors),
113
+ ...(code === undefined ? {} : { code }),
110
114
  ...(errors === undefined ? {} : { errors }),
111
115
  })
112
116
  })