@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,60 +1,91 @@
1
- import { useQuery } from '@tanstack/react-query'
2
- import { useState } from 'react'
1
+ import { useMutation, useQuery } from '@tanstack/react-query'
2
+ import { ApiError, type AuthClient } from '@/api-contract'
3
3
  import { Button } from '@/components/ui/button'
4
- import type { AuthClient } from '@/features/auth/auth-client'
5
4
 
6
- type PublicClient = { client_name?: string }
5
+ type AuthorizationView = {
6
+ clientId: string
7
+ clientName: string
8
+ clientHost: string
9
+ redirectHost: string
10
+ redirectsToThisDevice: boolean
11
+ }
12
+
13
+ type ConsentAuth = Pick<AuthClient, 'baseUrl' | 'fetch'>
14
+
15
+ async function call<T>(auth: ConsentAuth, path: string, init?: RequestInit): Promise<T> {
16
+ const response = await auth.fetch(`${auth.baseUrl}/api/oauth${path}`, init)
17
+
18
+ if (!response.ok) {
19
+ throw await ApiError.fromResponse(response)
20
+ }
21
+
22
+ return (await response.json()) as T
23
+ }
7
24
 
8
- // Better Auth checks the signed query on submit; what is shown here comes from the same URL.
25
+ // What the user agrees to is shown by host, not only by name: a name is whatever the client chose to write.
9
26
  export function ConsentForm({
10
27
  auth,
11
- clientId,
12
- scopes,
28
+ requestId,
29
+ leave,
13
30
  }: {
14
- auth: AuthClient
15
- clientId: string
16
- scopes: string[]
31
+ auth: ConsentAuth
32
+ requestId: string
33
+ leave: (url: string) => void
17
34
  }) {
18
- const [failed, setFailed] = useState(false)
19
- const client = useQuery({
20
- queryKey: ['oauth-client', clientId],
21
- queryFn: async () => {
22
- const { data } = await auth.$fetch<PublicClient>('/oauth2/public-client', {
23
- query: { client_id: clientId },
24
- })
25
-
26
- return data
27
- },
35
+ const view = useQuery({
36
+ queryKey: ['oauth-authorization', requestId],
37
+ queryFn: () => call<AuthorizationView>(auth, `/authorizations/${requestId}`),
38
+ retry: false,
39
+ })
40
+ const answer = useMutation({
41
+ mutationFn: (accept: boolean) =>
42
+ call<{ redirectTo: string }>(auth, `/authorizations/${requestId}/decision`, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({ accept }),
46
+ }),
47
+ // The answer goes back to the client either way, as the code or as access_denied.
48
+ onSuccess: ({ redirectTo }) => leave(redirectTo),
28
49
  })
29
- const name = client.data?.client_name ?? clientId
30
50
 
31
- async function answer(accept: boolean) {
32
- // On success Better Auth sends the browser back to the client, so nothing follows here.
33
- const { error } = await auth.$fetch('/oauth2/consent', { method: 'POST', body: { accept } })
51
+ if (view.isPending) {
52
+ return null
53
+ }
34
54
 
35
- setFailed(error !== null)
55
+ if (view.isError) {
56
+ return (
57
+ <p role="alert" className="text-sm text-destructive">
58
+ This request has expired or was already answered. Start again from the application.
59
+ </p>
60
+ )
36
61
  }
37
62
 
63
+ const { clientName, clientHost, redirectHost, redirectsToThisDevice } = view.data
64
+
38
65
  return (
39
66
  <div className="flex flex-col gap-6">
40
67
  <p>
41
- <strong>{name}</strong> wants to act on your behalf.
68
+ <strong>{clientName}</strong> ({clientHost}) wants to act on your behalf.
69
+ </p>
70
+ <p className="text-sm text-muted-foreground">
71
+ If you allow it, you will be sent to <strong>{redirectHost}</strong>.
42
72
  </p>
43
- {scopes.length > 0 && (
44
- <ul className="list-disc pl-6 text-sm text-muted-foreground">
45
- {scopes.map((scope) => (
46
- <li key={scope}>{scope}</li>
47
- ))}
48
- </ul>
73
+ {redirectsToThisDevice && (
74
+ <p role="note" className="text-sm text-destructive">
75
+ It returns to a program on this device. Allow it only if you started this from a program
76
+ you trust.
77
+ </p>
49
78
  )}
50
- {failed && (
79
+ {answer.isError && (
51
80
  <p role="alert" className="text-sm text-destructive">
52
81
  The authorization could not be completed. Start again from the application.
53
82
  </p>
54
83
  )}
55
84
  <div className="flex gap-3">
56
- <Button onClick={() => answer(true)}>Allow</Button>
57
- <Button variant="outline" onClick={() => answer(false)}>
85
+ <Button onClick={() => answer.mutate(true)} disabled={answer.isPending}>
86
+ Allow
87
+ </Button>
88
+ <Button variant="outline" onClick={() => answer.mutate(false)} disabled={answer.isPending}>
58
89
  Deny
59
90
  </Button>
60
91
  </div>
@@ -2,17 +2,19 @@ import '@/index.css'
2
2
  import { QueryClient } from '@tanstack/react-query'
3
3
  import { StrictMode } from 'react'
4
4
  import { createRoot } from 'react-dom/client'
5
- import { createClient, type Transport } from '@/api-contract'
6
- import { createAuth } from '@/features/auth/auth-client'
5
+ import { createAuthClient, createClient, type Transport } from '@/api-contract'
7
6
  import { App } from './app'
8
7
  import { config } from './config'
9
8
 
10
9
  const transport: Transport = (input, init) => fetch(input, { ...init, credentials: 'include' })
11
10
 
11
+ const auth = createAuthClient({ baseUrl: config.VITE_API_URL, fetch: transport })
12
+
12
13
  const context = {
13
14
  queryClient: new QueryClient(),
14
- api: createClient({ baseUrl: config.VITE_API_URL, fetch: transport }),
15
- auth: createAuth({ baseUrl: config.VITE_API_URL, fetch: transport }),
15
+ // Through the auth client, so an expired access token is refreshed and the request repeated.
16
+ api: createClient({ baseUrl: config.VITE_API_URL, fetch: auth.fetch }),
17
+ auth,
16
18
  }
17
19
 
18
20
  const root = document.getElementById('root')
@@ -1,7 +1,6 @@
1
1
  import type { QueryClient } from '@tanstack/react-query'
2
2
  import { createRouter, type RouterHistory } from '@tanstack/react-router'
3
- import type { ApiClient } from '@/api-contract'
4
- import type { AuthClient } from '@/features/auth/auth-client'
3
+ import type { ApiClient, AuthClient } from '@/api-contract'
5
4
  import { routeTree } from './routeTree.gen'
6
5
 
7
6
  export type RouterContext = {
@@ -2,20 +2,21 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { z } from 'zod'
3
3
  import { ConsentForm } from '@/features/oauth/consent-form'
4
4
 
5
- // Loose, because every parameter Better Auth signed has to stay in the URL for the consent request.
5
+ // The API sends the browser here with the id of the authorization an MCP client asked for. Signing in first, when
6
+ // needed, comes back to this same address.
6
7
  export const Route = createFileRoute('/_authenticated/consent')({
7
- validateSearch: z.looseObject({ client_id: z.string(), scope: z.string().default('') }),
8
+ validateSearch: z.object({ request: z.uuid() }),
8
9
  component: ConsentPage,
9
10
  })
10
11
 
11
12
  function ConsentPage() {
12
13
  const { auth } = Route.useRouteContext()
13
- const { client_id, scope } = Route.useSearch()
14
+ const { request } = Route.useSearch()
14
15
 
15
16
  return (
16
17
  <>
17
18
  <h1 className="text-2xl font-semibold">Allow access</h1>
18
- <ConsentForm auth={auth} clientId={client_id} scopes={scope.split(' ').filter(Boolean)} />
19
+ <ConsentForm auth={auth} requestId={request} leave={(url) => window.location.assign(url)} />
19
20
  </>
20
21
  )
21
22
  }
@@ -1,5 +1,5 @@
1
1
  import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
2
- import { sessionQuery } from '@/features/auth/session'
2
+ import { sessionQuery } from '@/api-contract'
3
3
 
4
4
  export const Route = createFileRoute('/_authenticated')({
5
5
  beforeLoad: async ({ context, location }) => {
@@ -0,0 +1,155 @@
1
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createAuthClient, type TokenStore, type Tokens } from '@/api-contract'
4
+
5
+ const TOKENS: Tokens = { accessToken: 'access-1', refreshToken: 'refresh-1', expiresIn: 900 }
6
+
7
+ function memoryStore(initial: Tokens | null = null): TokenStore & { current: Tokens | null } {
8
+ return {
9
+ current: initial,
10
+ async read() {
11
+ return this.current
12
+ },
13
+ async write(tokens) {
14
+ this.current = tokens
15
+ },
16
+ }
17
+ }
18
+
19
+ describe('createAuthClient on the web', () => {
20
+ it('refreshes once on a 401, repeats the request, and asks nothing of the body', async () => {
21
+ const calls: string[] = []
22
+ let valid = false
23
+ const auth = createAuthClient({
24
+ baseUrl: API_URL,
25
+ fetch: fakeTransport({
26
+ 'GET /api/v1/things': () => {
27
+ calls.push('things')
28
+ return valid ? json(200, []) : json(401, {})
29
+ },
30
+ 'POST /api/auth/refresh': (request) => {
31
+ calls.push(`refresh:${request.headers.get('x-auth-transport')}`)
32
+ valid = true
33
+ return new Response(null, { status: 204 })
34
+ },
35
+ }),
36
+ })
37
+
38
+ const response = await auth.fetch(`${API_URL}/api/v1/things`)
39
+
40
+ expect(response.status).toBe(200)
41
+ expect(calls).toEqual(['things', 'refresh:null', 'things'])
42
+ })
43
+
44
+ it('makes one refresh for every request refused at once', async () => {
45
+ let refreshes = 0
46
+ let valid = false
47
+ const auth = createAuthClient({
48
+ baseUrl: API_URL,
49
+ fetch: fakeTransport({
50
+ 'GET /api/v1/things': () => (valid ? json(200, []) : json(401, {})),
51
+ 'POST /api/auth/refresh': async () => {
52
+ refreshes += 1
53
+ await new Promise((resolve) => setTimeout(resolve, 10))
54
+ valid = true
55
+ return new Response(null, { status: 204 })
56
+ },
57
+ }),
58
+ })
59
+
60
+ const responses = await Promise.all([1, 2, 3].map(() => auth.fetch(`${API_URL}/api/v1/things`)))
61
+
62
+ expect(responses.map((response) => response.status)).toEqual([200, 200, 200])
63
+ expect(refreshes).toBe(1)
64
+ })
65
+
66
+ it('repeats the request when another tab rotated the token first', async () => {
67
+ let valid = false
68
+ const auth = createAuthClient({
69
+ baseUrl: API_URL,
70
+ fetch: fakeTransport({
71
+ 'GET /api/v1/things': () => {
72
+ const response = valid ? json(200, []) : json(401, {})
73
+ valid = true
74
+ return response
75
+ },
76
+ 'POST /api/auth/refresh': () => json(409, {}),
77
+ }),
78
+ })
79
+
80
+ expect((await auth.fetch(`${API_URL}/api/v1/things`)).status).toBe(200)
81
+ })
82
+
83
+ it('answers a visitor without a session with null', async () => {
84
+ const auth = createAuthClient({
85
+ baseUrl: API_URL,
86
+ fetch: fakeTransport({
87
+ 'GET /api/auth/session': () => json(401, {}),
88
+ 'POST /api/auth/refresh': () => json(401, {}),
89
+ }),
90
+ })
91
+
92
+ expect(await auth.getSession()).toBeNull()
93
+ })
94
+ })
95
+
96
+ describe('createAuthClient for a native app', () => {
97
+ it('keeps the tokens it signs in with, and sends them as a bearer', async () => {
98
+ const tokens = memoryStore()
99
+ let authorization: string | null = null
100
+ const auth = createAuthClient({
101
+ baseUrl: API_URL,
102
+ tokens,
103
+ fetch: fakeTransport({
104
+ 'POST /api/auth/sign-in': (request) =>
105
+ request.headers.get('x-auth-transport') === 'bearer' ? json(200, TOKENS) : json(400, {}),
106
+ 'GET /api/auth/session': (request) => {
107
+ authorization = request.headers.get('authorization')
108
+ return json(200, { user: { id: 'user-1', email: 'ana@example.com' } })
109
+ },
110
+ }),
111
+ })
112
+
113
+ await auth.signIn({ email: 'ana@example.com', password: 'correct-horse-battery' })
114
+ await auth.getSession()
115
+
116
+ expect(tokens.current).toEqual(TOKENS)
117
+ expect(authorization).toBe('Bearer access-1')
118
+ })
119
+
120
+ it('refreshes with the stored token, and forgets both when that fails', async () => {
121
+ const tokens = memoryStore(TOKENS)
122
+ let sent: unknown
123
+ const auth = createAuthClient({
124
+ baseUrl: API_URL,
125
+ tokens,
126
+ fetch: fakeTransport({
127
+ 'GET /api/auth/session': () => json(401, {}),
128
+ 'POST /api/auth/refresh': async (request) => {
129
+ sent = await request.json()
130
+ return json(401, {})
131
+ },
132
+ }),
133
+ })
134
+
135
+ expect(await auth.getSession()).toBeNull()
136
+ expect(sent).toEqual({ refreshToken: 'refresh-1' })
137
+ expect(tokens.current).toBeNull()
138
+ })
139
+
140
+ it('forgets the tokens on sign-out even when the request never arrives', async () => {
141
+ const tokens = memoryStore(TOKENS)
142
+ const auth = createAuthClient({
143
+ baseUrl: API_URL,
144
+ tokens,
145
+ fetch: fakeTransport({
146
+ 'POST /api/auth/sign-out': () => {
147
+ throw new TypeError('Network request failed')
148
+ },
149
+ }),
150
+ })
151
+
152
+ await expect(auth.signOut()).rejects.toThrow('Network request failed')
153
+ expect(tokens.current).toBeNull()
154
+ })
155
+ })
@@ -5,9 +5,17 @@ import userEvent from '@testing-library/user-event'
5
5
  import { describe, expect, it } from 'vitest'
6
6
  import type { Profile } from '@/api-contract'
7
7
 
8
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
8
+ const user = { id: 'user-1', email: 'ana@example.com' }
9
9
 
10
- const session = { session: { id: 'session-1', userId: user.id }, user }
10
+ const session = { user }
11
+
12
+ const empty = (status: number) => new Response(null, { status })
13
+
14
+ // What the API answers a visitor without cookies: no session, and nothing to refresh it with.
15
+ const signedOut = {
16
+ 'GET /api/auth/session': () => json(401, {}),
17
+ 'POST /api/auth/refresh': () => json(401, {}),
18
+ }
11
19
 
12
20
  const profile: Profile = {
13
21
  id: 'profile-1',
@@ -21,7 +29,7 @@ const profile: Profile = {
21
29
 
22
30
  describe('App', () => {
23
31
  it('sends a visitor without a session to sign in', async () => {
24
- renderApp('/', fakeTransport({ 'GET /api/auth/get-session': () => json(200, null) }))
32
+ renderApp('/', fakeTransport(signedOut))
25
33
 
26
34
  expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
27
35
  })
@@ -35,10 +43,11 @@ describe('App', () => {
35
43
  renderApp(
36
44
  '/',
37
45
  fakeTransport({
38
- 'GET /api/auth/get-session': () => json(200, signedIn ? session : null),
39
- 'POST /api/auth/sign-in/email': () => {
46
+ ...signedOut,
47
+ 'GET /api/auth/session': () => (signedIn ? json(200, session) : json(401, {})),
48
+ 'POST /api/auth/sign-in': () => {
40
49
  signedIn = true
41
- return json(200, { redirect: false, token: 'token-1', user })
50
+ return empty(204)
42
51
  },
43
52
  'GET /api/v1/users/me': () => json(200, profile),
44
53
  }),
@@ -59,17 +68,18 @@ describe('App', () => {
59
68
  renderApp(
60
69
  '/',
61
70
  fakeTransport({
62
- 'GET /api/auth/get-session': () => json(200, registered ? session : null),
63
- 'POST /api/auth/sign-up/email': () => {
71
+ ...signedOut,
72
+ 'GET /api/auth/session': () => (registered ? json(200, session) : json(401, {})),
73
+ 'POST /api/auth/sign-up': () => {
64
74
  registered = true
65
- return json(200, { token: 'token-1', user })
75
+ return empty(201)
66
76
  },
67
77
  'GET /api/v1/users/me': () => json(200, profile),
68
78
  }),
69
79
  )
70
80
 
71
81
  await visitor.click(await screen.findByRole('link', { name: 'Sign up' }))
72
- await visitor.type(await screen.findByLabelText('Name'), user.name)
82
+ await visitor.type(await screen.findByLabelText('Name'), 'Ana')
73
83
  await visitor.type(screen.getByLabelText('Email'), user.email)
74
84
  await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
75
85
  await visitor.click(screen.getByRole('button', { name: 'Create account' }))
@@ -82,7 +92,7 @@ describe('App', () => {
82
92
  const people = {
83
93
  ana: { user, profile },
84
94
  bruno: {
85
- user: { id: 'user-2', email: 'bruno@example.com', name: 'Bruno' },
95
+ user: { id: 'user-2', email: 'bruno@example.com' },
86
96
  profile: { ...profile, id: 'profile-2', userId: 'user-2', displayName: 'Bruno' },
87
97
  },
88
98
  }
@@ -91,20 +101,16 @@ describe('App', () => {
91
101
  renderApp(
92
102
  '/',
93
103
  fakeTransport({
94
- 'GET /api/auth/get-session': () =>
95
- json(
96
- 200,
97
- current === null
98
- ? null
99
- : { session: { id: `session-${current}` }, user: people[current].user },
100
- ),
104
+ ...signedOut,
105
+ 'GET /api/auth/session': () =>
106
+ current === null ? json(401, {}) : json(200, { user: people[current].user }),
101
107
  'POST /api/auth/sign-out': () => {
102
108
  current = null
103
- return json(200, { success: true })
109
+ return empty(204)
104
110
  },
105
- 'POST /api/auth/sign-in/email': () => {
111
+ 'POST /api/auth/sign-in': () => {
106
112
  current = 'bruno'
107
- return json(200, { redirect: false, token: 'token-2', user: people.bruno.user })
113
+ return empty(204)
108
114
  },
109
115
  'GET /api/v1/users/me': () => json(200, current === null ? null : people[current].profile),
110
116
  }),
@@ -121,13 +127,32 @@ describe('App', () => {
121
127
  expect(screen.getByLabelText('Display name')).toHaveValue('Bruno')
122
128
  })
123
129
 
130
+ // Fifteen minutes in, the access cookie has expired: the next request is refused once, refreshed, and repeated.
131
+ it('keeps a visitor signed in across an expired access token', async () => {
132
+ let refreshed = false
133
+
134
+ renderApp(
135
+ '/',
136
+ fakeTransport({
137
+ 'GET /api/auth/session': () => (refreshed ? json(200, session) : json(401, {})),
138
+ 'POST /api/auth/refresh': () => {
139
+ refreshed = true
140
+ return empty(204)
141
+ },
142
+ 'GET /api/v1/users/me': () => json(200, profile),
143
+ }),
144
+ )
145
+
146
+ expect(await screen.findByRole('heading', { name: 'Your profile' })).toBeInTheDocument()
147
+ })
148
+
124
149
  it('signs a visitor out of this tab even when the sign-out request never arrives', async () => {
125
150
  const visitor = userEvent.setup()
126
151
 
127
152
  renderApp(
128
153
  '/',
129
154
  fakeTransport({
130
- 'GET /api/auth/get-session': () => json(200, session),
155
+ 'GET /api/auth/session': () => json(200, session),
131
156
  'GET /api/v1/users/me': () => json(200, profile),
132
157
  'POST /api/auth/sign-out': () => {
133
158
  throw new TypeError('Failed to fetch')
@@ -11,8 +11,12 @@ describe('SignInForm', () => {
11
11
  renderApp(
12
12
  '/sign-in',
13
13
  fakeTransport({
14
- 'POST /api/auth/sign-in/email': () =>
15
- json(401, { message: 'Invalid email or password', code: 'INVALID_EMAIL_OR_PASSWORD' }),
14
+ 'POST /api/auth/sign-in': () =>
15
+ json(
16
+ 401,
17
+ { title: 'Unauthorized', status: 401, detail: 'Invalid email or password' },
18
+ 'application/problem+json',
19
+ ),
16
20
  }),
17
21
  )
18
22
 
@@ -1,45 +1,102 @@
1
- import { fakeTransport, json } from '@test/support/fake-transport'
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2
+ import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
2
3
  import { renderApp } from '@test/support/render-app'
3
- import { screen } from '@testing-library/react'
4
+ import { render, screen } from '@testing-library/react'
4
5
  import userEvent from '@testing-library/user-event'
5
- import { afterEach, describe, expect, it } from 'vitest'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import { createAuthClient } from '@/api-contract'
8
+ import { ConsentForm } from '@/features/oauth/consent-form'
6
9
 
7
- const user = { id: 'user-1', email: 'ana@example.com', name: 'Ana' }
10
+ const REQUEST = '0199a7e2-0000-7000-8000-000000000000'
8
11
 
9
- // The shape Better Auth signs: every signed name listed in ba_param, then the signature.
10
- const SIGNED =
11
- 'client_id=client-1&scope=openid+profile&ba_param=ba_param&ba_param=client_id&ba_param=scope&sig=signature'
12
+ const view = {
13
+ clientId: 'https://claude.example.com/client.json',
14
+ clientName: 'Claude',
15
+ clientHost: 'claude.example.com',
16
+ redirectHost: 'claude.example.com',
17
+ redirectsToThisDevice: false,
18
+ }
12
19
 
13
- afterEach(() => {
14
- window.history.replaceState(null, '', '/')
15
- })
20
+ function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
21
+ const leave = vi.fn()
22
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: fakeTransport(routes) })
23
+
24
+ render(
25
+ <QueryClientProvider client={new QueryClient()}>
26
+ <ConsentForm auth={auth} requestId={REQUEST} leave={leave} />
27
+ </QueryClientProvider>,
28
+ )
29
+
30
+ return leave
31
+ }
16
32
 
17
33
  describe('ConsentForm', () => {
18
- it('names the client, lists the scopes and sends the signed query with the answer', async () => {
34
+ it('names the client and both hosts, and sends the browser where the API says', async () => {
19
35
  const visitor = userEvent.setup()
20
- let consent: Record<string, unknown> | undefined
36
+ let decision: unknown
37
+ const leave = renderForm({
38
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
39
+ [`POST /api/oauth/authorizations/${REQUEST}/decision`]: async (request) => {
40
+ decision = await request.json()
41
+ return json(200, { redirectTo: 'https://claude.example.com/callback?code=c' })
42
+ },
43
+ })
21
44
 
22
- // The OAuth client plugin reads the browser's own URL, not the router's memory history.
23
- window.history.replaceState(null, '', `/consent?${SIGNED}&unrelated=1`)
45
+ expect(await screen.findByText('Claude')).toBeInTheDocument()
46
+ expect(screen.getByText(/claude\.example\.com\)/)).toBeInTheDocument()
47
+
48
+ await visitor.click(screen.getByRole('button', { name: 'Allow' }))
49
+
50
+ expect(decision).toEqual({ accept: true })
51
+ expect(leave).toHaveBeenCalledWith('https://claude.example.com/callback?code=c')
52
+ })
53
+
54
+ it('warns when the code returns to a program on this device', async () => {
55
+ renderForm({
56
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () =>
57
+ json(200, { ...view, redirectHost: 'localhost:33418', redirectsToThisDevice: true }),
58
+ })
59
+
60
+ expect(await screen.findByRole('note')).toHaveTextContent('a program on this device')
61
+ })
62
+
63
+ it('says so when the request has expired', async () => {
64
+ renderForm({
65
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () =>
66
+ json(404, { title: 'Not Found', status: 404 }, 'application/problem+json'),
67
+ })
68
+
69
+ expect(await screen.findByRole('alert')).toHaveTextContent('expired')
70
+ })
71
+ })
72
+
73
+ describe('the consent route', () => {
74
+ // The MCP client opened the browser at the API, which sent it here; without a session, sign-in comes first.
75
+ it('sends a visitor to sign in first, and back to the same request after', async () => {
76
+ const visitor = userEvent.setup()
77
+ let signedIn = false
24
78
 
25
79
  renderApp(
26
- `/consent?${SIGNED}`,
80
+ `/consent?request=${REQUEST}`,
27
81
  fakeTransport({
28
- 'GET /api/auth/get-session': () =>
29
- json(200, { session: { id: 's-1', userId: user.id }, user }),
30
- 'GET /api/auth/oauth2/public-client': () => json(200, { client_name: 'Claude' }),
31
- 'POST /api/auth/oauth2/consent': async (request) => {
32
- consent = await request.json()
33
- return json(200, { redirect: false })
82
+ 'GET /api/auth/session': () =>
83
+ signedIn
84
+ ? json(200, { user: { id: 'user-1', email: 'ana@example.com' } })
85
+ : json(401, {}),
86
+ 'POST /api/auth/refresh': () => json(401, {}),
87
+ 'POST /api/auth/sign-in': () => {
88
+ signedIn = true
89
+ return new Response(null, { status: 204 })
34
90
  },
91
+ [`GET /api/oauth/authorizations/${REQUEST}`]: () => json(200, view),
35
92
  }),
36
93
  )
37
94
 
38
- expect(await screen.findByText('Claude')).toBeInTheDocument()
39
- expect(screen.getByText('profile')).toBeInTheDocument()
95
+ await visitor.type(await screen.findByLabelText('Email'), 'ana@example.com')
96
+ await visitor.type(screen.getByLabelText('Password'), 'correct-horse-battery')
97
+ await visitor.click(screen.getByRole('button', { name: 'Sign in' }))
40
98
 
41
- await visitor.click(screen.getByRole('button', { name: 'Allow' }))
42
-
43
- expect(consent).toMatchObject({ accept: true, oauth_query: SIGNED })
99
+ expect(await screen.findByRole('heading', { name: 'Allow access' })).toBeInTheDocument()
100
+ expect(await screen.findByText('Claude')).toBeInTheDocument()
44
101
  })
45
102
  })
@@ -2,15 +2,15 @@ import { QueryClient } from '@tanstack/react-query'
2
2
  import { createMemoryHistory } from '@tanstack/react-router'
3
3
  import { API_URL } from '@test/support/fake-transport'
4
4
  import { render } from '@testing-library/react'
5
- import { createClient, type Transport } from '@/api-contract'
5
+ import { createAuthClient, createClient, type Transport } from '@/api-contract'
6
6
  import { App } from '@/app'
7
- import { createAuth } from '@/features/auth/auth-client'
8
7
 
9
8
  export function renderApp(path: string, transport: Transport) {
9
+ const auth = createAuthClient({ baseUrl: API_URL, fetch: transport })
10
10
  const context = {
11
11
  queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }),
12
- api: createClient({ baseUrl: API_URL, fetch: transport }),
13
- auth: createAuth({ baseUrl: API_URL, fetch: transport }),
12
+ api: createClient({ baseUrl: API_URL, fetch: auth.fetch }),
13
+ auth,
14
14
  }
15
15
 
16
16
  return render(<App context={context} history={createMemoryHistory({ initialEntries: [path] })} />)