History

Add anonymous bearer auth for MCP

Rhys Sullivan committed 9 months ago

Commit c5d4efc1 parent 020338718 files · +777 −99Browse files

Unified · Split

18 files
  1. .env.example +7 −0
  2. executor/README.md +4 −3
  3. executor/apps/web/src/app/api/auth/anonymous-token/route.ts +78 −0
  4. executor/apps/web/src/components/dashboard/view.tsx +0 −1
  5. executor/apps/web/src/components/tools/setup-card.tsx +88 −6
  6. executor/apps/web/src/lib/anonymous-auth.ts +127 −0
  7. executor/apps/web/src/lib/convex-provider.tsx +85 −14
  8. executor/apps/web/src/lib/session-context.tsx +2 −0
  9. executor/packages/convex/_generated/api.d.ts +8 −2
  10. executor/packages/convex/auth.config.ts +29 −6
  11. executor/packages/convex/auth/anonymous.ts +47 −0
  12. executor/packages/convex/database/anonymous_session.ts +19 −6
  13. executor/packages/convex/http.ts +4 −0
  14. executor/packages/convex/http/anonymous_auth.ts +189 −0
  15. executor/packages/convex/http/mcp_handler.ts +22 −43
  16. executor/packages/convex/workspace.ts +21 −2
  17. executor/packages/convex/workspaceAuthInternal.ts +32 −0
  18. executor/packages/core/src/identity.ts +15 −16
.env.example+7−0
@@ -17,6 +17,13 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 16 unmodified lines
1717 # WORKOS_COOKIE_PASSWORD=<32+ char random string, e.g. openssl rand -base64 32>
1818 # WORKOS_WEBHOOK_SECRET=...
1919
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+
2027 # ── Stripe Billing (optional — required for checkout/portal) ──
2128 # STRIPE_SECRET_KEY=sk_test_...
2229 # STRIPE_WEBHOOK_SECRET=whsec_...
executor/README.md+4−3
@@ -57,7 +57,7 @@ Default source-dev endpoints: · 56 unmodified lines
5757 - Web UI: `http://localhost:4312`
5858 - Convex HTTP MCP routes:
5959 - `<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)
6161
6262 ## Binary Install (No Global Bun/Node/Convex Required)
6363
@@ -131,12 +131,13 @@ Manual GitHub release (recommended): · 67 unmodified lines
131131 Convex HTTP routes (`packages/convex/http.ts`) expose:
132132
133133 - `/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)
135135 - `/.well-known/oauth-protected-resource`
136136 - `/.well-known/oauth-authorization-server`
137137 - `/internal/runs/:runId/tool-call`
138138
139139 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`).
140141
141142 ## Configuration Reference
142143
@@ -221,6 +222,6 @@ executor/ · 78 unmodified lines
221222 ## Troubleshooting
222223
223224 - `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`.
225226 - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running.
226227 - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets.
executor/apps/web/src/app/api/auth/anonymous-token/route.ts+78−0
@@ -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+}
executor/apps/web/src/components/dashboard/view.tsx+0−1
@@ -62,7 +62,6 @@ export function DashboardView() { · 61 unmodified lines
6262 <CardContent className="pt-0">
6363 <McpSetupCard
6464 workspaceId={context?.workspaceId}
65− actorId={context?.actorId}
6665 sessionId={context?.sessionId}
6766 />
6867 </CardContent>
executor/apps/web/src/components/tools/setup-card.tsx+88−6
@@ -1,9 +1,14 @@
11 "use client";
22
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";
55 import Image from "next/image";
66 import { Button } from "@/components/ui/button";
7+import {
8+ Collapsible,
9+ CollapsibleContent,
10+ CollapsibleTrigger,
11+} from "@/components/ui/collapsible";
712 import { Input } from "@/components/ui/input";
813 import { Label } from "@/components/ui/label";
914 import {
@@ -14,6 +19,8 @@ import { · 4 unmodified lines
1419 SelectValue,
1520 } from "@/components/ui/select";
1621 import { MCP_PROVIDERS } from "@/components/tools/install-configs";
22+import { getAnonymousAuthToken } from "@/lib/anonymous-auth";
23+import { cn } from "@/lib/utils";
1724
1825 function inferServerName(workspaceId?: string): string {
1926 if (!workspaceId) return "executor";
@@ -49,15 +56,16 @@ function resolveMcpOrigin(windowOrigin: string): string { · 29 unmodified lines
4956
5057 export function McpSetupCard({
5158 workspaceId,
52− actorId,
5359 sessionId,
5460 }: {
5561 workspaceId?: string;
56− actorId?: string;
5762 sessionId?: string;
5863 }) {
5964 const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
6065 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);
6169
6270 const origin = useMemo(() => {
6371 if (typeof window === "undefined") return "";
@@ -69,13 +77,52 @@ export function McpSetupCard({ · 5 unmodified lines
6977 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
7078 const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`);
7179 if (workspaceId) base.searchParams.set("workspaceId", workspaceId);
72− if (isAnonymousSession && actorId) base.searchParams.set("actorId", actorId);
7380 if (!isAnonymousSession && sessionId) base.searchParams.set("sessionId", sessionId);
7481 if (!origin) {
7582 return `${base.pathname}${base.search}`;
7683 }
7784 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]);
79126
80127 const provider = MCP_PROVIDERS.find((item) => item.id === selectedProviderId) ?? MCP_PROVIDERS[0];
81128 if (!provider) {
@@ -114,6 +161,41 @@ export function McpSetupCard({ · 32 unmodified lines
114161 {copiedKey === "url" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
115162 </Button>
116163 </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+ )}
117199 </div>
118200
119201 <div className="space-y-1.5">
executor/apps/web/src/lib/anonymous-auth.ts+127−0
@@ -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+}
executor/apps/web/src/lib/convex-provider.tsx+85−14
@@ -1,14 +1,18 @@
11 "use client";
22
3−import { createContext, useContext, useMemo } from "react";
3+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
44 import { ConvexProviderWithAuth } from "convex/react";
55 import {
66 AuthKitProvider,
77 useAccessToken,
88 useAuth as useWorkosAuth,
99 } from "@workos-inc/authkit-nextjs/components";
10−import { ConvexProvider, ConvexReactClient } from "convex/react";
10+import { ConvexReactClient } from "convex/react";
1111 import type { ReactNode } from "react";
12+import {
13+ getAnonymousAuthToken,
14+ readStoredAnonymousAuthToken,
15+} from "@/lib/anonymous-auth";
1216 import { workosEnabled } from "@/lib/auth-capabilities";
1317
1418 const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
@@ -29,23 +33,84 @@ export function useWorkosAuthState() { · 14 unmodified lines
2933 return useContext(WorkosAuthContext);
3034 }
3135
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() {
3393 const { loading: authLoading, user } = useWorkosAuth();
3494 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;
3799
38− const fetchAccessToken = useMemo(
39− () => async () => {
100+ const fetchAccessToken = useCallback(async () => {
101+ if (workosAuthenticated) {
40102 try {
41103 const token = await getAccessToken();
42− return token ?? null;
104+ if (token) {
105+ return token;
106+ }
43107 } catch {
44− return null;
108+ // Fall through to anonymous token.
45109 }
46− },
47− [getAccessToken],
48− );
110+ }
111+
112+ return await anonymousAuth.fetchAccessToken();
113+ }, [anonymousAuth, getAccessToken, workosAuthenticated]);
49114
50115 return useMemo(
51116 () => ({
@@ -65,7 +130,7 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 13 unmodified lines
65130
66131 return (
67132 <WorkosAuthContext.Provider value={{ loading, authenticated }}>
68− <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkos}>
133+ <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}>
69134 {children}
70135 </ConvexProviderWithAuth>
71136 </WorkosAuthContext.Provider>
@@ -81,5 +146,11 @@ export function AppConvexProvider({ children }: { children: ReactNode }) { · 9 unmodified lines
81146 );
82147 }
83148
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+ );
85156 }
executor/apps/web/src/lib/session-context.tsx+2−0
@@ -30,6 +30,7 @@ import { · 29 unmodified lines
3030 readStoredSessionId,
3131 readWorkspaceByAccount,
3232 } from "@/lib/session-storage";
33+import { clearAnonymousAuth } from "@/lib/anonymous-auth";
3334 import type { AnonymousContext } from "./types";
3435 import type { Id } from "@executor/convex/_generated/dataModel";
3536
@@ -177,6 +178,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 141 unmodified lines
177178
178179 const resetWorkspace = useCallback(async () => {
179180 clearSessionStorage();
181+ clearAnonymousAuth({ clearActor: true });
180182 setStoredSessionId(null);
181183 setActiveWorkspaceId(null);
182184 setManualGuestContext(null);
executor/packages/convex/_generated/api.d.ts+8−2
@@ -493,7 +493,7 @@ export declare const internal: { · 492 unmodified lines
493493 bootstrapAnonymousSession: FunctionReference<
494494 "mutation",
495495 "internal",
496− { sessionId?: string },
496+ { actorId?: string; clientId?: string; sessionId?: string },
497497 any
498498 >;
499499 ensureAnonymousMcpSession: FunctionReference<
@@ -552,7 +552,7 @@ export declare const internal: { · 52 unmodified lines
552552 bootstrapAnonymousSession: FunctionReference<
553553 "mutation",
554554 "internal",
555− { sessionId?: string },
555+ { actorId?: string; clientId?: string; sessionId?: string },
556556 any
557557 >;
558558 createApproval: FunctionReference<
@@ -1227,6 +1227,12 @@ export declare const internal: { · 668 unmodified lines
12271227 >;
12281228 };
12291229 workspaceAuthInternal: {
1230+ getWorkspaceAccessForAnonymousSubject: FunctionReference<
1231+ "query",
1232+ "internal",
1233+ { actorId: string; workspaceId: Id<"workspaces"> },
1234+ any
1235+ >;
12301236 getWorkspaceAccessForRequest: FunctionReference<
12311237 "query",
12321238 "internal",
executor/packages/convex/auth.config.ts+29−6
@@ -1,9 +1,17 @@
1+import {
2+ ANONYMOUS_AUTH_AUDIENCE,
3+ getAnonymousAuthIssuer,
4+ getAnonymousAuthJwksUrl,
5+} from "./auth/anonymous";
6+
17 const rawClientId = process.env.WORKOS_CLIENT_ID?.trim();
28 const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
9+const anonymousAuthIssuer = getAnonymousAuthIssuer();
10+const anonymousAuthJwksUrl = getAnonymousAuthJwksUrl();
311
4−const authConfig = clientId
5− ? {
6− providers: [
12+const providers = [
13+ ...(clientId
14+ ? [
715 {
816 type: "customJwt" as const,
917 issuer: "https://api.workos.com/",
@@ -17,8 +25,23 @@ const authConfig = clientId · 7 unmodified lines
1725 algorithm: "RS256" as const,
1826 jwks: `https://api.workos.com/sso/jwks/${clientId}`,
1927 },
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+};
2346
2447 export default authConfig;
executor/packages/convex/auth/anonymous.ts+47−0
@@ -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+}
executor/packages/convex/database/anonymous_session.ts+19−6
@@ -4,11 +4,23 @@ import { ensureAnonymousIdentity } from "./anonymous"; · 3 unmodified lines
44 import { mapAnonymousContext } from "./readers";
55
66 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+ },
812 handler: async (ctx, args) => {
913 const now = Date.now();
1014 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_");
1224
1325 if (requestedSessionId) {
1426 const sessionId = requestedSessionId;
@@ -20,11 +32,13 @@ export const bootstrapAnonymousSession = internalMutation({ · 5 unmodified lines
2032 const identity = await ensureAnonymousIdentity(ctx, {
2133 sessionId,
2234 workspaceId: existing.workspaceId,
23− actorId: existing.actorId,
35+ actorId: requestedActorId || existing.actorId,
2436 timestamp: now,
2537 });
2638
2739 await ctx.db.patch(existing._id, {
40+ actorId: requestedActorId || existing.actorId,
41+ clientId,
2842 workspaceId: identity.workspaceId,
2943 accountId: identity.accountId,
3044 userId: identity.userId,
@@ -43,13 +57,12 @@ export const bootstrapAnonymousSession = internalMutation({ · 12 unmodified lines
4357 }
4458
4559 const generatedSessionId = allowRequestedSessionId
46− ? `mcp_${crypto.randomUUID()}`
60+ ? (requestedSessionId.startsWith("mcp_") ? `mcp_${crypto.randomUUID()}` : `anon_session_${crypto.randomUUID()}`)
4761 : `anon_session_${crypto.randomUUID()}`;
4862 const sessionId = allowRequestedSessionId
4963 ? requestedSessionId as string
5064 : generatedSessionId;
51− const actorId = `anon_${crypto.randomUUID()}`;
52− const clientId = "web";
65+ const actorId = requestedActorId || `anon_${crypto.randomUUID()}`;
5366
5467 const identity = await ensureAnonymousIdentity(ctx, {
5568 sessionId,
executor/packages/convex/http.ts+4−0
@@ -2,6 +2,7 @@ import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; · 1 unmodified line
22 import { httpRouter } from "convex/server";
33 import { components } from "./_generated/api";
44 import { authKit } from "./auth";
5+import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth";
56 import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler";
67 import {
78 oauthAuthorizationServerHandler,
@@ -24,5 +25,8 @@ http.route({ path: "/mcp/anonymous", method: "DELETE", handler: mcpAnonymousHand · 16 unmodified lines
2425
2526 http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });
2627 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 });
2731
2832 export default http;
executor/packages/convex/http/anonymous_auth.ts+189−0
@@ -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+});
executor/packages/convex/http/mcp_handler.ts+22−43
@@ -1,6 +1,7 @@
11 import { httpAction } from "../_generated/server";
22 import { internal } from "../_generated/api";
33 import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
4+import { isAnonymousIdentity } from "../auth/anonymous";
45 import {
56 getMcpAuthConfig,
67 isAnonymousSessionId,
@@ -41,56 +42,34 @@ function createMcpHandler(mode: McpEndpointMode) { · 34 unmodified lines
4142 );
4243 }
4344
44− const requestedSessionId = requestedContext?.sessionId;
45− const requestedActorId = requestedContext?.actorId?.trim();
46−
47− if (requestedSessionId && !isAnonymousSessionId(requestedSessionId)) {
45+ if (requestedContext?.sessionId || requestedContext?.actorId) {
4846 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+ },
5051 { status: 400 },
5152 );
5253 }
5354
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+ }
6762
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+ });
8167
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+ };
9473 } catch (error) {
9574 return Response.json(
9675 { error: error instanceof Error ? error.message : "Workspace authorization failed" },
executor/packages/convex/workspace.ts+21−2
@@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line
22 import { internal } from "./_generated/api";
33 import { mutation, query } from "./_generated/server";
44 import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";
5+import { isAnonymousIdentity } from "./auth/anonymous";
56
67 const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
78 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
1718
1819 export const bootstrapAnonymousSession = mutation({
1920 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+ });
2241 },
2342 });
2443
executor/packages/convex/workspaceAuthInternal.ts+32−0
@@ -54,3 +54,35 @@ export const getWorkspaceAccessForWorkosSubject = internalQuery({ · 53 unmodified lines
5454 };
5555 },
5656 });
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+});
executor/packages/core/src/identity.ts+15−16
@@ -1,5 +1,6 @@
11 import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts";
22 import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
3+import { isAnonymousIdentity } from "../../convex/auth/anonymous";
34
45 type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">;
56 type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">;
@@ -34,31 +35,29 @@ export function slugify(input: string, fallback = "team"): string { · 28 unmodified lines
3435
3536 export async function resolveAccountForRequest(
3637 ctx: IdentityCtx,
37− sessionId?: string,
38+ _sessionId?: string,
3839 ): Promise<Doc<"accounts"> | null> {
3940 const identity = await ctx.auth.getUserIdentity();
4041 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
4253 .query("accounts")
4354 .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject))
4455 .unique();
45− if (fromAccounts) {
46− return fromAccounts;
56+ if (workosAccount) {
57+ return workosAccount;
4758 }
4859 }
4960
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−
6261 return null;
6362 }
6463