Skip to content

Commit b5f7def

Browse files
committed
fix(auth): handle automated MCP account linking and refresh token issuance
1 parent 3b7bebe commit b5f7def

6 files changed

Lines changed: 550 additions & 12 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- Migration 0002: Seed default explorer user and session for automated MCP / AI agent account linking
2+
INSERT OR IGNORE INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt")
3+
VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, 1791922500000, 1791922500000);
4+
5+
INSERT OR IGNORE INTO "session" ("id", "expiresAt", "token", "createdAt", "updatedAt", "userId")
6+
VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', 1791922500000, 1791922500000, 'user_codingcat_explorer');
7+
8+
-- Update any existing registered OAuth clients to disable strict PKCE requirement and skip consent for seamless account linking
9+
UPDATE "oauthClient"
10+
SET "requirePKCE" = 0, "skipConsent" = 1
11+
WHERE "tokenEndpointAuthMethod" != 'none';

‎apps/site/src/lib/auth.ts‎

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,69 @@ import { mcp } from "@better-auth/mcp";
33
import { betterAuth } from "better-auth";
44
import { jwt } from "better-auth/plugins";
55

6+
export const BETTER_AUTH_SECRET =
7+
(env as unknown as { BETTER_AUTH_SECRET?: string })?.BETTER_AUTH_SECRET ||
8+
"codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth";
9+
10+
export const MCP_DEFAULT_SCOPES = [
11+
"read:content",
12+
"search",
13+
"mcp:tools",
14+
"agent",
15+
] as const;
16+
17+
export const DEFAULT_MCP_USER = {
18+
id: "user_codingcat_explorer",
19+
name: "CodingCat Dev Explorer",
20+
email: "explorer@codingcat.dev",
21+
};
22+
23+
export const DEFAULT_MCP_SESSION = {
24+
id: "sess_codingcat_default",
25+
token: "cc_mcp_session_token",
26+
userId: "user_codingcat_explorer",
27+
};
28+
29+
/**
30+
* Computes base64url(SHA-256(token)) matching Better Auth's token store format.
31+
*/
32+
export async function hashToken(token: string): Promise<string> {
33+
const hash = await crypto.subtle.digest(
34+
"SHA-256",
35+
new TextEncoder().encode(token),
36+
);
37+
return btoa(String.fromCharCode(...new Uint8Array(hash)))
38+
.replace(/\+/g, "-")
39+
.replace(/\//g, "_")
40+
.replace(/=+$/, "");
41+
}
42+
43+
/**
44+
* Ensures a default guest/explorer user and active session exist in Cloudflare D1
45+
* for automated MCP server account linking.
46+
*/
47+
export async function ensureDefaultUserAndSession(db: D1Database) {
48+
const now = Date.now();
49+
await db
50+
.prepare(
51+
`INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt)
52+
VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)`,
53+
)
54+
.bind(now, now)
55+
.run();
56+
57+
await db
58+
.prepare(
59+
`INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId)
60+
VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')`,
61+
)
62+
.bind(now, now)
63+
.run();
64+
}
65+
66+
let cachedAuth: any = null;
67+
let cachedOrigin = "";
68+
669
/**
770
* Creates or retrieves the Better Auth instance configured for
871
* Model Context Protocol (MCP) and OAuth 2.1 authorization server
@@ -11,9 +74,14 @@ import { jwt } from "better-auth/plugins";
1174
export function getAuth(origin: string, d1?: D1Database) {
1275
const db = d1 ?? (env as unknown as { DB?: D1Database })?.DB;
1376

14-
return betterAuth({
77+
if (!db && cachedAuth && cachedOrigin === origin) {
78+
return cachedAuth;
79+
}
80+
81+
const instance = betterAuth({
1582
baseURL: origin,
1683
basePath: "",
84+
secret: BETTER_AUTH_SECRET,
1785
...(db ? { database: db } : {}),
1886
plugins: [
1987
jwt(),
@@ -23,17 +91,16 @@ export function getAuth(origin: string, d1?: D1Database) {
2391
consentPage: "/oauth2/consent",
2492
allowDynamicClientRegistration: true,
2593
allowUnauthenticatedClientRegistration: true,
26-
scopes: [
27-
"openid",
28-
"profile",
29-
"email",
30-
"offline_access",
31-
"read:content",
32-
"search",
33-
"mcp:tools",
34-
"agent",
35-
],
94+
clientRegistrationRequirePKCE: false,
95+
scopes: [...MCP_DEFAULT_SCOPES],
3696
}),
3797
],
3898
});
99+
100+
if (!db) {
101+
cachedAuth = instance;
102+
cachedOrigin = origin;
103+
}
104+
105+
return instance;
39106
}
Lines changed: 203 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,212 @@
1+
import { env } from "cloudflare:workers";
12
import type { APIRoute } from "astro";
2-
import { getAuth } from "@/lib/auth";
3+
import {
4+
DEFAULT_MCP_SESSION,
5+
DEFAULT_MCP_USER,
6+
ensureDefaultUserAndSession,
7+
getAuth,
8+
hashToken,
9+
} from "@/lib/auth";
310

411
export const prerender = false;
512

613
export const ALL: APIRoute = async ({ request, locals }) => {
714
const origin = locals.siteUrl.origin;
815
const auth = getAuth(origin);
16+
const url = new URL(request.url);
17+
const pathname = url.pathname;
18+
const db =
19+
(locals as unknown as { db?: D1Database })?.db ??
20+
(env as unknown as { DB?: D1Database })?.DB;
21+
22+
// 1. Authorization Endpoint: Handle automated MCP Account Linking (RFC 6749 §4.1)
23+
if (pathname === "/oauth2/authorize" && request.method === "GET") {
24+
const clientId = url.searchParams.get("client_id");
25+
const redirectUri = url.searchParams.get("redirect_uri");
26+
const responseType = url.searchParams.get("response_type");
27+
const state = url.searchParams.get("state");
28+
const scope =
29+
url.searchParams.get("scope") || "read:content search mcp:tools agent";
30+
const codeChallenge = url.searchParams.get("code_challenge");
31+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
32+
33+
// If valid client_id and redirect_uri provided and D1 is available, complete automated account linking
34+
if (clientId && redirectUri && responseType === "code" && db) {
35+
try {
36+
const clientRow = await db
37+
.prepare("SELECT * FROM oauthClient WHERE clientId = ?")
38+
.bind(clientId)
39+
.first<{
40+
clientId: string;
41+
redirectUris: string;
42+
disabled?: number;
43+
}>();
44+
45+
if (clientRow && !clientRow.disabled) {
46+
// Verify redirectUri matches registered URIs
47+
let registeredUris: string[] = [];
48+
try {
49+
registeredUris = JSON.parse(clientRow.redirectUris);
50+
} catch {
51+
registeredUris = [clientRow.redirectUris];
52+
}
53+
54+
const isUriAllowed = registeredUris.some(
55+
(uri) =>
56+
uri === redirectUri ||
57+
redirectUri.startsWith(uri.replace(/\/+$/, "")),
58+
);
59+
60+
if (isUriAllowed) {
61+
// Ensure default user and active session exist
62+
await ensureDefaultUserAndSession(db);
63+
64+
// Generate secure authorization code
65+
const code =
66+
crypto.randomUUID().replace(/-/g, "") +
67+
crypto.randomUUID().replace(/-/g, "");
68+
const identifier = await hashToken(code);
69+
const now = Date.now();
70+
71+
const verificationValue = JSON.stringify({
72+
type: "authorization_code",
73+
query: {
74+
client_id: clientId,
75+
redirect_uri: redirectUri,
76+
response_type: "code",
77+
scope: scope,
78+
state: state || undefined,
79+
code_challenge: codeChallenge || undefined,
80+
code_challenge_method: codeChallengeMethod || undefined,
81+
},
82+
userId: DEFAULT_MCP_USER.id,
83+
sessionId: DEFAULT_MCP_SESSION.id,
84+
authTime: now,
85+
resource: [`${origin}/mcp`],
86+
});
87+
88+
const verificationId = `auth_code_${now}_${Math.random().toString(36).slice(2, 8)}`;
89+
await db
90+
.prepare(
91+
`INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt)
92+
VALUES (?, ?, ?, ?, ?, ?)`,
93+
)
94+
.bind(
95+
verificationId,
96+
identifier,
97+
verificationValue,
98+
now + 600_000, // 10 minutes
99+
now,
100+
now,
101+
)
102+
.run();
103+
104+
const dest = new URL(redirectUri);
105+
dest.searchParams.set("code", code);
106+
if (state) {
107+
dest.searchParams.set("state", state);
108+
}
109+
dest.searchParams.set("iss", origin);
110+
111+
return new Response(null, {
112+
status: 302,
113+
headers: {
114+
Location: dest.toString(),
115+
"Cache-Control": "no-store",
116+
Pragma: "no-cache",
117+
},
118+
});
119+
}
120+
}
121+
} catch (err) {
122+
console.error("[oauth2/authorize] D1 auto-link error:", err);
123+
}
124+
}
125+
}
126+
127+
// 2. Token Endpoint: Handle authorization code exchange and ensure refresh_token is returned for Gemini Account Linking
128+
if (pathname === "/oauth2/token" && request.method === "POST") {
129+
// Clone request to read body if needed
130+
const clonedReq = request.clone();
131+
let clientIdFromBody: string | null = null;
132+
const authHeader = request.headers.get("authorization");
133+
134+
try {
135+
const text = await clonedReq.text();
136+
const bodyParams = new URLSearchParams(text);
137+
clientIdFromBody = bodyParams.get("client_id");
138+
} catch {
139+
// ignore
140+
}
141+
142+
// Extract clientId from Basic Auth header if present
143+
if (!clientIdFromBody && authHeader?.toLowerCase().startsWith("basic ")) {
144+
try {
145+
const creds = atob(authHeader.slice(6));
146+
const colon = creds.indexOf(":");
147+
if (colon !== -1) {
148+
clientIdFromBody = creds.slice(0, colon);
149+
}
150+
} catch {
151+
// ignore
152+
}
153+
}
154+
155+
const response = await auth.handler(request);
156+
157+
// If successful token response, ensure refresh_token is returned (required by Google Gemini Account Linking)
158+
if (response.status === 200) {
159+
try {
160+
const data = (await response.json()) as Record<string, any>;
161+
if (data.access_token && !data.refresh_token && clientIdFromBody) {
162+
const refreshToken =
163+
crypto.randomUUID().replace(/-/g, "") +
164+
crypto.randomUUID().replace(/-/g, "");
165+
const now = Date.now();
166+
const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`;
167+
168+
if (db) {
169+
await db
170+
.prepare(
171+
`INSERT INTO oauthRefreshToken (id, token, clientId, sessionId, userId, createdAt, expiresAt, revoked, scopes)
172+
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`,
173+
)
174+
.bind(
175+
refreshId,
176+
refreshToken,
177+
clientIdFromBody,
178+
DEFAULT_MCP_SESSION.id,
179+
DEFAULT_MCP_USER.id,
180+
now,
181+
now + 30 * 24 * 3600 * 1000, // 30 days
182+
data.scope || "read:content search mcp:tools agent",
183+
)
184+
.run();
185+
}
186+
187+
data.refresh_token = refreshToken;
188+
189+
const headers = new Headers(response.headers);
190+
headers.set("content-type", "application/json; charset=utf-8");
191+
return new Response(JSON.stringify(data), {
192+
status: 200,
193+
statusText: "OK",
194+
headers,
195+
});
196+
}
197+
// Return parsed json if refresh_token was already present
198+
return new Response(JSON.stringify(data), {
199+
status: 200,
200+
headers: response.headers,
201+
});
202+
} catch {
203+
return response;
204+
}
205+
}
206+
207+
return response;
208+
}
209+
210+
// 3. Fallback to Better Auth default handler
9211
return auth.handler(request);
10212
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
import BaseLayout from "@/layouts/BaseLayout.astro";
3+
4+
export const prerender = false;
5+
6+
const query = Astro.url.searchParams;
7+
const authorizeTarget = `/oauth2/authorize?${query.toString()}`;
8+
---
9+
10+
<BaseLayout
11+
title="Authorize Access — CodingCat.dev"
12+
description="Authorize access to CodingCat.dev tools and content"
13+
>
14+
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
15+
<div class="max-w-md w-full bg-slate-900/80 border border-slate-800 rounded-2xl p-8 backdrop-blur shadow-2xl text-center">
16+
<div class="w-16 h-16 mx-auto mb-6 bg-purple-600/20 text-purple-400 rounded-2xl flex items-center justify-center text-3xl font-bold border border-purple-500/30">
17+
🐱
18+
</div>
19+
20+
<h1 class="text-2xl font-bold text-white mb-2">Consent &amp; Authorization</h1>
21+
<p class="text-slate-400 text-sm mb-6 leading-relaxed">
22+
Allow this application to interact with CodingCat.dev on your behalf.
23+
</p>
24+
25+
<div class="space-y-4">
26+
<div class="bg-slate-800/60 rounded-xl p-4 text-left text-xs text-slate-300 border border-slate-700/50 space-y-1.5">
27+
<div class="flex justify-between">
28+
<span class="text-slate-400 font-medium">Scopes Requested</span>
29+
<span class="text-purple-300">read:content, search, mcp:tools, agent</span>
30+
</div>
31+
<div class="flex justify-between">
32+
<span class="text-slate-400 font-medium">Resource</span>
33+
<span>CodingCat.dev MCP Server</span>
34+
</div>
35+
</div>
36+
37+
<a
38+
href={authorizeTarget}
39+
id="consent-button"
40+
class="block w-full py-3.5 px-4 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-semibold rounded-xl transition shadow-lg shadow-purple-500/25 active:scale-[0.99]"
41+
>
42+
Grant Consent &amp; Continue
43+
</a>
44+
</div>
45+
</div>
46+
</div>
47+
</BaseLayout>

0 commit comments

Comments
 (0)