@stjoseph/prumo 0.1.1 → 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 (173) hide show
  1. package/.prumo-templates/api/auth.md +50 -48
  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 +19 -14
  16. package/dist/clean.js +13 -3
  17. package/dist/cli.js +38 -9
  18. package/dist/commands.js +7 -4
  19. package/dist/compose.js +4 -12
  20. package/dist/doctor.js +31 -17
  21. package/dist/generate.js +56 -9
  22. package/dist/mcp.js +15 -8
  23. package/dist/names.js +42 -0
  24. package/dist/questions.js +32 -16
  25. package/package.json +2 -2
  26. package/templates/api/.env.example +3 -5
  27. package/templates/api/README.md +5 -6
  28. package/templates/api/docker-compose.yml +0 -1
  29. package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
  30. package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
  31. package/templates/api/package.json +7 -8
  32. package/templates/api/scripts/database.mjs +3 -11
  33. package/templates/api/scripts/ports.mjs +1 -1
  34. package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
  35. package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
  36. package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
  37. package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
  38. package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
  39. package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
  40. package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
  41. package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
  42. package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
  43. package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
  44. package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
  45. package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
  46. package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
  47. package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
  48. package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
  49. package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
  50. package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
  51. package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
  52. package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
  53. package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
  54. package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
  55. package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
  56. package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
  57. package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
  58. package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
  59. package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
  60. package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
  61. package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
  62. package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
  63. package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
  64. package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
  65. package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
  66. package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
  67. package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
  68. package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
  69. package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
  70. package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
  71. package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
  72. package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
  73. package/templates/api/src/infra/config/env.ts +3 -7
  74. package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
  75. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
  76. package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
  77. package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
  78. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
  79. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
  80. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
  81. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
  82. package/templates/api/src/infra/di/auth.di.ts +21 -0
  83. package/templates/api/src/infra/di/index.ts +4 -10
  84. package/templates/api/src/infra/di/oauth.di.ts +14 -0
  85. package/templates/api/src/infra/http/app.ts +11 -1
  86. package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
  87. package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
  88. package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
  89. package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
  90. package/templates/api/src/infra/http/fastify.d.ts +2 -3
  91. package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
  92. package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
  93. package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
  94. package/templates/api/src/infra/http/server.ts +1 -4
  95. package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
  96. package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
  97. package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
  98. package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
  99. package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
  100. package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
  101. package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
  102. package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
  103. package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
  104. package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
  105. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
  106. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
  107. package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
  108. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
  109. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
  110. package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
  111. package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
  112. package/templates/api/test/support/factories/user.factory.ts +9 -11
  113. package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
  114. package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
  115. package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
  116. package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
  117. package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
  118. package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
  119. package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
  120. package/templates/api/test/support/setup.ts +6 -28
  121. package/templates/mobile/README.md +1 -1
  122. package/templates/mobile/jest.config.js +0 -17
  123. package/templates/mobile/package.json +1 -4
  124. package/templates/mobile/scripts/ports.mjs +1 -1
  125. package/templates/mobile/src/api-contract/api-error.ts +0 -11
  126. package/templates/mobile/src/api-contract/auth.ts +161 -0
  127. package/templates/mobile/src/api-contract/index.ts +10 -0
  128. package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
  129. package/templates/mobile/src/app/_layout.tsx +5 -5
  130. package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
  131. package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
  132. package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
  133. package/templates/mobile/src/features/auth/token-store.ts +21 -0
  134. package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
  135. package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
  136. package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
  137. package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
  138. package/templates/mobile/test/support/memory-token-store.ts +15 -0
  139. package/templates/site/README.md +1 -1
  140. package/templates/site/package.json +1 -1
  141. package/templates/site/scripts/ports.mjs +1 -1
  142. package/templates/web/README.md +1 -1
  143. package/templates/web/package.json +1 -3
  144. package/templates/web/scripts/ports.mjs +1 -1
  145. package/templates/web/src/api-contract/api-error.ts +0 -11
  146. package/templates/web/src/api-contract/auth.ts +161 -0
  147. package/templates/web/src/api-contract/index.ts +10 -0
  148. package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
  149. package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
  150. package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
  151. package/templates/web/src/features/oauth/consent-form.tsx +66 -35
  152. package/templates/web/src/main.tsx +6 -4
  153. package/templates/web/src/router.tsx +1 -2
  154. package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
  155. package/templates/web/src/routes/_authenticated.tsx +1 -1
  156. package/templates/web/test/api-contract/auth.spec.ts +155 -0
  157. package/templates/web/test/app.spec.tsx +47 -22
  158. package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
  159. package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
  160. package/templates/web/test/support/render-app.tsx +4 -4
  161. package/templates/workspace/README.md +1 -1
  162. package/templates/workspace/package.json +1 -1
  163. package/templates/workspace/scripts/ports.mjs +1 -1
  164. package/templates/api/docker/init/01-auth-schema.sql +0 -1
  165. package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
  166. package/templates/api/src/infra/auth/auth.cli.ts +0 -5
  167. package/templates/api/src/infra/auth/auth.factory.ts +0 -77
  168. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
  169. package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
  170. package/templates/mobile/src/features/auth/auth-client.ts +0 -27
  171. package/templates/mobile/src/features/auth/session.ts +0 -17
  172. package/templates/web/src/features/auth/auth-client.ts +0 -17
  173. package/templates/web/src/features/auth/session.ts +0 -17
@@ -0,0 +1,21 @@
1
+ import { hash, verify } from '@node-rs/argon2'
2
+ import type { PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
3
+
4
+ // The library's defaults are Argon2id with 19 MiB, two passes and one lane: OWASP's minimum, so none is restated.
5
+ export class Argon2PasswordHasher implements PasswordHasher {
6
+ // A real hash of nothing in particular, so an unknown email costs one verification like any other.
7
+ private readonly decoy = hash('decoy password that nobody holds')
8
+
9
+ hash(password: string): Promise<string> {
10
+ return hash(password)
11
+ }
12
+
13
+ async verify(stored: string | undefined, password: string): Promise<boolean> {
14
+ if (stored === undefined) {
15
+ await verify(await this.decoy, password)
16
+ return false
17
+ }
18
+
19
+ return verify(stored, password)
20
+ }
21
+ }
@@ -0,0 +1,14 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
3
+
4
+ // 256 random bits need no slow hash: nothing can be guessed, so a plain SHA-256 is enough to keep a stolen
5
+ // database from handing out working tokens.
6
+ export class CryptoOpaqueTokens implements OpaqueTokens {
7
+ create(): string {
8
+ return randomBytes(32).toString('base64url')
9
+ }
10
+
11
+ digest(token: string): string {
12
+ return createHash('sha256').update(token).digest('base64url')
13
+ }
14
+ }
@@ -0,0 +1,62 @@
1
+ import { jwtVerify, SignJWT } from 'jose'
2
+ import type {
3
+ AccessClaims,
4
+ AccessTokens,
5
+ IssuedToken,
6
+ } from '@/domain/auth/ports/access-tokens.port'
7
+ import type { Env } from '@/infra/config/env'
8
+
9
+ // One algorithm, named on both sides: a token that declares any other, `none` included, is refused.
10
+ const ALGORITHM = 'HS256'
11
+
12
+ export class JoseAccessTokens implements AccessTokens {
13
+ private readonly key: Uint8Array
14
+ private readonly issuer: string
15
+
16
+ constructor(env: Pick<Env, 'JWT_SECRET' | 'API_URL'>) {
17
+ this.key = new TextEncoder().encode(env.JWT_SECRET)
18
+ this.issuer = env.API_URL
19
+ }
20
+
21
+ async issue(
22
+ { userId, sessionId, clientId }: AccessClaims,
23
+ expiresAt: Date,
24
+ audience = this.issuer,
25
+ ): Promise<IssuedToken> {
26
+ const token = await new SignJWT({
27
+ sid: sessionId,
28
+ ...(clientId !== undefined && { client_id: clientId }),
29
+ })
30
+ .setProtectedHeader({ alg: ALGORITHM })
31
+ .setSubject(userId)
32
+ .setIssuer(this.issuer)
33
+ .setAudience(audience)
34
+ .setIssuedAt()
35
+ .setExpirationTime(Math.floor(expiresAt.getTime() / 1000))
36
+ .sign(this.key)
37
+
38
+ return { token, expiresAt }
39
+ }
40
+
41
+ async verify(token: string, audience = this.issuer): Promise<AccessClaims | null> {
42
+ try {
43
+ const { payload } = await jwtVerify(token, this.key, {
44
+ algorithms: [ALGORITHM],
45
+ issuer: this.issuer,
46
+ audience,
47
+ })
48
+
49
+ if (typeof payload.sub !== 'string' || typeof payload.sid !== 'string') {
50
+ return null
51
+ }
52
+
53
+ return {
54
+ userId: payload.sub,
55
+ sessionId: payload.sid,
56
+ ...(typeof payload.client_id === 'string' && { clientId: payload.client_id }),
57
+ }
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+ }
@@ -5,14 +5,10 @@ const envSchema = z.object({
5
5
  NODE_ENV: z.enum(['development', 'test', 'production']),
6
6
  PORT: z.coerce.number().int().min(1).max(65535),
7
7
  DATABASE_URL: z.string().min(1),
8
- AUTH_DATABASE_URL: z.string().min(1),
9
- BETTER_AUTH_SECRET: z.string().min(32),
10
- BETTER_AUTH_URL: z.string().min(1),
8
+ JWT_SECRET: z.string().min(32),
9
+ // Where this API is reached from outside: the issuer of its tokens, and what decides whether cookies are Secure.
10
+ API_URL: z.url(),
11
11
  WEB_ORIGIN: z.string().min(1),
12
- MOBILE_APP_SCHEME: z
13
- .string()
14
- .regex(/^[a-z][a-z0-9+.-]*$/)
15
- .optional(),
16
12
  LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']),
17
13
  })
18
14
 
@@ -0,0 +1,37 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+
4
+ export const AuthorizationSchema = new EntitySchema({
5
+ class: Authorization,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ clientId: { type: 'text' },
9
+ clientName: { type: 'text' },
10
+ redirectUri: { type: 'text' },
11
+ codeChallenge: { type: 'text' },
12
+ clientState: { type: 'text', nullable: true },
13
+ resource: { type: 'text' },
14
+ status: { type: 'text' },
15
+ userId: {
16
+ kind: 'm:1',
17
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
18
+ entity: () => 'User' as never,
19
+ mapToPk: true,
20
+ fieldName: 'user_id',
21
+ nullable: true,
22
+ index: true,
23
+ // An authorization means nothing without the user who gave it.
24
+ deleteRule: 'cascade',
25
+ },
26
+ codeHash: { type: 'text', nullable: true },
27
+ sessionId: { type: 'uuid', nullable: true },
28
+ expiresAt: { type: 'datetime' },
29
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
30
+ updatedAt: {
31
+ type: 'datetime',
32
+ onCreate: () => new Date(),
33
+ onUpdate: () => new Date(),
34
+ defaultRaw: 'now()',
35
+ },
36
+ },
37
+ })
@@ -8,7 +8,7 @@ export const ProfileSchema = new EntitySchema({
8
8
  userId: {
9
9
  kind: 'm:1',
10
10
  // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
- entity: () => 'AuthUser' as never,
11
+ entity: () => 'User' as never,
12
12
  mapToPk: true,
13
13
  fieldName: 'user_id',
14
14
  unique: true,
@@ -0,0 +1,33 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { Session } from '@/domain/auth/entities/session.entity'
3
+
4
+ export const SessionSchema = new EntitySchema({
5
+ class: Session,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ userId: {
9
+ kind: 'm:1',
10
+ // EntitySchema cannot type a to-one relation held as its primary key on a string property.
11
+ entity: () => 'User' as never,
12
+ mapToPk: true,
13
+ fieldName: 'user_id',
14
+ index: true,
15
+ // A session means nothing without its user.
16
+ deleteRule: 'cascade',
17
+ },
18
+ tokenHash: { type: 'text' },
19
+ clientId: { type: 'text', nullable: true },
20
+ resource: { type: 'text', nullable: true },
21
+ previousTokenHash: { type: 'text', nullable: true },
22
+ rotatedAt: { type: 'datetime', nullable: true },
23
+ expiresAt: { type: 'datetime' },
24
+ revokedAt: { type: 'datetime', nullable: true },
25
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
26
+ updatedAt: {
27
+ type: 'datetime',
28
+ onCreate: () => new Date(),
29
+ onUpdate: () => new Date(),
30
+ defaultRaw: 'now()',
31
+ },
32
+ },
33
+ })
@@ -0,0 +1,18 @@
1
+ import { EntitySchema } from '@mikro-orm/core'
2
+ import { User } from '@/domain/auth/entities/user.entity'
3
+
4
+ export const UserSchema = new EntitySchema({
5
+ class: User,
6
+ properties: {
7
+ id: { type: 'uuid', primary: true, defaultRaw: 'uuidv7()' },
8
+ email: { type: 'text', unique: true },
9
+ passwordHash: { type: 'text' },
10
+ createdAt: { type: 'datetime', onCreate: () => new Date(), defaultRaw: 'now()' },
11
+ updatedAt: {
12
+ type: 'datetime',
13
+ onCreate: () => new Date(),
14
+ onUpdate: () => new Date(),
15
+ defaultRaw: 'now()',
16
+ },
17
+ },
18
+ })
@@ -1,17 +1,22 @@
1
1
  import { Migrator } from '@mikro-orm/migrations'
2
2
  import { defineConfig } from '@mikro-orm/postgresql'
3
3
  import type { Env } from '@/infra/config/env'
4
- import { AuthUserSchema } from './entities/auth-user.schema'
4
+ import { AuthorizationSchema } from './entities/authorization.schema' // prumo:mcp
5
5
  import { ProfileSchema } from './entities/profile.schema'
6
+ import { SessionSchema } from './entities/session.schema'
7
+ import { UserSchema } from './entities/user.schema'
6
8
 
7
9
  export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
8
10
  return defineConfig({
9
11
  clientUrl: env.DATABASE_URL,
10
- entities: [ProfileSchema, AuthUserSchema],
12
+ entities: [
13
+ UserSchema,
14
+ ProfileSchema,
15
+ SessionSchema,
16
+ AuthorizationSchema, // prumo:mcp
17
+ ],
11
18
  extensions: [Migrator],
12
19
  migrations: { path: './migrations', snapshot: false },
13
- // Better Auth migrates its own schema; without this, every generated migration drops it.
14
- schemaGenerator: { ignoreSchema: ['auth'], skipTables: ['auth.user'] },
15
20
  debug: env.NODE_ENV === 'development',
16
21
  })
17
22
  }
@@ -0,0 +1,21 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
4
+ import type { AuthorizationRepository } from '@/domain/oauth/repositories/authorization.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmAuthorizationRepository implements AuthorizationRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Authorization | null> {
11
+ return this.em.findOne(Authorization, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Authorization | null> {
15
+ return this.em.findOne(Authorization, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(authorization: Authorization): Promise<void> {
19
+ await this.em.persist(authorization).flush()
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import { EntityManager, LockMode } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { Session } from '@/domain/auth/entities/session.entity'
4
+ import type { SessionRepository } from '@/domain/auth/repositories/session.repository'
5
+
6
+ @injectable()
7
+ export class MikroOrmSessionRepository implements SessionRepository {
8
+ constructor(private readonly em: EntityManager) {}
9
+
10
+ findById(id: string): Promise<Session | null> {
11
+ return this.em.findOne(Session, { id })
12
+ }
13
+
14
+ lockById(id: string): Promise<Session | null> {
15
+ return this.em.findOne(Session, { id }, { lockMode: LockMode.PESSIMISTIC_WRITE })
16
+ }
17
+
18
+ async save(session: Session): Promise<void> {
19
+ await this.em.persist(session).flush()
20
+ }
21
+ }
@@ -0,0 +1,31 @@
1
+ import { EntityManager, UniqueConstraintViolationException } from '@mikro-orm/postgresql'
2
+ import { injectable } from 'tsyringe'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import type { UserRepository } from '@/domain/auth/repositories/user.repository'
6
+
7
+ @injectable()
8
+ export class MikroOrmUserRepository implements UserRepository {
9
+ constructor(private readonly em: EntityManager) {}
10
+
11
+ findById(id: string): Promise<User | null> {
12
+ return this.em.findOne(User, { id })
13
+ }
14
+
15
+ findByEmail(email: string): Promise<User | null> {
16
+ return this.em.findOne(User, { email })
17
+ }
18
+
19
+ async save(user: User): Promise<void> {
20
+ try {
21
+ await this.em.persist(user).flush()
22
+ } catch (error) {
23
+ // Two sign-ups with one email can both pass the check; the unique index settles which one exists.
24
+ if (error instanceof UniqueConstraintViolationException) {
25
+ throw new EmailTakenError()
26
+ }
27
+
28
+ throw error
29
+ }
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { ACCESS_TOKENS } from '@/domain/auth/ports/access-tokens.port'
3
+ import { OPAQUE_TOKENS } from '@/domain/auth/ports/opaque-tokens.port'
4
+ import { PASSWORD_HASHER } from '@/domain/auth/ports/password-hasher.port'
5
+ import { SESSION_REPOSITORY } from '@/domain/auth/repositories/session.repository'
6
+ import { USER_REPOSITORY } from '@/domain/auth/repositories/user.repository'
7
+ import { Argon2PasswordHasher } from '@/infra/auth/argon2-password-hasher.adapter'
8
+ import { CryptoOpaqueTokens } from '@/infra/auth/crypto-opaque-tokens.adapter'
9
+ import { JoseAccessTokens } from '@/infra/auth/jose-access-tokens.adapter'
10
+ import { ENV, type Env } from '@/infra/config/env'
11
+ import { MikroOrmSessionRepository } from '@/infra/database/mikroorm/repositories/mikroorm-session.repository'
12
+ import { MikroOrmUserRepository } from '@/infra/database/mikroorm/repositories/mikroorm-user.repository'
13
+
14
+ export function registerAuth(container: DependencyContainer): void {
15
+ container.register(USER_REPOSITORY, { useClass: MikroOrmUserRepository })
16
+ container.register(SESSION_REPOSITORY, { useClass: MikroOrmSessionRepository })
17
+ // One instance each: the hasher keeps its decoy hash, the signer its key.
18
+ container.register(PASSWORD_HASHER, { useValue: new Argon2PasswordHasher() })
19
+ container.register(ACCESS_TOKENS, { useValue: new JoseAccessTokens(container.resolve<Env>(ENV)) })
20
+ container.register(OPAQUE_TOKENS, { useValue: new CryptoOpaqueTokens() })
21
+ }
@@ -1,10 +1,10 @@
1
1
  import { EntityManager, MikroORM } from '@mikro-orm/postgresql'
2
2
  import { type DependencyContainer, container as root } from 'tsyringe'
3
3
  import { TRANSACTION_MANAGER } from '@/domain/shared/transactions/transaction-manager'
4
- import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
5
- import { AUTH, createAuth } from '@/infra/auth/auth.factory'
6
4
  import { ENV, type Env } from '@/infra/config/env'
7
5
  import { MikroOrmTransactionManager } from '@/infra/database/mikroorm/transactions/mikroorm-transaction-manager'
6
+ import { registerAuth } from './auth.di'
7
+ import { registerOauth } from './oauth.di' // prumo:mcp
8
8
  import { registerUsers } from './users.di'
9
9
 
10
10
  export type Dependencies = { env: Env; orm: MikroORM }
@@ -18,15 +18,9 @@ export function createContainer({ env, orm }: Dependencies): DependencyContainer
18
18
  container.register(EntityManager, { useValue: orm.em })
19
19
  container.register(TRANSACTION_MANAGER, { useClass: MikroOrmTransactionManager })
20
20
 
21
+ registerAuth(container)
21
22
  registerUsers(container)
22
-
23
- container.register(AUTH, {
24
- useValue: createAuth(env, {
25
- onUserCreated: async (user) => {
26
- await container.resolve(CreateProfileUseCase).execute(user.id, user.name)
27
- },
28
- }),
29
- })
23
+ registerOauth(container) // prumo:mcp
30
24
 
31
25
  return container
32
26
  }
@@ -0,0 +1,14 @@
1
+ import type { DependencyContainer } from 'tsyringe'
2
+ import { AUTHORIZATION_CODES } from '@/domain/oauth/ports/authorization-codes.port'
3
+ import { CLIENT_METADATA } from '@/domain/oauth/ports/client-metadata.port'
4
+ import { AUTHORIZATION_REPOSITORY } from '@/domain/oauth/repositories/authorization.repository'
5
+ import { MikroOrmAuthorizationRepository } from '@/infra/database/mikroorm/repositories/mikroorm-authorization.repository'
6
+ import { CryptoAuthorizationCodes } from '@/infra/oauth/crypto-authorization-codes.adapter'
7
+ import { HttpsClientMetadata } from '@/infra/oauth/https-client-metadata.adapter'
8
+
9
+ export function registerOauth(container: DependencyContainer): void {
10
+ container.register(AUTHORIZATION_REPOSITORY, { useClass: MikroOrmAuthorizationRepository })
11
+ container.register(AUTHORIZATION_CODES, { useValue: new CryptoAuthorizationCodes() })
12
+ // One instance, so its cache of client documents outlives a request.
13
+ container.register(CLIENT_METADATA, { useValue: new HttpsClientMetadata() })
14
+ }
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import cookie from '@fastify/cookie'
2
3
  import cors from '@fastify/cors'
4
+ import rateLimit from '@fastify/rate-limit'
3
5
  import swagger from '@fastify/swagger'
4
6
  import swaggerUi from '@fastify/swagger-ui'
5
7
  import { EntityManager } from '@mikro-orm/postgresql'
@@ -14,9 +16,11 @@ import { ENV, type Env } from '@/infra/config/env'
14
16
  import { authController } from '@/infra/http/controllers/auth.controller'
15
17
  import { healthController } from '@/infra/http/controllers/health.controller'
16
18
  import { mcpController } from '@/infra/http/controllers/mcp.controller' // prumo:mcp
19
+ import { oauthController } from '@/infra/http/controllers/oauth.controller' // prumo:mcp
17
20
  import { usersController } from '@/infra/http/controllers/users.controller'
18
21
  import { registerErrorHandler } from '@/infra/http/errors/error-handler'
19
22
  import { registerAuthHook } from '@/infra/http/hooks/auth.hook'
23
+ import { registerOriginCheck } from '@/infra/http/hooks/origin.hook'
20
24
  import { registerRequestContext } from '@/infra/http/hooks/request-context.hook'
21
25
 
22
26
  const DOCS = '/api/docs'
@@ -82,16 +86,22 @@ export async function buildApp(container: DependencyContainer): Promise<FastifyI
82
86
  exposedHeaders: ['X-Request-Id'],
83
87
  })
84
88
 
89
+ await app.register(cookie)
90
+ // Off by default: a route opts in through its config, so only the routes that take a password are limited.
91
+ await app.register(rateLimit, { global: false })
92
+ registerOriginCheck(app, env.WEB_ORIGIN)
93
+
85
94
  if (env.NODE_ENV !== 'production') {
86
95
  await registerDocs(app)
87
96
  }
88
97
 
89
98
  registerAuthHook(app)
90
99
 
91
- await app.register(authController)
100
+ await app.register(authController, { prefix: '/api/auth' })
92
101
  await app.register(healthController, { prefix: '/api/health' })
93
102
  await app.register(usersController, { prefix: '/api/v1/users' })
94
103
  // prumo:mcp
104
+ await app.register(oauthController)
95
105
  await app.register(mcpController, { prefix: '/api/mcp' })
96
106
  // prumo:end-mcp
97
107
 
@@ -1,36 +1,159 @@
1
- import type { FastifyInstance } from 'fastify'
2
- import { AUTH, type Auth } from '@/infra/auth/auth.factory'
3
- import { sendFetchResponse, toFetchRequest } from '@/infra/http/fetch-bridge'
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
2
+ import type { ZodTypeProvider } from 'fastify-type-provider-zod'
3
+ import { refreshSchema } from '@/domain/auth/dto/refresh.dto'
4
+ import { sessionResponseSchema } from '@/domain/auth/dto/session-response.dto'
5
+ import { signInSchema } from '@/domain/auth/dto/sign-in.dto'
6
+ import { signUpSchema } from '@/domain/auth/dto/sign-up.dto'
7
+ import { type TokensResponseDto, tokensResponseSchema } from '@/domain/auth/dto/tokens-response.dto'
8
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
9
+ import { FindUserUseCase } from '@/domain/auth/use-cases/find-user.use-case'
10
+ import { RefreshSessionUseCase } from '@/domain/auth/use-cases/refresh-session.use-case'
11
+ import { SignInUseCase } from '@/domain/auth/use-cases/sign-in.use-case'
12
+ import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
13
+ import { SignUpUseCase } from '@/domain/auth/use-cases/sign-up.use-case'
14
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
15
+ import { ENV, type Env } from '@/infra/config/env'
16
+ import { ACCESS_COOKIE, currentSession, currentUser } from '@/infra/http/hooks/auth.hook'
17
+
18
+ const REFRESH_COOKIE = 'refresh_token'
19
+
20
+ // The refresh cookie travels only to the one route that reads it, never with an ordinary request.
21
+ const REFRESH_PATH = '/api/auth/refresh'
22
+
23
+ // Repeated guessing is slowed down per address; a refresh happens every quarter hour per tab, so it gets more room.
24
+ const ATTEMPTS = { rateLimit: { max: 10, timeWindow: '1 minute' } }
25
+ const REFRESHES = { rateLimit: { max: 60, timeWindow: '1 minute' } }
26
+
27
+ // A native client asks for its tokens in the body. The web never does: a token JavaScript can read is a token an
28
+ // injected script can take.
29
+ function wantsBearer(request: FastifyRequest): boolean {
30
+ return request.headers['x-auth-transport'] === 'bearer'
31
+ }
32
+
33
+ function asBody(tokens: SessionTokens): TokensResponseDto {
34
+ return {
35
+ accessToken: tokens.accessToken.token,
36
+ refreshToken: tokens.refreshToken,
37
+ expiresIn: Math.round((tokens.accessToken.expiresAt.getTime() - Date.now()) / 1000),
38
+ }
39
+ }
4
40
 
5
41
  export async function authController(fastify: FastifyInstance): Promise<void> {
6
- const auth = fastify.container.resolve<Auth>(AUTH)
7
- const routes = { config: { public: true }, schema: { hide: true } } as const
8
-
9
- // prumo:mcp
10
- // OAuth's token endpoint takes a form. It is kept as text and handed to Better Auth as sent.
11
- fastify.addContentTypeParser(
12
- 'application/x-www-form-urlencoded',
13
- { parseAs: 'string' },
14
- (_request, body, done) => done(null, body),
42
+ const app = fastify.withTypeProvider<ZodTypeProvider>()
43
+ const env = app.container.resolve<Env>(ENV)
44
+ const secure = env.API_URL.startsWith('https://')
45
+ const cookie = { httpOnly: true, sameSite: 'lax', secure } as const
46
+
47
+ function setCookies(reply: FastifyReply, tokens: SessionTokens): void {
48
+ reply.setCookie(ACCESS_COOKIE, tokens.accessToken.token, {
49
+ ...cookie,
50
+ path: '/',
51
+ expires: tokens.accessToken.expiresAt,
52
+ })
53
+ reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
54
+ ...cookie,
55
+ path: REFRESH_PATH,
56
+ expires: tokens.refreshExpiresAt,
57
+ })
58
+ }
59
+
60
+ function clearCookies(reply: FastifyReply): void {
61
+ reply.clearCookie(ACCESS_COOKIE, { ...cookie, path: '/' })
62
+ reply.clearCookie(REFRESH_COOKIE, { ...cookie, path: REFRESH_PATH })
63
+ }
64
+
65
+ // The web gets cookies and an empty body; a native client gets the tokens and no cookie.
66
+ function deliver(
67
+ request: FastifyRequest,
68
+ reply: FastifyReply,
69
+ tokens: SessionTokens,
70
+ status: number,
71
+ ) {
72
+ if (wantsBearer(request)) {
73
+ return reply.code(status).send(asBody(tokens))
74
+ }
75
+
76
+ setCookies(reply, tokens)
77
+ return reply.code(status === 201 ? 201 : 204).send()
78
+ }
79
+
80
+ app.post(
81
+ '/sign-up',
82
+ {
83
+ config: { public: true, ...ATTEMPTS },
84
+ schema: { tags: ['auth'], body: signUpSchema, response: { 201: tokensResponseSchema } },
85
+ },
86
+ async (request, reply) =>
87
+ deliver(
88
+ request,
89
+ reply,
90
+ await app.container.resolve(SignUpUseCase).execute(request.body),
91
+ 201,
92
+ ),
15
93
  )
16
- // prumo:end-mcp
17
-
18
- fastify.route({
19
- ...routes,
20
- method: ['GET', 'POST'],
21
- url: '/api/auth/*',
22
- handler: async (request, reply) =>
23
- sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
24
- })
25
94
 
26
- // prumo:mcp
27
- // OAuth discovery lives at the root, outside Better Auth's base path, and still reaches its handler.
28
- fastify.route({
29
- ...routes,
30
- method: 'GET',
31
- url: '/.well-known/*',
32
- handler: async (request, reply) =>
33
- sendFetchResponse(reply, await auth.handler(toFetchRequest(request))),
95
+ app.post(
96
+ '/sign-in',
97
+ {
98
+ config: { public: true, ...ATTEMPTS },
99
+ schema: { tags: ['auth'], body: signInSchema, response: { 200: tokensResponseSchema } },
100
+ },
101
+ async (request, reply) =>
102
+ deliver(
103
+ request,
104
+ reply,
105
+ await app.container.resolve(SignInUseCase).execute(request.body),
106
+ 200,
107
+ ),
108
+ )
109
+
110
+ app.post(
111
+ '/refresh',
112
+ {
113
+ config: { public: true, ...REFRESHES },
114
+ schema: { tags: ['auth'], body: refreshSchema, response: { 200: tokensResponseSchema } },
115
+ },
116
+ async (request, reply) => {
117
+ const token = request.body?.refreshToken ?? request.cookies[REFRESH_COOKIE]
118
+
119
+ try {
120
+ if (token === undefined) {
121
+ throw new InvalidSessionError()
122
+ }
123
+
124
+ return deliver(
125
+ request,
126
+ reply,
127
+ await app.container.resolve(RefreshSessionUseCase).execute(token),
128
+ 200,
129
+ )
130
+ } catch (error) {
131
+ // A refresh that cannot succeed will not succeed later either: drop what the browser holds.
132
+ if (error instanceof InvalidSessionError && !wantsBearer(request)) {
133
+ clearCookies(reply)
134
+ }
135
+
136
+ throw error
137
+ }
138
+ },
139
+ )
140
+
141
+ app.post('/sign-out', { schema: { tags: ['auth'] } }, async (request, reply) => {
142
+ await app.container
143
+ .resolve(SignOutUseCase)
144
+ .execute(currentUser(request).id, currentSession(request))
145
+ clearCookies(reply)
146
+
147
+ return reply.code(204).send()
34
148
  })
35
- // prumo:end-mcp
149
+
150
+ app.get(
151
+ '/session',
152
+ { schema: { tags: ['auth'], response: { 200: sessionResponseSchema } } },
153
+ async (request) => {
154
+ const user = await app.container.resolve(FindUserUseCase).execute(currentUser(request).id)
155
+
156
+ return { user: { id: user.id, email: user.email } }
157
+ },
158
+ )
36
159
  }