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
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_...
executor/README.md+4−3
@@ -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
131Convex HTTP routes (`packages/convex/http.ts`) expose:131Convex 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
139MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured.139MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured.
140Anonymous bearer tokens can be minted from `/auth/anonymous/token` (and verified by `/.well-known/jwks.json`).
140 141
141## Configuration Reference142## Configuration Reference
142 143
@@ -221,6 +222,6 @@ executor/ · 78 unmodified lines
221## Troubleshooting222## 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.
executor/apps/web/src/app/api/auth/anonymous-token/route.ts+78−0
@@ -0,0 +1,78 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3
4function 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
23function 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
34function noStoreJson(payload: unknown, status: number): NextResponse {
35 return NextResponse.json(payload, {
36 status,
37 headers: {
38 "cache-control": "no-store",
39 },
40 });
41}
42
43export 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
62 <CardContent className="pt-0">62 <CardContent className="pt-0">
63 <McpSetupCard63 <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>
executor/apps/web/src/components/tools/setup-card.tsx+88−6
@@ -1,9 +1,14 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useEffect, useMemo, useState } from "react";
4import { Check, Copy } from "lucide-react";4import { Check, ChevronRight, Copy } from "lucide-react";
5import Image from "next/image";5import Image from "next/image";
6import { Button } from "@/components/ui/button";6import { Button } from "@/components/ui/button";
7import {
8 Collapsible,
9 CollapsibleContent,
10 CollapsibleTrigger,
11} from "@/components/ui/collapsible";
7import { Input } from "@/components/ui/input";12import { Input } from "@/components/ui/input";
8import { Label } from "@/components/ui/label";13import { Label } from "@/components/ui/label";
9import {14import {
@@ -14,6 +19,8 @@ import { · 4 unmodified lines
14 SelectValue,19 SelectValue,
15} from "@/components/ui/select";20} from "@/components/ui/select";
16import { MCP_PROVIDERS } from "@/components/tools/install-configs";21import { MCP_PROVIDERS } from "@/components/tools/install-configs";
22import { getAnonymousAuthToken } from "@/lib/anonymous-auth";
23import { cn } from "@/lib/utils";
17 24
18function inferServerName(workspaceId?: string): string {25function 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
50export function McpSetupCard({57export 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">
executor/apps/web/src/lib/anonymous-auth.ts+127−0
@@ -0,0 +1,127 @@
1export type AnonymousAuthToken = {
2 accessToken: string;
3 actorId: string;
4 expiresAtMs: number;
5};
6
7const TOKEN_STORAGE_KEY = "executor_anonymous_access_token";
8const ACTOR_STORAGE_KEY = "executor_anonymous_actor_id";
9const EXPIRES_STORAGE_KEY = "executor_anonymous_token_expires_at";
10const TOKEN_EXPIRY_SKEW_MS = 60_000;
11
12function canUseStorage(): boolean {
13 return typeof window !== "undefined";
14}
15
16function readStorageValue(key: string): string | null {
17 if (!canUseStorage()) {
18 return null;
19 }
20 return localStorage.getItem(key);
21}
22
23function writeStorageValue(key: string, value: string) {
24 if (!canUseStorage()) {
25 return;
26 }
27 localStorage.setItem(key, value);
28}
29
30export 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
42function 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
48function readStoredActorId(): string | null {
49 const raw = readStorageValue(ACTOR_STORAGE_KEY);
50 return raw && raw.trim().length > 0 ? raw.trim() : null;
51}
52
53export 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
80async 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
115export 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 @@
1"use client";1"use client";
2 2
3import { createContext, useContext, useMemo } from "react";3import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4import { ConvexProviderWithAuth } from "convex/react";4import { ConvexProviderWithAuth } from "convex/react";
5import {5import {
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";
10import { ConvexProvider, ConvexReactClient } from "convex/react";10import { ConvexReactClient } from "convex/react";
11import type { ReactNode } from "react";11import type { ReactNode } from "react";
12import {
13 getAnonymousAuthToken,
14 readStoredAnonymousAuthToken,
15} from "@/lib/anonymous-auth";
12import { workosEnabled } from "@/lib/auth-capabilities";16import { workosEnabled } from "@/lib/auth-capabilities";
13 17
14const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL;18const 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
32function useConvexAuthFromWorkos() {36function 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
92function 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}
executor/apps/web/src/lib/session-context.tsx+2−0
@@ -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";
33import { clearAnonymousAuth } from "@/lib/anonymous-auth";
33import type { AnonymousContext } from "./types";34import type { AnonymousContext } from "./types";
34import type { Id } from "@executor/convex/_generated/dataModel";35import 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);
executor/packages/convex/_generated/api.d.ts+8−2
@@ -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 any497 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 any556 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",
executor/packages/convex/auth.config.ts+29−6
@@ -1,9 +1,17 @@
1import {
2 ANONYMOUS_AUTH_AUDIENCE,
3 getAnonymousAuthIssuer,
4 getAnonymousAuthJwksUrl,
5} from "./auth/anonymous";
6
1const rawClientId = process.env.WORKOS_CLIENT_ID?.trim();7const rawClientId = process.env.WORKOS_CLIENT_ID?.trim();
2const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;8const clientId = rawClientId && rawClientId !== "disabled" ? rawClientId : undefined;
9const anonymousAuthIssuer = getAnonymousAuthIssuer();
10const anonymousAuthJwksUrl = getAnonymousAuthJwksUrl();
3 11
4const authConfig = clientId12const 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
43const authConfig = {
44 providers,
45};
23 46
24export default authConfig;47export default authConfig;
executor/packages/convex/auth/anonymous.ts+47−0
@@ -0,0 +1,47 @@
1export const ANONYMOUS_AUTH_AUDIENCE = "executor-anonymous";
2export const ANONYMOUS_AUTH_KEY_ID = "executor-anonymous-es256";
3export const ANONYMOUS_AUTH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 7;
4
5function trimOrNull(value: string | undefined): string | null {
6 const trimmed = value?.trim();
7 return trimmed && trimmed.length > 0 ? trimmed : null;
8}
9
10function 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
30export function getAnonymousAuthIssuer(): string | null {
31 return toSiteUrl(process.env.CONVEX_SITE_URL) ?? toSiteUrl(process.env.CONVEX_URL);
32}
33
34export function getAnonymousAuthJwksUrl(): string | null {
35 const issuer = getAnonymousAuthIssuer();
36 return issuer ? `${issuer}/.well-known/jwks.json` : null;
37}
38
39export 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
4import { mapAnonymousContext } from "./readers";4import { mapAnonymousContext } from "./readers";
5 5
6export const bootstrapAnonymousSession = internalMutation({6export 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 = allowRequestedSessionId59 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 = allowRequestedSessionId62 const sessionId = allowRequestedSessionId
49 ? requestedSessionId as string63 ? 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,
executor/packages/convex/http.ts+4−0
@@ -2,6 +2,7 @@ import { registerRoutes as registerStripeRoutes } from "@convex-dev/stripe"; · 1 unmodified line
2import { httpRouter } from "convex/server";2import { httpRouter } from "convex/server";
3import { components } from "./_generated/api";3import { components } from "./_generated/api";
4import { authKit } from "./auth";4import { authKit } from "./auth";
5import { anonymousJwksHandler, anonymousTokenHandler } from "./http/anonymous_auth";
5import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler";6import { mcpAnonymousHandler, mcpHandler } from "./http/mcp_handler";
6import {7import {
7 oauthAuthorizationServerHandler,8 oauthAuthorizationServerHandler,
@@ -24,5 +25,8 @@ http.route({ path: "/mcp/anonymous", method: "DELETE", handler: mcpAnonymousHand · 16 unmodified lines
24 25
25http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });26http.route({ path: "/.well-known/oauth-protected-resource", method: "GET", handler: oauthProtectedResourceHandler });
26http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler });27http.route({ path: "/.well-known/oauth-authorization-server", method: "GET", handler: oauthAuthorizationServerHandler });
28http.route({ path: "/.well-known/jwks.json", method: "GET", handler: anonymousJwksHandler });
29http.route({ path: "/auth/anonymous/token", method: "POST", handler: anonymousTokenHandler });
30http.route({ path: "/auth/anonymous/token", method: "GET", handler: anonymousTokenHandler });
27 31
28export default http;32export default http;
executor/packages/convex/http/anonymous_auth.ts+189−0
@@ -0,0 +1,189 @@
1import { exportJWK, importPKCS8, importSPKI, SignJWT } from "jose";
2import { httpAction } from "../_generated/server";
3import {
4 ANONYMOUS_AUTH_AUDIENCE,
5 ANONYMOUS_AUTH_KEY_ID,
6 ANONYMOUS_AUTH_TOKEN_TTL_SECONDS,
7 getAnonymousAuthIssuer,
8} from "../auth/anonymous";
9
10type AnonymousTokenBody = {
11 actorId?: string;
12};
13
14type AnonymousTokenConfig = {
15 issuer: string | null;
16 audience: string;
17 keyId: string;
18 tokenTtlSeconds: number;
19 privateKeyPem: string | null;
20 publicKeyPem: string | null;
21};
22
23function trimOrNull(value: string | undefined): string | null {
24 const trimmed = value?.trim();
25 return trimmed && trimmed.length > 0 ? trimmed : null;
26}
27
28function 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
36function 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
41function 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
52function noStoreHeaders(extra?: Record<string, string>) {
53 return {
54 "cache-control": "no-store",
55 ...extra,
56 };
57}
58
59function 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
73async 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
93function createActorId(): string {
94 return `anon_${crypto.randomUUID().replace(/-/g, "")}`;
95}
96
97let signingKeyPromise: Promise<CryptoKey> | null = null;
98let jwksPromise: Promise<Record<string, unknown>> | null = null;
99
100async function loadSigningKey(privateKeyPem: string): Promise<CryptoKey> {
101 if (!signingKeyPromise) {
102 signingKeyPromise = importPKCS8(privateKeyPem, "ES256");
103 }
104 return await signingKeyPromise;
105}
106
107async 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
128export 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
173export 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 @@
1import { httpAction } from "../_generated/server";1import { httpAction } from "../_generated/server";
2import { internal } from "../_generated/api";2import { internal } from "../_generated/api";
3import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";3import { handleMcpRequest, type McpWorkspaceContext } from "../../core/src/mcp-server";
4import { isAnonymousIdentity } from "../auth/anonymous";
4import {5import {
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" },
executor/packages/convex/workspace.ts+21−2
@@ -2,6 +2,7 @@ import { v } from "convex/values"; · 1 unmodified line
2import { internal } from "./_generated/api";2import { internal } from "./_generated/api";
3import { mutation, query } from "./_generated/server";3import { mutation, query } from "./_generated/server";
4import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";4import { workspaceMutation, workspaceQuery } from "../core/src/function-builders";
5import { isAnonymousIdentity } from "./auth/anonymous";
5 6
6const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));7const policyDecisionValidator = v.union(v.literal("allow"), v.literal("require_approval"), v.literal("deny"));
7const credentialScopeValidator = v.union(v.literal("workspace"), v.literal("actor"));8const 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
18export const bootstrapAnonymousSession = mutation({19export 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
executor/packages/convex/workspaceAuthInternal.ts+32−0
@@ -54,3 +54,35 @@ export const getWorkspaceAccessForWorkosSubject = internalQuery({ · 53 unmodified lines
54 };54 };
55 },55 },
56});56});
57
58export 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 @@
1import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts";1import type { Doc, Id } from "../../convex/_generated/dataModel.d.ts";
2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";2import type { MutationCtx, QueryCtx } from "../../convex/_generated/server";
3import { isAnonymousIdentity } from "../../convex/auth/anonymous";
3 4
4type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">;5type IdentityCtx = Pick<QueryCtx, "auth" | "db"> | Pick<MutationCtx, "auth" | "db">;
5type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">;6type MembershipCtx = Pick<QueryCtx, "db"> | Pick<MutationCtx, "db">;
@@ -34,31 +35,29 @@ export function slugify(input: string, fallback = "team"): string { · 28 unmodified lines
34 35
35export async function resolveAccountForRequest(36export 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.db42 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