@stjoseph/prumo 0.3.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 (148) 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/social/sign-in.md +77 -0
  7. package/README.md +6 -4
  8. package/dist/asker.js +13 -0
  9. package/dist/cli.js +16 -2
  10. package/dist/commands.js +7 -0
  11. package/dist/compose.js +3 -3
  12. package/dist/context.js +4 -0
  13. package/dist/features.js +230 -0
  14. package/dist/generate.js +17 -3
  15. package/dist/questions.js +64 -1
  16. package/package.json +1 -1
  17. package/templates/api/.env.example +18 -0
  18. package/templates/api/migrations/{Migration20261002210629_create_user_profile_session.ts → Migration20261005111545_create_user_profile_session.ts} +4 -4
  19. package/templates/api/migrations/{Migration20261002210631_create_authorization.ts → Migration20261005111547_create_authorization.ts} +3 -3
  20. package/templates/api/migrations/Migration20261005111549_create_email_code.ts +18 -0
  21. package/templates/api/migrations/Migration20261005111550_create_identity_social_sign_in.ts +25 -0
  22. package/templates/api/package.json +1 -0
  23. package/templates/api/src/domain/auth/dto/email-request.dto.ts +5 -0
  24. package/templates/api/src/domain/auth/dto/reset-password.dto.ts +9 -0
  25. package/templates/api/src/domain/auth/dto/verify-email.dto.ts +8 -0
  26. package/templates/api/src/domain/auth/entities/email-code.entity.ts +46 -0
  27. package/templates/api/src/domain/auth/entities/identity.entity.ts +18 -0
  28. package/templates/api/src/domain/auth/entities/session.entity.ts +5 -3
  29. package/templates/api/src/domain/auth/entities/social-sign-in.entity.ts +82 -0
  30. package/templates/api/src/domain/auth/entities/user.entity.ts +19 -2
  31. package/templates/api/src/domain/auth/errors/email-not-verified.error.ts +10 -0
  32. package/templates/api/src/domain/auth/errors/invalid-code.error.ts +11 -0
  33. package/templates/api/src/domain/auth/errors/provider-email-unverified.error.ts +11 -0
  34. package/templates/api/src/domain/auth/errors/provider-not-configured.error.ts +10 -0
  35. package/templates/api/src/domain/auth/errors/social-sign-in-failed.error.ts +11 -0
  36. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +1 -1
  37. package/templates/api/src/domain/auth/ports/identity-providers.port.ts +29 -0
  38. package/templates/api/src/domain/auth/ports/mailer.port.ts +8 -0
  39. package/templates/api/src/domain/auth/ports/one-time-codes.port.ts +7 -0
  40. package/templates/api/src/domain/auth/repositories/email-code.repository.ts +9 -0
  41. package/templates/api/src/domain/auth/repositories/identity.repository.ts +9 -0
  42. package/templates/api/src/domain/auth/repositories/session.repository.ts +2 -0
  43. package/templates/api/src/domain/auth/repositories/social-sign-in.repository.ts +9 -0
  44. package/templates/api/src/domain/auth/use-cases/complete-social-sign-in.use-case.ts +103 -0
  45. package/templates/api/src/domain/auth/use-cases/exchange-social-code.use-case.ts +56 -0
  46. package/templates/api/src/domain/auth/use-cases/issue-email-code.use-case.ts +55 -0
  47. package/templates/api/src/domain/auth/use-cases/link-identity.use-case.ts +77 -0
  48. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +2 -0
  49. package/templates/api/src/domain/auth/use-cases/request-email-verification.use-case.ts +21 -0
  50. package/templates/api/src/domain/auth/use-cases/request-password-reset.use-case.ts +21 -0
  51. package/templates/api/src/domain/auth/use-cases/reset-password.use-case.ts +73 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +9 -1
  53. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +12 -1
  54. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +8 -1
  55. package/templates/api/src/domain/auth/use-cases/start-social-sign-in.use-case.ts +54 -0
  56. package/templates/api/src/domain/auth/use-cases/verify-email.use-case.ts +62 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +1 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +4 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +5 -1
  60. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +1 -0
  61. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +15 -1
  62. package/templates/api/src/domain/shared/errors/domain-error.ts +2 -0
  63. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +3 -1
  64. package/templates/api/src/infra/config/env.ts +18 -0
  65. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +1 -0
  66. package/templates/api/src/infra/database/mikroorm/entities/email-code.schema.ts +30 -0
  67. package/templates/api/src/infra/database/mikroorm/entities/identity.schema.ts +29 -0
  68. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +1 -0
  69. package/templates/api/src/infra/database/mikroorm/entities/social-sign-in.schema.ts +35 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +2 -1
  71. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +6 -0
  72. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-email-code.repository.ts +17 -0
  73. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-identity.repository.ts +18 -0
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +4 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-social-sign-in.repository.ts +17 -0
  76. package/templates/api/src/infra/di/email.di.ts +23 -0
  77. package/templates/api/src/infra/di/index.ts +4 -0
  78. package/templates/api/src/infra/di/social.di.ts +17 -0
  79. package/templates/api/src/infra/email/hmac-one-time-codes.adapter.ts +16 -0
  80. package/templates/api/src/infra/email/log-mailer.adapter.ts +11 -0
  81. package/templates/api/src/infra/http/app.ts +4 -0
  82. package/templates/api/src/infra/http/controllers/auth-email.controller.ts +73 -0
  83. package/templates/api/src/infra/http/controllers/auth.controller.ts +23 -67
  84. package/templates/api/src/infra/http/controllers/mcp.controller.ts +17 -3
  85. package/templates/api/src/infra/http/controllers/oauth.controller.ts +10 -0
  86. package/templates/api/src/infra/http/controllers/social.controller.ts +192 -0
  87. package/templates/api/src/infra/http/errors/error-handler.ts +4 -0
  88. package/templates/api/src/infra/http/session-delivery.ts +69 -0
  89. package/templates/api/src/infra/mcp/mcp.scopes.ts +27 -0
  90. package/templates/api/src/infra/mcp/tools/users.tool.ts +6 -0
  91. package/templates/api/src/infra/social/openid-identity-providers.adapter.ts +199 -0
  92. package/templates/api/test/domain/auth/use-cases/email.use-case.spec.ts +87 -0
  93. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +8 -8
  94. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +4 -4
  95. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +13 -5
  96. package/templates/api/test/domain/auth/use-cases/social.use-case.spec.ts +148 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +1 -0
  98. package/templates/api/test/infra/email/hmac-one-time-codes.adapter.spec.ts +15 -0
  99. package/templates/api/test/infra/http/controllers/auth-email.controller.spec.ts +92 -0
  100. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +13 -46
  101. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +87 -23
  102. package/templates/api/test/infra/http/controllers/social.controller.spec.ts +108 -0
  103. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +12 -34
  104. package/templates/api/test/infra/social/openid-identity-providers.adapter.spec.ts +154 -0
  105. package/templates/api/test/support/fake-oidc-server.ts +96 -0
  106. package/templates/api/test/support/fakes/auth-fakes.ts +58 -6
  107. package/templates/api/test/support/fakes/fake-mailer.ts +21 -0
  108. package/templates/api/test/support/fakes/fake-one-time-codes.ts +14 -0
  109. package/templates/api/test/support/fakes/in-memory-email-code.repository.ts +15 -0
  110. package/templates/api/test/support/fakes/in-memory-session.repository.ts +8 -0
  111. package/templates/api/test/support/fakes/social-fakes.ts +112 -0
  112. package/templates/api/test/support/test-app.ts +65 -0
  113. package/templates/mobile/src/api-contract/api-error.ts +4 -0
  114. package/templates/mobile/src/api-contract/auth.ts +63 -3
  115. package/templates/mobile/src/api-contract/index.ts +4 -0
  116. package/templates/mobile/src/app/(auth)/forgot-password.tsx +20 -0
  117. package/templates/mobile/src/app/(auth)/reset-password.tsx +18 -0
  118. package/templates/mobile/src/app/(auth)/sign-in.tsx +9 -0
  119. package/templates/mobile/src/app/(auth)/sign-up.tsx +4 -0
  120. package/templates/mobile/src/app/(auth)/verify-email.tsx +18 -0
  121. package/templates/mobile/src/features/auth/forgot-password-form.tsx +67 -0
  122. package/templates/mobile/src/features/auth/reset-password-form.tsx +93 -0
  123. package/templates/mobile/src/features/auth/sign-in-form.tsx +10 -0
  124. package/templates/mobile/src/features/auth/sign-up-form.tsx +15 -3
  125. package/templates/mobile/src/features/auth/social-buttons.tsx +79 -0
  126. package/templates/mobile/src/features/auth/verify-email-form.tsx +81 -0
  127. package/templates/mobile/test/features/auth/email-flows.spec.tsx +86 -0
  128. package/templates/mobile/test/features/auth/social-buttons.spec.tsx +77 -0
  129. package/templates/web/src/api-contract/api-error.ts +4 -0
  130. package/templates/web/src/api-contract/auth.ts +63 -3
  131. package/templates/web/src/api-contract/index.ts +4 -0
  132. package/templates/web/src/features/auth/forgot-password-form.tsx +52 -0
  133. package/templates/web/src/features/auth/reset-password-form.tsx +82 -0
  134. package/templates/web/src/features/auth/sign-in-form.tsx +13 -0
  135. package/templates/web/src/features/auth/sign-up-form.tsx +13 -3
  136. package/templates/web/src/features/auth/social-buttons.tsx +45 -0
  137. package/templates/web/src/features/auth/verify-email-form.tsx +84 -0
  138. package/templates/web/src/features/oauth/consent-form.tsx +7 -1
  139. package/templates/web/src/routeTree.gen.ts +73 -2
  140. package/templates/web/src/routes/forgot-password.tsx +17 -0
  141. package/templates/web/src/routes/reset-password.tsx +20 -0
  142. package/templates/web/src/routes/sign-in.tsx +10 -3
  143. package/templates/web/src/routes/sign-up.tsx +4 -0
  144. package/templates/web/src/routes/verify-email.tsx +20 -0
  145. package/templates/web/test/features/auth/email-flows.spec.tsx +121 -0
  146. package/templates/web/test/features/auth/social-buttons.spec.tsx +35 -0
  147. package/templates/web/test/features/oauth/consent-form.spec.tsx +7 -0
  148. package/dist/mcp.js +0 -107
@@ -0,0 +1,82 @@
1
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
2
+
3
+ // Long enough to sign in at the provider; the code a mobile app exchanges afterwards is spent within two minutes.
4
+ export const SOCIAL_SIGN_IN_LIFETIME_MS = 10 * 60 * 1000
5
+ export const SOCIAL_EXCHANGE_LIFETIME_MS = 2 * 60 * 1000
6
+
7
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+
9
+ export type SocialClient = 'web' | 'mobile'
10
+
11
+ export type SocialSignInStatus = 'started' | 'signed-in' | 'exchanged'
12
+
13
+ // One trip to a provider and back. Its id travels as OAuth's state; the browser that started it holds a secret the
14
+ // callback must present, so a callback carrying someone else's code cannot sign this browser into their account.
15
+ export class SocialSignIn {
16
+ id: string
17
+ provider: ProviderName
18
+ client: SocialClient
19
+ returnTo: string
20
+ browserHash: string
21
+ codeVerifier: string | null = null
22
+ nonce = ''
23
+ status: SocialSignInStatus = 'started'
24
+ userId: string | null = null
25
+ exchangeHash: string | null = null
26
+ expiresAt: Date
27
+ createdAt: Date
28
+ updatedAt: Date
29
+
30
+ constructor(
31
+ provider: ProviderName,
32
+ client: SocialClient,
33
+ returnTo: string,
34
+ browserHash: string,
35
+ now: Date,
36
+ ) {
37
+ this.provider = provider
38
+ this.client = client
39
+ this.returnTo = returnTo
40
+ this.browserHash = browserHash
41
+ this.expiresAt = new Date(now.getTime() + SOCIAL_SIGN_IN_LIFETIME_MS)
42
+ }
43
+
44
+ // `<id>.<secret>`, as refresh tokens and authorization codes are written.
45
+ static parseToken(token: string): { id: string; secret: string } | null {
46
+ const dot = token.indexOf('.')
47
+ const id = token.slice(0, dot)
48
+ const secret = token.slice(dot + 1)
49
+
50
+ return dot === -1 || !UUID.test(id) || secret === '' ? null : { id, secret }
51
+ }
52
+
53
+ token(secret: string): string {
54
+ return `${this.id}.${secret}`
55
+ }
56
+
57
+ // What the provider's redirect requires back: the PKCE verifier, when the provider takes PKCE, and the nonce.
58
+ prepare(codeVerifier: string | null, nonce: string): void {
59
+ this.codeVerifier = codeVerifier
60
+ this.nonce = nonce
61
+ }
62
+
63
+ isStarted(now: Date): boolean {
64
+ return this.status === 'started' && this.expiresAt > now
65
+ }
66
+
67
+ // The web is signed in at once; a mobile app gets a code to exchange, so its tokens never travel in a URL.
68
+ signIn(userId: string, exchangeHash: string | null, now: Date): void {
69
+ this.status = 'signed-in'
70
+ this.userId = userId
71
+ this.exchangeHash = exchangeHash
72
+ this.expiresAt = new Date(now.getTime() + SOCIAL_EXCHANGE_LIFETIME_MS)
73
+ }
74
+
75
+ isExchangeable(now: Date): boolean {
76
+ return this.status === 'signed-in' && this.exchangeHash !== null && this.expiresAt > now
77
+ }
78
+
79
+ exchange(): void {
80
+ this.status = 'exchanged'
81
+ }
82
+ }
@@ -1,11 +1,13 @@
1
1
  export class User {
2
2
  id: string
3
3
  email: string
4
- passwordHash: string
4
+ // Null for an account that only ever signed in through a provider, or whose password a provider's proof voided.
5
+ passwordHash: string | null
6
+ emailVerifiedAt: Date | null = null
5
7
  createdAt: Date
6
8
  updatedAt: Date
7
9
 
8
- constructor(email: string, passwordHash: string) {
10
+ constructor(email: string, passwordHash: string | null) {
9
11
  this.email = User.normalizeEmail(email)
10
12
  this.passwordHash = passwordHash
11
13
  }
@@ -14,4 +16,19 @@ export class User {
14
16
  static normalizeEmail(email: string): string {
15
17
  return email.trim().toLowerCase()
16
18
  }
19
+
20
+ verifyEmail(now: Date): void {
21
+ this.emailVerifiedAt ??= now
22
+ }
23
+
24
+ changePassword(passwordHash: string): void {
25
+ this.passwordHash = passwordHash
26
+ }
27
+
28
+ // prumo:social
29
+ // A provider proved the address belongs to someone else than whoever chose this password.
30
+ removePassword(): void {
31
+ this.passwordHash = null
32
+ }
33
+ // prumo:end-social
17
34
  }
@@ -0,0 +1,10 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class EmailNotVerifiedError extends DomainError {
4
+ readonly kind = 'forbidden'
5
+ override readonly code = 'email_not_verified'
6
+
7
+ constructor() {
8
+ super('Confirm your email before signing in')
9
+ }
10
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // One answer for a wrong code, a dead one and an unknown email, so the form does not say which accounts exist.
4
+ export class InvalidCodeError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ override readonly code = 'invalid_code'
7
+
8
+ constructor() {
9
+ super('The code is wrong or has expired')
10
+ }
11
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // Without the provider's word that the address is theirs, an email cannot be trusted to name an account.
4
+ export class ProviderEmailUnverifiedError extends DomainError {
5
+ readonly kind = 'forbidden'
6
+ override readonly code = 'provider_email_unverified'
7
+
8
+ constructor() {
9
+ super('The provider has not verified this email address')
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class ProviderNotConfiguredError extends DomainError {
4
+ readonly kind = 'invalid'
5
+ override readonly code = 'provider_not_configured'
6
+
7
+ constructor(provider: string) {
8
+ super(`Sign-in with ${provider} is not configured: set its variables in .env`)
9
+ }
10
+ }
@@ -0,0 +1,11 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // Expired, already used, started in another browser, or refused by the provider: the user starts again.
4
+ export class SocialSignInFailedError extends DomainError {
5
+ readonly kind = 'unauthorized'
6
+ override readonly code = 'social_sign_in_failed'
7
+
8
+ constructor(message = 'The sign-in did not complete. Start again.') {
9
+ super(message)
10
+ }
11
+ }
@@ -1,4 +1,4 @@
1
- export type AccessClaims = { userId: string; sessionId: string; clientId?: string }
1
+ export type AccessClaims = { userId: string; sessionId: string; clientId?: string; scope?: string }
2
2
 
3
3
  export type IssuedToken = { token: string; expiresAt: Date }
4
4
 
@@ -0,0 +1,29 @@
1
+ export type ProviderName = 'google' | 'apple'
2
+
3
+ export type ProviderProfile = {
4
+ subject: string
5
+ email: string
6
+ emailVerified: boolean
7
+ name: string | null
8
+ }
9
+
10
+ export type ProviderRedirect = { url: string; codeVerifier: string | null; nonce: string }
11
+
12
+ export type ProviderCallback = {
13
+ callbackUrl: URL
14
+ state: string
15
+ codeVerifier: string | null
16
+ nonce: string
17
+ // Apple sends the user's name once, beside the code, and never inside the ID token.
18
+ name: string | null
19
+ }
20
+
21
+ // Google and Apple speak OpenID Connect: the adapter builds the provider's URL and, on the way back, validates the
22
+ // ID token (signature, issuer, audience, nonce) before anything here reads it.
23
+ export interface IdentityProviders {
24
+ isConfigured(provider: ProviderName): boolean
25
+ redirect(provider: ProviderName, state: string): Promise<ProviderRedirect>
26
+ profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile>
27
+ }
28
+
29
+ export const IDENTITY_PROVIDERS = Symbol('IdentityProviders')
@@ -0,0 +1,8 @@
1
+ export type Mail = { to: string; subject: string; text: string }
2
+
3
+ // The template writes mail to the log; a project that sends real mail replaces the adapter, not the callers.
4
+ export interface Mailer {
5
+ send(mail: Mail): Promise<void>
6
+ }
7
+
8
+ export const MAILER = Symbol('Mailer')
@@ -0,0 +1,7 @@
1
+ // Six digits a person can type. The digest is keyed, so a copy of the table alone cannot try the million codes.
2
+ export interface OneTimeCodes {
3
+ create(): string
4
+ digest(code: string): string
5
+ }
6
+
7
+ export const ONE_TIME_CODES = Symbol('OneTimeCodes')
@@ -0,0 +1,9 @@
1
+ import type { EmailCode, EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
2
+
3
+ export interface EmailCodeRepository {
4
+ // Reads the row and holds it until the surrounding transaction ends, so two guesses cannot both spend one attempt.
5
+ lockFor(userId: string, purpose: EmailCodePurpose): Promise<EmailCode | null>
6
+ save(code: EmailCode): Promise<void>
7
+ }
8
+
9
+ export const EMAIL_CODE_REPOSITORY = Symbol('EmailCodeRepository')
@@ -0,0 +1,9 @@
1
+ import type { Identity } from '@/domain/auth/entities/identity.entity'
2
+ import type { ProviderName } from '@/domain/auth/ports/identity-providers.port'
3
+
4
+ export interface IdentityRepository {
5
+ findBySubject(provider: ProviderName, subject: string): Promise<Identity | null>
6
+ save(identity: Identity): Promise<void>
7
+ }
8
+
9
+ export const IDENTITY_REPOSITORY = Symbol('IdentityRepository')
@@ -5,6 +5,8 @@ export interface SessionRepository {
5
5
  // Reads the row and holds it until the surrounding transaction ends, so two rotations cannot interleave.
6
6
  lockById(id: string): Promise<Session | null>
7
7
  save(session: Session): Promise<void>
8
+ // Every session of the user, at once: a new password ends whatever the old one opened.
9
+ revokeAllFor(userId: string, now: Date): Promise<void>
8
10
  }
9
11
 
10
12
  export const SESSION_REPOSITORY = Symbol('SessionRepository')
@@ -0,0 +1,9 @@
1
+ import type { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
2
+
3
+ export interface SocialSignInRepository {
4
+ // Reads the row and holds it until the surrounding transaction ends, so a callback or a code is used once.
5
+ lockById(id: string): Promise<SocialSignIn | null>
6
+ save(signIn: SocialSignIn): Promise<void>
7
+ }
8
+
9
+ export const SOCIAL_SIGN_IN_REPOSITORY = Symbol('SocialSignInRepository')
@@ -0,0 +1,103 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.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
+ import { LinkIdentityUseCase } from '@/domain/auth/use-cases/link-identity.use-case'
15
+ import {
16
+ type SessionTokens,
17
+ StartSessionUseCase,
18
+ } from '@/domain/auth/use-cases/start-session.use-case'
19
+ import {
20
+ TRANSACTION_MANAGER,
21
+ type TransactionManager,
22
+ } from '@/domain/shared/transactions/transaction-manager'
23
+
24
+ export type SocialCallback = {
25
+ provider: ProviderName
26
+ state: string
27
+ browserToken: string | undefined
28
+ callbackUrl: URL
29
+ name: string | null
30
+ }
31
+
32
+ export type SocialOutcome =
33
+ | { client: 'web'; returnTo: string; tokens: SessionTokens }
34
+ | { client: 'mobile'; code: string }
35
+
36
+ @injectable()
37
+ export class CompleteSocialSignInUseCase {
38
+ constructor(
39
+ @inject(IDENTITY_PROVIDERS) private readonly providers: IdentityProviders,
40
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
41
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
42
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
43
+ private readonly linkIdentity: LinkIdentityUseCase,
44
+ private readonly startSession: StartSessionUseCase,
45
+ ) {}
46
+
47
+ async execute(callback: SocialCallback): Promise<SocialOutcome> {
48
+ const browser =
49
+ callback.browserToken === undefined ? null : SocialSignIn.parseToken(callback.browserToken)
50
+
51
+ // The browser that comes back must be the one that left: a callback forwarded from elsewhere carries no token.
52
+ if (browser === null || browser.id !== callback.state) {
53
+ throw new SocialSignInFailedError('The sign-in was started in another browser. Start again.')
54
+ }
55
+
56
+ const started = await this.transactions.run(async () => {
57
+ const signIn = await this.signIns.lockById(browser.id)
58
+
59
+ return signIn !== null &&
60
+ signIn.provider === callback.provider &&
61
+ signIn.isStarted(new Date()) &&
62
+ signIn.browserHash === this.opaque.digest(browser.secret)
63
+ ? signIn
64
+ : null
65
+ })
66
+
67
+ if (started === null) {
68
+ throw new SocialSignInFailedError()
69
+ }
70
+
71
+ const profile = await this.providers.profile(callback.provider, {
72
+ callbackUrl: callback.callbackUrl,
73
+ state: started.id,
74
+ codeVerifier: started.codeVerifier,
75
+ nonce: started.nonce,
76
+ name: callback.name,
77
+ })
78
+ const userId = await this.linkIdentity.execute(callback.provider, profile)
79
+ const exchange = started.client === 'mobile' ? this.opaque.create() : null
80
+
81
+ await this.transactions.run(async () => {
82
+ const signIn = await this.signIns.lockById(started.id)
83
+
84
+ // Another callback for the same trip got here first.
85
+ if (signIn === null || !signIn.isStarted(new Date())) {
86
+ throw new SocialSignInFailedError()
87
+ }
88
+
89
+ signIn.signIn(userId, exchange === null ? null : this.opaque.digest(exchange), new Date())
90
+ await this.signIns.save(signIn)
91
+ })
92
+
93
+ if (exchange !== null) {
94
+ return { client: 'mobile', code: started.token(exchange) }
95
+ }
96
+
97
+ return {
98
+ client: 'web',
99
+ returnTo: started.returnTo,
100
+ tokens: await this.startSession.execute(userId),
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,56 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SocialSignIn } from '@/domain/auth/entities/social-sign-in.entity'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
5
+ import {
6
+ SOCIAL_SIGN_IN_REPOSITORY,
7
+ type SocialSignInRepository,
8
+ } from '@/domain/auth/repositories/social-sign-in.repository'
9
+ import {
10
+ type SessionTokens,
11
+ StartSessionUseCase,
12
+ } from '@/domain/auth/use-cases/start-session.use-case'
13
+ import {
14
+ TRANSACTION_MANAGER,
15
+ type TransactionManager,
16
+ } from '@/domain/shared/transactions/transaction-manager'
17
+
18
+ // The mobile app's half: the browser brought back a code, the app trades it once for its own tokens.
19
+ @injectable()
20
+ export class ExchangeSocialCodeUseCase {
21
+ constructor(
22
+ @inject(SOCIAL_SIGN_IN_REPOSITORY) private readonly signIns: SocialSignInRepository,
23
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
24
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
25
+ private readonly startSession: StartSessionUseCase,
26
+ ) {}
27
+
28
+ async execute(code: string): Promise<SessionTokens> {
29
+ const parsed = SocialSignIn.parseToken(code)
30
+
31
+ const userId = await this.transactions.run(async () => {
32
+ const signIn = parsed === null ? null : await this.signIns.lockById(parsed.id)
33
+
34
+ if (
35
+ parsed === null ||
36
+ signIn === null ||
37
+ signIn.userId === null ||
38
+ !signIn.isExchangeable(new Date()) ||
39
+ signIn.exchangeHash !== this.opaque.digest(parsed.secret)
40
+ ) {
41
+ return null
42
+ }
43
+
44
+ signIn.exchange()
45
+ await this.signIns.save(signIn)
46
+
47
+ return signIn.userId
48
+ })
49
+
50
+ if (userId === null) {
51
+ throw new SocialSignInFailedError()
52
+ }
53
+
54
+ return this.startSession.execute(userId)
55
+ }
56
+ }
@@ -0,0 +1,55 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { EmailCode, type EmailCodePurpose } from '@/domain/auth/entities/email-code.entity'
3
+ import type { User } from '@/domain/auth/entities/user.entity'
4
+ import { MAILER, type Mail, type Mailer } from '@/domain/auth/ports/mailer.port'
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 {
11
+ TRANSACTION_MANAGER,
12
+ type TransactionManager,
13
+ } from '@/domain/shared/transactions/transaction-manager'
14
+
15
+ const MAILS: Record<EmailCodePurpose, (to: string, code: string) => Mail> = {
16
+ 'verify-email': (to, code) => ({
17
+ to,
18
+ subject: `${code} is your verification code`,
19
+ text: `Enter ${code} to confirm this email address. The code expires in 15 minutes.`,
20
+ }),
21
+ 'reset-password': (to, code) => ({
22
+ to,
23
+ subject: `${code} is your password reset code`,
24
+ text: `Enter ${code} to choose a new password. The code expires in 15 minutes. If you did not ask for it, ignore this email: your password stays as it is.`,
25
+ }),
26
+ }
27
+
28
+ @injectable()
29
+ export class IssueEmailCodeUseCase {
30
+ constructor(
31
+ @inject(EMAIL_CODE_REPOSITORY) private readonly codes: EmailCodeRepository,
32
+ @inject(ONE_TIME_CODES) private readonly oneTimeCodes: OneTimeCodes,
33
+ @inject(MAILER) private readonly mailer: Mailer,
34
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
35
+ ) {}
36
+
37
+ async execute(user: User, purpose: EmailCodePurpose): Promise<void> {
38
+ const code = this.oneTimeCodes.create()
39
+ const codeHash = this.oneTimeCodes.digest(code)
40
+
41
+ await this.transactions.run(async () => {
42
+ const now = new Date()
43
+ const existing = await this.codes.lockFor(user.id, purpose)
44
+
45
+ if (existing === null) {
46
+ await this.codes.save(new EmailCode(user.id, purpose, codeHash, now))
47
+ } else {
48
+ existing.renew(codeHash, now)
49
+ await this.codes.save(existing)
50
+ }
51
+ })
52
+
53
+ await this.mailer.send(MAILS[purpose](user.email, code))
54
+ }
55
+ }
@@ -0,0 +1,77 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Identity } from '@/domain/auth/entities/identity.entity'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
5
+ import type { ProviderName, ProviderProfile } from '@/domain/auth/ports/identity-providers.port'
6
+ import {
7
+ IDENTITY_REPOSITORY,
8
+ type IdentityRepository,
9
+ } from '@/domain/auth/repositories/identity.repository'
10
+ import {
11
+ SESSION_REPOSITORY,
12
+ type SessionRepository,
13
+ } from '@/domain/auth/repositories/session.repository'
14
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
20
+
21
+ const DISPLAY_NAME_MAX = 80
22
+
23
+ @injectable()
24
+ export class LinkIdentityUseCase {
25
+ constructor(
26
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
27
+ @inject(IDENTITY_REPOSITORY) private readonly identities: IdentityRepository,
28
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
29
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
30
+ private readonly createProfile: CreateProfileUseCase,
31
+ ) {}
32
+
33
+ // Returns the id of the user the provider's account belongs to, creating or linking it as the rules say.
34
+ execute(provider: ProviderName, profile: ProviderProfile): Promise<string> {
35
+ return this.transactions.run(async () => {
36
+ const known = await this.identities.findBySubject(provider, profile.subject)
37
+
38
+ if (known !== null) {
39
+ return known.userId
40
+ }
41
+
42
+ if (!profile.emailVerified) {
43
+ throw new ProviderEmailUnverifiedError()
44
+ }
45
+
46
+ const now = new Date()
47
+ const existing = await this.users.findByEmail(User.normalizeEmail(profile.email))
48
+
49
+ if (existing !== null) {
50
+ // Nobody had shown this inbox was theirs, and the provider just did: whoever chose the password, possibly
51
+ // to wait for the real owner, loses it and every session it opened.
52
+ if (existing.emailVerifiedAt === null) {
53
+ existing.removePassword()
54
+ existing.verifyEmail(now)
55
+ await this.users.save(existing)
56
+ await this.sessions.revokeAllFor(existing.id, now)
57
+ }
58
+
59
+ await this.identities.save(new Identity(existing.id, provider, profile.subject))
60
+
61
+ return existing.id
62
+ }
63
+
64
+ const user = new User(profile.email, null)
65
+
66
+ user.verifyEmail(now)
67
+ await this.users.save(user)
68
+ await this.createProfile.execute(
69
+ user.id,
70
+ (profile.name ?? profile.email.split('@')[0] ?? profile.email).slice(0, DISPLAY_NAME_MAX),
71
+ )
72
+ await this.identities.save(new Identity(user.id, provider, profile.subject))
73
+
74
+ return user.id
75
+ })
76
+ }
77
+ }
@@ -81,12 +81,14 @@ export class RefreshSessionUseCase {
81
81
  userId: session.userId,
82
82
  sessionId: session.id,
83
83
  ...(session.clientId !== null && { clientId: session.clientId }),
84
+ ...(session.scope !== null && { scope: session.scope }),
84
85
  },
85
86
  new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
86
87
  session.resource ?? undefined,
87
88
  ),
88
89
  refreshToken: session.refreshToken(next),
89
90
  refreshExpiresAt: session.expiresAt,
91
+ scope: session.scope,
90
92
  }
91
93
  }
92
94
  }
@@ -0,0 +1,21 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
4
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
5
+
6
+ @injectable()
7
+ export class RequestEmailVerificationUseCase {
8
+ constructor(
9
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
10
+ private readonly issueEmailCode: IssueEmailCodeUseCase,
11
+ ) {}
12
+
13
+ // Answers the same whether the account exists, is verified, or neither: the caller learns nothing about who signed up.
14
+ async execute(email: string): Promise<void> {
15
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
16
+
17
+ if (user !== null && user.emailVerifiedAt === null) {
18
+ await this.issueEmailCode.execute(user, 'verify-email')
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
4
+ import { IssueEmailCodeUseCase } from '@/domain/auth/use-cases/issue-email-code.use-case'
5
+
6
+ @injectable()
7
+ export class RequestPasswordResetUseCase {
8
+ constructor(
9
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
10
+ private readonly issueEmailCode: IssueEmailCodeUseCase,
11
+ ) {}
12
+
13
+ // Answers the same for an unknown email: the caller learns nothing about who has an account.
14
+ async execute(email: string): Promise<void> {
15
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
16
+
17
+ if (user !== null) {
18
+ await this.issueEmailCode.execute(user, 'reset-password')
19
+ }
20
+ }
21
+ }