Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization server in front of it: an agent registers itself, sends a human to the browser to approve it, and exchanges the resulting code for a token. PKCE is required, codes are single-use and stored only as hashes, the browser is redirected to the URI that was registered rather than the one asked for, and refresh tokens rotate so that replaying a spent one revokes the whole line. Twenty tools cover reading, building, publishing and running flows, and each one calls the same REST endpoint the dashboard calls, in-process, carrying the caller's own token. That keeps one description of what a flow is and what may be done to it — validation, the draft/publish split, the version check — and means an agent can do nothing a person could not do in the browser. Agent tokens are RS256 with a keypair of their own rather than the secret that signs browser sessions, so deleting the key withdraws every agent without logging anyone out, and deps.decode_token grew the branch that trusting a second issuer will need when the hosted login arrives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
148 lines
4.6 KiB
TypeScript
148 lines
4.6 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query"
|
|
import { createFileRoute, redirect } from "@tanstack/react-router"
|
|
import { AlertTriangle } from "lucide-react"
|
|
|
|
import { OauthService } from "@/client"
|
|
import { AuthLayout } from "@/components/Common/AuthLayout"
|
|
import { Alert, AlertDescription } from "@/components/ui/alert"
|
|
import { Button } from "@/components/ui/button"
|
|
import { LoadingButton } from "@/components/ui/loading-button"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { isLoggedIn } from "@/hooks/useAuth"
|
|
|
|
/**
|
|
* Where an agent sends someone to be let in.
|
|
*
|
|
* Everything shown here is checked by the server first, and the URL the browser
|
|
* is finally sent to is the one the server hands back — never the one in the
|
|
* address bar — so a doctored link cannot redirect an approval somewhere else.
|
|
*/
|
|
export const Route = createFileRoute("/oauth/authorize")({
|
|
component: Authorize,
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
client_id: String(search.client_id ?? ""),
|
|
redirect_uri: String(search.redirect_uri ?? ""),
|
|
code_challenge: String(search.code_challenge ?? ""),
|
|
code_challenge_method: String(search.code_challenge_method ?? "S256"),
|
|
state: search.state ? String(search.state) : undefined,
|
|
resource: search.resource ? String(search.resource) : undefined,
|
|
}),
|
|
beforeLoad: ({ location }) => {
|
|
if (!isLoggedIn()) {
|
|
throw redirect({
|
|
to: "/login",
|
|
search: { redirect: location.href },
|
|
})
|
|
}
|
|
},
|
|
head: () => ({
|
|
meta: [{ title: "Authorize - Fluksio" }],
|
|
}),
|
|
})
|
|
|
|
function Authorize() {
|
|
const search = Route.useSearch()
|
|
|
|
const {
|
|
data: info,
|
|
isPending,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ["oauth", "authorize", search.client_id, search.redirect_uri],
|
|
queryFn: () =>
|
|
OauthService.authorizeValidate({
|
|
clientId: search.client_id,
|
|
redirectUri: search.redirect_uri,
|
|
}),
|
|
retry: false,
|
|
})
|
|
|
|
const approve = useMutation({
|
|
mutationFn: () =>
|
|
OauthService.authorize({
|
|
requestBody: {
|
|
client_id: search.client_id,
|
|
redirect_uri: search.redirect_uri,
|
|
code_challenge: search.code_challenge,
|
|
code_challenge_method: search.code_challenge_method,
|
|
state: search.state,
|
|
resource: search.resource,
|
|
},
|
|
}),
|
|
onSuccess: (response) => {
|
|
// The server's URL, built from the registered redirect.
|
|
window.location.assign(response.redirect_url)
|
|
},
|
|
})
|
|
|
|
const deny = () => {
|
|
if (!info) return
|
|
const url = new URL(info.redirect_uri)
|
|
url.searchParams.set("error", "access_denied")
|
|
if (search.state) url.searchParams.set("state", search.state)
|
|
window.location.assign(url.toString())
|
|
}
|
|
|
|
return (
|
|
<AuthLayout>
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<h1 className="text-2xl font-bold">Authorize access</h1>
|
|
</div>
|
|
|
|
{isPending ? (
|
|
<div className="grid gap-3">
|
|
<Skeleton className="h-5 w-56" />
|
|
<Skeleton className="h-5 w-40" />
|
|
</div>
|
|
) : error || !info ? (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle />
|
|
<AlertDescription>
|
|
This authorization request is not valid. Start it again from the
|
|
application that sent you here.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : (
|
|
<>
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
<span className="font-medium text-foreground">
|
|
{info.client_name}
|
|
</span>{" "}
|
|
wants to read and change your flows, and to run them. It will act
|
|
as you. Only allow this if you started it.
|
|
</p>
|
|
|
|
<div className="grid gap-2">
|
|
<LoadingButton
|
|
loading={approve.isPending}
|
|
onClick={() => approve.mutate()}
|
|
data-testid="approve-client"
|
|
>
|
|
Allow
|
|
</LoadingButton>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={deny}
|
|
disabled={approve.isPending}
|
|
data-testid="deny-client"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
|
|
{approve.isError ? (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle />
|
|
<AlertDescription>
|
|
That did not work. Start again from the application.
|
|
</AlertDescription>
|
|
</Alert>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</AuthLayout>
|
|
)
|
|
}
|