@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
@@ -0,0 +1,9 @@
1
+ import type { User } from '@/domain/auth/entities/user.entity'
2
+
3
+ export interface UserRepository {
4
+ findById(id: string): Promise<User | null>
5
+ findByEmail(email: string): Promise<User | null>
6
+ save(user: User): Promise<void>
7
+ }
8
+
9
+ export const USER_REPOSITORY = Symbol('UserRepository')
@@ -0,0 +1,20 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { User } from '@/domain/auth/entities/user.entity'
3
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
4
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
5
+
6
+ @injectable()
7
+ export class FindUserUseCase {
8
+ constructor(@inject(USER_REPOSITORY) private readonly users: UserRepository) {}
9
+
10
+ async execute(userId: string): Promise<User> {
11
+ const user = await this.users.findById(userId)
12
+
13
+ // A valid token for a user who no longer exists is a session that should have ended.
14
+ if (user === null) {
15
+ throw new InvalidSessionError()
16
+ }
17
+
18
+ return user
19
+ }
20
+ }
@@ -0,0 +1,92 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { Session } from '@/domain/auth/entities/session.entity'
3
+ import { InvalidSessionError } from '@/domain/auth/errors/invalid-session.error'
4
+ import { SupersededTokenError } from '@/domain/auth/errors/superseded-token.error'
5
+ import {
6
+ ACCESS_TOKEN_LIFETIME_MS,
7
+ ACCESS_TOKENS,
8
+ type AccessTokens,
9
+ } from '@/domain/auth/ports/access-tokens.port'
10
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
11
+ import {
12
+ SESSION_REPOSITORY,
13
+ type SessionRepository,
14
+ } from '@/domain/auth/repositories/session.repository'
15
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
16
+ import {
17
+ TRANSACTION_MANAGER,
18
+ type TransactionManager,
19
+ } from '@/domain/shared/transactions/transaction-manager'
20
+
21
+ @injectable()
22
+ export class RefreshSessionUseCase {
23
+ constructor(
24
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
25
+ @inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
26
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
27
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
28
+ ) {}
29
+
30
+ // A first-party refresh passes no client; an OAuth client passes its own id, and neither can use the other's token.
31
+ async execute(refreshToken: string, clientId: string | null = null): Promise<SessionTokens> {
32
+ const parsed = Session.parseRefreshToken(refreshToken)
33
+
34
+ if (parsed === null) {
35
+ throw new InvalidSessionError()
36
+ }
37
+
38
+ const { sessionId, secret } = parsed
39
+ const now = new Date()
40
+ const rotated = await this.transactions.run(async () => {
41
+ const session = await this.sessions.lockById(sessionId)
42
+
43
+ if (session === null || session.clientId !== clientId) {
44
+ return 'missing' as const
45
+ }
46
+
47
+ const outcome = session.check(this.opaque.digest(secret), now)
48
+
49
+ if (outcome === 'reused') {
50
+ // An old token came back: whoever holds it is not the client that rotated it. End the session for both.
51
+ session.revoke(now)
52
+ await this.sessions.save(session)
53
+ }
54
+
55
+ if (outcome !== 'valid') {
56
+ return outcome
57
+ }
58
+
59
+ const next = this.opaque.create()
60
+
61
+ session.rotate(this.opaque.digest(next), now)
62
+ await this.sessions.save(session)
63
+
64
+ return { session, next }
65
+ })
66
+
67
+ if (rotated === 'superseded') {
68
+ throw new SupersededTokenError()
69
+ }
70
+
71
+ if (typeof rotated === 'string') {
72
+ throw new InvalidSessionError()
73
+ }
74
+
75
+ const { session, next } = rotated
76
+
77
+ return {
78
+ sessionId: session.id,
79
+ accessToken: await this.tokens.issue(
80
+ {
81
+ userId: session.userId,
82
+ sessionId: session.id,
83
+ ...(session.clientId !== null && { clientId: session.clientId }),
84
+ },
85
+ new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
86
+ session.resource ?? undefined,
87
+ ),
88
+ refreshToken: session.refreshToken(next),
89
+ refreshExpiresAt: session.expiresAt,
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,30 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { SignInDto } from '@/domain/auth/dto/sign-in.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { InvalidCredentialsError } from '@/domain/auth/errors/invalid-credentials.error'
5
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
6
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
7
+ import {
8
+ type SessionTokens,
9
+ StartSessionUseCase,
10
+ } from '@/domain/auth/use-cases/start-session.use-case'
11
+
12
+ @injectable()
13
+ export class SignInUseCase {
14
+ constructor(
15
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
16
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
17
+ private readonly startSession: StartSessionUseCase,
18
+ ) {}
19
+
20
+ async execute({ email, password }: SignInDto): Promise<SessionTokens> {
21
+ const user = await this.users.findByEmail(User.normalizeEmail(email))
22
+ const valid = await this.hasher.verify(user?.passwordHash, password)
23
+
24
+ if (user === null || !valid) {
25
+ throw new InvalidCredentialsError()
26
+ }
27
+
28
+ return this.startSession.execute(user.id)
29
+ }
30
+ }
@@ -0,0 +1,22 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import {
3
+ SESSION_REPOSITORY,
4
+ type SessionRepository,
5
+ } from '@/domain/auth/repositories/session.repository'
6
+
7
+ @injectable()
8
+ export class SignOutUseCase {
9
+ constructor(@inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository) {}
10
+
11
+ // Signing out twice, or out of a session already gone, is not an error: the outcome is the same.
12
+ async execute(userId: string, sessionId: string): Promise<void> {
13
+ const session = await this.sessions.findById(sessionId)
14
+
15
+ if (session === null || session.userId !== userId) {
16
+ return
17
+ }
18
+
19
+ session.revoke(new Date())
20
+ await this.sessions.save(session)
21
+ }
22
+ }
@@ -0,0 +1,46 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { SignUpDto } from '@/domain/auth/dto/sign-up.dto'
3
+ import { User } from '@/domain/auth/entities/user.entity'
4
+ import { EmailTakenError } from '@/domain/auth/errors/email-taken.error'
5
+ import { PASSWORD_HASHER, type PasswordHasher } from '@/domain/auth/ports/password-hasher.port'
6
+ import { USER_REPOSITORY, type UserRepository } from '@/domain/auth/repositories/user.repository'
7
+ import {
8
+ type SessionTokens,
9
+ StartSessionUseCase,
10
+ } from '@/domain/auth/use-cases/start-session.use-case'
11
+ import {
12
+ TRANSACTION_MANAGER,
13
+ type TransactionManager,
14
+ } from '@/domain/shared/transactions/transaction-manager'
15
+ import { CreateProfileUseCase } from '@/domain/users/use-cases/create-profile.use-case'
16
+
17
+ @injectable()
18
+ export class SignUpUseCase {
19
+ constructor(
20
+ @inject(USER_REPOSITORY) private readonly users: UserRepository,
21
+ @inject(PASSWORD_HASHER) private readonly hasher: PasswordHasher,
22
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
23
+ private readonly createProfile: CreateProfileUseCase,
24
+ private readonly startSession: StartSessionUseCase,
25
+ ) {}
26
+
27
+ async execute({ name, email, password }: SignUpDto): Promise<SessionTokens> {
28
+ const passwordHash = await this.hasher.hash(password)
29
+
30
+ // The account and its profile exist together or not at all.
31
+ const user = await this.transactions.run(async () => {
32
+ if ((await this.users.findByEmail(User.normalizeEmail(email))) !== null) {
33
+ throw new EmailTakenError()
34
+ }
35
+
36
+ const user = new User(email, passwordHash)
37
+
38
+ await this.users.save(user)
39
+ await this.createProfile.execute(user.id, name)
40
+
41
+ return user
42
+ })
43
+
44
+ return this.startSession.execute(user.id)
45
+ }
46
+ }
@@ -0,0 +1,48 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { type Grant, Session } from '@/domain/auth/entities/session.entity'
3
+ import {
4
+ ACCESS_TOKEN_LIFETIME_MS,
5
+ ACCESS_TOKENS,
6
+ type AccessTokens,
7
+ type IssuedToken,
8
+ } from '@/domain/auth/ports/access-tokens.port'
9
+ import { OPAQUE_TOKENS, type OpaqueTokens } from '@/domain/auth/ports/opaque-tokens.port'
10
+ import {
11
+ SESSION_REPOSITORY,
12
+ type SessionRepository,
13
+ } from '@/domain/auth/repositories/session.repository'
14
+
15
+ export type SessionTokens = {
16
+ sessionId: string
17
+ accessToken: IssuedToken
18
+ refreshToken: string
19
+ refreshExpiresAt: Date
20
+ }
21
+
22
+ @injectable()
23
+ export class StartSessionUseCase {
24
+ constructor(
25
+ @inject(SESSION_REPOSITORY) private readonly sessions: SessionRepository,
26
+ @inject(ACCESS_TOKENS) private readonly tokens: AccessTokens,
27
+ @inject(OPAQUE_TOKENS) private readonly opaque: OpaqueTokens,
28
+ ) {}
29
+
30
+ async execute(userId: string, grant?: Grant): Promise<SessionTokens> {
31
+ const now = new Date()
32
+ const secret = this.opaque.create()
33
+ const session = new Session(userId, this.opaque.digest(secret), now, grant)
34
+
35
+ await this.sessions.save(session)
36
+
37
+ return {
38
+ sessionId: session.id,
39
+ accessToken: await this.tokens.issue(
40
+ { userId, sessionId: session.id, ...(grant && { clientId: grant.clientId }) },
41
+ new Date(now.getTime() + ACCESS_TOKEN_LIFETIME_MS),
42
+ grant?.resource,
43
+ ),
44
+ refreshToken: session.refreshToken(secret),
45
+ refreshExpiresAt: session.expiresAt,
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod'
2
+
3
+ export const authorizationDecisionSchema = z.strictObject({ accept: z.boolean() })
4
+
5
+ export type AuthorizationDecisionDto = z.infer<typeof authorizationDecisionSchema>
6
+
7
+ export const authorizationRedirectSchema = z.object({ redirectTo: z.string() })
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod'
2
+
3
+ // What the consent page shows. The hosts come first: a name is whatever the client chose to write.
4
+ export const authorizationViewSchema = z.object({
5
+ clientId: z.string(),
6
+ clientName: z.string(),
7
+ clientHost: z.string(),
8
+ redirectHost: z.string(),
9
+ // A localhost redirect cannot prove which program is listening there, so the page warns about it.
10
+ redirectsToThisDevice: z.boolean(),
11
+ })
12
+
13
+ export type AuthorizationViewDto = z.infer<typeof authorizationViewSchema>
@@ -0,0 +1,83 @@
1
+ // Long enough to sign in and read the consent page; the code that follows is spent within a minute.
2
+ export const AUTHORIZATION_REQUEST_LIFETIME_MS = 10 * 60 * 1000
3
+ export const AUTHORIZATION_CODE_LIFETIME_MS = 60 * 1000
4
+
5
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
6
+
7
+ export type AuthorizationStatus = 'pending' | 'approved' | 'denied' | 'exchanged'
8
+
9
+ export type AuthorizationRequest = {
10
+ clientId: string
11
+ clientName: string
12
+ redirectUri: string
13
+ codeChallenge: string
14
+ clientState: string | null
15
+ resource: string
16
+ }
17
+
18
+ // One OAuth authorization, from the client's request through the user's answer to the code it is exchanged for.
19
+ export class Authorization {
20
+ id: string
21
+ clientId: string
22
+ clientName: string
23
+ redirectUri: string
24
+ codeChallenge: string
25
+ clientState: string | null
26
+ resource: string
27
+ status: AuthorizationStatus = 'pending'
28
+ userId: string | null = null
29
+ codeHash: string | null = null
30
+ sessionId: string | null = null
31
+ expiresAt: Date
32
+ createdAt: Date
33
+ updatedAt: Date
34
+
35
+ constructor(request: AuthorizationRequest, now: Date) {
36
+ this.clientId = request.clientId
37
+ this.clientName = request.clientName
38
+ this.redirectUri = request.redirectUri
39
+ this.codeChallenge = request.codeChallenge
40
+ this.clientState = request.clientState
41
+ this.resource = request.resource
42
+ this.expiresAt = new Date(now.getTime() + AUTHORIZATION_REQUEST_LIFETIME_MS)
43
+ }
44
+
45
+ // The code names its authorization, as a refresh token names its session.
46
+ static parseCode(code: string): { authorizationId: string; secret: string } | null {
47
+ const dot = code.indexOf('.')
48
+ const authorizationId = code.slice(0, dot)
49
+ const secret = code.slice(dot + 1)
50
+
51
+ return dot === -1 || !UUID.test(authorizationId) || secret === ''
52
+ ? null
53
+ : { authorizationId, secret }
54
+ }
55
+
56
+ code(secret: string): string {
57
+ return `${this.id}.${secret}`
58
+ }
59
+
60
+ isPending(now: Date): boolean {
61
+ return this.status === 'pending' && this.expiresAt > now
62
+ }
63
+
64
+ approve(userId: string, codeHash: string, now: Date): void {
65
+ this.status = 'approved'
66
+ this.userId = userId
67
+ this.codeHash = codeHash
68
+ this.expiresAt = new Date(now.getTime() + AUTHORIZATION_CODE_LIFETIME_MS)
69
+ }
70
+
71
+ deny(): void {
72
+ this.status = 'denied'
73
+ }
74
+
75
+ isRedeemable(now: Date): boolean {
76
+ return this.status === 'approved' && this.expiresAt > now
77
+ }
78
+
79
+ exchange(sessionId: string): void {
80
+ this.status = 'exchanged'
81
+ this.sessionId = sessionId
82
+ }
83
+ }
@@ -0,0 +1,9 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ export class AuthorizationNotFoundError extends DomainError {
4
+ readonly kind = 'not_found'
5
+
6
+ constructor() {
7
+ super('This authorization request has expired or was already answered')
8
+ }
9
+ }
@@ -0,0 +1,14 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // A request from a client already known to be who it says, with a redirect URI it registered: the error goes back
4
+ // to that URI, in OAuth's own terms.
5
+ export class AuthorizationRequestError extends DomainError {
6
+ readonly kind = 'invalid'
7
+
8
+ constructor(
9
+ readonly code: 'invalid_request' | 'unsupported_response_type' | 'invalid_target',
10
+ message: string,
11
+ ) {
12
+ super(message)
13
+ }
14
+ }
@@ -0,0 +1,6 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // The client or its redirect URI cannot be trusted, so the error is shown here and never sent to that URI.
4
+ export class InvalidClientError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ }
@@ -0,0 +1,6 @@
1
+ import { DomainError } from '@/domain/shared/errors/domain-error'
2
+
3
+ // OAuth's invalid_grant: the code or refresh token is wrong, spent, expired, or not this client's.
4
+ export class InvalidGrantError extends DomainError {
5
+ readonly kind = 'invalid'
6
+ }
@@ -0,0 +1,8 @@
1
+ export interface AuthorizationCodes {
2
+ create(): string
3
+ digest(code: string): string
4
+ // PKCE's S256: whether the verifier the client kept hashes to the challenge it sent first.
5
+ matchesChallenge(verifier: string, challenge: string): boolean
6
+ }
7
+
8
+ export const AUTHORIZATION_CODES = Symbol('AuthorizationCodes')
@@ -0,0 +1,9 @@
1
+ export type ClientMetadata = { clientId: string; clientName: string; redirectUris: string[] }
2
+
3
+ // A client identifies itself by the HTTPS URL of a document describing it. Reading one that is missing, malformed,
4
+ // or not a public client's throws InvalidClientError.
5
+ export interface ClientMetadataSource {
6
+ read(clientId: string): Promise<ClientMetadata>
7
+ }
8
+
9
+ export const CLIENT_METADATA = Symbol('ClientMetadataSource')
@@ -0,0 +1,10 @@
1
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
2
+
3
+ export interface AuthorizationRepository {
4
+ findById(id: string): Promise<Authorization | null>
5
+ // Reads the row and holds it until the surrounding transaction ends, so a code is spent once.
6
+ lockById(id: string): Promise<Authorization | null>
7
+ save(authorization: Authorization): Promise<void>
8
+ }
9
+
10
+ export const AUTHORIZATION_REPOSITORY = Symbol('AuthorizationRepository')
@@ -0,0 +1,53 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
4
+ import {
5
+ AUTHORIZATION_CODES,
6
+ type AuthorizationCodes,
7
+ } from '@/domain/oauth/ports/authorization-codes.port'
8
+ import {
9
+ AUTHORIZATION_REPOSITORY,
10
+ type AuthorizationRepository,
11
+ } from '@/domain/oauth/repositories/authorization.repository'
12
+ import {
13
+ TRANSACTION_MANAGER,
14
+ type TransactionManager,
15
+ } from '@/domain/shared/transactions/transaction-manager'
16
+
17
+ export type Decision = { authorization: Authorization; code: string | null }
18
+
19
+ @injectable()
20
+ export class DecideAuthorizationUseCase {
21
+ constructor(
22
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
23
+ @inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
24
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
25
+ ) {}
26
+
27
+ // The user who answers is the user the client will act for.
28
+ execute(id: string, userId: string, accept: boolean): Promise<Decision> {
29
+ return this.transactions.run(async () => {
30
+ const now = new Date()
31
+ const authorization = await this.authorizations.lockById(id)
32
+
33
+ if (authorization === null || !authorization.isPending(now)) {
34
+ throw new AuthorizationNotFoundError()
35
+ }
36
+
37
+ let code: string | null = null
38
+
39
+ if (accept) {
40
+ const secret = this.codes.create()
41
+
42
+ authorization.approve(userId, this.codes.digest(secret), now)
43
+ code = authorization.code(secret)
44
+ } else {
45
+ authorization.deny()
46
+ }
47
+
48
+ await this.authorizations.save(authorization)
49
+
50
+ return { authorization, code }
51
+ })
52
+ }
53
+ }
@@ -0,0 +1,96 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import { SignOutUseCase } from '@/domain/auth/use-cases/sign-out.use-case'
3
+ import type { SessionTokens } from '@/domain/auth/use-cases/start-session.use-case'
4
+ import { StartSessionUseCase } from '@/domain/auth/use-cases/start-session.use-case'
5
+ import { Authorization } from '@/domain/oauth/entities/authorization.entity'
6
+ import { InvalidGrantError } from '@/domain/oauth/errors/invalid-grant.error'
7
+ import {
8
+ AUTHORIZATION_CODES,
9
+ type AuthorizationCodes,
10
+ } from '@/domain/oauth/ports/authorization-codes.port'
11
+ import {
12
+ AUTHORIZATION_REPOSITORY,
13
+ type AuthorizationRepository,
14
+ } from '@/domain/oauth/repositories/authorization.repository'
15
+ import {
16
+ TRANSACTION_MANAGER,
17
+ type TransactionManager,
18
+ } from '@/domain/shared/transactions/transaction-manager'
19
+
20
+ export type CodeExchange = {
21
+ code: string
22
+ clientId: string
23
+ redirectUri: string
24
+ codeVerifier: string
25
+ resource: string
26
+ }
27
+
28
+ @injectable()
29
+ export class ExchangeCodeUseCase {
30
+ constructor(
31
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
32
+ @inject(AUTHORIZATION_CODES) private readonly codes: AuthorizationCodes,
33
+ @inject(TRANSACTION_MANAGER) private readonly transactions: TransactionManager,
34
+ private readonly startSession: StartSessionUseCase,
35
+ private readonly signOut: SignOutUseCase,
36
+ ) {}
37
+
38
+ async execute(request: CodeExchange): Promise<SessionTokens> {
39
+ const parsed = Authorization.parseCode(request.code)
40
+
41
+ if (parsed === null) {
42
+ throw new InvalidGrantError('Unknown authorization code')
43
+ }
44
+
45
+ const outcome = await this.transactions.run(async () => {
46
+ const now = new Date()
47
+ const authorization = await this.authorizations.lockById(parsed.authorizationId)
48
+
49
+ if (authorization === null || authorization.codeHash !== this.codes.digest(parsed.secret)) {
50
+ return { error: 'Unknown authorization code' }
51
+ }
52
+
53
+ // A code presented twice was copied: the tokens the first use obtained are no longer trusted either.
54
+ if (authorization.status === 'exchanged' && authorization.userId !== null) {
55
+ if (authorization.sessionId !== null) {
56
+ await this.signOut.execute(authorization.userId, authorization.sessionId)
57
+ }
58
+
59
+ return { error: 'Authorization code already used' }
60
+ }
61
+
62
+ if (!authorization.isRedeemable(now) || authorization.userId === null) {
63
+ return { error: 'Authorization code expired' }
64
+ }
65
+
66
+ if (
67
+ authorization.clientId !== request.clientId ||
68
+ authorization.redirectUri !== request.redirectUri ||
69
+ authorization.resource !== request.resource
70
+ ) {
71
+ return { error: 'The code was issued for another client, redirect URI or resource' }
72
+ }
73
+
74
+ if (!this.codes.matchesChallenge(request.codeVerifier, authorization.codeChallenge)) {
75
+ return { error: 'code_verifier does not match the code_challenge' }
76
+ }
77
+
78
+ const tokens = await this.startSession.execute(authorization.userId, {
79
+ clientId: authorization.clientId,
80
+ resource: authorization.resource,
81
+ })
82
+
83
+ authorization.exchange(tokens.sessionId)
84
+ await this.authorizations.save(authorization)
85
+
86
+ return { tokens }
87
+ })
88
+
89
+ // Thrown after the transaction, so revoking a copied code's session is committed and not rolled back.
90
+ if ('error' in outcome) {
91
+ throw new InvalidGrantError(outcome.error)
92
+ }
93
+
94
+ return outcome.tokens
95
+ }
96
+ }
@@ -0,0 +1,24 @@
1
+ import { inject, injectable } from 'tsyringe'
2
+ import type { Authorization } from '@/domain/oauth/entities/authorization.entity'
3
+ import { AuthorizationNotFoundError } from '@/domain/oauth/errors/authorization-not-found.error'
4
+ import {
5
+ AUTHORIZATION_REPOSITORY,
6
+ type AuthorizationRepository,
7
+ } from '@/domain/oauth/repositories/authorization.repository'
8
+
9
+ @injectable()
10
+ export class FindAuthorizationUseCase {
11
+ constructor(
12
+ @inject(AUTHORIZATION_REPOSITORY) private readonly authorizations: AuthorizationRepository,
13
+ ) {}
14
+
15
+ async execute(id: string): Promise<Authorization> {
16
+ const authorization = await this.authorizations.findById(id)
17
+
18
+ if (authorization === null || !authorization.isPending(new Date())) {
19
+ throw new AuthorizationNotFoundError()
20
+ }
21
+
22
+ return authorization
23
+ }
24
+ }