Unified · Split
| @@ -17,6 +17,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines | ||
| 17 | 17 | # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32> |
| 18 | 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 | 27 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 21 | 28 | # STRIPE_SECRET_KEY=sk_test_... |
| 22 | 29 | # STRIPE_WEBHOOK_SECRET=whsec_... |
| @@ -57,7 +57,7 @@ Default source-dev endpoints: · 56 unmodified lines | ||
| 57 | 57 | - Web UI: `http://localhost:4312` |
| 58 | 58 | - Convex HTTP MCP routes: |
| 59 | 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 | 62 | ## Binary Install (No Global Bun/Node/Convex Required) |
| 63 | 63 | |
| @@ -131,12 +131,13 @@ Manual GitHub release (recommended): · 67 unmodified lines | ||
| 131 | 131 | Convex HTTP routes (`packages/convex/http.ts`) expose: |
| 132 | 132 | |
| 133 | 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 | 135 | - `/.well-known/oauth-protected-resource` |
| 136 | 136 | - `/.well-known/oauth-authorization-server` |
| 137 | 137 | - `/internal/runs/:runId/tool-call` |
| 138 | 138 | |
| 139 | 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 | 142 | ## Configuration Reference |
| 142 | 143 | |
| @@ -221,6 +222,6 @@ executor/ · 78 unmodified lines | ||
| 221 | 222 | ## Troubleshooting |
| 222 | 223 | |
| 223 | 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 | 226 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. |
| 226 | 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 | 62 | <CardContent className="pt-0"> |
| 63 | 63 | <McpSetupCard |
| 64 | 64 | workspaceId={context?.workspaceId} |
| 65 | − actorId={context?.actorId} | |
| 66 | 65 | sessionId={context?.sessionId} |
| 67 | 66 | /> |
| 68 | 67 | </CardContent> |
| @@ -1,9 +1,14 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { useMemo, useState } from "react"; | |
| 4 | −import { Check, Copy } from "lucide-react"; | |
| 3 | +import { useEffect, useMemo, useState } from "react"; | |
| 4 | +import { Check, ChevronRight, Copy } from "lucide-react"; | |
| 5 | 5 | import Image from "next/image"; |
| 6 | 6 | import { Button } from "@/components/ui/button"; |
| 7 | +import { | |
| 8 | + Collapsible, | |
| 9 | + CollapsibleContent, | |
| 10 | + CollapsibleTrigger, | |
| 11 | +} from "@/components/ui/collapsible"; | |
| 7 | 12 | import { Input } from "@/components/ui/input"; |
| 8 | 13 | import { Label } from "@/components/ui/label"; |
| 9 | 14 | import { |
| @@ -14,6 +19,8 @@ import { · 4 unmodified lines | ||
| 14 | 19 | SelectValue, |
| 15 | 20 | } from "@/components/ui/select"; |
| 16 | 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 | 25 | function inferServerName(workspaceId?: string): string { |
| 19 | 26 | if (!workspaceId) return "executor"; |
| @@ -49,15 +56,16 @@ function resolveMcpOrigin(windowOrigin: string): string { · 29 unmodified lines | ||
| 49 | 56 | |
| 50 | 57 | export function McpSetupCard({ |
| 51 | 58 | workspaceId, |
| 52 | − actorId, | |
| 53 | 59 | sessionId, |
| 54 | 60 | }: { |
| 55 | 61 | workspaceId?: string; |
| 56 | − actorId?: string; | |
| 57 | 62 | sessionId?: string; |
| 58 | 63 | }) { |
| 59 | 64 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); |
| 60 | 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 | 70 | const origin = useMemo(() => { |
| 63 | 71 | if (typeof window === "undefined") return ""; |
| @@ -69,13 +77,52 @@ export function McpSetupCard({ · 5 unmodified lines | ||
| 69 | 77 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; |
| 70 | 78 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); |
| 71 | 79 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); |
| 72 | − if (isAnonymousSession && actorId) base.searchParams.set("actorId", actorId); | |
| 73 | 80 | if (!isAnonymousSession && sessionId) base.searchParams.set("sessionId", sessionId); |
| 74 | 81 | if (!origin) { |
| 75 | 82 | return `${base.pathname}${base.search}`; |
| 76 | 83 | } |
| 77 | 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 | 127 | const provider = MCP_PROVIDERS.find((item) => item.id === selectedProviderId) ?? MCP_PROVIDERS[0]; |
| 81 | 128 | if (!provider) { |
| @@ -114,6 +161,41 @@ export function McpSetupCard({ · 32 unmodified lines | ||
| 114 | 161 | {copiedKey === "url" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} |
| 115 | 162 | </Button> |
| 116 | 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 | 199 | </div> |
| 118 | 200 | |
| 119 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | −import { createContext, useContext, useMemo } from "react"; | |
| 3 | +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; | |
| 4 | 4 | import { ConvexProviderWithAuth } from "convex/react"; |
| 5 | 5 | import { |
| 6 | 6 | AuthKitProvider, |
| 7 | 7 | useAccessToken, |
| 8 | 8 | useAuth as useWorkosAuth, |
| 9 | 9 | } from "@workos-inc/authkit-nextjs/components"; |
| 10 | −import { ConvexProvider, ConvexReactClient } from "convex/react"; | |
| 10 | +import { ConvexReactClient } from "convex/react"; | |
| 11 | 11 | import type { ReactNode } from "react"; |
| 12 | +import { | |
| 13 | + getAnonymousAuthToken, | |
| 14 | + readStoredAnonymousAuthToken, | |
| 15 | +} from "@/lib/anonymous-auth"; | |
| 12 | 16 | import { workosEnabled } from "@/lib/auth-capabilities"; |
| 13 | 17 | |
| 14 | 18 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; |
| @@ -29,23 +33,84 @@ export function useWorkosAuthState() { · 14 unmodified lines | ||
| 29 | 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 | 93 | const { loading: authLoading, user } = useWorkosAuth(); |
| 34 | 94 | const { loading: tokenLoading, getAccessToken } = useAccessToken(); |
| 35 | − const isAuthenticated = Boolean(user); | |
| 36 | − const isLoading = authLoading || (isAuthenticated && tokenLoading); | |
| 95 | + const anonymousAuth = useConvexAuthFromAnonymous(); | |
| 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( | |
| 39 | − () => async () => { | |
| 100 | + const fetchAccessToken = useCallback(async () => { | |
| 101 | + if (workosAuthenticated) { | |
| 40 | 102 | try { |
| 41 | 103 | const token = await getAccessToken(); |
| 42 | − return token ?? null; | |
| 104 | + if (token) { | |
| 105 | + return token; | |
| 106 | + } | |
| 43 | 107 | } catch { |
| 44 | − return null; | |
| 108 | + // Fall through to anonymous token. | |
| 45 | 109 | } |
| 46 | − }, | |
| 47 | − [getAccessToken], | |
| 48 | − ); | |
| 110 | + } | |
| 111 | + | |
| 112 | + return await anonymousAuth.fetchAccessToken(); | |
| 113 | + }, [anonymousAuth, getAccessToken, workosAuthenticated]); | |
| 49 | 114 | |
| 50 | 115 | return useMemo( |
| 51 | 116 | () => ({ |
| @@ -65,7 +130,7 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 13 unmodified lines | ||
| 65 | 130 | |
| 66 | 131 | return ( |
| 67 | 132 | <WorkosAuthContext.Provider value={{ loading, authenticated }}> |
| 68 | − <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkos}> | |
| 133 | + <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}> | |
| 69 | 134 | {children} |
| 70 | 135 | </ConvexProviderWithAuth> |
| 71 | 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 | 30 | readStoredSessionId, |
| 31 | 31 | readWorkspaceByAccount, |
| 32 | 32 | } from "@/lib/session-storage"; |
| 33 | +import { clearAnonymousAuth } from "@/lib/anonymous-auth"; | |
| 33 | 34 | import type { AnonymousContext } from "./types"; |
| 34 | 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 | 179 | const resetWorkspace = useCallback(async () => { |
| 179 | 180 | clearSessionStorage(); |
| 181 | + clearAnonymousAuth({ clearActor: true }); | |
| 180 | 182 | setStoredSessionId(null); |
| 181 | 183 | setActiveWorkspaceId(null); |
| 182 | 184 | setManualGuestContext(null); |
| @@ -493,7 +493,7 @@ export declare const internal: { · 492 unmodified lines | ||
| 493 | 493 | bootstrapAnonymousSession: FunctionReference< |
| 494 | 494 | "mutation", |
| 495 | 495 | "internal", |
| 496 | − { sessionId?: string }, | |
| 496 | + { actorId?: string; clientId?: string; sessionId?: string }, | |
| 497 | 497 | any |
| 498 | 498 | >; |
| 499 | 499 | ensureAnonymousMcpSession: FunctionReference< |
| @@ -552,7 +552,7 @@ export declare const internal: { · 52 unmodified lines | ||
| 552 | 552 | bootstrapAnonymousSession: FunctionReference< |
| 553 | 553 | "mutation", |
| 554 | 554 | "internal", |
| 555 | − { sessionId?: string }, | |
| 555 | + { actorId?: string; clientId?: string; sessionId?: string }, | |
| 556 | 556 | any |
| 557 | 557 | >; |
| 558 | 558 | createApproval: FunctionReference< |
| @@ -1227,6 +1227,12 @@ export declare const internal: { · 668 unmodified lines | ||
| 1227 | 1227 | >; |
| 1228 | 1228 | }; |
| 1229 | 1229 | workspaceAuthInternal: { |
| 1230 | + getWorkspaceAccessForAnonymousSubject: FunctionReference< | |
| 1231 | + "query", | |
| 1232 | + "internal", | |
| 1233 | + { actorId: string; workspaceId: Id<"workspaces"> }, | |
| 1234 | + any | |
| 1235 | + >; | |
| 1230 | 1236 | getWorkspaceAccessForRequest: FunctionReference< |
| 1231 | 1237 | "query", |
| 1232 | 1238 | "internal", |
| @@ -1,9 +1,17 @@ | ||
| 1 | +import { | |
| 2 | + ANONYMOUS_AUTH_AUDIENCE, | |
| 3 | + getAnonymousAuthIssuer, | |
| 4 | + getAnonymousAuthJwksUrl, | |
| 5 | +} from "./auth/anonymous"; | |
| 6 | + | |
| 1 | 7 | const rawClientId = process.env.WORKOS_CLIENT_ID?.trim(); |
| 2 | 8 | const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined; |
| 9 | +const anonymousAuthIssuer = getAnonymousAuthIssuer(); | |
| 10 | +const anonymousAuthJwksUrl = getAnonymousAuthJwksUrl(); | |
| 3 | 11 | |
| 4 | −const authConfig = clientId | |
| 5 | − ? { | |
| 6 | − providers: [ | |
| 12 | +const providers = [ | |
| 13 | + ...(clientId | |
| 14 | + ? [ | |
| 7 | 15 | { |
| 8 | 16 | type: "customJwt" as const, |
| 9 | 17 | issuer: "https://api.workos.com/", |
| @@ -17,8 +25,23 @@ const authConfig = clientId · 7 unmodified lines | ||
| 17 | 25 | algorithm: "RS256" as const, |
| 18 | 26 | jwks: `https://api.workos.com/sso/jwks/${clientId}`, |
| 19 | 27 | }, |
| 20 | − ], | |
| 21 | − } | |
| 22 | − : { providers: [] as const }; | |
| 28 | + ] | |
| 29 | + : []), | |
| 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 | 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 | 4 | import { mapAnonymousContext } from "./readers"; |
| 5 | 5 | |
| 6 | 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 | 12 | handler: async (ctx, args) => { |
| 9 | 13 | const now = Date.now(); |
| 10 | 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 | 25 | if (requestedSessionId) { |
| 14 | 26 | const sessionId = requestedSessionId; |
| @@ -20,11 +32,13 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines | ||
| 20 | 32 | const identity = await ensureAnonymousIdentity(ctx, { |
| 21 | 33 | sessionId, |
| 22 | 34 | workspaceId: existing.workspaceId, |
| 23 | − actorId: existing.actorId, | |
| 35 | + actorId: requestedActorId || existing.actorId, | |
| 24 | 36 | timestamp: now, |
| 25 | 37 | }); |
| 26 | 38 | |
| 27 | 39 | await ctx.db.patch(existing._id, { |
| 40 | + actorId: requestedActorId || existing.actorId, | |
| 41 | + clientId, | |
| 28 | 42 | workspaceId: identity.workspaceId, |
| 29 | 43 | accountId: identity.accountId, |
| 30 | 44 | userId: identity.userId, |
| @@ -43,13 +57,12 @@ export const bootstrapAnonymousSession = internalMutation({ · 12 unmodified lines | ||
| 43 | 57 | } |
| 44 | 58 | |
| 45 | 59 | const generatedSessionId = allowRequestedSessionId |
| 46 | − ? `mcp_${crypto.randomUUID()}` | |
| 60 | + ? (requestedSessionId.startsWith("mcp_") ? `mcp_${crypto.randomUUID()}` : `anon_session_${crypto.randomUUID()}`) | |
| 47 | 61 | : `anon_session_${crypto.randomUUID()}`; |
| 48 | 62 | const sessionId = allowRequestedSessionId |
| 49 | 63 | ? requestedSessionId as string |
| 50 | 64 | : generatedSessionId; |
| 51 | − const actorId = `anon_${crypto.randomUUID()}`; | |
| 52 | − const clientId = "web"; | |
| 65 | + const actorId = requestedActorId || `anon_${crypto.randomUUID()}`; | |
| 53 | 66 | |
| 54 | 67 | const identity = await ensureAnonymousIdentity(ctx, { |
| 55 | 68 | sessionId, |
| @@ -2,6 +2,7 @@ import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; · 1 unmodified line | ||
| 2 | 2 | import { httpRouter } from "convex/server"; |
| 3 | 3 | import { components } from "./_generated/api"; |
| 4 | 4 | import { authKit } from "./auth"; |
| 5 | +import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth"; | |
| 5 | 6 | import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler"; |
| 6 | 7 | import { |
| 7 | 8 | oauthAuthorizationServerHandler, |
| @@ -24,5 +25,8 @@ http.route({ path: "/mcp/anonymous", method: "DELETE", handler: mcpAnonymousHand · 16 unmodified lines | ||
| 24 | 25 | |
| 25 | 26 | http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler }); |
| 26 | 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 | 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 | 1 | import { httpAction } from "../_generated/server"; |
| 2 | 2 | import { internal } from "../_generated/api"; |
| 3 | 3 | import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server"; |
| 4 | +import { isAnonymousIdentity } from "../auth/anonymous"; | |
| 4 | 5 | import { |
| 5 | 6 | getMcpAuthConfig, |
| 6 | 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 | − const requestedActorId = requestedContext?.actorId?.trim(); | |
| 46 | − | |
| 47 | − if (requestedSessionId && !isAnonymousSessionId(requestedSessionId)) { | |
| 45 | + if (requestedContext?.sessionId || requestedContext?.actorId) { | |
| 48 | 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 | 51 | { status: 400 }, |
| 51 | 52 | ); |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | − if (requestedSessionId) { | |
| 55 | − const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForRequest, { | |
| 56 | − workspaceId, | |
| 57 | − sessionId: requestedSessionId, | |
| 58 | − }); | |
| 59 | − | |
| 60 | − if (access.provider !== "anonymous") { | |
| 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 | − } | |
| 55 | + const identity = await ctx.auth.getUserIdentity().catch(() => null); | |
| 56 | + if (!identity || !isAnonymousIdentity(identity)) { | |
| 57 | + return Response.json( | |
| 58 | + { error: "Anonymous bearer token is required for /mcp/anonymous" }, | |
| 59 | + { status: 401 }, | |
| 60 | + ); | |
| 61 | + } | |
| 67 | 62 | |
| 68 | − context = { | |
| 69 | − workspaceId, | |
| 70 | − actorId: access.actorId, | |
| 71 | − clientId: requestedContext?.clientId, | |
| 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 | − } | |
| 63 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, { | |
| 64 | + workspaceId, | |
| 65 | + actorId: identity.subject, | |
| 66 | + }); | |
| 81 | 67 | |
| 82 | − const ensured = await ctx.runMutation(internal.database.ensureAnonymousMcpSession, { | |
| 83 | − workspaceId, | |
| 84 | − actorId: requestedActorId, | |
| 85 | − }); | |
| 86 | − | |
| 87 | − context = { | |
| 88 | − workspaceId, | |
| 89 | − actorId: ensured.actorId, | |
| 90 | − clientId: requestedContext?.clientId, | |
| 91 | − sessionId: ensured.sessionId, | |
| 92 | − }; | |
| 93 | − } | |
| 68 | + context = { | |
| 69 | + workspaceId, | |
| 70 | + actorId: access.actorId, | |
| 71 | + clientId: requestedContext?.clientId, | |
| 72 | + }; | |
| 94 | 73 | } catch (error) { |
| 95 | 74 | return Response.json( |
| 96 | 75 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, |
| @@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line | ||
| 2 | 2 | import { internal } from "./_generated/api"; |
| 3 | 3 | import { mutation, query } from "./_generated/server"; |
| 4 | 4 | import { workspaceMutation, workspaceQuery } from "../core/src/function-builders"; |
| 5 | +import { isAnonymousIdentity } from "./auth/anonymous"; | |
| 5 | 6 | |
| 6 | 7 | const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny")); |
| 7 | 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 | 19 | export const bootstrapAnonymousSession = mutation({ |
| 19 | 20 | args: { sessionId: v.optional(v.string()) }, |
| 20 | − handler: async (ctx, args) => { | |
| 21 | − return await ctx.runMutation(internal.database.bootstrapAnonymousSession, args); | |
| 21 | + handler: async (ctx) => { | |
| 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 | 1 | import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts"; |
| 2 | 2 | import type { MutationCtx, QueryCtx } from "../../convex/_generated/server"; |
| 3 | +import { isAnonymousIdentity } from "../../convex/auth/anonymous"; | |
| 3 | 4 | |
| 4 | 5 | type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">; |
| 5 | 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 | 36 | export async function resolveAccountForRequest( |
| 36 | 37 | ctx: IdentityCtx, |
| 37 | − sessionId?: string, | |
| 38 | + _sessionId?: string, | |
| 38 | 39 | ): Promise<Doc<"accounts"> | null> { |
| 39 | 40 | const identity = await ctx.auth.getUserIdentity(); |
| 40 | 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 | 53 | .query("accounts") |
| 43 | 54 | .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject)) |
| 44 | 55 | .unique(); |
| 45 | − if (fromAccounts) { | |
| 46 | − return fromAccounts; | |
| 56 | + if (workosAccount) { | |
| 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 | 61 | return null; |
| 63 | 62 | } |
| 64 | 63 | |