@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,69 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify'
2
+ import type { TokensResponseDto } from '@/domain/auth/dto/tokens-response.dto'
3
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
4
+ import type { Env } from '@/infra/config/env'
5
+ import { ACCESS_COOKIE } from '@/infra/http/hooks/auth.hook'
6
+
7
+ export const REFRESH_COOKIE = 'refresh_token'
8
+
9
+ // The refresh cookie travels only to the one route that reads it, never with an ordinary request.
10
+ const REFRESH_PATH = '/api/auth/refresh'
11
+
12
+ // A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
13
+ // injected script can take.
14
+ export function wantsBearer(request: FastifyRequest): boolean {
15
+ return request.headers['x-auth-transport'] === 'bearer'
16
+ }
17
+
18
+ function asBody(tokens: SessionTokens): TokensResponseDto {
19
+ return {
20
+ accessToken: tokens.accessToken.token,
21
+ refreshToken: tokens.refreshToken,
22
+ expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
23
+ }
24
+ }
25
+
26
+ // How a started session reaches the client, wherever it was started: sign-in, a confirmed email, a provider.
27
+ export function sessionDelivery(env: Pick<Env, 'API_URL'>) {
28
+ const cookie = {
29
+ httpOnly: true,
30
+ sameSite: 'lax',
31
+ secure: env.API_URL.startsWith('https://'),
32
+ } as const
33
+
34
+ function clearCookies(reply: FastifyReply): void {
35
+ reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
36
+ reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
37
+ }
38
+
39
+ function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
40
+ reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
41
+ ...cookie,
42
+ path: '/',
43
+ expires: tokens.accessToken.expiresAt,
44
+ })
45
+ reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
46
+ ...cookie,
47
+ path: REFRESH_PATH,
48
+ expires: tokens.refreshExpiresAt,
49
+ })
50
+ }
51
+
52
+ // The web gets cookies and an empty body; a native client gets the tokens and no cookie.
53
+ function deliver(
54
+ request: FastifyRequest,
55
+ reply: FastifyReply,
56
+ tokens: SessionTokens,
57
+ status: 200 | 201,
58
+ ) {
59
+ if (wantsBearer(request)) {
60
+ return reply.code(status).send(asBody(tokens))
61
+ }
62
+
63
+ setCookies(reply, tokens)
64
+
65
+ return reply.code(status === 201 ? 201 : 204).send()
66
+ }
67
+
68
+ return { deliver, setCookies, clearCookies }
69
+ }
@@ -0,0 +1,27 @@
1
+ import { USER_TOOL_SCOPES } from './tools/users.tool'
2
+
3
+ // Every scope this server offers, as the consent page words it. A module adds its read and write here, with its
4
+ // tools' scopes below.
5
+ export const SCOPES: Record<string, string> = {
6
+ 'profile:read': 'Read your profile',
7
+ 'profile:write': 'Change your display name, language and timezone',
8
+ }
9
+
10
+ export const TOOL_SCOPES: Record<string, string> = { ...USER_TOOL_SCOPES }
11
+
12
+ // The scopes a batch of JSON-RPC messages needs and the token lacks, all of them at once: MCP asks for one
13
+ // challenge per operation, not one per retry.
14
+ export function missingScopes(messages: unknown, granted: readonly string[]): string[] {
15
+ const needed = [messages]
16
+ .flat()
17
+ .flatMap((message) => {
18
+ const call = message as { method?: unknown; params?: { name?: unknown } } | null
19
+
20
+ return call?.method === 'tools/call' && typeof call.params?.name === 'string'
21
+ ? [TOOL_SCOPES[call.params.name]]
22
+ : []
23
+ })
24
+ .filter((scope): scope is string => scope !== undefined)
25
+
26
+ return [...new Set(needed)].filter((scope) => !granted.includes(scope))
27
+ }
@@ -16,6 +16,12 @@ function asResult(profile: Profile) {
16
16
  }
17
17
  }
18
18
 
19
+ // The scope each tool needs: reading a module's data is `<module>:read`, changing it `<module>:write`.
20
+ export const USER_TOOL_SCOPES = {
21
+ get_profile: 'profile:read',
22
+ update_profile: 'profile:write',
23
+ } as const
24
+
19
25
  export function registerUserTools(
20
26
  server: McpServer,
21
27
  container: DependencyContainer,
@@ -0,0 +1,199 @@
1
+ import { importPKCS8, SignJWT } from 'jose'
2
+ import * as oidc from 'openid-client'
3
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
4
+ import type {
5
+ IdentityProviders,
6
+ ProviderCallback,
7
+ ProviderName,
8
+ ProviderProfile,
9
+ ProviderRedirect,
10
+ } from '@/domain/auth/ports/identity-providers.port'
11
+ import { DomainError } from '@/domain/shared/errors/domain-error'
12
+ import type { Env } from '@/infra/config/env'
13
+
14
+ type Settings = {
15
+ issuer: URL
16
+ clientId: string
17
+ scope: string
18
+ // Apple answers with a POST when the email is asked for; Google redirects back with a GET.
19
+ formPost: boolean
20
+ secret: () => Promise<string>
21
+ }
22
+
23
+ type Options = {
24
+ // A test points a provider at a local issuer, over plain HTTP.
25
+ issuers?: Partial<Record<ProviderName, URL>>
26
+ insecure?: boolean
27
+ }
28
+
29
+ // Apple accepts a client secret valid for up to six months; one hour keeps a leaked one short-lived.
30
+ const SECRET_LIFETIME_MS = 60 * 60 * 1000
31
+
32
+ export class OpenIdIdentityProviders implements IdentityProviders {
33
+ private readonly configurations = new Map<
34
+ ProviderName,
35
+ { configuration: oidc.Configuration; until: number }
36
+ >()
37
+
38
+ constructor(
39
+ private readonly env: Env,
40
+ private readonly options: Options = {},
41
+ ) {}
42
+
43
+ private settings(provider: ProviderName): Settings | null {
44
+ const { env } = this
45
+ const issuer = (fallback: string) => this.options.issuers?.[provider] ?? new URL(fallback)
46
+
47
+ switch (provider) {
48
+ // prumo:google
49
+ case 'google': {
50
+ const secret = env.GOOGLE_CLIENT_SECRET
51
+
52
+ return env.GOOGLE_CLIENT_ID === undefined || secret === undefined
53
+ ? null
54
+ : {
55
+ issuer: issuer('https://accounts.google.com'),
56
+ clientId: env.GOOGLE_CLIENT_ID,
57
+ scope: 'openid email profile',
58
+ formPost: false,
59
+ secret: async () => secret,
60
+ }
61
+ }
62
+ // prumo:end-google
63
+ // prumo:apple
64
+ case 'apple': {
65
+ const { APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY } = env
66
+
67
+ if (
68
+ APPLE_CLIENT_ID === undefined ||
69
+ APPLE_TEAM_ID === undefined ||
70
+ APPLE_KEY_ID === undefined ||
71
+ APPLE_PRIVATE_KEY === undefined
72
+ ) {
73
+ return null
74
+ }
75
+
76
+ return {
77
+ issuer: issuer('https://appleid.apple.com'),
78
+ clientId: APPLE_CLIENT_ID,
79
+ scope: 'openid email name',
80
+ formPost: true,
81
+ // Apple's client secret is a JWT signed with the team's .p8 key, not a string it hands out.
82
+ secret: async () =>
83
+ new SignJWT({})
84
+ .setProtectedHeader({ alg: 'ES256', kid: APPLE_KEY_ID })
85
+ .setIssuer(APPLE_TEAM_ID)
86
+ .setSubject(APPLE_CLIENT_ID)
87
+ .setAudience('https://appleid.apple.com')
88
+ .setIssuedAt()
89
+ .setExpirationTime(Math.floor((Date.now() + SECRET_LIFETIME_MS) / 1000))
90
+ .sign(await importPKCS8(APPLE_PRIVATE_KEY.replaceAll('\\n', '\n'), 'ES256')),
91
+ }
92
+ }
93
+ // prumo:end-apple
94
+ }
95
+
96
+ return null
97
+ }
98
+
99
+ private redirectUri(provider: ProviderName): string {
100
+ return `${this.env.API_URL}/api/auth/social/${provider}/callback`
101
+ }
102
+
103
+ private async configuration(provider: ProviderName, settings: Settings) {
104
+ const cached = this.configurations.get(provider)
105
+
106
+ if (cached !== undefined && cached.until > Date.now()) {
107
+ return cached.configuration
108
+ }
109
+
110
+ const configuration = await oidc.discovery(
111
+ settings.issuer,
112
+ settings.clientId,
113
+ undefined,
114
+ oidc.ClientSecretPost(await settings.secret()),
115
+ { execute: this.options.insecure === true ? [oidc.allowInsecureRequests] : [] },
116
+ )
117
+
118
+ // Renewed before the secret it was built with expires.
119
+ this.configurations.set(provider, {
120
+ configuration,
121
+ until: Date.now() + SECRET_LIFETIME_MS / 2,
122
+ })
123
+
124
+ return configuration
125
+ }
126
+
127
+ isConfigured(provider: ProviderName): boolean {
128
+ return this.settings(provider) !== null
129
+ }
130
+
131
+ async redirect(provider: ProviderName, state: string): Promise<ProviderRedirect> {
132
+ const settings = this.settings(provider)
133
+
134
+ if (settings === null) {
135
+ throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
136
+ }
137
+
138
+ const configuration = await this.configuration(provider, settings)
139
+ const nonce = oidc.randomNonce()
140
+ // PKCE where the provider says it takes it: Google does, Apple's metadata names none, and relies on state and nonce.
141
+ const codeVerifier = configuration.serverMetadata().supportsPKCE()
142
+ ? oidc.randomPKCECodeVerifier()
143
+ : null
144
+ const url = oidc.buildAuthorizationUrl(configuration, {
145
+ redirect_uri: this.redirectUri(provider),
146
+ scope: settings.scope,
147
+ state,
148
+ nonce,
149
+ ...(codeVerifier !== null && {
150
+ code_challenge: await oidc.calculatePKCECodeChallenge(codeVerifier),
151
+ code_challenge_method: 'S256',
152
+ }),
153
+ ...(settings.formPost && { response_mode: 'form_post' }),
154
+ })
155
+
156
+ return { url: url.href, codeVerifier, nonce }
157
+ }
158
+
159
+ async profile(provider: ProviderName, callback: ProviderCallback): Promise<ProviderProfile> {
160
+ const settings = this.settings(provider)
161
+
162
+ if (settings === null) {
163
+ throw new SocialSignInFailedError(`Sign-in with ${provider} is not configured`)
164
+ }
165
+
166
+ try {
167
+ const tokens = await oidc.authorizationCodeGrant(
168
+ await this.configuration(provider, settings),
169
+ callback.callbackUrl,
170
+ {
171
+ expectedState: callback.state,
172
+ expectedNonce: callback.nonce,
173
+ idTokenExpected: true,
174
+ ...(callback.codeVerifier !== null && { pkceCodeVerifier: callback.codeVerifier }),
175
+ },
176
+ )
177
+ // Read only after openid-client checked the ID token's signature, issuer, audience, expiry and nonce.
178
+ const claims = tokens.claims()
179
+
180
+ if (claims === undefined || typeof claims.email !== 'string') {
181
+ throw new SocialSignInFailedError('The provider did not share an email address')
182
+ }
183
+
184
+ return {
185
+ subject: claims.sub,
186
+ email: claims.email,
187
+ // Apple writes it as the string "true".
188
+ emailVerified: claims.email_verified === true || claims.email_verified === 'true',
189
+ name: typeof claims.name === 'string' ? claims.name : callback.name,
190
+ }
191
+ } catch (error) {
192
+ if (error instanceof DomainError) {
193
+ throw error
194
+ }
195
+
196
+ throw new SocialSignInFailedError()
197
+ }
198
+ }
199
+ }
@@ -0,0 +1,87 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { EMAIL_CODE_ATTEMPTS } from '@/domain/auth/entities/email-code.entity'
4
+ import { EmailNotVerifiedError } from '@/domain/auth/errors/email-not-verified.error'
5
+ import { InvalidCodeError } from '@/domain/auth/errors/invalid-code.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+
9
+ describe('email verification', () => {
10
+ it('refuses to sign in an unconfirmed account, and lets it in once confirmed', async () => {
11
+ const { signUp, signIn, verifyEmail, mailer } = authFakes()
12
+ await signUp.execute(ANA)
13
+
14
+ await expect(signIn.execute(ANA)).rejects.toBeInstanceOf(EmailNotVerifiedError)
15
+
16
+ await verifyEmail.execute({ email: ANA.email, code: mailer.lastCode(ANA.email) })
17
+
18
+ await expect(signIn.execute(ANA)).resolves.toBeDefined()
19
+ })
20
+
21
+ it('kills a code after five wrong guesses, even if the right one comes next', async () => {
22
+ const { signUp, verifyEmail, mailer } = authFakes()
23
+ await signUp.execute(ANA)
24
+ const code = mailer.lastCode(ANA.email)
25
+
26
+ for (let i = 0; i < EMAIL_CODE_ATTEMPTS; i += 1) {
27
+ await expect(
28
+ verifyEmail.execute({ email: ANA.email, code: '000000' }),
29
+ ).rejects.toBeInstanceOf(InvalidCodeError)
30
+ }
31
+
32
+ await expect(verifyEmail.execute({ email: ANA.email, code })).rejects.toBeInstanceOf(
33
+ InvalidCodeError,
34
+ )
35
+ })
36
+
37
+ it('answers an unknown email as it answers a wrong code', async () => {
38
+ const { verifyEmail } = authFakes()
39
+
40
+ await expect(
41
+ verifyEmail.execute({ email: 'nobody@example.com', code: '123456' }),
42
+ ).rejects.toBeInstanceOf(InvalidCodeError)
43
+ })
44
+ })
45
+
46
+ describe('password reset', () => {
47
+ it('sets the new password, ends every old session, and confirms the email it reached', async () => {
48
+ const { register, signIn, sessions, users, requestPasswordReset, resetPassword, mailer } =
49
+ authFakes()
50
+ await register(ANA)
51
+ await signIn.execute(ANA)
52
+
53
+ await requestPasswordReset.execute(ANA.email)
54
+ await resetPassword.execute({
55
+ email: ANA.email,
56
+ code: mailer.lastCode(ANA.email),
57
+ password: 'another-good-password',
58
+ })
59
+
60
+ const revoked = [...sessions.rows.values()].filter((session) => session.revokedAt !== null)
61
+ const [user] = [...users.rows.values()]
62
+
63
+ expect(revoked).toHaveLength(2)
64
+ expect(user?.passwordHash).toBe('hashed:another-good-password')
65
+ await expect(signIn.execute(ANA)).rejects.toThrow()
66
+ })
67
+
68
+ it('sends nothing for an unknown email, and says nothing about it either', async () => {
69
+ const { requestPasswordReset, mailer } = authFakes()
70
+
71
+ await expect(requestPasswordReset.execute('nobody@example.com')).resolves.toBeUndefined()
72
+ expect(mailer.sent).toEqual([])
73
+ })
74
+
75
+ it('voids the previous code when a new one is sent', async () => {
76
+ const { register, requestPasswordReset, resetPassword, mailer } = authFakes()
77
+ await register(ANA)
78
+
79
+ await requestPasswordReset.execute(ANA.email)
80
+ const first = mailer.lastCode(ANA.email)
81
+ await requestPasswordReset.execute(ANA.email)
82
+
83
+ await expect(
84
+ resetPassword.execute({ email: ANA.email, code: first, password: 'another-good-password' }),
85
+ ).rejects.toBeInstanceOf(InvalidCodeError)
86
+ })
87
+ })
@@ -12,8 +12,8 @@ afterEach(() => {
12
12
 
13
13
  describe('RefreshSessionUseCase', () => {
14
14
  it('rotates the refresh token on every use', async () => {
15
- const { signUp, refresh } = authFakes()
16
- const first = await signUp.execute(ANA)
15
+ const { register, refresh } = authFakes()
16
+ const first = await register(ANA)
17
17
 
18
18
  const second = await refresh.execute(first.refreshToken)
19
19
  const third = await refresh.execute(second.refreshToken)
@@ -22,8 +22,8 @@ describe('RefreshSessionUseCase', () => {
22
22
  })
23
23
 
24
24
  it('asks a client that lost a race to retry, and revokes nothing', async () => {
25
- const { signUp, refresh, sessions } = authFakes()
26
- const first = await signUp.execute(ANA)
25
+ const { register, refresh, sessions } = authFakes()
26
+ const first = await register(ANA)
27
27
 
28
28
  const second = await refresh.execute(first.refreshToken)
29
29
 
@@ -34,8 +34,8 @@ describe('RefreshSessionUseCase', () => {
34
34
 
35
35
  it('ends the session when a token comes back after the grace period', async () => {
36
36
  vi.useFakeTimers()
37
- const { signUp, refresh, sessions } = authFakes()
38
- const first = await signUp.execute(ANA)
37
+ const { register, refresh, sessions } = authFakes()
38
+ const first = await register(ANA)
39
39
  const second = await refresh.execute(first.refreshToken)
40
40
 
41
41
  vi.advanceTimersByTime(ROTATION_GRACE_MS)
@@ -55,8 +55,8 @@ describe('RefreshSessionUseCase', () => {
55
55
  })
56
56
 
57
57
  it('refuses every token of a session signed out of', async () => {
58
- const { signUp, signOut, refresh, sessions } = authFakes()
59
- const tokens = await signUp.execute(ANA)
58
+ const { register, signOut, refresh, sessions } = authFakes()
59
+ const tokens = await register(ANA)
60
60
  const session = [...sessions.rows.values()][0]
61
61
 
62
62
  await signOut.execute(session?.userId ?? '', session?.id ?? '')
@@ -6,8 +6,8 @@ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-ba
6
6
 
7
7
  describe('SignInUseCase', () => {
8
8
  it('starts a session for the right password, whatever the case of the email', async () => {
9
- const { signUp, signIn, sessions } = authFakes()
10
- await signUp.execute(ANA)
9
+ const { register, signIn, sessions } = authFakes()
10
+ await register(ANA)
11
11
 
12
12
  await signIn.execute({ email: 'ANA@example.com', password: ANA.password })
13
13
 
@@ -15,8 +15,8 @@ describe('SignInUseCase', () => {
15
15
  })
16
16
 
17
17
  it('answers a wrong password and an unknown email alike, after checking a password both times', async () => {
18
- const { signUp, signIn, hasher } = authFakes()
19
- await signUp.execute(ANA)
18
+ const { register, signIn, hasher } = authFakes()
19
+ await register(ANA)
20
20
 
21
21
  await expect(signIn.execute({ email: ANA.email, password: 'wrong' })).rejects.toBeInstanceOf(
22
22
  InvalidCredentialsError,
@@ -5,10 +5,10 @@ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
5
  const ANA = { name: 'Ana', email: ' Ana@Example.com', password: 'correct-horse-battery' }
6
6
 
7
7
  describe('SignUpUseCase', () => {
8
- it('creates the user and the profile together, and starts a session', async () => {
9
- const { signUp, users, profiles, sessions } = authFakes()
8
+ it('creates the user and the profile together', async () => {
9
+ const { signUp, users, profiles } = authFakes()
10
10
 
11
- const tokens = await signUp.execute(ANA)
11
+ await signUp.execute(ANA)
12
12
  const [user] = [...users.rows.values()]
13
13
 
14
14
  expect(user).toMatchObject({
@@ -16,8 +16,6 @@ describe('SignUpUseCase', () => {
16
16
  passwordHash: 'hashed:correct-horse-battery',
17
17
  })
18
18
  expect(profiles.rows.get(user?.id ?? '')).toMatchObject({ displayName: 'Ana' })
19
- expect(sessions.rows.size).toBe(1)
20
- expect(tokens.refreshToken.startsWith(`${[...sessions.rows.keys()][0]}.`)).toBe(true)
21
19
  })
22
20
 
23
21
  it('refuses an email already taken, whatever its case', async () => {
@@ -30,4 +28,14 @@ describe('SignUpUseCase', () => {
30
28
  )
31
29
  expect(profiles.rows.size).toBe(1)
32
30
  })
31
+
32
+ // prumo:email
33
+ it('sends a code and opens no session until the email is confirmed', async () => {
34
+ const { signUp, sessions, mailer } = authFakes()
35
+
36
+ expect(await signUp.execute(ANA)).toBeNull()
37
+ expect(sessions.rows.size).toBe(0)
38
+ expect(mailer.lastCode('ana@example.com')).toMatch(/^\d{6}$/)
39
+ })
40
+ // prumo:end-email
33
41
  })
@@ -0,0 +1,148 @@
1
+ import { socialFakes } from '@test/support/fakes/social-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ProviderEmailUnverifiedError } from '@/domain/auth/errors/provider-email-unverified.error'
4
+ import { ProviderNotConfiguredError } from '@/domain/auth/errors/provider-not-configured.error'
5
+ import { SocialSignInFailedError } from '@/domain/auth/errors/social-sign-in-failed.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+ const GOOGLE = {
9
+ subject: 'google-sub-1',
10
+ email: 'Ana@Example.com',
11
+ emailVerified: true,
12
+ name: 'Ana G',
13
+ }
14
+
15
+ describe('LinkIdentityUseCase', () => {
16
+ it('creates a verified, password-less account with its profile for someone new', async () => {
17
+ const { linkIdentity, users, profiles, identities } = socialFakes()
18
+
19
+ const userId = await linkIdentity.execute('google', GOOGLE)
20
+ const user = users.rows.get(userId)
21
+
22
+ expect(user).toMatchObject({ email: 'ana@example.com', passwordHash: null })
23
+ expect(user?.emailVerifiedAt).not.toBeNull()
24
+ expect(profiles.rows.get(userId)).toMatchObject({ displayName: 'Ana G' })
25
+ expect(identities.rows).toEqual([expect.objectContaining({ provider: 'google', userId })])
26
+ })
27
+
28
+ it('recognises the provider account the next time, by subject even if the email changed', async () => {
29
+ const { linkIdentity } = socialFakes()
30
+
31
+ const first = await linkIdentity.execute('google', GOOGLE)
32
+ const again = await linkIdentity.execute('google', { ...GOOGLE, email: 'new@example.com' })
33
+
34
+ expect(again).toBe(first)
35
+ })
36
+
37
+ it('links to a confirmed account and leaves its password alone', async () => {
38
+ const { register, linkIdentity, users, signIn } = socialFakes()
39
+ await register(ANA)
40
+ // Confirmed by a code where the project verifies emails; marked here so the rule is proved either way.
41
+ for (const user of users.rows.values()) {
42
+ user.verifyEmail(new Date())
43
+ }
44
+
45
+ const userId = await linkIdentity.execute('google', GOOGLE)
46
+
47
+ expect(users.rows.get(userId)?.passwordHash).toBe('hashed:correct-horse-battery')
48
+ await expect(signIn.execute(ANA)).resolves.toBeDefined()
49
+ })
50
+
51
+ // The attack: someone signs up with another person's email first, and waits for them to arrive by Google.
52
+ it('takes an unconfirmed account from whoever chose its password, ending their sessions', async () => {
53
+ const { signUp, linkIdentity, users, sessions } = socialFakes()
54
+ await signUp.execute(ANA)
55
+ const squatter = [...users.rows.values()][0]
56
+
57
+ const userId = await linkIdentity.execute('google', GOOGLE)
58
+
59
+ expect(userId).toBe(squatter?.id)
60
+ expect(users.rows.get(userId)?.passwordHash).toBeNull()
61
+ expect([...sessions.rows.values()].every((session) => session.revokedAt !== null)).toBe(true)
62
+ })
63
+
64
+ it('refuses an email the provider has not verified', async () => {
65
+ const { linkIdentity } = socialFakes()
66
+
67
+ await expect(
68
+ linkIdentity.execute('google', { ...GOOGLE, emailVerified: false }),
69
+ ).rejects.toBeInstanceOf(ProviderEmailUnverifiedError)
70
+ })
71
+ })
72
+
73
+ describe('a social sign-in, start to session', () => {
74
+ it('signs the web in on return, and only the browser that started', async () => {
75
+ const { start, complete } = socialFakes()
76
+ const { url, browserToken } = await start.execute({
77
+ provider: 'google',
78
+ client: 'web',
79
+ returnTo: '/orders',
80
+ })
81
+ const state = new URL(url).searchParams.get('state') ?? ''
82
+ const callback = {
83
+ provider: 'google' as const,
84
+ state,
85
+ callbackUrl: new URL('http://localhost:3000/cb'),
86
+ name: null,
87
+ }
88
+
89
+ await expect(complete.execute({ ...callback, browserToken: undefined })).rejects.toBeInstanceOf(
90
+ SocialSignInFailedError,
91
+ )
92
+
93
+ const outcome = await complete.execute({ ...callback, browserToken })
94
+
95
+ expect(outcome).toMatchObject({ client: 'web', returnTo: '/orders' })
96
+ await expect(complete.execute({ ...callback, browserToken })).rejects.toBeInstanceOf(
97
+ SocialSignInFailedError,
98
+ )
99
+ })
100
+
101
+ it('gives a mobile app a code it can exchange once', async () => {
102
+ const { start, complete, exchange } = socialFakes()
103
+ const { url, browserToken } = await start.execute({
104
+ provider: 'apple',
105
+ client: 'mobile',
106
+ returnTo: '/',
107
+ })
108
+ const outcome = await complete.execute({
109
+ provider: 'apple',
110
+ state: new URL(url).searchParams.get('state') ?? '',
111
+ browserToken,
112
+ callbackUrl: new URL('http://localhost:3000/cb'),
113
+ name: 'Ana',
114
+ })
115
+
116
+ if (outcome.client !== 'mobile') {
117
+ throw new Error('expected a code for the mobile app')
118
+ }
119
+
120
+ await expect(exchange.execute(outcome.code)).resolves.toBeDefined()
121
+ await expect(exchange.execute(outcome.code)).rejects.toBeInstanceOf(SocialSignInFailedError)
122
+ })
123
+
124
+ it('refuses a provider without credentials, and a callback for another provider', async () => {
125
+ const { start, complete, providers } = socialFakes()
126
+ const { url, browserToken } = await start.execute({
127
+ provider: 'google',
128
+ client: 'web',
129
+ returnTo: '/',
130
+ })
131
+
132
+ await expect(
133
+ complete.execute({
134
+ provider: 'apple',
135
+ state: new URL(url).searchParams.get('state') ?? '',
136
+ browserToken,
137
+ callbackUrl: new URL('http://localhost:3000/cb'),
138
+ name: null,
139
+ }),
140
+ ).rejects.toBeInstanceOf(SocialSignInFailedError)
141
+
142
+ Object.assign(providers, { configured: [] })
143
+
144
+ await expect(
145
+ start.execute({ provider: 'google', client: 'web', returnTo: '/' }),
146
+ ).rejects.toBeInstanceOf(ProviderNotConfiguredError)
147
+ })
148
+ })
@@ -15,6 +15,7 @@ const request = {
15
15
  codeChallenge: 'challenge',
16
16
  clientState: null,
17
17
  resource: 'http://localhost:3000/api/mcp',
18
+ scope: 'profile:read',
18
19
  }
19
20
 
20
21
  describe('Authorization', () => {
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { HmacOneTimeCodes } from '@/infra/email/hmac-one-time-codes.adapter'
3
+
4
+ describe('HmacOneTimeCodes', () => {
5
+ it('makes six digits, and a digest that depends on the secret', () => {
6
+ const codes = new HmacOneTimeCodes({ JWT_SECRET: 'a'.repeat(40) })
7
+ const other = new HmacOneTimeCodes({ JWT_SECRET: 'b'.repeat(40) })
8
+
9
+ for (let i = 0; i < 50; i += 1) {
10
+ expect(codes.create()).toMatch(/^\d{6}$/)
11
+ }
12
+ expect(codes.digest('123456')).toBe(codes.digest('123456'))
13
+ expect(codes.digest('123456')).not.toBe(other.digest('123456'))
14
+ })
15
+ })