@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.
- package/.prumo-templates/api/auth.md +50 -60
- package/.prumo-templates/api/config.md +2 -2
- package/.prumo-templates/api/routes.md +6 -4
- package/.prumo-templates/api/structure.md +4 -1
- package/.prumo-templates/client/data.md +16 -18
- package/.prumo-templates/client/forms.md +2 -2
- package/.prumo-templates/core/testing.md +4 -5
- package/.prumo-templates/database/testing.md +6 -8
- package/.prumo-templates/mcp/server.md +39 -12
- package/.prumo-templates/mobile/routing.md +1 -1
- package/.prumo-templates/mobile/storage.md +7 -6
- package/.prumo-templates/monorepo/layout.md +4 -4
- package/.prumo-templates/multi-tenancy/client.md +6 -6
- package/.prumo-templates/multi-tenancy/isolation.md +17 -10
- package/README.md +2 -2
- package/dist/clean.js +2 -2
- package/dist/compose.js +1 -9
- package/dist/generate.js +3 -4
- package/dist/mcp.js +15 -8
- package/package.json +1 -1
- package/templates/api/.env.example +3 -5
- package/templates/api/README.md +4 -5
- package/templates/api/docker-compose.yml +0 -1
- package/templates/api/migrations/Migration20261002210629_create_user_profile_session.ts +31 -0
- package/templates/api/migrations/Migration20261002210631_create_authorization.ts +18 -0
- package/templates/api/package.json +6 -8
- package/templates/api/scripts/database.mjs +3 -11
- package/templates/api/scripts/ports.mjs +1 -1
- package/templates/api/src/domain/auth/dto/refresh.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/session-response.dto.ts +7 -0
- package/templates/api/src/domain/auth/dto/sign-in.dto.ts +8 -0
- package/templates/api/src/domain/auth/dto/sign-up.dto.ts +9 -0
- package/templates/api/src/domain/auth/dto/tokens-response.dto.ts +10 -0
- package/templates/api/src/domain/auth/entities/session.entity.ts +76 -0
- package/templates/api/src/domain/auth/entities/user.entity.ts +17 -0
- package/templates/api/src/domain/auth/errors/email-taken.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/invalid-credentials.error.ts +10 -0
- package/templates/api/src/domain/auth/errors/invalid-session.error.ts +9 -0
- package/templates/api/src/domain/auth/errors/superseded-token.error.ts +11 -0
- package/templates/api/src/domain/auth/ports/access-tokens.port.ts +15 -0
- package/templates/api/src/domain/auth/ports/opaque-tokens.port.ts +7 -0
- package/templates/api/src/domain/auth/ports/password-hasher.port.ts +8 -0
- package/templates/api/src/domain/auth/repositories/session.repository.ts +10 -0
- package/templates/api/src/domain/auth/repositories/user.repository.ts +9 -0
- package/templates/api/src/domain/auth/use-cases/find-user.use-case.ts +20 -0
- package/templates/api/src/domain/auth/use-cases/refresh-session.use-case.ts +92 -0
- package/templates/api/src/domain/auth/use-cases/sign-in.use-case.ts +30 -0
- package/templates/api/src/domain/auth/use-cases/sign-out.use-case.ts +22 -0
- package/templates/api/src/domain/auth/use-cases/sign-up.use-case.ts +46 -0
- package/templates/api/src/domain/auth/use-cases/start-session.use-case.ts +48 -0
- package/templates/api/src/domain/oauth/dto/authorization-decision.dto.ts +7 -0
- package/templates/api/src/domain/oauth/dto/authorization-view.dto.ts +13 -0
- package/templates/api/src/domain/oauth/entities/authorization.entity.ts +83 -0
- package/templates/api/src/domain/oauth/errors/authorization-not-found.error.ts +9 -0
- package/templates/api/src/domain/oauth/errors/authorization-request.error.ts +14 -0
- package/templates/api/src/domain/oauth/errors/invalid-client.error.ts +6 -0
- package/templates/api/src/domain/oauth/errors/invalid-grant.error.ts +6 -0
- package/templates/api/src/domain/oauth/ports/authorization-codes.port.ts +8 -0
- package/templates/api/src/domain/oauth/ports/client-metadata.port.ts +9 -0
- package/templates/api/src/domain/oauth/repositories/authorization.repository.ts +10 -0
- package/templates/api/src/domain/oauth/use-cases/decide-authorization.use-case.ts +53 -0
- package/templates/api/src/domain/oauth/use-cases/exchange-code.use-case.ts +96 -0
- package/templates/api/src/domain/oauth/use-cases/find-authorization.use-case.ts +24 -0
- package/templates/api/src/domain/oauth/use-cases/start-authorization.use-case.ts +75 -0
- package/templates/api/src/infra/auth/argon2-password-hasher.adapter.ts +21 -0
- package/templates/api/src/infra/auth/crypto-opaque-tokens.adapter.ts +14 -0
- package/templates/api/src/infra/auth/jose-access-tokens.adapter.ts +62 -0
- package/templates/api/src/infra/config/env.ts +3 -7
- package/templates/api/src/infra/database/mikroorm/entities/authorization.schema.ts +37 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +1 -1
- package/templates/api/src/infra/database/mikroorm/entities/session.schema.ts +33 -0
- package/templates/api/src/infra/database/mikroorm/entities/user.schema.ts +18 -0
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +9 -4
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-authorization.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-session.repository.ts +21 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-user.repository.ts +31 -0
- package/templates/api/src/infra/di/auth.di.ts +21 -0
- package/templates/api/src/infra/di/index.ts +4 -10
- package/templates/api/src/infra/di/oauth.di.ts +14 -0
- package/templates/api/src/infra/http/app.ts +11 -1
- package/templates/api/src/infra/http/controllers/auth.controller.ts +153 -30
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +32 -27
- package/templates/api/src/infra/http/controllers/oauth.controller.ts +257 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +1 -0
- package/templates/api/src/infra/http/fastify.d.ts +2 -3
- package/templates/api/src/infra/http/fetch-bridge.ts +17 -5
- package/templates/api/src/infra/http/hooks/auth.hook.ts +25 -8
- package/templates/api/src/infra/http/hooks/origin.hook.ts +22 -0
- package/templates/api/src/infra/http/server.ts +1 -4
- package/templates/api/src/infra/mcp/mcp.server.ts +10 -0
- package/templates/api/src/infra/oauth/crypto-authorization-codes.adapter.ts +21 -0
- package/templates/api/src/infra/oauth/https-client-metadata.adapter.ts +231 -0
- package/templates/api/test/domain/auth/entities/session.entity.spec.ts +50 -0
- package/templates/api/test/domain/auth/use-cases/refresh-session.use-case.spec.ts +66 -0
- package/templates/api/test/domain/auth/use-cases/sign-in.use-case.spec.ts +29 -0
- package/templates/api/test/domain/auth/use-cases/sign-up.use-case.spec.ts +33 -0
- package/templates/api/test/domain/oauth/entities/authorization.entity.spec.ts +52 -0
- package/templates/api/test/infra/auth/argon2-password-hasher.adapter.spec.ts +17 -0
- package/templates/api/test/infra/auth/jose-access-tokens.adapter.spec.ts +57 -0
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +1 -1
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-user.repository.spec.ts +28 -0
- package/templates/api/test/infra/http/controllers/auth.controller.spec.ts +201 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +232 -54
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +3 -3
- package/templates/api/test/infra/oauth/crypto-authorization-codes.adapter.spec.ts +18 -0
- package/templates/api/test/infra/oauth/https-client-metadata.adapter.spec.ts +58 -0
- package/templates/api/test/support/factories/user.factory.ts +9 -11
- package/templates/api/test/support/fakes/auth-fakes.ts +42 -0
- package/templates/api/test/support/fakes/fake-access-tokens.ts +19 -0
- package/templates/api/test/support/fakes/fake-opaque-tokens.ts +14 -0
- package/templates/api/test/support/fakes/fake-password-hasher.ts +15 -0
- package/templates/api/test/support/fakes/immediate-transaction-manager.ts +8 -0
- package/templates/api/test/support/fakes/in-memory-session.repository.ts +19 -0
- package/templates/api/test/support/fakes/in-memory-user.repository.ts +20 -0
- package/templates/api/test/support/setup.ts +6 -28
- package/templates/mobile/README.md +1 -1
- package/templates/mobile/jest.config.js +0 -17
- package/templates/mobile/package.json +0 -4
- package/templates/mobile/scripts/ports.mjs +1 -1
- package/templates/mobile/src/api-contract/api-error.ts +0 -11
- package/templates/mobile/src/api-contract/auth.ts +161 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +1 -1
- package/templates/mobile/src/app/_layout.tsx +5 -5
- package/templates/mobile/src/features/auth/sign-in-form.tsx +12 -8
- package/templates/mobile/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/mobile/src/features/auth/sign-up-form.tsx +13 -9
- package/templates/mobile/src/features/auth/token-store.ts +21 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +1 -2
- package/templates/mobile/test/features/auth/authenticated-layout.spec.tsx +16 -14
- package/templates/mobile/test/features/auth/sign-in-form.spec.tsx +12 -11
- package/templates/mobile/test/features/auth/sign-out-button.spec.tsx +8 -9
- package/templates/mobile/test/support/memory-token-store.ts +15 -0
- package/templates/site/scripts/ports.mjs +1 -1
- package/templates/web/package.json +0 -3
- package/templates/web/scripts/ports.mjs +1 -1
- package/templates/web/src/api-contract/api-error.ts +0 -11
- package/templates/web/src/api-contract/auth.ts +161 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +12 -15
- package/templates/web/src/features/auth/sign-out-button.tsx +4 -4
- package/templates/web/src/features/auth/sign-up-form.tsx +7 -9
- package/templates/web/src/features/oauth/consent-form.tsx +66 -35
- package/templates/web/src/main.tsx +6 -4
- package/templates/web/src/router.tsx +1 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +5 -4
- package/templates/web/src/routes/_authenticated.tsx +1 -1
- package/templates/web/test/api-contract/auth.spec.ts +155 -0
- package/templates/web/test/app.spec.tsx +47 -22
- package/templates/web/test/features/auth/sign-in-form.spec.tsx +6 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +83 -26
- package/templates/web/test/support/render-app.tsx +4 -4
- package/templates/workspace/scripts/ports.mjs +1 -1
- package/templates/api/docker/init/01-auth-schema.sql +0 -1
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +0 -13
- package/templates/api/src/infra/auth/auth.cli.ts +0 -5
- package/templates/api/src/infra/auth/auth.factory.ts +0 -77
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +0 -16
- package/templates/api/test/infra/auth/auth.factory.spec.ts +0 -22
- package/templates/mobile/src/features/auth/auth-client.ts +0 -27
- package/templates/mobile/src/features/auth/session.ts +0 -17
- package/templates/web/src/features/auth/auth-client.ts +0 -17
- package/templates/web/src/features/auth/session.ts +0 -17
|
@@ -1,60 +1,91 @@
|
|
|
1
|
-
import { useQuery } from '@tanstack/react-query'
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
12
|
-
|
|
28
|
+
requestId,
|
|
29
|
+
leave,
|
|
13
30
|
}: {
|
|
14
|
-
auth:
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
auth: ConsentAuth
|
|
32
|
+
requestId: string
|
|
33
|
+
leave: (url: string) => void
|
|
17
34
|
}) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
if (view.isPending) {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
34
54
|
|
|
35
|
-
|
|
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>{
|
|
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
|
-
{
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
{
|
|
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)}>
|
|
57
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
//
|
|
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.
|
|
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 {
|
|
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}
|
|
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 '@/
|
|
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'
|
|
8
|
+
const user = { id: 'user-1', email: 'ana@example.com' }
|
|
9
9
|
|
|
10
|
-
const session = {
|
|
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(
|
|
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
|
-
|
|
39
|
-
'
|
|
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
|
|
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
|
-
|
|
63
|
-
'
|
|
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
|
|
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'),
|
|
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'
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
109
|
+
return empty(204)
|
|
104
110
|
},
|
|
105
|
-
'POST /api/auth/sign-in
|
|
111
|
+
'POST /api/auth/sign-in': () => {
|
|
106
112
|
current = 'bruno'
|
|
107
|
-
return
|
|
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/
|
|
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
|
|
15
|
-
json(
|
|
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 {
|
|
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 {
|
|
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
|
|
10
|
+
const REQUEST = '0199a7e2-0000-7000-8000-000000000000'
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
'
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
80
|
+
`/consent?request=${REQUEST}`,
|
|
27
81
|
fakeTransport({
|
|
28
|
-
'GET /api/auth/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
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:
|
|
13
|
-
auth
|
|
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] })} />)
|