@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,73 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { ResetPasswordDto } from '@/domain/auth/dto/reset-password.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
5
+ import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
6
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
7
+ import {
8
+ EMAIL_CODE_REPOSITORY,
9
+ type EmailCodeRepository,
10
+ } from '@/domain/auth/repositories/email-code.repository'
11
+ import {
12
+ SESSION_REPOSITORY,
13
+ type SessionRepository,
14
+ } from '@/domain/auth/repositories/session.repository'
15
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
16
+ import {
17
+ type SessionTokens,
18
+ StartSessionUseCase,
19
+ } from '@/domain/auth/use-cases/start-session.use-case'
20
+ import {
21
+ TRANSACTION_MANAGER,
22
+ type TransactionManager,
23
+ } from '@/domain/shared/transactions/transaction-manager'
24
+
25
+ @injectable()
26
+ export class ResetPasswordUseCase {
27
+ constructor(
28
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
29
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
30
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
31
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
32
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
33
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
34
+ private readonly startSession: StartSessionUseCase,
35
+ ) {}
36
+
37
+ async execute({ email, code, password }: ResetPasswordDto): Promise<SessionTokens> {
38
+ const passwordHash = await this.hasher.hash(password)
39
+
40
+ const reset = await this.transactions.run(async () => {
41
+ const now = new Date()
42
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
43
+ const stored = user === null ? null : await this.codes.lockFor(user.id, 'reset-password')
44
+
45
+ if (user === null || stored === null) {
46
+ return null
47
+ }
48
+
49
+ const outcome = stored.check(this.oneTimeCodes.digest(code), now)
50
+
51
+ await this.codes.save(stored)
52
+
53
+ if (outcome !== 'valid') {
54
+ return null
55
+ }
56
+
57
+ // The code arrived in the inbox, which is the proof verification asks for.
58
+ user.changePassword(passwordHash)
59
+ user.verifyEmail(now)
60
+ await this.users.save(user)
61
+ // Whoever knew the old password may still hold a session it opened.
62
+ await this.sessions.revokeAllFor(user.id, now)
63
+
64
+ return user
65
+ })
66
+
67
+ if (reset === null) {
68
+ throw new InvalidCodeError()
69
+ }
70
+
71
+ return this.startSession.execute(reset.id)
72
+ }
73
+ }
@@ -1,6 +1,7 @@
1
1
  import { inject, injectable } from 'tsyringe'
2
2
  import type { SignInDto } from '@/domain/auth/dto/sign-in.dto'
3
3
  import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error' // prumo:email
4
5
  import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
5
6
  import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
6
7
  import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
@@ -19,12 +20,19 @@ export class SignInUseCase {
19
20
 
20
21
  async execute({ email, password }: SignInDto): Promise<SessionTokens> {
21
22
  const user = await this.users.findByEmail(User.normalizeEmail(email))
22
- const valid = await this.hasher.verify(user?.passwordHash, password)
23
+ const valid = await this.hasher.verify(user?.passwordHash ?? undefined, password)
23
24
 
24
25
  if (user === null || !valid) {
25
26
  throw new InvalidCredentialsError()
26
27
  }
27
28
 
29
+ // prumo:email
30
+ // Asked only after the password matched, so the answer says nothing to someone who does not know it.
31
+ if (user.emailVerifiedAt === null) {
32
+ throw new EmailNotVerifiedError()
33
+ }
34
+
35
+ // prumo:end-email
28
36
  return this.startSession.execute(user.id)
29
37
  }
30
38
  }
@@ -4,6 +4,7 @@ import { User } from '@/domain/auth/entities/user.entity'
4
4
  import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
5
  import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
6
6
  import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
7
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case' // prumo:email
7
8
  import {
8
9
  type SessionTokens,
9
10
  StartSessionUseCase,
@@ -22,9 +23,11 @@ export class SignUpUseCase {
22
23
  @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
23
24
  private readonly createProfile: CreateProfileUseCase,
24
25
  private readonly startSession: StartSessionUseCase,
26
+ private readonly issueEmailCode: IssueEmailCodeUseCase, // prumo:email
25
27
  ) {}
26
28
 
27
- async execute({ name, email, password }: SignUpDto): Promise<SessionTokens> {
29
+ // Null when the account must confirm its email before it may sign in.
30
+ async execute({ name, email, password }: SignUpDto): Promise<SessionTokens | null> {
28
31
  const passwordHash = await this.hasher.hash(password)
29
32
 
30
33
  // The account and its profile exist together or not at all.
@@ -41,6 +44,14 @@ export class SignUpUseCase {
41
44
  return user
42
45
  })
43
46
 
47
+ // prumo:email
48
+ // Until the address is confirmed the account cannot sign in, so it opens no session either.
49
+ if (user.emailVerifiedAt === null) {
50
+ await this.issueEmailCode.execute(user, 'verify-email')
51
+ return null
52
+ }
53
+
54
+ // prumo:end-email
44
55
  return this.startSession.execute(user.id)
45
56
  }
46
57
  }
@@ -17,6 +17,8 @@ export type SessionTokens = {
17
17
  accessToken: IssuedToken
18
18
  refreshToken: string
19
19
  refreshExpiresAt: Date
20
+ // What an OAuth client's tokens may do; null for the application's own sessions, which may do everything.
21
+ scope: string | null
20
22
  }
21
23
 
22
24
  @injectable()
@@ -37,12 +39,17 @@ export class StartSessionUseCase {
37
39
  return {
38
40
  sessionId: session.id,
39
41
  accessToken: await this.tokens.issue(
40
- { userId, sessionId: session.id, ...(grant && { clientId: grant.clientId }) },
42
+ {
43
+ userId,
44
+ sessionId: session.id,
45
+ ...(grant && { clientId: grant.clientId, scope: grant.scope }),
46
+ },
41
47
  new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
42
48
  grant?.resource,
43
49
  ),
44
50
  refreshToken: session.refreshToken(secret),
45
51
  refreshExpiresAt: session.expiresAt,
52
+ scope: session.scope,
46
53
  }
47
54
  }
48
55
  }
@@ -0,0 +1,54 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { type SocialClient, SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.error'
4
+ import {
5
+ IDENTITY_PROVIDERS,
6
+ type IdentityProviders,
7
+ type ProviderName,
8
+ } from '@/domain/auth/ports/identity-providers.port'
9
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
10
+ import {
11
+ SOCIAL_SIGN_IN_REPOSITORY,
12
+ type SocialSignInRepository,
13
+ } from '@/domain/auth/repositories/social-sign-in.repository'
14
+
15
+ export type SocialStart = { provider: ProviderName; client: SocialClient; returnTo: string }
16
+
17
+ @injectable()
18
+ export class StartSocialSignInUseCase {
19
+ constructor(
20
+ @inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
21
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
22
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
23
+ ) {}
24
+
25
+ // Returns where to send the browser, and the token the browser keeps to prove, on the way back, that it started.
26
+ async execute({
27
+ provider,
28
+ client,
29
+ returnTo,
30
+ }: SocialStart): Promise<{ url: string; browserToken: string }> {
31
+ if (!this.providers.isConfigured(provider)) {
32
+ throw new ProviderNotConfiguredError(provider)
33
+ }
34
+
35
+ const secret = this.opaque.create()
36
+ const signIn = new SocialSignIn(
37
+ provider,
38
+ client,
39
+ returnTo,
40
+ this.opaque.digest(secret),
41
+ new Date(),
42
+ )
43
+
44
+ // Saved first: its id is the state the provider sends back.
45
+ await this.signIns.save(signIn)
46
+
47
+ const redirect = await this.providers.redirect(provider, signIn.id)
48
+
49
+ signIn.prepare(redirect.codeVerifier, redirect.nonce)
50
+ await this.signIns.save(signIn)
51
+
52
+ return { url: redirect.url, browserToken: signIn.token(secret) }
53
+ }
54
+ }
@@ -0,0 +1,62 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { VerifyEmailDto } from '@/domain/auth/dto/verify-email.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
5
+ import { ONE_TIME_CODES, type OneTimeCodes } from '@/domain/auth/ports/one-time-codes.port'
6
+ import {
7
+ EMAIL_CODE_REPOSITORY,
8
+ type EmailCodeRepository,
9
+ } from '@/domain/auth/repositories/email-code.repository'
10
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
11
+ import {
12
+ type SessionTokens,
13
+ StartSessionUseCase,
14
+ } from '@/domain/auth/use-cases/start-session.use-case'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+
20
+ @injectable()
21
+ export class VerifyEmailUseCase {
22
+ constructor(
23
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
24
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
25
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
26
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
27
+ private readonly startSession: StartSessionUseCase,
28
+ ) {}
29
+
30
+ // A confirmed address signs the user in: they proved the inbox and already chose a password.
31
+ async execute({ email, code }: VerifyEmailDto): Promise<SessionTokens> {
32
+ // The spent attempt is committed before the error is thrown, or a wrong guess would cost nothing.
33
+ const verified = await this.transactions.run(async () => {
34
+ const now = new Date()
35
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
36
+ const stored = user === null ? null : await this.codes.lockFor(user.id, 'verify-email')
37
+
38
+ if (user === null || stored === null) {
39
+ return null
40
+ }
41
+
42
+ const outcome = stored.check(this.oneTimeCodes.digest(code), now)
43
+
44
+ await this.codes.save(stored)
45
+
46
+ if (outcome !== 'valid') {
47
+ return null
48
+ }
49
+
50
+ user.verifyEmail(now)
51
+ await this.users.save(user)
52
+
53
+ return user
54
+ })
55
+
56
+ if (verified === null) {
57
+ throw new InvalidCodeError()
58
+ }
59
+
60
+ return this.startSession.execute(verified.id)
61
+ }
62
+ }
@@ -8,6 +8,7 @@ export const authorizationViewSchema = z.object({
8
8
  redirectHost: z.string(),
9
9
  // A localhost redirect cannot prove which program is listening there, so the page warns about it.
10
10
  redirectsToThisDevice: z.boolean(),
11
+ scopes: z.array(z.object({ scope: z.string(), description: z.string() })),
11
12
  })
12
13
 
13
14
  export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
@@ -13,6 +13,8 @@ export type AuthorizationRequest = {
13
13
  codeChallenge: string
14
14
  clientState: string | null
15
15
  resource: string
16
+ // The scopes the client asked for and the user is shown, space-separated.
17
+ scope: string
16
18
  }
17
19
 
18
20
  // One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
@@ -24,6 +26,7 @@ export class Authorization {
24
26
  codeChallenge: string
25
27
  clientState: string | null
26
28
  resource: string
29
+ scope: string
27
30
  status: AuthorizationStatus = 'pending'
28
31
  userId: string | null = null
29
32
  codeHash: string | null = null
@@ -39,6 +42,7 @@ export class Authorization {
39
42
  this.codeChallenge = request.codeChallenge
40
43
  this.clientState = request.clientState
41
44
  this.resource = request.resource
45
+ this.scope = request.scope
42
46
  this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
43
47
  }
44
48
 
@@ -6,7 +6,11 @@ export class AuthorizationRequestError extends DomainError {
6
6
  readonly kind = 'invalid'
7
7
 
8
8
  constructor(
9
- readonly code: 'invalid_request' | 'unsupported_response_type' | 'invalid_target',
9
+ readonly code:
10
+ | 'invalid_request'
11
+ | 'unsupported_response_type'
12
+ | 'invalid_target'
13
+ | 'invalid_scope',
10
14
  message: string,
11
15
  ) {
12
16
  super(message)
@@ -78,6 +78,7 @@ export class ExchangeCodeUseCase {
78
78
  const tokens = await this.startSession.execute(authorization.userId, {
79
79
  clientId: authorization.clientId,
80
80
  resource: authorization.resource,
81
+ scope: authorization.scope,
81
82
  })
82
83
 
83
84
  authorization.exchange(tokens.sessionId)
@@ -22,6 +22,7 @@ export type AuthorizeRequest = {
22
22
  codeChallengeMethod: string | undefined
23
23
  clientState: string | null
24
24
  resource: string | undefined
25
+ scope: string | undefined
25
26
  }
26
27
 
27
28
  @injectable()
@@ -32,7 +33,11 @@ export class StartAuthorizationUseCase {
32
33
  ) {}
33
34
 
34
35
  // The client and its redirect URI are checked first: until both are, no error may be sent anywhere.
35
- async execute(request: AuthorizeRequest, expectedResource: string): Promise<Authorization> {
36
+ async execute(
37
+ request: AuthorizeRequest,
38
+ expectedResource: string,
39
+ supportedScopes: readonly string[],
40
+ ): Promise<Authorization> {
36
41
  const client = await this.clients.read(request.clientId)
37
42
 
38
43
  // Exact match only: a prefix or a pattern is how an authorization code ends up somewhere else.
@@ -56,6 +61,14 @@ export class StartAuthorizationUseCase {
56
61
  throw new AuthorizationRequestError('invalid_target', `resource must be ${expectedResource}`)
57
62
  }
58
63
 
64
+ // A client that asks for nothing gets everything this server offers, and the consent page says so.
65
+ const requested = (request.scope ?? '').split(' ').filter(Boolean)
66
+ const unknown = requested.filter((scope) => !supportedScopes.includes(scope))
67
+
68
+ if (unknown.length > 0) {
69
+ throw new AuthorizationRequestError('invalid_scope', `Unknown scope: ${unknown.join(' ')}`)
70
+ }
71
+
59
72
  const authorization = new Authorization(
60
73
  {
61
74
  clientId: request.clientId,
@@ -64,6 +77,7 @@ export class StartAuthorizationUseCase {
64
77
  codeChallenge: request.codeChallenge as string,
65
78
  clientState: request.clientState,
66
79
  resource: expectedResource,
80
+ scope: (requested.length > 0 ? requested : supportedScopes).join(' '),
67
81
  },
68
82
  new Date(),
69
83
  )
@@ -2,4 +2,6 @@ export type DomainErrorKind = 'not_found' | 'conflict' | 'invalid' | 'forbidden'
2
2
 
3
3
  export abstract class DomainError extends Error {
4
4
  abstract readonly kind: DomainErrorKind
5
+ // A stable name a client can act on, when the status alone does not say what to do next.
6
+ readonly code?: string
5
7
  }
@@ -19,13 +19,14 @@ export class JoseAccessTokens implements AccessTokens {
19
19
  }
20
20
 
21
21
  async issue(
22
- { userId, sessionId, clientId }: AccessClaims,
22
+ { userId, sessionId, clientId, scope }: AccessClaims,
23
23
  expiresAt: Date,
24
24
  audience = this.issuer,
25
25
  ): Promise<IssuedToken> {
26
26
  const token = await new SignJWT({
27
27
  sid: sessionId,
28
28
  ...(clientId !== undefined && { client_id: clientId }),
29
+ ...(scope !== undefined && { scope }),
29
30
  })
30
31
  .setProtectedHeader({ alg: ALGORITHM })
31
32
  .setSubject(userId)
@@ -54,6 +55,7 @@ export class JoseAccessTokens implements AccessTokens {
54
55
  userId: payload.sub,
55
56
  sessionId: payload.sid,
56
57
  ...(typeof payload.client_id === 'string' && { clientId: payload.client_id }),
58
+ ...(typeof payload.scope === 'string' && { scope: payload.scope }),
57
59
  }
58
60
  } catch {
59
61
  return null
@@ -9,6 +9,24 @@ const envSchema = z.object({
9
9
  // Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
10
10
  API_URL: z.url(),
11
11
  WEB_ORIGIN: z.string().min(1),
12
+ // prumo:social
13
+ // Where a mobile app is sent back after signing in with a provider: its own scheme, as in app.json.
14
+ MOBILE_APP_SCHEME: z
15
+ .string()
16
+ .regex(/^[a-z][a-z0-9+.-]*$/)
17
+ .optional(),
18
+ // prumo:end-social
19
+ // prumo:google
20
+ GOOGLE_CLIENT_ID: z.string().min(1).optional(),
21
+ GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
22
+ // prumo:end-google
23
+ // prumo:apple
24
+ APPLE_CLIENT_ID: z.string().min(1).optional(),
25
+ APPLE_TEAM_ID: z.string().min(1).optional(),
26
+ APPLE_KEY_ID: z.string().min(1).optional(),
27
+ // The .p8 key's contents; on one line, with its line breaks written as \n.
28
+ APPLE_PRIVATE_KEY: z.string().min(1).optional(),
29
+ // prumo:end-apple
12
30
  LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
13
31
  })
14
32
 
@@ -11,6 +11,7 @@ export const AuthorizationSchema = new EntitySchema({
11
11
  codeChallenge: { type: 'text' },
12
12
  clientState: { type: 'text', nullable: true },
13
13
  resource: { type: 'text' },
14
+ scope: { type: 'text' },
14
15
  status: { type: 'text' },
15
16
  userId: {
16
17
  kind: 'm:1',
@@ -0,0 +1,30 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { EmailCode } from '@/domain/auth/entities/email-code.entity'
3
+
4
+ export const EmailCodeSchema = new EntitySchema({
5
+ class: EmailCode,
6
+ uniques: [{ properties: ['userId', 'purpose'] }],
7
+ properties: {
8
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
9
+ userId: {
10
+ kind: 'm:1',
11
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
12
+ entity: () => 'User' as never,
13
+ mapToPk: true,
14
+ fieldName: 'user_id',
15
+ // A code means nothing without its user.
16
+ deleteRule: 'cascade',
17
+ },
18
+ purpose: { type: 'text' },
19
+ codeHash: { type: 'text' },
20
+ attemptsLeft: { type: 'integer' },
21
+ expiresAt: { type: 'datetime' },
22
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
23
+ updatedAt: {
24
+ type: 'datetime',
25
+ onCreate: () => new Date(),
26
+ onUpdate: () => new Date(),
27
+ defaultRaw: 'now()',
28
+ },
29
+ },
30
+ })
@@ -0,0 +1,29 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Identity } from '@/domain/auth/entities/identity.entity'
3
+
4
+ export const IdentitySchema = new EntitySchema({
5
+ class: Identity,
6
+ uniques: [{ properties: ['provider', 'subject'] }],
7
+ properties: {
8
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
9
+ userId: {
10
+ kind: 'm:1',
11
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
12
+ entity: () => 'User' as never,
13
+ mapToPk: true,
14
+ fieldName: 'user_id',
15
+ index: true,
16
+ // An identity means nothing without its user.
17
+ deleteRule: 'cascade',
18
+ },
19
+ provider: { type: 'text' },
20
+ subject: { type: 'text' },
21
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
22
+ updatedAt: {
23
+ type: 'datetime',
24
+ onCreate: () => new Date(),
25
+ onUpdate: () => new Date(),
26
+ defaultRaw: 'now()',
27
+ },
28
+ },
29
+ })
@@ -18,6 +18,7 @@ export const SessionSchema = new EntitySchema({
18
18
  tokenHash: { type: 'text' },
19
19
  clientId: { type: 'text', nullable: true },
20
20
  resource: { type: 'text', nullable: true },
21
+ scope: { type: 'text', nullable: true },
21
22
  previousTokenHash: { type: 'text', nullable: true },
22
23
  rotatedAt: { type: 'datetime', nullable: true },
23
24
  expiresAt: { type: 'datetime' },
@@ -0,0 +1,35 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+
4
+ export const SocialSignInSchema = new EntitySchema({
5
+ class: SocialSignIn,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ provider: { type: 'text' },
9
+ client: { type: 'text' },
10
+ returnTo: { type: 'text' },
11
+ browserHash: { type: 'text' },
12
+ codeVerifier: { type: 'text', nullable: true },
13
+ nonce: { type: 'text' },
14
+ status: { type: 'text' },
15
+ userId: {
16
+ kind: 'm:1',
17
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
18
+ entity: () => 'User' as never,
19
+ mapToPk: true,
20
+ fieldName: 'user_id',
21
+ nullable: true,
22
+ index: true,
23
+ deleteRule: 'cascade',
24
+ },
25
+ exchangeHash: { type: 'text', nullable: true },
26
+ expiresAt: { type: 'datetime' },
27
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
28
+ updatedAt: {
29
+ type: 'datetime',
30
+ onCreate: () => new Date(),
31
+ onUpdate: () => new Date(),
32
+ defaultRaw: 'now()',
33
+ },
34
+ },
35
+ })
@@ -6,7 +6,8 @@ export const UserSchema = new EntitySchema({
6
6
  properties: {
7
7
  id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
8
  email: { type: 'text', unique: true },
9
- passwordHash: { type: 'text' },
9
+ passwordHash: { type: 'text', nullable: true },
10
+ emailVerifiedAt: { type: 'datetime', nullable: true },
10
11
  createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
11
12
  updatedAt: {
12
13
  type: 'datetime',
@@ -2,8 +2,11 @@ import { Migrator } from '@mikro-orm/migrations'
2
2
  import { defineConfig } from '@mikro-orm/postgresql'
3
3
  import type { Env } from '@/infra/config/env'
4
4
  import { AuthorizationSchema } from './entities/authorization.schema' // prumo:mcp
5
+ import { EmailCodeSchema } from './entities/email-code.schema' // prumo:email
6
+ import { IdentitySchema } from './entities/identity.schema' // prumo:social
5
7
  import { ProfileSchema } from './entities/profile.schema'
6
8
  import { SessionSchema } from './entities/session.schema'
9
+ import { SocialSignInSchema } from './entities/social-sign-in.schema' // prumo:social
7
10
  import { UserSchema } from './entities/user.schema'
8
11
 
9
12
  export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
@@ -14,6 +17,9 @@ export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
14
17
  ProfileSchema,
15
18
  SessionSchema,
16
19
  AuthorizationSchema, // prumo:mcp
20
+ EmailCodeSchema, // prumo:email
21
+ IdentitySchema, // prumo:social
22
+ SocialSignInSchema, // prumo:social
17
23
  ],
18
24
  extensions: [Migrator],
19
25
  migrations: { path: './migrations', snapshot: false },
@@ -0,0 +1,17 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
4
+ import type { EmailCodeRepository } from '@/domain/auth/repositories/email-code.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmEmailCodeRepository implements EmailCodeRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null> {
11
+ return this.em.findOne(EmailCode, { userId, purpose }, { lockMode: LockMode.PESSIMISTIC_WRITE })
12
+ }
13
+
14
+ async save(code: EmailCode): Promise<void> {
15
+ await this.em.persist(code).flush()
16
+ }
17
+ }
@@ -0,0 +1,18 @@
1
+ import { EntityManager } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Identity } from '@/domain/auth/entities/identity.entity'
4
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
5
+ import type { IdentityRepository } from '@/domain/auth/repositories/identity.repository'
6
+
7
+ @injectable()
8
+ export class MikroOrmIdentityRepository implements IdentityRepository {
9
+ constructor(private readonly em: EntityManager) {}
10
+
11
+ findBySubject(provider: ProviderName, subject: string): Promise<Identity | null> {
12
+ return this.em.findOne(Identity, { provider, subject })
13
+ }
14
+
15
+ async save(identity: Identity): Promise<void> {
16
+ await this.em.persist(identity).flush()
17
+ }
18
+ }
@@ -18,4 +18,8 @@ export class MikroOrmSessionRepository implements SessionRepository {
18
18
  async save(session: Session): Promise<void> {
19
19
  await this.em.persist(session).flush()
20
20
  }
21
+
22
+ async revokeAllFor(userId: string, now: Date): Promise<void> {
23
+ await this.em.nativeUpdate(Session, { userId, revokedAt: null }, { revokedAt: now })
24
+ }
21
25
  }
@@ -0,0 +1,17 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
4
+ import type { SocialSignInRepository } from '@/domain/auth/repositories/social-sign-in.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmSocialSignInRepository implements SocialSignInRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ lockById(id: string): Promise<SocialSignIn | null> {
11
+ return this.em.findOne(SocialSignIn, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
12
+ }
13
+
14
+ async save(signIn: SocialSignIn): Promise<void> {
15
+ await this.em.persist(signIn).flush()
16
+ }
17
+ }