Unified · Split
| @@ -17,6 +17,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines | |||
| 17 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> | 17 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> |
| 18 | # WORKOS_WEBHOOK_SECRET=... | 18 | # WORKOS_WEBHOOK_SECRET=... |
| 19 | 19 | ||
| 20 | # ── Anonymous JWT Auth (optional — enables guest bearer auth via Convex Auth) ── | ||
| 21 | # Generate an ES256 key pair (PKCS8 private + SPKI public), then set both PEM values. | ||
| 22 | # ANONYMOUS_AUTH_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" | ||
| 23 | # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" | ||
| 24 | # Optional overrides: | ||
| 25 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 | ||
| 26 | |||
| 20 | # ── Stripe Billing (optional — required for checkout/portal) ── | 27 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 21 | # STRIPE_SECRET_KEY=sk_test_... | 28 | # STRIPE_SECRET_KEY=sk_test_... |
| 22 | # STRIPE_WEBHOOK_SECRET=whsec_... | 29 | # STRIPE_WEBHOOK_SECRET=whsec_... |
| @@ -57,7 +57,7 @@ Default source-dev endpoints: · 56 unmodified lines | |||
| 57 | - Web UI: `http://localhost:4312` | 57 | - Web UI: `http://localhost:4312` |
| 58 | - Convex HTTP MCP routes: | 58 | - Convex HTTP MCP routes: |
| 59 | - `<CONVEX_SITE_URL>/mcp` (WorkOS / external OAuth) | 59 | - `<CONVEX_SITE_URL>/mcp` (WorkOS / external OAuth) |
| 60 | - `<CONVEX_SITE_URL>/mcp/anonymous` (anonymous trial mode, no OAuth) | 60 | - `<CONVEX_SITE_URL>/mcp/anonymous` (anonymous bearer-token mode) |
| 61 | 61 | ||
| 62 | ## Binary Install (No Global Bun/Node/Convex Required) | 62 | ## Binary Install (No Global Bun/Node/Convex Required) |
| 63 | 63 | ||
| @@ -131,12 +131,13 @@ Manual GitHub release (recommended): · 67 unmodified lines | |||
| 131 | Convex HTTP routes (`packages/convex/http.ts`) expose: | 131 | Convex HTTP routes (`packages/convex/http.ts`) expose: |
| 132 | 132 | ||
| 133 | - `/mcp` (direct Convex MCP transport for WorkOS / external OAuth) | 133 | - `/mcp` (direct Convex MCP transport for WorkOS / external OAuth) |
| 134 | - `/mcp/anonymous` (direct Convex MCP transport for anonymous trial mode) | 134 | - `/mcp/anonymous` (direct Convex MCP transport for anonymous bearer tokens) |
| 135 | - `/.well-known/oauth-protected-resource` | 135 | - `/.well-known/oauth-protected-resource` |
| 136 | - `/.well-known/oauth-authorization-server` | 136 | - `/.well-known/oauth-authorization-server` |
| 137 | - `/internal/runs/:runId/tool-call` | 137 | - `/internal/runs/:runId/tool-call` |
| 138 | 138 | ||
| 139 | MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured. | 139 | MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured. |
| 140 | Anonymous bearer tokens can be minted from `/auth/anonymous/token` (and verified by `/.well-known/jwks.json`). | ||
| 140 | 141 | ||
| 141 | ## Configuration Reference | 142 | ## Configuration Reference |
| 142 | 143 | ||
| @@ -221,6 +222,6 @@ executor/ · 78 unmodified lines | |||
| 221 | ## Troubleshooting | 222 | ## Troubleshooting |
| 222 | 223 | ||
| 223 | - `401` on `/mcp`: verify your bearer token issuer matches `MCP_AUTHORIZATION_SERVER`. | 224 | - `401` on `/mcp`: verify your bearer token issuer matches `MCP_AUTHORIZATION_SERVER`. |
| 224 | - `400` on `/mcp` with `anon_*` context: use `/mcp/anonymous` instead. | 225 | - `401` on `/mcp/anonymous`: pass `Authorization: Bearer <anonymous token>` from `/auth/anonymous/token`. |
| 225 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. | 226 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. |
| 226 | - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets. | 227 | - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets. |
| @@ -0,0 +1,78 @@ | |||
| 1 | import type { NextRequest } from "next/server"; | ||
| 2 | import { NextResponse } from "next/server"; | ||
| 3 | |||
| 4 | function toSiteUrl(convexUrl?: string): string | null { | ||
| 5 | if (!convexUrl) { | ||
| 6 | return null; | ||
| 7 | } | ||
| 8 | |||
| 9 | try { | ||
| 10 | const parsed = new URL(convexUrl); | ||
| 11 | if (parsed.hostname.endsWith(".convex.cloud")) { | ||
| 12 | parsed.hostname = parsed.hostname.replace(/\.convex\.cloud$/, ".convex.site"); | ||
| 13 | } | ||
| 14 | parsed.pathname = ""; | ||
| 15 | parsed.search = ""; | ||
| 16 | parsed.hash = ""; | ||
| 17 | return parsed.toString().replace(/\/$/, ""); | ||
| 18 | } catch { | ||
| 19 | return null; | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | function resolveConvexSiteUrl(): string | null { | ||
| 24 | return toSiteUrl( | ||
| 25 | process.env.EXECUTOR_WEB_CONVEX_SITE_URL | ||
| 26 | ?? process.env.NEXT_PUBLIC_CONVEX_SITE_URL | ||
| 27 | ?? process.env.CONVEX_SITE_URL | ||
| 28 | ?? process.env.EXECUTOR_WEB_CONVEX_URL | ||
| 29 | ?? process.env.NEXT_PUBLIC_CONVEX_URL | ||
| 30 | ?? process.env.CONVEX_URL, | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | |||
| 34 | function noStoreJson(payload: unknown, status: number): NextResponse { | ||
| 35 | return NextResponse.json(payload, { | ||
| 36 | status, | ||
| 37 | headers: { | ||
| 38 | "cache-control": "no-store", | ||
| 39 | }, | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | export async function POST(request: NextRequest) { | ||
| 44 | const convexSiteUrl = resolveConvexSiteUrl(); | ||
| 45 | if (!convexSiteUrl) { | ||
| 46 | return noStoreJson({ error: "Convex site URL is not configured" }, 500); | ||
| 47 | } | ||
| 48 | |||
| 49 | let body: { actorId?: string } = {}; | ||
| 50 | try { | ||
| 51 | const parsed = await request.json() as { actorId?: unknown }; | ||
| 52 | if (typeof parsed.actorId === "string" && parsed.actorId.trim().length > 0) { | ||
| 53 | body.actorId = parsed.actorId.trim(); | ||
| 54 | } | ||
| 55 | } catch { | ||
| 56 | body = {}; | ||
| 57 | } | ||
| 58 | |||
| 59 | const response = await fetch(`${convexSiteUrl}/auth/anonymous/token`, { | ||
| 60 | method: "POST", | ||
| 61 | headers: { | ||
| 62 | "content-type": "application/json", | ||
| 63 | accept: "application/json", | ||
| 64 | }, | ||
| 65 | body: JSON.stringify(body), | ||
| 66 | cache: "no-store", | ||
| 67 | }); | ||
| 68 | |||
| 69 | const text = await response.text(); | ||
| 70 | let payload: unknown = null; | ||
| 71 | try { | ||
| 72 | payload = JSON.parse(text); | ||
| 73 | } catch { | ||
| 74 | payload = { error: text || "Anonymous token endpoint returned invalid JSON" }; | ||
| 75 | } | ||
| 76 | |||
| 77 | return noStoreJson(payload, response.status); | ||
| 78 | } | ||
| @@ -62,7 +62,6 @@ export function DashboardView() { · 61 unmodified lines | |||
| 62 | <CardContent className="pt-0"> | 62 | <CardContent className="pt-0"> |
| 63 | <McpSetupCard | 63 | <McpSetupCard |
| 64 | workspaceId={context?.workspaceId} | 64 | workspaceId={context?.workspaceId} |
| 65 | actorId={context?.actorId} | ||
| 66 | sessionId={context?.sessionId} | 65 | sessionId={context?.sessionId} |
| 67 | /> | 66 | /> |
| 68 | </CardContent> | 67 | </CardContent> |
| @@ -1,9 +1,14 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo, useState } from "react"; | 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | import { Check, Copy } from "lucide-react"; | 4 | import { Check, ChevronRight, Copy } from "lucide-react"; |
| 5 | import Image from "next/image"; | 5 | import Image from "next/image"; |
| 6 | import { Button } from "@/components/ui/button"; | 6 | import { Button } from "@/components/ui/button"; |
| 7 | import { | ||
| 8 | Collapsible, | ||
| 9 | CollapsibleContent, | ||
| 10 | CollapsibleTrigger, | ||
| 11 | } from "@/components/ui/collapsible"; | ||
| 7 | import { Input } from "@/components/ui/input"; | 12 | import { Input } from "@/components/ui/input"; |
| 8 | import { Label } from "@/components/ui/label"; | 13 | import { Label } from "@/components/ui/label"; |
| 9 | import { | 14 | import { |
| @@ -14,6 +19,8 @@ import { · 4 unmodified lines | |||
| 14 | SelectValue, | 19 | SelectValue, |
| 15 | } from "@/components/ui/select"; | 20 | } from "@/components/ui/select"; |
| 16 | import { MCP_PROVIDERS } from "@/components/tools/install-configs"; | 21 | import { MCP_PROVIDERS } from "@/components/tools/install-configs"; |
| 22 | import { getAnonymousAuthToken } from "@/lib/anonymous-auth"; | ||
| 23 | import { cn } from "@/lib/utils"; | ||
| 17 | 24 | ||
| 18 | function inferServerName(workspaceId?: string): string { | 25 | function inferServerName(workspaceId?: string): string { |
| 19 | if (!workspaceId) return "executor"; | 26 | if (!workspaceId) return "executor"; |
| @@ -49,15 +56,16 @@ function resolveMcpOrigin(windowOrigin: string): string { · 29 unmodified lines | |||
| 49 | 56 | ||
| 50 | export function McpSetupCard({ | 57 | export function McpSetupCard({ |
| 51 | workspaceId, | 58 | workspaceId, |
| 52 | actorId, | ||
| 53 | sessionId, | 59 | sessionId, |
| 54 | }: { | 60 | }: { |
| 55 | workspaceId?: string; | 61 | workspaceId?: string; |
| 56 | actorId?: string; | ||
| 57 | sessionId?: string; | 62 | sessionId?: string; |
| 58 | }) { | 63 | }) { |
| 59 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); | 64 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); |
| 60 | const [copiedKey, setCopiedKey] = useState<string | null>(null); | 65 | const [copiedKey, setCopiedKey] = useState<string | null>(null); |
| 66 | const [headersOpen, setHeadersOpen] = useState(false); | ||
| 67 | const [anonymousAccessToken, setAnonymousAccessToken] = useState<string | null>(null); | ||
| 68 | const [anonymousTokenError, setAnonymousTokenError] = useState<string | null>(null); | ||
| 61 | 69 | ||
| 62 | const origin = useMemo(() => { | 70 | const origin = useMemo(() => { |
| 63 | if (typeof window === "undefined") return ""; | 71 | if (typeof window === "undefined") return ""; |
| @@ -69,13 +77,52 @@ export function McpSetupCard({ · 5 unmodified lines | |||
| 69 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; | 77 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; |
| 70 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); | 78 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); |
| 71 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); | 79 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); |
| 72 | if (isAnonymousSession && actorId) base.searchParams.set("actorId", actorId); | ||
| 73 | if (!isAnonymousSession && sessionId) base.searchParams.set("sessionId", sessionId); | 80 | if (!isAnonymousSession && sessionId) base.searchParams.set("sessionId", sessionId); |
| 74 | if (!origin) { | 81 | if (!origin) { |
| 75 | return `${base.pathname}${base.search}`; | 82 | return `${base.pathname}${base.search}`; |
| 76 | } | 83 | } |
| 77 | return base.toString(); | 84 | return base.toString(); |
| 78 | }, [origin, workspaceId, actorId, sessionId]); | 85 | }, [origin, workspaceId, sessionId]); |
| 86 | |||
| 87 | const isAnonymousSession = isAnonymousSessionId(sessionId); | ||
| 88 | |||
| 89 | useEffect(() => { | ||
| 90 | let cancelled = false; | ||
| 91 | |||
| 92 | if (!isAnonymousSession) { | ||
| 93 | setAnonymousAccessToken(null); | ||
| 94 | setAnonymousTokenError(null); | ||
| 95 | return; | ||
| 96 | } | ||
| 97 | |||
| 98 | const hydrateToken = async () => { | ||
| 99 | try { | ||
| 100 | const token = await getAnonymousAuthToken(); | ||
| 101 | if (!cancelled) { | ||
| 102 | setAnonymousAccessToken(token.accessToken); | ||
| 103 | setAnonymousTokenError(null); | ||
| 104 | } | ||
| 105 | } catch (error) { | ||
| 106 | if (!cancelled) { | ||
| 107 | setAnonymousAccessToken(null); | ||
| 108 | setAnonymousTokenError(error instanceof Error ? error.message : "Failed to get anonymous token"); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | }; | ||
| 112 | |||
| 113 | void hydrateToken(); | ||
| 114 | |||
| 115 | return () => { | ||
| 116 | cancelled = true; | ||
| 117 | }; | ||
| 118 | }, [isAnonymousSession, sessionId]); | ||
| 119 | |||
| 120 | const headerLines = useMemo(() => { | ||
| 121 | return [ | ||
| 122 | `Authorization: Bearer ${anonymousAccessToken ?? "<loading anonymous token...>"}`, | ||
| 123 | "Accept: application/json, text/event-stream", | ||
| 124 | ]; | ||
| 125 | }, [anonymousAccessToken]); | ||
| 79 | 126 | ||
| 80 | const provider = MCP_PROVIDERS.find((item) => item.id === selectedProviderId) ?? MCP_PROVIDERS[0]; | 127 | const provider = MCP_PROVIDERS.find((item) => item.id === selectedProviderId) ?? MCP_PROVIDERS[0]; |
| 81 | if (!provider) { | 128 | if (!provider) { |
| @@ -114,6 +161,41 @@ export function McpSetupCard({ · 32 unmodified lines | |||
| 114 | {copiedKey === "url" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} | 161 | {copiedKey === "url" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} |
| 115 | </Button> | 162 | </Button> |
| 116 | </div> | 163 | </div> |
| 164 | |||
| 165 | {isAnonymousSession && ( | ||
| 166 | <Collapsible open={headersOpen} onOpenChange={setHeadersOpen}> | ||
| 167 | <div className="mt-2"> | ||
| 168 | <CollapsibleTrigger asChild> | ||
| 169 | <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | ||
| 170 | <ChevronRight | ||
| 171 | className={cn("mr-1 h-3 w-3 transition-transform", headersOpen && "rotate-90")} | ||
| 172 | /> | ||
| 173 | {headersOpen ? "Hide headers" : "Set these headers also"} | ||
| 174 | </Button> | ||
| 175 | </CollapsibleTrigger> | ||
| 176 | </div> | ||
| 177 | |||
| 178 | <CollapsibleContent className="mt-1.5"> | ||
| 179 | <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2 space-y-2"> | ||
| 180 | <div className="flex items-center justify-end"> | ||
| 181 | <Button | ||
| 182 | type="button" | ||
| 183 | variant="outline" | ||
| 184 | size="sm" | ||
| 185 | className="h-6 text-[10px]" | ||
| 186 | onClick={() => void copyText("headers", headerLines.join("\n"))} | ||
| 187 | > | ||
| 188 | {copiedKey === "headers" ? "Copied" : "Copy headers"} | ||
| 189 | </Button> | ||
| 190 | </div> | ||
| 191 | <pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-foreground"> | ||
| 192 | <code>{headerLines.join("\n")}</code> | ||
| 193 | </pre> | ||
| 194 | {anonymousTokenError && <p className="text-[10px] text-destructive">{anonymousTokenError}</p>} | ||
| 195 | </div> | ||
| 196 | </CollapsibleContent> | ||
| 197 | </Collapsible> | ||
| 198 | )} | ||
| 117 | </div> | 199 | </div> |
| 118 | 200 | ||
| 119 | <div className="space-y-1.5"> | 201 | <div className="space-y-1.5"> |
| @@ -0,0 +1,127 @@ | |||
| 1 | export type AnonymousAuthToken = { | ||
| 2 | accessToken: string; | ||
| 3 | actorId: string; | ||
| 4 | expiresAtMs: number; | ||
| 5 | }; | ||
| 6 | |||
| 7 | const TOKEN_STORAGE_KEY = "executor_anonymous_access_token"; | ||
| 8 | const ACTOR_STORAGE_KEY = "executor_anonymous_actor_id"; | ||
| 9 | const EXPIRES_STORAGE_KEY = "executor_anonymous_token_expires_at"; | ||
| 10 | const TOKEN_EXPIRY_SKEW_MS = 60_000; | ||
| 11 | |||
| 12 | function canUseStorage(): boolean { | ||
| 13 | return typeof window !== "undefined"; | ||
| 14 | } | ||
| 15 | |||
| 16 | function readStorageValue(key: string): string | null { | ||
| 17 | if (!canUseStorage()) { | ||
| 18 | return null; | ||
| 19 | } | ||
| 20 | return localStorage.getItem(key); | ||
| 21 | } | ||
| 22 | |||
| 23 | function writeStorageValue(key: string, value: string) { | ||
| 24 | if (!canUseStorage()) { | ||
| 25 | return; | ||
| 26 | } | ||
| 27 | localStorage.setItem(key, value); | ||
| 28 | } | ||
| 29 | |||
| 30 | export function clearAnonymousAuth(options?: { clearActor?: boolean }) { | ||
| 31 | if (!canUseStorage()) { | ||
| 32 | return; | ||
| 33 | } | ||
| 34 | |||
| 35 | localStorage.removeItem(TOKEN_STORAGE_KEY); | ||
| 36 | if (options?.clearActor) { | ||
| 37 | localStorage.removeItem(ACTOR_STORAGE_KEY); | ||
| 38 | } | ||
| 39 | localStorage.removeItem(EXPIRES_STORAGE_KEY); | ||
| 40 | } | ||
| 41 | |||
| 42 | function persistAnonymousAuth(token: AnonymousAuthToken) { | ||
| 43 | writeStorageValue(TOKEN_STORAGE_KEY, token.accessToken); | ||
| 44 | writeStorageValue(ACTOR_STORAGE_KEY, token.actorId); | ||
| 45 | writeStorageValue(EXPIRES_STORAGE_KEY, String(token.expiresAtMs)); | ||
| 46 | } | ||
| 47 | |||
| 48 | function readStoredActorId(): string | null { | ||
| 49 | const raw = readStorageValue(ACTOR_STORAGE_KEY); | ||
| 50 | return raw && raw.trim().length > 0 ? raw.trim() : null; | ||
| 51 | } | ||
| 52 | |||
| 53 | export function readStoredAnonymousAuthToken(): AnonymousAuthToken | null { | ||
| 54 | const accessToken = readStorageValue(TOKEN_STORAGE_KEY); | ||
| 55 | const actorId = readStoredActorId(); | ||
| 56 | const expiresAtRaw = readStorageValue(EXPIRES_STORAGE_KEY); | ||
| 57 | |||
| 58 | if (!accessToken || !actorId || !expiresAtRaw) { | ||
| 59 | return null; | ||
| 60 | } | ||
| 61 | |||
| 62 | const expiresAtMs = Number.parseInt(expiresAtRaw, 10); | ||
| 63 | if (!Number.isFinite(expiresAtMs)) { | ||
| 64 | clearAnonymousAuth(); | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | |||
| 68 | if (Date.now() + TOKEN_EXPIRY_SKEW_MS >= expiresAtMs) { | ||
| 69 | clearAnonymousAuth(); | ||
| 70 | return null; | ||
| 71 | } | ||
| 72 | |||
| 73 | return { | ||
| 74 | accessToken, | ||
| 75 | actorId, | ||
| 76 | expiresAtMs, | ||
| 77 | }; | ||
| 78 | } | ||
| 79 | |||
| 80 | async function requestAnonymousAuthToken(actorId?: string): Promise<AnonymousAuthToken> { | ||
| 81 | const response = await fetch("/api/auth/anonymous-token", { | ||
| 82 | method: "POST", | ||
| 83 | headers: { | ||
| 84 | "content-type": "application/json", | ||
| 85 | }, | ||
| 86 | body: JSON.stringify({ actorId }), | ||
| 87 | }); | ||
| 88 | |||
| 89 | if (!response.ok) { | ||
| 90 | const message = await response.text(); | ||
| 91 | throw new Error(message || "Failed to obtain anonymous auth token"); | ||
| 92 | } | ||
| 93 | |||
| 94 | const payload = await response.json() as { | ||
| 95 | accessToken?: unknown; | ||
| 96 | actorId?: unknown; | ||
| 97 | expiresAtMs?: unknown; | ||
| 98 | }; | ||
| 99 | |||
| 100 | if ( | ||
| 101 | typeof payload.accessToken !== "string" | ||
| 102 | || typeof payload.actorId !== "string" | ||
| 103 | || typeof payload.expiresAtMs !== "number" | ||
| 104 | ) { | ||
| 105 | throw new Error("Anonymous token response was malformed"); | ||
| 106 | } | ||
| 107 | |||
| 108 | return { | ||
| 109 | accessToken: payload.accessToken, | ||
| 110 | actorId: payload.actorId, | ||
| 111 | expiresAtMs: payload.expiresAtMs, | ||
| 112 | }; | ||
| 113 | } | ||
| 114 | |||
| 115 | export async function getAnonymousAuthToken(forceRefresh = false): Promise<AnonymousAuthToken> { | ||
| 116 | if (!forceRefresh) { | ||
| 117 | const stored = readStoredAnonymousAuthToken(); | ||
| 118 | if (stored) { | ||
| 119 | return stored; | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | const actorId = readStoredActorId() ?? undefined; | ||
| 124 | const fresh = await requestAnonymousAuthToken(actorId); | ||
| 125 | persistAnonymousAuth(fresh); | ||
| 126 | return fresh; | ||
| 127 | } | ||
| @@ -1,14 +1,18 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { createContext, useContext, useMemo } from "react"; | 3 | import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; |
| 4 | import { ConvexProviderWithAuth } from "convex/react"; | 4 | import { ConvexProviderWithAuth } from "convex/react"; |
| 5 | import { | 5 | import { |
| 6 | AuthKitProvider, | 6 | AuthKitProvider, |
| 7 | useAccessToken, | 7 | useAccessToken, |
| 8 | useAuth as useWorkosAuth, | 8 | useAuth as useWorkosAuth, |
| 9 | } from "@workos-inc/authkit-nextjs/components"; | 9 | } from "@workos-inc/authkit-nextjs/components"; |
| 10 | import { ConvexProvider, ConvexReactClient } from "convex/react"; | 10 | import { ConvexReactClient } from "convex/react"; |
| 11 | import type { ReactNode } from "react"; | 11 | import type { ReactNode } from "react"; |
| 12 | import { | ||
| 13 | getAnonymousAuthToken, | ||
| 14 | readStoredAnonymousAuthToken, | ||
| 15 | } from "@/lib/anonymous-auth"; | ||
| 12 | import { workosEnabled } from "@/lib/auth-capabilities"; | 16 | import { workosEnabled } from "@/lib/auth-capabilities"; |
| 13 | 17 | ||
| 14 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; | 18 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; |
| @@ -29,23 +33,84 @@ export function useWorkosAuthState() { · 14 unmodified lines | |||
| 29 | return useContext(WorkosAuthContext); | 33 | return useContext(WorkosAuthContext); |
| 30 | } | 34 | } |
| 31 | 35 | ||
| 32 | function useConvexAuthFromWorkos() { | 36 | function useConvexAuthFromAnonymous() { |
| 37 | const [loading, setLoading] = useState(() => readStoredAnonymousAuthToken() === null); | ||
| 38 | const [token, setToken] = useState<string | null>(() => readStoredAnonymousAuthToken()?.accessToken ?? null); | ||
| 39 | |||
| 40 | useEffect(() => { | ||
| 41 | let cancelled = false; | ||
| 42 | |||
| 43 | const bootstrap = async () => { | ||
| 44 | try { | ||
| 45 | const auth = await getAnonymousAuthToken(); | ||
| 46 | if (!cancelled) { | ||
| 47 | setToken(auth.accessToken); | ||
| 48 | } | ||
| 49 | } catch { | ||
| 50 | if (!cancelled) { | ||
| 51 | setToken(null); | ||
| 52 | } | ||
| 53 | } finally { | ||
| 54 | if (!cancelled) { | ||
| 55 | setLoading(false); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | }; | ||
| 59 | |||
| 60 | void bootstrap(); | ||
| 61 | |||
| 62 | return () => { | ||
| 63 | cancelled = true; | ||
| 64 | }; | ||
| 65 | }, []); | ||
| 66 | |||
| 67 | const fetchAccessToken = useCallback(async () => { | ||
| 68 | const stored = readStoredAnonymousAuthToken(); | ||
| 69 | if (stored) { | ||
| 70 | if (stored.accessToken !== token) { | ||
| 71 | setToken(stored.accessToken); | ||
| 72 | } | ||
| 73 | return stored.accessToken; | ||
| 74 | } | ||
| 75 | |||
| 76 | const refreshed = await getAnonymousAuthToken(true); | ||
| 77 | setToken(refreshed.accessToken); | ||
| 78 | setLoading(false); | ||
| 79 | return refreshed.accessToken; | ||
| 80 | }, [token]); | ||
| 81 | |||
| 82 | return useMemo( | ||
| 83 | () => ({ | ||
| 84 | isLoading: loading, | ||
| 85 | isAuthenticated: Boolean(token), | ||
| 86 | fetchAccessToken, | ||
| 87 | }), | ||
| 88 | [fetchAccessToken, loading, token], | ||
| 89 | ); | ||
| 90 | } | ||
| 91 | |||
| 92 | function useConvexAuthFromWorkosOrAnonymous() { | ||
| 33 | const { loading: authLoading, user } = useWorkosAuth(); | 93 | const { loading: authLoading, user } = useWorkosAuth(); |
| 34 | const { loading: tokenLoading, getAccessToken } = useAccessToken(); | 94 | const { loading: tokenLoading, getAccessToken } = useAccessToken(); |
| 35 | const isAuthenticated = Boolean(user); | 95 | const anonymousAuth = useConvexAuthFromAnonymous(); |
| 36 | const isLoading = authLoading || (isAuthenticated && tokenLoading); | 96 | const workosAuthenticated = Boolean(user); |
| 97 | const isLoading = authLoading || (workosAuthenticated ? tokenLoading : anonymousAuth.isLoading); | ||
| 98 | const isAuthenticated = workosAuthenticated || anonymousAuth.isAuthenticated; | ||
| 37 | 99 | ||
| 38 | const fetchAccessToken = useMemo( | 100 | const fetchAccessToken = useCallback(async () => { |
| 39 | () => async () => { | 101 | if (workosAuthenticated) { |
| 40 | try { | 102 | try { |
| 41 | const token = await getAccessToken(); | 103 | const token = await getAccessToken(); |
| 42 | return token ?? null; | 104 | if (token) { |
| 105 | return token; | ||
| 106 | } | ||
| 43 | } catch { | 107 | } catch { |
| 44 | return null; | 108 | // Fall through to anonymous token. |
| 45 | } | 109 | } |
| 46 | }, | 110 | } |
| 47 | [getAccessToken], | 111 | |
| 48 | ); | 112 | return await anonymousAuth.fetchAccessToken(); |
| 113 | }, [anonymousAuth, getAccessToken, workosAuthenticated]); | ||
| 49 | 114 | ||
| 50 | return useMemo( | 115 | return useMemo( |
| 51 | () => ({ | 116 | () => ({ |
| @@ -65,7 +130,7 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 13 unmodified lines | |||
| 65 | 130 | ||
| 66 | return ( | 131 | return ( |
| 67 | <WorkosAuthContext.Provider value={{ loading, authenticated }}> | 132 | <WorkosAuthContext.Provider value={{ loading, authenticated }}> |
| 68 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkos}> | 133 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}> |
| 69 | {children} | 134 | {children} |
| 70 | </ConvexProviderWithAuth> | 135 | </ConvexProviderWithAuth> |
| 71 | </WorkosAuthContext.Provider> | 136 | </WorkosAuthContext.Provider> |
| @@ -81,5 +146,11 @@ export function AppConvexProvider({ children }: { children: ReactNode }) { · 9 unmodified lines | |||
| 81 | ); | 146 | ); |
| 82 | } | 147 | } |
| 83 | 148 | ||
| 84 | return <ConvexProvider client={convexClient}>{children}</ConvexProvider>; | 149 | return ( |
| 150 | <WorkosAuthContext.Provider value={{ loading: false, authenticated: false }}> | ||
| 151 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromAnonymous}> | ||
| 152 | {children} | ||
| 153 | </ConvexProviderWithAuth> | ||
| 154 | </WorkosAuthContext.Provider> | ||
| 155 | ); | ||
| 85 | } | 156 | } |
| @@ -30,6 +30,7 @@ import { · 29 unmodified lines | |||
| 30 | readStoredSessionId, | 30 | readStoredSessionId, |
| 31 | readWorkspaceByAccount, | 31 | readWorkspaceByAccount, |
| 32 | } from "@/lib/session-storage"; | 32 | } from "@/lib/session-storage"; |
| 33 | import { clearAnonymousAuth } from "@/lib/anonymous-auth"; | ||
| 33 | import type { AnonymousContext } from "./types"; | 34 | import type { AnonymousContext } from "./types"; |
| 34 | import type { Id } from "@executor/convex/_generated/dataModel"; | 35 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| 35 | 36 | ||
| @@ -177,6 +178,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 141 unmodified lines | |||
| 177 | 178 | ||
| 178 | const resetWorkspace = useCallback(async () => { | 179 | const resetWorkspace = useCallback(async () => { |
| 179 | clearSessionStorage(); | 180 | clearSessionStorage(); |
| 181 | clearAnonymousAuth({ clearActor: true }); | ||
| 180 | setStoredSessionId(null); | 182 | setStoredSessionId(null); |
| 181 | setActiveWorkspaceId(null); | 183 | setActiveWorkspaceId(null); |
| 182 | setManualGuestContext(null); | 184 | setManualGuestContext(null); |
| @@ -493,7 +493,7 @@ export declare const internal: { · 492 unmodified lines | |||
| 493 | bootstrapAnonymousSession: FunctionReference< | 493 | bootstrapAnonymousSession: FunctionReference< |
| 494 | "mutation", | 494 | "mutation", |
| 495 | "internal", | 495 | "internal", |
| 496 | { sessionId?: string }, | 496 | { actorId?: string; clientId?: string; sessionId?: string }, |
| 497 | any | 497 | any |
| 498 | >; | 498 | >; |
| 499 | ensureAnonymousMcpSession: FunctionReference< | 499 | ensureAnonymousMcpSession: FunctionReference< |
| @@ -552,7 +552,7 @@ export declare const internal: { · 52 unmodified lines | |||
| 552 | bootstrapAnonymousSession: FunctionReference< | 552 | bootstrapAnonymousSession: FunctionReference< |
| 553 | "mutation", | 553 | "mutation", |
| 554 | "internal", | 554 | "internal", |
| 555 | { sessionId?: string }, | 555 | { actorId?: string; clientId?: string; sessionId?: string }, |
| 556 | any | 556 | any |
| 557 | >; | 557 | >; |
| 558 | createApproval: FunctionReference< | 558 | createApproval: FunctionReference< |
| @@ -1227,6 +1227,12 @@ export declare const internal: { · 668 unmodified lines | |||
| 1227 | >; | 1227 | >; |
| 1228 | }; | 1228 | }; |
| 1229 | workspaceAuthInternal: { | 1229 | workspaceAuthInternal: { |
| 1230 | getWorkspaceAccessForAnonymousSubject: FunctionReference< | ||
| 1231 | "query", | ||
| 1232 | "internal", | ||
| 1233 | { actorId: string; workspaceId: Id<"workspaces"> }, | ||
| 1234 | any | ||
| 1235 | >; | ||
| 1230 | getWorkspaceAccessForRequest: FunctionReference< | 1236 | getWorkspaceAccessForRequest: FunctionReference< |
| 1231 | "query", | 1237 | "query", |
| 1232 | "internal", | 1238 | "internal", |
| @@ -1,9 +1,17 @@ | |||
| 1 | import { | ||
| 2 | ANONYMOUS_AUTH_AUDIENCE, | ||
| 3 | getAnonymousAuthIssuer, | ||
| 4 | getAnonymousAuthJwksUrl, | ||
| 5 | } from "./auth/anonymous"; | ||
| 6 | |||
| 1 | const rawClientId = process.env.WORKOS_CLIENT_ID?.trim(); | 7 | const rawClientId = process.env.WORKOS_CLIENT_ID?.trim(); |
| 2 | const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; | 8 | const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; |
| 9 | const anonymousAuthIssuer = getAnonymousAuthIssuer(); | ||
| 10 | const anonymousAuthJwksUrl = getAnonymousAuthJwksUrl(); | ||
| 3 | 11 | ||
| 4 | const authConfig = clientId | 12 | const providers = [ |
| 5 | ? { | 13 | ...(clientId |
| 6 | providers: [ | 14 | ? [ |
| 7 | { | 15 | { |
| 8 | type: "customJwt" as const, | 16 | type: "customJwt" as const, |
| 9 | issuer: "https://api.workos.com/", | 17 | issuer: "https://api.workos.com/", |
| @@ -17,8 +25,23 @@ const authConfig = clientId · 7 unmodified lines | |||
| 17 | algorithm: "RS256" as const, | 25 | algorithm: "RS256" as const, |
| 18 | jwks: `https://api.workos.com/sso/jwks/${clientId}`, | 26 | jwks: `https://api.workos.com/sso/jwks/${clientId}`, |
| 19 | }, | 27 | }, |
| 20 | ], | 28 | ] |
| 21 | } | 29 | : []), |
| 22 | : { providers: [] as const }; | 30 | ...(anonymousAuthIssuer && anonymousAuthJwksUrl |
| 31 | ? [ | ||
| 32 | { | ||
| 33 | type: "customJwt" as const, | ||
| 34 | issuer: anonymousAuthIssuer, | ||
| 35 | algorithm: "ES256" as const, | ||
| 36 | applicationID: ANONYMOUS_AUTH_AUDIENCE, | ||
| 37 | jwks: anonymousAuthJwksUrl, | ||
| 38 | }, | ||
| 39 | ] | ||
| 40 | : []), | ||
| 41 | ]; | ||
| 42 | |||
| 43 | const authConfig = { | ||
| 44 | providers, | ||
| 45 | }; | ||
| 23 | 46 | ||
| 24 | export default authConfig; | 47 | export default authConfig; |
| @@ -0,0 +1,47 @@ | |||
| 1 | export const ANONYMOUS_AUTH_AUDIENCE = "executor-anonymous"; | ||
| 2 | export const ANONYMOUS_AUTH_KEY_ID = "executor-anonymous-es256"; | ||
| 3 | export const ANONYMOUS_AUTH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7; | ||
| 4 | |||
| 5 | function trimOrNull(value: string | undefined): string | null { | ||
| 6 | const trimmed = value?.trim(); | ||
| 7 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 8 | } | ||
| 9 | |||
| 10 | function toSiteUrl(raw: string | undefined): string | null { | ||
| 11 | const trimmed = trimOrNull(raw); | ||
| 12 | if (!trimmed) { | ||
| 13 | return null; | ||
| 14 | } | ||
| 15 | |||
| 16 | try { | ||
| 17 | const parsed = new URL(trimmed); | ||
| 18 | if (parsed.hostname.endsWith(".convex.cloud")) { | ||
| 19 | parsed.hostname = parsed.hostname.replace(/\.convex\.cloud$/, ".convex.site"); | ||
| 20 | } | ||
| 21 | parsed.pathname = ""; | ||
| 22 | parsed.search = ""; | ||
| 23 | parsed.hash = ""; | ||
| 24 | return parsed.toString().replace(/\/$/, ""); | ||
| 25 | } catch { | ||
| 26 | return null; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | export function getAnonymousAuthIssuer(): string | null { | ||
| 31 | return toSiteUrl(process.env.CONVEX_SITE_URL) ?? toSiteUrl(process.env.CONVEX_URL); | ||
| 32 | } | ||
| 33 | |||
| 34 | export function getAnonymousAuthJwksUrl(): string | null { | ||
| 35 | const issuer = getAnonymousAuthIssuer(); | ||
| 36 | return issuer ? `${issuer}/.well-known/jwks.json` : null; | ||
| 37 | } | ||
| 38 | |||
| 39 | export function isAnonymousIdentity(identity: { subject?: unknown; provider?: unknown } | null): boolean { | ||
| 40 | if (!identity || typeof identity.subject !== "string") { | ||
| 41 | return false; | ||
| 42 | } | ||
| 43 | if (identity.subject.startsWith("anon_")) { | ||
| 44 | return true; | ||
| 45 | } | ||
| 46 | return identity.provider === "anonymous"; | ||
| 47 | } | ||
| @@ -4,11 +4,23 @@ import { ensureAnonymousIdentity } from "./anonymous"; · 3 unmodified lines | |||
| 4 | import { mapAnonymousContext } from "./readers"; | 4 | import { mapAnonymousContext } from "./readers"; |
| 5 | 5 | ||
| 6 | export const bootstrapAnonymousSession = internalMutation({ | 6 | export const bootstrapAnonymousSession = internalMutation({ |
| 7 | args: { sessionId: v.optional(v.string()) }, | 7 | args: { |
| 8 | sessionId: v.optional(v.string()), | ||
| 9 | actorId: v.optional(v.string()), | ||
| 10 | clientId: v.optional(v.string()), | ||
| 11 | }, | ||
| 8 | handler: async (ctx, args) => { | 12 | handler: async (ctx, args) => { |
| 9 | const now = Date.now(); | 13 | const now = Date.now(); |
| 10 | const requestedSessionId = args.sessionId?.trim() || ""; | 14 | const requestedSessionId = args.sessionId?.trim() || ""; |
| 11 | const allowRequestedSessionId = requestedSessionId?.startsWith("mcp_") ?? false; | 15 | const requestedActorId = args.actorId?.trim() || ""; |
| 16 | const clientId = args.clientId?.trim() || "web"; | ||
| 17 | |||
| 18 | if (requestedActorId && !requestedActorId.startsWith("anon_")) { | ||
| 19 | throw new Error("actorId must be an anonymous actor"); | ||
| 20 | } | ||
| 21 | |||
| 22 | const allowRequestedSessionId = requestedSessionId.startsWith("mcp_") | ||
| 23 | || requestedSessionId.startsWith("anon_session_"); | ||
| 12 | 24 | ||
| 13 | if (requestedSessionId) { | 25 | if (requestedSessionId) { |
| 14 | const sessionId = requestedSessionId; | 26 | const sessionId = requestedSessionId; |
| @@ -20,11 +32,13 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines | |||
| 20 | const identity = await ensureAnonymousIdentity(ctx, { | 32 | const identity = await ensureAnonymousIdentity(ctx, { |
| 21 | sessionId, | 33 | sessionId, |
| 22 | workspaceId: existing.workspaceId, | 34 | workspaceId: existing.workspaceId, |
| 23 | actorId: existing.actorId, | 35 | actorId: requestedActorId || existing.actorId, |
| 24 | timestamp: now, | 36 | timestamp: now, |
| 25 | }); | 37 | }); |
| 26 | 38 | ||
| 27 | await ctx.db.patch(existing._id, { | 39 | await ctx.db.patch(existing._id, { |
| 40 | actorId: requestedActorId || existing.actorId, | ||
| 41 | clientId, | ||
| 28 | workspaceId: identity.workspaceId, | 42 | workspaceId: identity.workspaceId, |
| 29 | accountId: identity.accountId, | 43 | accountId: identity.accountId, |
| 30 | userId: identity.userId, | 44 | userId: identity.userId, |
| @@ -43,13 +57,12 @@ export const bootstrapAnonymousSession = internalMutation({ · 12 unmodified lines | |||
| 43 | } | 57 | } |
| 44 | 58 | ||
| 45 | const generatedSessionId = allowRequestedSessionId | 59 | const generatedSessionId = allowRequestedSessionId |
| 46 | ? `mcp_${crypto.randomUUID()}` | 60 | ? (requestedSessionId.startsWith("mcp_") ? `mcp_${crypto.randomUUID()}` : `anon_session_${crypto.randomUUID()}`) |
| 47 | : `anon_session_${crypto.randomUUID()}`; | 61 | : `anon_session_${crypto.randomUUID()}`; |
| 48 | const sessionId = allowRequestedSessionId | 62 | const sessionId = allowRequestedSessionId |
| 49 | ? requestedSessionId as string | 63 | ? requestedSessionId as string |
| 50 | : generatedSessionId; | 64 | : generatedSessionId; |
| 51 | const actorId = `anon_${crypto.randomUUID()}`; | 65 | const actorId = requestedActorId || `anon_${crypto.randomUUID()}`; |
| 52 | const clientId = "web"; | ||
| 53 | 66 | ||
| 54 | const identity = await ensureAnonymousIdentity(ctx, { | 67 | const identity = await ensureAnonymousIdentity(ctx, { |
| 55 | sessionId, | 68 | sessionId, |
| @@ -2,6 +2,7 @@ import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; · 1 unmodified line | |||
| 2 | import { httpRouter } from "convex/server"; | 2 | import { httpRouter } from "convex/server"; |
| 3 | import { components } from "./_generated/api"; | 3 | import { components } from "./_generated/api"; |
| 4 | import { authKit } from "./auth"; | 4 | import { authKit } from "./auth"; |
| 5 | import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth"; | ||
| 5 | import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler"; | 6 | import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler"; |
| 6 | import { | 7 | import { |
| 7 | oauthAuthorizationServerHandler, | 8 | oauthAuthorizationServerHandler, |
| @@ -24,5 +25,8 @@ http.route({ path: "/mcp/anonymous", method: "DELETE", handler: mcpAnonymousHand · 16 unmodified lines | |||
| 24 | 25 | ||
| 25 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); | 26 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); |
| 26 | http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler }); | 27 | http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler }); |
| 28 | http.route({ path: "/.well-known/jwks.json", method: "GET", handler: anonymousJwksHandler }); | ||
| 29 | http.route({ path: "/auth/anonymous/token", method: "POST", handler: anonymousTokenHandler }); | ||
| 30 | http.route({ path: "/auth/anonymous/token", method: "GET", handler: anonymousTokenHandler }); | ||
| 27 | 31 | ||
| 28 | export default http; | 32 | export default http; |
| @@ -0,0 +1,189 @@ | |||
| 1 | import { exportJWK, importPKCS8, importSPKI, SignJWT } from "jose"; | ||
| 2 | import { httpAction } from "../_generated/server"; | ||
| 3 | import { | ||
| 4 | ANONYMOUS_AUTH_AUDIENCE, | ||
| 5 | ANONYMOUS_AUTH_KEY_ID, | ||
| 6 | ANONYMOUS_AUTH_TOKEN_TTL_SECONDS, | ||
| 7 | getAnonymousAuthIssuer, | ||
| 8 | } from "../auth/anonymous"; | ||
| 9 | |||
| 10 | type AnonymousTokenBody = { | ||
| 11 | actorId?: string; | ||
| 12 | }; | ||
| 13 | |||
| 14 | type AnonymousTokenConfig = { | ||
| 15 | issuer: string | null; | ||
| 16 | audience: string; | ||
| 17 | keyId: string; | ||
| 18 | tokenTtlSeconds: number; | ||
| 19 | privateKeyPem: string | null; | ||
| 20 | publicKeyPem: string | null; | ||
| 21 | }; | ||
| 22 | |||
| 23 | function trimOrNull(value: string | undefined): string | null { | ||
| 24 | const trimmed = value?.trim(); | ||
| 25 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 26 | } | ||
| 27 | |||
| 28 | function normalizePem(value: string | undefined): string | null { | ||
| 29 | const raw = trimOrNull(value); | ||
| 30 | if (!raw) { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | return raw.replace(/\\n/g, "\n"); | ||
| 34 | } | ||
| 35 | |||
| 36 | function parseTokenTtlSeconds(raw: string | undefined): number { | ||
| 37 | const parsed = Number.parseInt(raw ?? "", 10); | ||
| 38 | return Number.isFinite(parsed) && parsed > 0 ? parsed : ANONYMOUS_AUTH_TOKEN_TTL_SECONDS; | ||
| 39 | } | ||
| 40 | |||
| 41 | function getAnonymousTokenConfig(): AnonymousTokenConfig { | ||
| 42 | return { | ||
| 43 | issuer: getAnonymousAuthIssuer(), | ||
| 44 | audience: ANONYMOUS_AUTH_AUDIENCE, | ||
| 45 | keyId: ANONYMOUS_AUTH_KEY_ID, | ||
| 46 | tokenTtlSeconds: parseTokenTtlSeconds(process.env.ANONYMOUS_AUTH_TOKEN_TTL_SECONDS), | ||
| 47 | privateKeyPem: normalizePem(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM), | ||
| 48 | publicKeyPem: normalizePem(process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM), | ||
| 49 | }; | ||
| 50 | } | ||
| 51 | |||
| 52 | function noStoreHeaders(extra?: Record<string, string>) { | ||
| 53 | return { | ||
| 54 | "cache-control": "no-store", | ||
| 55 | ...extra, | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 59 | function parseAnonymousActorId(raw: unknown): string | null { | ||
| 60 | if (typeof raw !== "string") { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | const actorId = raw.trim(); | ||
| 64 | if (!actorId) { | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | if (!/^anon_[a-zA-Z0-9_-]{8,}$/.test(actorId)) { | ||
| 68 | throw new Error("actorId must match anon_<id>"); | ||
| 69 | } | ||
| 70 | return actorId; | ||
| 71 | } | ||
| 72 | |||
| 73 | async function readActorIdFromRequest(request: Request): Promise<string | null> { | ||
| 74 | const url = new URL(request.url); | ||
| 75 | const queryActorId = url.searchParams.get("actorId"); | ||
| 76 | if (queryActorId) { | ||
| 77 | return parseAnonymousActorId(queryActorId); | ||
| 78 | } | ||
| 79 | |||
| 80 | if (request.method !== "POST") { | ||
| 81 | return null; | ||
| 82 | } | ||
| 83 | |||
| 84 | const contentType = request.headers.get("content-type") ?? ""; | ||
| 85 | if (!contentType.includes("application/json")) { | ||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | const body = (await request.json()) as AnonymousTokenBody; | ||
| 90 | return parseAnonymousActorId(body.actorId); | ||
| 91 | } | ||
| 92 | |||
| 93 | function createActorId(): string { | ||
| 94 | return `anon_${crypto.randomUUID().replace(/-/g, "")}`; | ||
| 95 | } | ||
| 96 | |||
| 97 | let signingKeyPromise: Promise<CryptoKey> | null = null; | ||
| 98 | let jwksPromise: Promise<Record<string, unknown>> | null = null; | ||
| 99 | |||
| 100 | async function loadSigningKey(privateKeyPem: string): Promise<CryptoKey> { | ||
| 101 | if (!signingKeyPromise) { | ||
| 102 | signingKeyPromise = importPKCS8(privateKeyPem, "ES256"); | ||
| 103 | } | ||
| 104 | return await signingKeyPromise; | ||
| 105 | } | ||
| 106 | |||
| 107 | async function loadJwks(publicKeyPem: string, keyId: string): Promise<Record<string, unknown>> { | ||
| 108 | if (!jwksPromise) { | ||
| 109 | jwksPromise = (async () => { | ||
| 110 | const verificationKey = await importSPKI(publicKeyPem, "ES256"); | ||
| 111 | const jwk = await exportJWK(verificationKey); | ||
| 112 | return { | ||
| 113 | keys: [ | ||
| 114 | { | ||
| 115 | ...jwk, | ||
| 116 | kid: keyId, | ||
| 117 | alg: "ES256", | ||
| 118 | use: "sig", | ||
| 119 | }, | ||
| 120 | ], | ||
| 121 | }; | ||
| 122 | })(); | ||
| 123 | } | ||
| 124 | |||
| 125 | return await jwksPromise; | ||
| 126 | } | ||
| 127 | |||
| 128 | export const anonymousTokenHandler = httpAction(async (_ctx, request) => { | ||
| 129 | const config = getAnonymousTokenConfig(); | ||
| 130 | if (!config.issuer || !config.privateKeyPem) { | ||
| 131 | return Response.json( | ||
| 132 | { error: "Anonymous auth is not configured" }, | ||
| 133 | { status: 503, headers: noStoreHeaders() }, | ||
| 134 | ); | ||
| 135 | } | ||
| 136 | |||
| 137 | try { | ||
| 138 | const actorId = (await readActorIdFromRequest(request)) ?? createActorId(); | ||
| 139 | const nowSeconds = Math.floor(Date.now() / 1000); | ||
| 140 | const expiresAtSeconds = nowSeconds + config.tokenTtlSeconds; | ||
| 141 | const signingKey = await loadSigningKey(config.privateKeyPem); | ||
| 142 | |||
| 143 | const accessToken = await new SignJWT({ provider: "anonymous" }) | ||
| 144 | .setProtectedHeader({ alg: "ES256", kid: config.keyId, typ: "JWT" }) | ||
| 145 | .setIssuer(config.issuer) | ||
| 146 | .setSubject(actorId) | ||
| 147 | .setAudience(config.audience) | ||
| 148 | .setIssuedAt(nowSeconds) | ||
| 149 | .setNotBefore(nowSeconds - 5) | ||
| 150 | .setExpirationTime(expiresAtSeconds) | ||
| 151 | .sign(signingKey); | ||
| 152 | |||
| 153 | return Response.json( | ||
| 154 | { | ||
| 155 | tokenType: "Bearer", | ||
| 156 | accessToken, | ||
| 157 | actorId, | ||
| 158 | expiresAtMs: expiresAtSeconds * 1000, | ||
| 159 | }, | ||
| 160 | { | ||
| 161 | status: 200, | ||
| 162 | headers: noStoreHeaders(), | ||
| 163 | }, | ||
| 164 | ); | ||
| 165 | } catch (error) { | ||
| 166 | return Response.json( | ||
| 167 | { error: error instanceof Error ? error.message : "Failed to issue anonymous token" }, | ||
| 168 | { status: 400, headers: noStoreHeaders() }, | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | }); | ||
| 172 | |||
| 173 | export const anonymousJwksHandler = httpAction(async (_ctx, _request) => { | ||
| 174 | const config = getAnonymousTokenConfig(); | ||
| 175 | if (!config.issuer || !config.publicKeyPem) { | ||
| 176 | return Response.json( | ||
| 177 | { error: "Anonymous auth is not configured" }, | ||
| 178 | { status: 503, headers: noStoreHeaders() }, | ||
| 179 | ); | ||
| 180 | } | ||
| 181 | |||
| 182 | const jwks = await loadJwks(config.publicKeyPem, config.keyId); | ||
| 183 | return Response.json(jwks, { | ||
| 184 | status: 200, | ||
| 185 | headers: noStoreHeaders({ | ||
| 186 | "content-type": "application/json", | ||
| 187 | }), | ||
| 188 | }); | ||
| 189 | }); | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import { httpAction } from "../_generated/server"; | 1 | import { httpAction } from "../_generated/server"; |
| 2 | import { internal } from "../_generated/api"; | 2 | import { internal } from "../_generated/api"; |
| 3 | import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server"; | 3 | import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server"; |
| 4 | import { isAnonymousIdentity } from "../auth/anonymous"; | ||
| 4 | import { | 5 | import { |
| 5 | getMcpAuthConfig, | 6 | getMcpAuthConfig, |
| 6 | isAnonymousSessionId, | 7 | isAnonymousSessionId, |
| @@ -41,56 +42,34 @@ function createMcpHandler(mode: McpEndpointMode) { · 34 unmodified lines | |||
| 41 | ); | 42 | ); |
| 42 | } | 43 | } |
| 43 | 44 | ||
| 44 | const requestedSessionId = requestedContext?.sessionId; | 45 | if (requestedContext?.sessionId || requestedContext?.actorId) { |
| 45 | const requestedActorId = requestedContext?.actorId?.trim(); | ||
| 46 | |||
| 47 | if (requestedSessionId && !isAnonymousSessionId(requestedSessionId)) { | ||
| 48 | return Response.json( | 46 | return Response.json( |
| 49 | { error: "sessionId for /mcp/anonymous must be an anonymous session" }, | 47 | { |
| 48 | error: | ||
| 49 | "Legacy anonymous context query params are disabled. Use Authorization: Bearer <anonymous token>.", | ||
| 50 | }, | ||
| 50 | { status: 400 }, | 51 | { status: 400 }, |
| 51 | ); | 52 | ); |
| 52 | } | 53 | } |
| 53 | 54 | ||
| 54 | if (requestedSessionId) { | 55 | const identity = await ctx.auth.getUserIdentity().catch(() => null); |
| 55 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | 56 | if (!identity || !isAnonymousIdentity(identity)) { |
| 56 | workspaceId, | 57 | return Response.json( |
| 57 | sessionId: requestedSessionId, | 58 | { error: "Anonymous bearer token is required for /mcp/anonymous" }, |
| 58 | }); | 59 | { status: 401 }, |
| 59 | 60 | ); | |
| 60 | if (access.provider !== "anonymous") { | 61 | } |
| 61 | return Response.json({ error: "Anonymous MCP endpoint requires an anonymous session" }, { status: 403 }); | ||
| 62 | } | ||
| 63 | |||
| 64 | if (requestedActorId && requestedActorId !== access.actorId) { | ||
| 65 | return Response.json({ error: "actorId does not match the provided anonymous session" }, { status: 403 }); | ||
| 66 | } | ||
| 67 | 62 | ||
| 68 | context = { | 63 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, { |
| 69 | workspaceId, | 64 | workspaceId, |
| 70 | actorId: access.actorId, | 65 | actorId: identity.subject, |
| 71 | clientId: requestedContext?.clientId, | 66 | }); |
| 72 | sessionId: requestedSessionId, | ||
| 73 | }; | ||
| 74 | } else { | ||
| 75 | if (!requestedActorId || !requestedActorId.startsWith("anon_")) { | ||
| 76 | return Response.json( | ||
| 77 | { error: "actorId query parameter is required for /mcp/anonymous when sessionId is omitted" }, | ||
| 78 | { status: 400 }, | ||
| 79 | ); | ||
| 80 | } | ||
| 81 | 67 | ||
| 82 | const ensured = await ctx.runMutation(internal.database.ensureAnonymousMcpSession, { | 68 | context = { |
| 83 | workspaceId, | 69 | workspaceId, |
| 84 | actorId: requestedActorId, | 70 | actorId: access.actorId, |
| 85 | }); | 71 | clientId: requestedContext?.clientId, |
| 86 | 72 | }; | |
| 87 | context = { | ||
| 88 | workspaceId, | ||
| 89 | actorId: ensured.actorId, | ||
| 90 | clientId: requestedContext?.clientId, | ||
| 91 | sessionId: ensured.sessionId, | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | } catch (error) { | 73 | } catch (error) { |
| 95 | return Response.json( | 74 | return Response.json( |
| 96 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | 75 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, |
| @@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line | |||
| 2 | import { internal } from "./_generated/api"; | 2 | import { internal } from "./_generated/api"; |
| 3 | import { mutation, query } from "./_generated/server"; | 3 | import { mutation, query } from "./_generated/server"; |
| 4 | import { workspaceMutation, workspaceQuery } from "../core/src/function-builders"; | 4 | import { workspaceMutation, workspaceQuery } from "../core/src/function-builders"; |
| 5 | import { isAnonymousIdentity } from "./auth/anonymous"; | ||
| 5 | 6 | ||
| 6 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); | 7 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 7 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); | 8 | const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor")); |
| @@ -17,8 +18,26 @@ function redactCredential<T extends { secretJson: Record<string, unknown> }>(cre · 9 unmodified lines | |||
| 17 | 18 | ||
| 18 | export const bootstrapAnonymousSession = mutation({ | 19 | export const bootstrapAnonymousSession = mutation({ |
| 19 | args: { sessionId: v.optional(v.string()) }, | 20 | args: { sessionId: v.optional(v.string()) }, |
| 20 | handler: async (ctx, args) => { | 21 | handler: async (ctx) => { |
| 21 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, args); | 22 | const identity = await ctx.auth.getUserIdentity(); |
| 23 | if (!identity) { | ||
| 24 | throw new Error("Anonymous auth token is required"); | ||
| 25 | } | ||
| 26 | |||
| 27 | if (!isAnonymousIdentity(identity)) { | ||
| 28 | throw new Error("Anonymous auth token is required"); | ||
| 29 | } | ||
| 30 | |||
| 31 | const actorId = identity.subject; | ||
| 32 | if (!actorId.startsWith("anon_")) { | ||
| 33 | throw new Error("Anonymous token subject must use anon_* actor ids"); | ||
| 34 | } | ||
| 35 | const stableSessionId = `anon_session_${actorId}`; | ||
| 36 | return await ctx.runMutation(internal.database.bootstrapAnonymousSession, { | ||
| 37 | sessionId: stableSessionId, | ||
| 38 | actorId, | ||
| 39 | clientId: "web", | ||
| 40 | }); | ||
| 22 | }, | 41 | }, |
| 23 | }); | 42 | }); |
| 24 | 43 | ||
| @@ -54,3 +54,35 @@ export const getWorkspaceAccessForWorkosSubject = internalQuery({ · 53 unmodified lines | |||
| 54 | }; | 54 | }; |
| 55 | }, | 55 | }, |
| 56 | }); | 56 | }); |
| 57 | |||
| 58 | export const getWorkspaceAccessForAnonymousSubject = internalQuery({ | ||
| 59 | args: { | ||
| 60 | workspaceId: v.id("workspaces"), | ||
| 61 | actorId: v.string(), | ||
| 62 | }, | ||
| 63 | handler: async (ctx, args) => { | ||
| 64 | const actorId = args.actorId.trim(); | ||
| 65 | if (!actorId.startsWith("anon_")) { | ||
| 66 | throw new Error("Anonymous actorId is required"); | ||
| 67 | } | ||
| 68 | |||
| 69 | const account = await ctx.db | ||
| 70 | .query("accounts") | ||
| 71 | .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", actorId)) | ||
| 72 | .unique(); | ||
| 73 | if (!account) { | ||
| 74 | throw new Error("Anonymous actor is not recognized"); | ||
| 75 | } | ||
| 76 | |||
| 77 | const access = await requireWorkspaceAccessForAccount(ctx, args.workspaceId, account); | ||
| 78 | |||
| 79 | return { | ||
| 80 | workspaceId: args.workspaceId, | ||
| 81 | accountId: account._id, | ||
| 82 | provider: account.provider, | ||
| 83 | providerAccountId: account.providerAccountId, | ||
| 84 | actorId: actorIdForAccount(account), | ||
| 85 | role: access.workspaceMembership.role, | ||
| 86 | }; | ||
| 87 | }, | ||
| 88 | }); | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; | 1 | import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; |
| 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | import { isAnonymousIdentity } from "../../convex/auth/anonymous"; | ||
| 3 | 4 | ||
| 4 | type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">; | 5 | type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">; |
| 5 | type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">; | 6 | type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">; |
| @@ -34,31 +35,29 @@ export function slugify(input: string, fallback = "team"): string { · 28 unmodified lines | |||
| 34 | 35 | ||
| 35 | export async function resolveAccountForRequest( | 36 | export async function resolveAccountForRequest( |
| 36 | ctx: IdentityCtx, | 37 | ctx: IdentityCtx, |
| 37 | sessionId?: string, | 38 | _sessionId?: string, |
| 38 | ): Promise<Doc<"accounts"> | null> { | 39 | ): Promise<Doc<"accounts"> | null> { |
| 39 | const identity = await ctx.auth.getUserIdentity(); | 40 | const identity = await ctx.auth.getUserIdentity(); |
| 40 | if (identity) { | 41 | if (identity) { |
| 41 | const fromAccounts = await ctx.db | 42 | if (isAnonymousIdentity(identity)) { |
| 43 | const anonymousAccount = await ctx.db | ||
| 44 | .query("accounts") | ||
| 45 | .withIndex("by_provider", (q) => q.eq("provider", "anonymous").eq("providerAccountId", identity.subject)) | ||
| 46 | .unique(); | ||
| 47 | if (anonymousAccount) { | ||
| 48 | return anonymousAccount; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | const workosAccount = await ctx.db | ||
| 42 | .query("accounts") | 53 | .query("accounts") |
| 43 | .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject)) | 54 | .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject)) |
| 44 | .unique(); | 55 | .unique(); |
| 45 | if (fromAccounts) { | 56 | if (workosAccount) { |
| 46 | return fromAccounts; | 57 | return workosAccount; |
| 47 | } | 58 | } |
| 48 | } | 59 | } |
| 49 | 60 | ||
| 50 | if (!sessionId) { | ||
| 51 | return null; | ||
| 52 | } | ||
| 53 | |||
| 54 | const anonymous = await ctx.db | ||
| 55 | .query("anonymousSessions") | ||
| 56 | .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId)) | ||
| 57 | .unique(); | ||
| 58 | if (anonymous?.accountId) { | ||
| 59 | return await ctx.db.get(anonymous.accountId); | ||
| 60 | } | ||
| 61 | |||
| 62 | return null; | 61 | return null; |
| 63 | } | 62 | } |
| 64 | 63 | ||