@stjoseph/prumo 0.2.0 → 0.3.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 (163) hide show
  1. package/.prumo-templates/api/auth.md +50 -60
  2. package/.prumo-templates/api/config.md +2 -2
  3. package/.prumo-templates/api/routes.md +6 -4
  4. package/.prumo-templates/api/structure.md +4 -1
  5. package/.prumo-templates/client/data.md +16 -18
  6. package/.prumo-templates/client/forms.md +2 -2
  7. package/.prumo-templates/core/testing.md +4 -5
  8. package/.prumo-templates/database/testing.md +6 -8
  9. package/.prumo-templates/mcp/server.md +39 -12
  10. package/.prumo-templates/mobile/routing.md +1 -1
  11. package/.prumo-templates/mobile/storage.md +7 -6
  12. package/.prumo-templates/monorepo/layout.md +4 -4
  13. package/.prumo-templates/multi-tenancy/client.md +6 -6
  14. package/.prumo-templates/multi-tenancy/isolation.md +17 -10
  15. package/README.md +2 -2
  16. package/dist/clean.js +2 -2
  17. package/dist/compose.js +1 -9
  18. package/dist/generate.js +3 -4
  19. package/dist/mcp.js +15 -8
  20. package/package.json +1 -1
  21. package/templates/api/.env.example +3 -5
  22. package/templates/api/README.md +4 -5
  23. package/templates/api/docker-compose.yml +0 -1
  24. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  25. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  26. package/templates/api/package.json +6 -8
  27. package/templates/api/scripts/database.mjs +3 -11
  28. package/templates/api/scripts/ports.mjs +1 -1
  29. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  30. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  31. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  32. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  33. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  34. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  35. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  36. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  37. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  38. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  39. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  40. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  41. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  42. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  43. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  44. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  45. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  46. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  47. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  48. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  49. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  50. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  51. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  52. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  53. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  54. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  55. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  56. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  57. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  58. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  59. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  60. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  61. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  62. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  63. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  64. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  65. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  66. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  67. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  68. package/templates/api/src/infra/config/env.ts +3 -7
  69. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  70. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  71. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  72. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  73. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  74. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  75. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  76. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  77. package/templates/api/src/infra/di/auth.di.ts +21 -0
  78. package/templates/api/src/infra/di/index.ts +4 -10
  79. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  80. package/templates/api/src/infra/http/app.ts +11 -1
  81. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  82. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  83. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  84. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  85. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  86. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  87. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  88. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  89. package/templates/api/src/infra/http/server.ts +1 -4
  90. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  91. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  92. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  93. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  94. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  95. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  96. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  97. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  98. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  99. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  100. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  101. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  102. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  103. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  104. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  105. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  106. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  107. package/templates/api/test/support/factories/user.factory.ts +9 -11
  108. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  109. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  110. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  111. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  112. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  113. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  114. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  115. package/templates/api/test/support/setup.ts +6 -28
  116. package/templates/mobile/README.md +1 -1
  117. package/templates/mobile/jest.config.js +0 -17
  118. package/templates/mobile/package.json +0 -4
  119. package/templates/mobile/scripts/ports.mjs +1 -1
  120. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  121. package/templates/mobile/src/api-contract/auth.ts +161 -0
  122. package/templates/mobile/src/api-contract/index.ts +10 -0
  123. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  124. package/templates/mobile/src/app/_layout.tsx +5 -5
  125. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  126. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  127. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  128. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  129. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  130. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  131. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  132. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  133. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  134. package/templates/site/scripts/ports.mjs +1 -1
  135. package/templates/web/package.json +0 -3
  136. package/templates/web/scripts/ports.mjs +1 -1
  137. package/templates/web/src/api-contract/api-error.ts +0 -11
  138. package/templates/web/src/api-contract/auth.ts +161 -0
  139. package/templates/web/src/api-contract/index.ts +10 -0
  140. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  141. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  142. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  143. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  144. package/templates/web/src/main.tsx +6 -4
  145. package/templates/web/src/router.tsx +1 -2
  146. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  147. package/templates/web/src/routes/_authenticated.tsx +1 -1
  148. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  149. package/templates/web/test/app.spec.tsx +47 -22
  150. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  151. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  152. package/templates/web/test/support/render-app.tsx +4 -4
  153. package/templates/workspace/scripts/ports.mjs +1 -1
  154. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  155. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  156. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  157. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  158. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  159. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  160. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  161. package/templates/mobile/src/features/auth/session.ts +0 -17
  162. package/templates/web/src/features/auth/auth-client.ts +0 -17
  163. package/templates/web/src/features/auth/session.ts +0 -17
@@ -1,8 +1,6 @@
1
1
  // tsyringe reads decorator metadata as each class is loaded, so this must come before any other import.
2
2
  import 'reflect-metadata'
3
3
  import { MikroORM } from '@mikro-orm/postgresql'
4
- import type { Pool } from 'pg'
5
- import { AUTH, type Auth } from '@/infra/auth/auth.factory'
6
4
  import { loadEnv } from '@/infra/config/env'
7
5
  import { createOrmConfig } from '@/infra/database/mikroorm/mikro-orm.factory'
8
6
  import { createContainer } from '@/infra/di'
@@ -18,10 +16,9 @@ async function main(): Promise<void> {
18
16
  const container = createContainer({ env, orm })
19
17
  const app = await buildApp(container)
20
18
 
21
- // Handling the signals takes away Node's default exit, so every open pool must close for the process to end.
19
+ // Handling the signals takes away Node's default exit, so the pool must close for the process to end.
22
20
  app.addHook('onClose', async () => {
23
21
  await orm.close()
24
- await (container.resolve<Auth>(AUTH).options.database as Pool).end()
25
22
  })
26
23
 
27
24
  for (const signal of ['SIGINT', 'SIGTERM'] as const) {
@@ -1,7 +1,17 @@
1
1
  import { createMcpHandler, type McpHttpHandler, McpServer } from '@modelcontextprotocol/server'
2
2
  import type { DependencyContainer } from 'tsyringe'
3
+ import type { Env } from '@/infra/config/env'
3
4
  import { registerUserTools } from './tools/users.tool'
4
5
 
6
+ // The protected resource MCP clients ask a token for; its access tokens carry it as their audience.
7
+ export function mcpResource(env: Pick<Env, 'API_URL'>): string {
8
+ return `${env.API_URL}/api/mcp`
9
+ }
10
+
11
+ export function mcpResourceMetadataUrl(env: Pick<Env, 'API_URL'>): string {
12
+ return `${env.API_URL}/.well-known/oauth-protected-resource/api/mcp`
13
+ }
14
+
5
15
  // One server per request: the protocol is stateless, and each instance knows only its caller.
6
16
  export function createMcpServerHandler(container: DependencyContainer): McpHttpHandler {
7
17
  return createMcpHandler(
@@ -0,0 +1,21 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import type { AuthorizationCodes } from '@/domain/oauth/ports/authorization-codes.port'
3
+
4
+ const sha256 = (value: string) => createHash('sha256').update(value).digest('base64url')
5
+
6
+ export class CryptoAuthorizationCodes implements AuthorizationCodes {
7
+ create(): string {
8
+ return randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ digest(code: string): string {
12
+ return sha256(code)
13
+ }
14
+
15
+ matchesChallenge(verifier: string, challenge: string): boolean {
16
+ const expected = Buffer.from(sha256(verifier))
17
+ const given = Buffer.from(challenge)
18
+
19
+ return expected.length === given.length && timingSafeEqual(expected, given)
20
+ }
21
+ }
@@ -0,0 +1,231 @@
1
+ import { type LookupAddress, lookup } from 'node:dns'
2
+ import { request } from 'node:https'
3
+ import { BlockList, isIP } from 'node:net'
4
+ import { z } from 'zod'
5
+ import { InvalidClientError } from '@/domain/oauth/errors/invalid-client.error'
6
+ import type {
7
+ ClientMetadata,
8
+ ClientMetadataSource,
9
+ } from '@/domain/oauth/ports/client-metadata.port'
10
+
11
+ // The draft's recommended ceiling: a description of a client has no reason to be larger.
12
+ const MAX_BYTES = 5 * 1024
13
+ const TIMEOUT_MS = 5000
14
+ const MAX_CACHE_MS = 60 * 60 * 1000
15
+
16
+ // Anything that is not the public internet. The client chooses the URL, so without this list it could make the API
17
+ // read its own network.
18
+ const PRIVATE = new BlockList()
19
+
20
+ for (const [network, prefix] of [
21
+ ['0.0.0.0', 8],
22
+ ['10.0.0.0', 8],
23
+ ['100.64.0.0', 10],
24
+ ['127.0.0.0', 8],
25
+ ['169.254.0.0', 16],
26
+ ['172.16.0.0', 12],
27
+ ['192.0.0.0', 24],
28
+ ['192.0.2.0', 24],
29
+ ['192.168.0.0', 16],
30
+ ['198.18.0.0', 15],
31
+ ['198.51.100.0', 24],
32
+ ['203.0.113.0', 24],
33
+ ['224.0.0.0', 4],
34
+ ['240.0.0.0', 4],
35
+ ] as const) {
36
+ PRIVATE.addSubnet(network, prefix, 'ipv4')
37
+ }
38
+
39
+ // An IPv4-mapped address (::ffff:127.0.0.1) is checked against the IPv4 rules by BlockList itself; a rule for the
40
+ // whole mapped range would match every IPv4 address, public ones included.
41
+ for (const [network, prefix] of [
42
+ ['::', 127],
43
+ ['64:ff9b::', 96],
44
+ ['100::', 64],
45
+ ['2001:db8::', 32],
46
+ ['fc00::', 7],
47
+ ['fe80::', 10],
48
+ ['ff00::', 8],
49
+ ] as const) {
50
+ PRIVATE.addSubnet(network, prefix, 'ipv6')
51
+ }
52
+
53
+ export function isPrivateAddress(address: string): boolean {
54
+ return PRIVATE.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')
55
+ }
56
+
57
+ const documentSchema = z.object({
58
+ client_id: z.string(),
59
+ client_name: z.string().trim().min(1).max(200),
60
+ redirect_uris: z.array(z.string()).min(1).max(20),
61
+ // A document is public by nature, so it cannot hold a secret, and a key-based method is not supported here.
62
+ token_endpoint_auth_method: z.literal('none').optional(),
63
+ client_secret: z.never().optional(),
64
+ client_secret_expires_at: z.never().optional(),
65
+ })
66
+
67
+ function parseJson(text: string): unknown {
68
+ try {
69
+ return JSON.parse(text)
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ function fail(message: string): never {
76
+ throw new InvalidClientError(message)
77
+ }
78
+
79
+ // https, a path, no credentials, no query, no fragment, no dot segments: and written exactly as the URL parser
80
+ // would write it, since the document's client_id is compared to it character by character.
81
+ function clientUrl(clientId: string): URL {
82
+ const url = URL.canParse(clientId) ? new URL(clientId) : fail('client_id is not a URL')
83
+
84
+ if (
85
+ url.protocol !== 'https:' ||
86
+ url.username !== '' ||
87
+ url.password !== '' ||
88
+ url.search !== '' ||
89
+ url.hash !== '' ||
90
+ url.pathname === '/' ||
91
+ url.href !== clientId
92
+ ) {
93
+ fail('client_id must be a canonical https URL with a path, and nothing else')
94
+ }
95
+
96
+ return url
97
+ }
98
+
99
+ // HTTPS anywhere, or plain HTTP back to this device, as MCP allows for native clients.
100
+ function isAllowedRedirect(uri: string): boolean {
101
+ if (!URL.canParse(uri)) {
102
+ return false
103
+ }
104
+
105
+ const url = new URL(uri)
106
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
107
+
108
+ return url.hash === '' && (url.protocol === 'https:' || (url.protocol === 'http:' && loopback))
109
+ }
110
+
111
+ // The connection goes to the address checked here, so a name that resolves differently a second time cannot reach
112
+ // the private network after passing.
113
+ function guardedLookup(
114
+ hostname: string,
115
+ options: { all?: boolean },
116
+ callback: (error: Error | null, address: string | LookupAddress[], family?: number) => void,
117
+ ): void {
118
+ lookup(hostname, { all: true }, (error, addresses) => {
119
+ if (error !== null) {
120
+ callback(error, [])
121
+ return
122
+ }
123
+
124
+ if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) {
125
+ callback(new Error(`${hostname} resolves to a private address`), [])
126
+ return
127
+ }
128
+
129
+ const [first] = addresses as [LookupAddress]
130
+
131
+ if (options.all === true) {
132
+ callback(null, addresses)
133
+ } else {
134
+ callback(null, first.address, first.family)
135
+ }
136
+ })
137
+ }
138
+
139
+ function download(url: URL): Promise<{ body: string; maxAgeMs: number }> {
140
+ return new Promise((resolve, reject) => {
141
+ const req = request(
142
+ url,
143
+ { method: 'GET', headers: { accept: 'application/json' }, lookup: guardedLookup as never },
144
+ (res) => {
145
+ if (res.statusCode !== 200) {
146
+ res.destroy()
147
+ reject(new Error(`answered ${res.statusCode}`))
148
+ return
149
+ }
150
+
151
+ const chunks: Buffer[] = []
152
+ let size = 0
153
+
154
+ res.on('data', (chunk: Buffer) => {
155
+ size += chunk.length
156
+
157
+ if (size > MAX_BYTES) {
158
+ res.destroy(new Error(`is larger than ${MAX_BYTES} bytes`))
159
+ } else {
160
+ chunks.push(chunk)
161
+ }
162
+ })
163
+ res.on('error', reject)
164
+ res.on('end', () => {
165
+ const maxAge = /max-age=(\d+)/.exec(res.headers['cache-control'] ?? '')?.[1]
166
+ const cacheable = !/no-store|no-cache/.test(res.headers['cache-control'] ?? '')
167
+
168
+ resolve({
169
+ body: Buffer.concat(chunks).toString('utf8'),
170
+ maxAgeMs:
171
+ cacheable && maxAge !== undefined ? Math.min(Number(maxAge) * 1000, MAX_CACHE_MS) : 0,
172
+ })
173
+ })
174
+ },
175
+ )
176
+
177
+ req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error('timed out')))
178
+ req.on('error', reject)
179
+ req.end()
180
+ })
181
+ }
182
+
183
+ export class HttpsClientMetadata implements ClientMetadataSource {
184
+ private readonly cache = new Map<string, { metadata: ClientMetadata; until: number }>()
185
+
186
+ async read(clientId: string): Promise<ClientMetadata> {
187
+ const cached = this.cache.get(clientId)
188
+
189
+ if (cached !== undefined && cached.until > Date.now()) {
190
+ return cached.metadata
191
+ }
192
+
193
+ const url = clientUrl(clientId)
194
+ const host = url.hostname.replace(/^\[|\]$/g, '')
195
+
196
+ // A literal address is connected to directly, without the lookup that guards a name.
197
+ if (isIP(host) !== 0 && isPrivateAddress(host)) {
198
+ fail('client_id points at a private address')
199
+ }
200
+
201
+ const { body, maxAgeMs } = await download(url).catch((error: Error) =>
202
+ fail(`The client metadata document could not be read: ${error.message}`),
203
+ )
204
+ const parsed = documentSchema.safeParse(parseJson(body))
205
+
206
+ if (!parsed.success) {
207
+ fail('The client metadata document is not a valid public client description')
208
+ }
209
+
210
+ if (parsed.data.client_id !== clientId) {
211
+ fail('The client metadata document names another client_id')
212
+ }
213
+
214
+ if (!parsed.data.redirect_uris.every(isAllowedRedirect)) {
215
+ fail('Every redirect URI must be https, or http to localhost')
216
+ }
217
+
218
+ const metadata = {
219
+ clientId,
220
+ clientName: parsed.data.client_name,
221
+ redirectUris: parsed.data.redirect_uris,
222
+ }
223
+
224
+ // Only a valid document is kept, and only as long as its server says it may be.
225
+ if (maxAgeMs > 0) {
226
+ this.cache.set(clientId, { metadata, until: Date.now() + maxAgeMs })
227
+ }
228
+
229
+ return metadata
230
+ }
231
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ROTATION_GRACE_MS, Session } from '@/domain/auth/entities/session.entity'
3
+
4
+ const NOW = new Date('2026-10-02T12:00:00Z')
5
+ const later = (ms: number) => new Date(NOW.getTime() + ms)
6
+
7
+ describe('Session', () => {
8
+ it('accepts the current token, and after a rotation only the new one', () => {
9
+ const session = new Session('user-1', 'first', NOW)
10
+
11
+ expect(session.check('first', NOW)).toBe('valid')
12
+
13
+ session.rotate('second', later(1000))
14
+
15
+ expect(session.check('second', later(2000))).toBe('valid')
16
+ })
17
+
18
+ it('tells a token rotated a moment ago from one kept and replayed', () => {
19
+ const session = new Session('user-1', 'first', NOW)
20
+
21
+ session.rotate('second', NOW)
22
+
23
+ expect(session.check('first', later(ROTATION_GRACE_MS - 1))).toBe('superseded')
24
+ expect(session.check('first', later(ROTATION_GRACE_MS))).toBe('reused')
25
+ expect(session.check('never-issued', NOW)).toBe('reused')
26
+ })
27
+
28
+ it('refuses every token once revoked or expired', () => {
29
+ const revoked = new Session('user-1', 'first', NOW)
30
+ revoked.revoke(NOW)
31
+
32
+ expect(revoked.check('first', NOW)).toBe('expired')
33
+ expect(new Session('user-1', 'first', NOW).check('first', later(31 * 86_400_000))).toBe(
34
+ 'expired',
35
+ )
36
+ })
37
+
38
+ it('reads back the refresh token it writes, and nothing malformed', () => {
39
+ const session = new Session('user-1', 'hash', NOW)
40
+ session.id = '0199a7e2-0000-7000-8000-000000000000'
41
+
42
+ expect(Session.parseRefreshToken(session.refreshToken('s3cret'))).toEqual({
43
+ sessionId: session.id,
44
+ secret: 's3cret',
45
+ })
46
+ for (const token of ['', 'no-dot', 'not-a-uuid.secret', `${session.id}.`]) {
47
+ expect(Session.parseRefreshToken(token), token).toBeNull()
48
+ }
49
+ })
50
+ })
@@ -0,0 +1,66 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { ROTATION_GRACE_MS } from '@/domain/auth/entities/session.entity'
4
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
5
+ import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
6
+
7
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
8
+
9
+ afterEach(() => {
10
+ vi.useRealTimers()
11
+ })
12
+
13
+ describe('RefreshSessionUseCase', () => {
14
+ it('rotates the refresh token on every use', async () => {
15
+ const { signUp, refresh } = authFakes()
16
+ const first = await signUp.execute(ANA)
17
+
18
+ const second = await refresh.execute(first.refreshToken)
19
+ const third = await refresh.execute(second.refreshToken)
20
+
21
+ expect(new Set([first.refreshToken, second.refreshToken, third.refreshToken]).size).toBe(3)
22
+ })
23
+
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)
27
+
28
+ const second = await refresh.execute(first.refreshToken)
29
+
30
+ await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(SupersededTokenError)
31
+ expect([...sessions.rows.values()][0]?.revokedAt).toBeNull()
32
+ await expect(refresh.execute(second.refreshToken)).resolves.toBeDefined()
33
+ })
34
+
35
+ it('ends the session when a token comes back after the grace period', async () => {
36
+ vi.useFakeTimers()
37
+ const { signUp, refresh, sessions } = authFakes()
38
+ const first = await signUp.execute(ANA)
39
+ const second = await refresh.execute(first.refreshToken)
40
+
41
+ vi.advanceTimersByTime(ROTATION_GRACE_MS)
42
+
43
+ await expect(refresh.execute(first.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
44
+ expect([...sessions.rows.values()][0]?.revokedAt).not.toBeNull()
45
+ // The thief's replay also locked out the rightful client: that is the point.
46
+ await expect(refresh.execute(second.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
47
+ })
48
+
49
+ it('refuses a token that names no session or is not one at all', async () => {
50
+ const { refresh } = authFakes()
51
+
52
+ for (const token of ['garbage', '0199a7e2-0000-7000-8000-000000000000.secret']) {
53
+ await expect(refresh.execute(token), token).rejects.toBeInstanceOf(InvalidSessionError)
54
+ }
55
+ })
56
+
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)
60
+ const session = [...sessions.rows.values()][0]
61
+
62
+ await signOut.execute(session?.userId ?? '', session?.id ?? '')
63
+
64
+ await expect(refresh.execute(tokens.refreshToken)).rejects.toBeInstanceOf(InvalidSessionError)
65
+ })
66
+ })
@@ -0,0 +1,29 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
4
+
5
+ const ANA = { name: 'Ana', email: 'ana@example.com', password: 'correct-horse-battery' }
6
+
7
+ describe('SignInUseCase', () => {
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)
11
+
12
+ await signIn.execute({ email: 'ANA@example.com', password: ANA.password })
13
+
14
+ expect(sessions.rows.size).toBe(2)
15
+ })
16
+
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)
20
+
21
+ await expect(signIn.execute({ email: ANA.email, password: 'wrong' })).rejects.toBeInstanceOf(
22
+ InvalidCredentialsError,
23
+ )
24
+ await expect(
25
+ signIn.execute({ email: 'nobody@example.com', password: ANA.password }),
26
+ ).rejects.toBeInstanceOf(InvalidCredentialsError)
27
+ expect(hasher.verifications).toBe(2)
28
+ })
29
+ })
@@ -0,0 +1,33 @@
1
+ import { authFakes } from '@test/support/fakes/auth-fakes'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
4
+
5
+ const ANA = { name: 'Ana', email: ' Ana@Example.com', password: 'correct-horse-battery' }
6
+
7
+ describe('SignUpUseCase', () => {
8
+ it('creates the user and the profile together, and starts a session', async () => {
9
+ const { signUp, users, profiles, sessions } = authFakes()
10
+
11
+ const tokens = await signUp.execute(ANA)
12
+ const [user] = [...users.rows.values()]
13
+
14
+ expect(user).toMatchObject({
15
+ email: 'ana@example.com',
16
+ passwordHash: 'hashed:correct-horse-battery',
17
+ })
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
+ })
22
+
23
+ it('refuses an email already taken, whatever its case', async () => {
24
+ const { signUp, profiles } = authFakes()
25
+
26
+ await signUp.execute(ANA)
27
+
28
+ await expect(signUp.execute({ ...ANA, email: 'ana@example.COM' })).rejects.toBeInstanceOf(
29
+ EmailTakenError,
30
+ )
31
+ expect(profiles.rows.size).toBe(1)
32
+ })
33
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ AUTHORIZATION_CODE_LIFETIME_MS,
4
+ AUTHORIZATION_REQUEST_LIFETIME_MS,
5
+ Authorization,
6
+ } from '@/domain/oauth/entities/authorization.entity'
7
+
8
+ const NOW = new Date('2026-10-02T12:00:00Z')
9
+ const later = (ms: number) => new Date(NOW.getTime() + ms)
10
+
11
+ const request = {
12
+ clientId: 'https://client.example.com/client.json',
13
+ clientName: 'Client',
14
+ redirectUri: 'https://client.example.com/callback',
15
+ codeChallenge: 'challenge',
16
+ clientState: null,
17
+ resource: 'http://localhost:3000/api/mcp',
18
+ }
19
+
20
+ describe('Authorization', () => {
21
+ it('waits for an answer until the request expires', () => {
22
+ const authorization = new Authorization(request, NOW)
23
+
24
+ expect(authorization.isPending(later(AUTHORIZATION_REQUEST_LIFETIME_MS - 1))).toBe(true)
25
+ expect(authorization.isPending(later(AUTHORIZATION_REQUEST_LIFETIME_MS))).toBe(false)
26
+ })
27
+
28
+ it('gives an approved code one minute, and no second exchange', () => {
29
+ const authorization = new Authorization(request, NOW)
30
+
31
+ authorization.approve('user-1', 'hash', NOW)
32
+
33
+ expect(authorization.isPending(NOW)).toBe(false)
34
+ expect(authorization.isRedeemable(later(AUTHORIZATION_CODE_LIFETIME_MS - 1))).toBe(true)
35
+ expect(authorization.isRedeemable(later(AUTHORIZATION_CODE_LIFETIME_MS))).toBe(false)
36
+
37
+ authorization.exchange('session-1')
38
+
39
+ expect(authorization.isRedeemable(NOW)).toBe(false)
40
+ })
41
+
42
+ it('reads back the code it writes, and nothing malformed', () => {
43
+ const authorization = new Authorization(request, NOW)
44
+ authorization.id = '0199a7e2-0000-7000-8000-000000000000'
45
+
46
+ expect(Authorization.parseCode(authorization.code('s3cret'))).toEqual({
47
+ authorizationId: authorization.id,
48
+ secret: 's3cret',
49
+ })
50
+ expect(Authorization.parseCode('nonsense')).toBeNull()
51
+ })
52
+ })
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { Argon2PasswordHasher } from '@/infra/auth/argon2-password-hasher.adapter'
3
+
4
+ describe('Argon2PasswordHasher', () => {
5
+ it('stores Argon2id with OWASP’s minimum, and verifies only the right password', async () => {
6
+ const hasher = new Argon2PasswordHasher()
7
+ const stored = await hasher.hash('correct-horse-battery')
8
+
9
+ expect(stored).toMatch(/^\$argon2id\$v=19\$m=19456,t=2,p=1\$/)
10
+ expect(await hasher.verify(stored, 'correct-horse-battery')).toBe(true)
11
+ expect(await hasher.verify(stored, 'wrong')).toBe(false)
12
+ })
13
+
14
+ it('answers false without a hash, after doing the same work', async () => {
15
+ expect(await new Argon2PasswordHasher().verify(undefined, 'anything')).toBe(false)
16
+ })
17
+ })
@@ -0,0 +1,57 @@
1
+ import { SignJWT, UnsecuredJWT } from 'jose'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { JoseAccessTokens } from '@/infra/auth/jose-access-tokens.adapter'
4
+
5
+ const ENV = {
6
+ JWT_SECRET: 'a-secret-that-is-at-least-32-characters',
7
+ API_URL: 'http://localhost:3000',
8
+ }
9
+ const CLAIMS = { userId: '0199a7e2-0000-7000-8000-000000000001', sessionId: 'session-1' }
10
+ const inAMinute = () => new Date(Date.now() + 60_000)
11
+
12
+ describe('JoseAccessTokens', () => {
13
+ it('reads back the claims of a token it issued', async () => {
14
+ const tokens = new JoseAccessTokens(ENV)
15
+ const { token } = await tokens.issue(CLAIMS, inAMinute())
16
+
17
+ expect(await tokens.verify(token)).toEqual(CLAIMS)
18
+ })
19
+
20
+ it('refuses an expired token, another secret, another issuer and garbage', async () => {
21
+ const tokens = new JoseAccessTokens(ENV)
22
+ const expired = await tokens.issue(CLAIMS, new Date(Date.now() - 1000))
23
+ const otherSecret = await new JoseAccessTokens({ ...ENV, JWT_SECRET: 'x'.repeat(40) }).issue(
24
+ CLAIMS,
25
+ inAMinute(),
26
+ )
27
+ const otherIssuer = await new JoseAccessTokens({ ...ENV, API_URL: 'https://elsewhere' }).issue(
28
+ CLAIMS,
29
+ inAMinute(),
30
+ )
31
+
32
+ for (const token of [expired.token, otherSecret.token, otherIssuer.token, 'not.a.jwt']) {
33
+ expect(await tokens.verify(token)).toBeNull()
34
+ }
35
+ })
36
+
37
+ it('refuses a token that names no algorithm or another one', async () => {
38
+ const tokens = new JoseAccessTokens(ENV)
39
+ const claims = { sid: CLAIMS.sessionId }
40
+ const unsigned = new UnsecuredJWT(claims)
41
+ .setSubject(CLAIMS.userId)
42
+ .setIssuer(ENV.API_URL)
43
+ .setAudience(ENV.API_URL)
44
+ .setExpirationTime('1m')
45
+ .encode()
46
+ const hs512 = await new SignJWT(claims)
47
+ .setProtectedHeader({ alg: 'HS512' })
48
+ .setSubject(CLAIMS.userId)
49
+ .setIssuer(ENV.API_URL)
50
+ .setAudience(ENV.API_URL)
51
+ .setExpirationTime('1m')
52
+ .sign(new TextEncoder().encode(`${ENV.JWT_SECRET}${'-'.repeat(32)}`))
53
+
54
+ expect(await tokens.verify(unsigned)).toBeNull()
55
+ expect(await tokens.verify(hs512)).toBeNull()
56
+ })
57
+ })
@@ -2,7 +2,7 @@ import { testOrm } from '@test/support/setup'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  describe('createOrmConfig', () => {
5
- it('finds nothing to change once migrated, and leaves Better Auth’s schema alone', async () => {
5
+ it('finds nothing to change once migrated', async () => {
6
6
  await expect(testOrm().schema.getUpdateSchemaSQL({ wrap: false })).resolves.toBe('')
7
7
  })
8
8
  })
@@ -0,0 +1,28 @@
1
+ import { testOrm } from '@test/support/setup'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import { MikroOrmUserRepository } from '@/infra/database/mikroorm/repositories/mikroorm-user.repository'
6
+
7
+ describe('MikroOrmUserRepository', () => {
8
+ it('finds a user by its normalized email', async () => {
9
+ const repository = new MikroOrmUserRepository(testOrm().em.fork())
10
+ const user = new User('Ana@Example.com', 'hash')
11
+
12
+ await repository.save(user)
13
+
14
+ expect(
15
+ await new MikroOrmUserRepository(testOrm().em.fork()).findByEmail('ana@example.com'),
16
+ ).toMatchObject({
17
+ id: user.id,
18
+ })
19
+ })
20
+
21
+ it('turns the unique index into EmailTakenError when two sign-ups race', async () => {
22
+ await new MikroOrmUserRepository(testOrm().em.fork()).save(new User('ana@example.com', 'hash'))
23
+
24
+ await expect(
25
+ new MikroOrmUserRepository(testOrm().em.fork()).save(new User('ana@example.com', 'hash')),
26
+ ).rejects.toBeInstanceOf(EmailTakenError)
27
+ })
28
+ })