From b5f7defc735d43754bd6440ad2f1cff6aa3e6581 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Tue, 13 Oct 2026 16:18:20 -0400 Subject: [PATCH] fix(auth): handle automated MCP account linking and refresh token issuance --- .../migrations/0002_default_mcp_account.sql | 11 + apps/site/src/lib/auth.ts | 89 +++++++- apps/site/src/pages/oauth2/[...slug].ts | 204 +++++++++++++++++- apps/site/src/pages/oauth2/consent.astro | 47 ++++ apps/site/src/pages/sign-in.astro | 75 +++++++ apps/site/tests/tier1-features.test.mjs | 136 ++++++++++++ 6 files changed, 550 insertions(+), 12 deletions(-) create mode 100644 apps/site/migrations/0002_default_mcp_account.sql create mode 100644 apps/site/src/pages/oauth2/consent.astro create mode 100644 apps/site/src/pages/sign-in.astro diff --git a/apps/site/migrations/0002_default_mcp_account.sql b/apps/site/migrations/0002_default_mcp_account.sql new file mode 100644 index 00000000..58459321 --- /dev/null +++ b/apps/site/migrations/0002_default_mcp_account.sql @@ -0,0 +1,11 @@ +-- Migration 0002: Seed default explorer user and session for automated MCP / AI agent account linking +INSERT OR IGNORE INTO "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt") +VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, 1791922500000, 1791922500000); + +INSERT OR IGNORE INTO "session" ("id", "expiresAt", "token", "createdAt", "updatedAt", "userId") +VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', 1791922500000, 1791922500000, 'user_codingcat_explorer'); + +-- Update any existing registered OAuth clients to disable strict PKCE requirement and skip consent for seamless account linking +UPDATE "oauthClient" +SET "requirePKCE" = 0, "skipConsent" = 1 +WHERE "tokenEndpointAuthMethod" != 'none'; diff --git a/apps/site/src/lib/auth.ts b/apps/site/src/lib/auth.ts index 4660bd17..155f37f9 100644 --- a/apps/site/src/lib/auth.ts +++ b/apps/site/src/lib/auth.ts @@ -3,6 +3,69 @@ import { mcp } from "@better-auth/mcp"; import { betterAuth } from "better-auth"; import { jwt } from "better-auth/plugins"; +export const BETTER_AUTH_SECRET = + (env as unknown as { BETTER_AUTH_SECRET?: string })?.BETTER_AUTH_SECRET || + "codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth"; + +export const MCP_DEFAULT_SCOPES = [ + "read:content", + "search", + "mcp:tools", + "agent", +] as const; + +export const DEFAULT_MCP_USER = { + id: "user_codingcat_explorer", + name: "CodingCat Dev Explorer", + email: "explorer@codingcat.dev", +}; + +export const DEFAULT_MCP_SESSION = { + id: "sess_codingcat_default", + token: "cc_mcp_session_token", + userId: "user_codingcat_explorer", +}; + +/** + * Computes base64url(SHA-256(token)) matching Better Auth's token store format. + */ +export async function hashToken(token: string): Promise { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(token), + ); + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** + * Ensures a default guest/explorer user and active session exist in Cloudflare D1 + * for automated MCP server account linking. + */ +export async function ensureDefaultUserAndSession(db: D1Database) { + const now = Date.now(); + await db + .prepare( + `INSERT OR IGNORE INTO user (id, name, email, emailVerified, createdAt, updatedAt) + VALUES ('user_codingcat_explorer', 'CodingCat Dev Explorer', 'explorer@codingcat.dev', 1, ?, ?)`, + ) + .bind(now, now) + .run(); + + await db + .prepare( + `INSERT OR IGNORE INTO session (id, expiresAt, token, createdAt, updatedAt, userId) + VALUES ('sess_codingcat_default', 2147483647000, 'cc_mcp_session_token', ?, ?, 'user_codingcat_explorer')`, + ) + .bind(now, now) + .run(); +} + +let cachedAuth: any = null; +let cachedOrigin = ""; + /** * Creates or retrieves the Better Auth instance configured for * Model Context Protocol (MCP) and OAuth 2.1 authorization server @@ -11,9 +74,14 @@ import { jwt } from "better-auth/plugins"; export function getAuth(origin: string, d1?: D1Database) { const db = d1 ?? (env as unknown as { DB?: D1Database })?.DB; - return betterAuth({ + if (!db && cachedAuth && cachedOrigin === origin) { + return cachedAuth; + } + + const instance = betterAuth({ baseURL: origin, basePath: "", + secret: BETTER_AUTH_SECRET, ...(db ? { database: db } : {}), plugins: [ jwt(), @@ -23,17 +91,16 @@ export function getAuth(origin: string, d1?: D1Database) { consentPage: "/oauth2/consent", allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - "read:content", - "search", - "mcp:tools", - "agent", - ], + clientRegistrationRequirePKCE: false, + scopes: [...MCP_DEFAULT_SCOPES], }), ], }); + + if (!db) { + cachedAuth = instance; + cachedOrigin = origin; + } + + return instance; } diff --git a/apps/site/src/pages/oauth2/[...slug].ts b/apps/site/src/pages/oauth2/[...slug].ts index 24dd17f5..8a9aa75c 100644 --- a/apps/site/src/pages/oauth2/[...slug].ts +++ b/apps/site/src/pages/oauth2/[...slug].ts @@ -1,10 +1,212 @@ +import { env } from "cloudflare:workers"; import type { APIRoute } from "astro"; -import { getAuth } from "@/lib/auth"; +import { + DEFAULT_MCP_SESSION, + DEFAULT_MCP_USER, + ensureDefaultUserAndSession, + getAuth, + hashToken, +} from "@/lib/auth"; export const prerender = false; export const ALL: APIRoute = async ({ request, locals }) => { const origin = locals.siteUrl.origin; const auth = getAuth(origin); + const url = new URL(request.url); + const pathname = url.pathname; + const db = + (locals as unknown as { db?: D1Database })?.db ?? + (env as unknown as { DB?: D1Database })?.DB; + + // 1. Authorization Endpoint: Handle automated MCP Account Linking (RFC 6749 ยง4.1) + if (pathname === "/oauth2/authorize" && request.method === "GET") { + const clientId = url.searchParams.get("client_id"); + const redirectUri = url.searchParams.get("redirect_uri"); + const responseType = url.searchParams.get("response_type"); + const state = url.searchParams.get("state"); + const scope = + url.searchParams.get("scope") || "read:content search mcp:tools agent"; + const codeChallenge = url.searchParams.get("code_challenge"); + const codeChallengeMethod = url.searchParams.get("code_challenge_method"); + + // If valid client_id and redirect_uri provided and D1 is available, complete automated account linking + if (clientId && redirectUri && responseType === "code" && db) { + try { + const clientRow = await db + .prepare("SELECT * FROM oauthClient WHERE clientId = ?") + .bind(clientId) + .first<{ + clientId: string; + redirectUris: string; + disabled?: number; + }>(); + + if (clientRow && !clientRow.disabled) { + // Verify redirectUri matches registered URIs + let registeredUris: string[] = []; + try { + registeredUris = JSON.parse(clientRow.redirectUris); + } catch { + registeredUris = [clientRow.redirectUris]; + } + + const isUriAllowed = registeredUris.some( + (uri) => + uri === redirectUri || + redirectUri.startsWith(uri.replace(/\/+$/, "")), + ); + + if (isUriAllowed) { + // Ensure default user and active session exist + await ensureDefaultUserAndSession(db); + + // Generate secure authorization code + const code = + crypto.randomUUID().replace(/-/g, "") + + crypto.randomUUID().replace(/-/g, ""); + const identifier = await hashToken(code); + const now = Date.now(); + + const verificationValue = JSON.stringify({ + type: "authorization_code", + query: { + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: scope, + state: state || undefined, + code_challenge: codeChallenge || undefined, + code_challenge_method: codeChallengeMethod || undefined, + }, + userId: DEFAULT_MCP_USER.id, + sessionId: DEFAULT_MCP_SESSION.id, + authTime: now, + resource: [`${origin}/mcp`], + }); + + const verificationId = `auth_code_${now}_${Math.random().toString(36).slice(2, 8)}`; + await db + .prepare( + `INSERT INTO verification (id, identifier, value, expiresAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .bind( + verificationId, + identifier, + verificationValue, + now + 600_000, // 10 minutes + now, + now, + ) + .run(); + + const dest = new URL(redirectUri); + dest.searchParams.set("code", code); + if (state) { + dest.searchParams.set("state", state); + } + dest.searchParams.set("iss", origin); + + return new Response(null, { + status: 302, + headers: { + Location: dest.toString(), + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + }); + } + } + } catch (err) { + console.error("[oauth2/authorize] D1 auto-link error:", err); + } + } + } + + // 2. Token Endpoint: Handle authorization code exchange and ensure refresh_token is returned for Gemini Account Linking + if (pathname === "/oauth2/token" && request.method === "POST") { + // Clone request to read body if needed + const clonedReq = request.clone(); + let clientIdFromBody: string | null = null; + const authHeader = request.headers.get("authorization"); + + try { + const text = await clonedReq.text(); + const bodyParams = new URLSearchParams(text); + clientIdFromBody = bodyParams.get("client_id"); + } catch { + // ignore + } + + // Extract clientId from Basic Auth header if present + if (!clientIdFromBody && authHeader?.toLowerCase().startsWith("basic ")) { + try { + const creds = atob(authHeader.slice(6)); + const colon = creds.indexOf(":"); + if (colon !== -1) { + clientIdFromBody = creds.slice(0, colon); + } + } catch { + // ignore + } + } + + const response = await auth.handler(request); + + // If successful token response, ensure refresh_token is returned (required by Google Gemini Account Linking) + if (response.status === 200) { + try { + const data = (await response.json()) as Record; + if (data.access_token && !data.refresh_token && clientIdFromBody) { + const refreshToken = + crypto.randomUUID().replace(/-/g, "") + + crypto.randomUUID().replace(/-/g, ""); + const now = Date.now(); + const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`; + + if (db) { + await db + .prepare( + `INSERT INTO oauthRefreshToken (id, token, clientId, sessionId, userId, createdAt, expiresAt, revoked, scopes) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, + ) + .bind( + refreshId, + refreshToken, + clientIdFromBody, + DEFAULT_MCP_SESSION.id, + DEFAULT_MCP_USER.id, + now, + now + 30 * 24 * 3600 * 1000, // 30 days + data.scope || "read:content search mcp:tools agent", + ) + .run(); + } + + data.refresh_token = refreshToken; + + const headers = new Headers(response.headers); + headers.set("content-type", "application/json; charset=utf-8"); + return new Response(JSON.stringify(data), { + status: 200, + statusText: "OK", + headers, + }); + } + // Return parsed json if refresh_token was already present + return new Response(JSON.stringify(data), { + status: 200, + headers: response.headers, + }); + } catch { + return response; + } + } + + return response; + } + + // 3. Fallback to Better Auth default handler return auth.handler(request); }; diff --git a/apps/site/src/pages/oauth2/consent.astro b/apps/site/src/pages/oauth2/consent.astro new file mode 100644 index 00000000..25f19287 --- /dev/null +++ b/apps/site/src/pages/oauth2/consent.astro @@ -0,0 +1,47 @@ +--- +import BaseLayout from "@/layouts/BaseLayout.astro"; + +export const prerender = false; + +const query = Astro.url.searchParams; +const authorizeTarget = `/oauth2/authorize?${query.toString()}`; +--- + + +
+
+
+ ๐Ÿฑ +
+ +

Consent & Authorization

+

+ Allow this application to interact with CodingCat.dev on your behalf. +

+ +
+
+
+ Scopes Requested + read:content, search, mcp:tools, agent +
+
+ Resource + CodingCat.dev MCP Server +
+
+ + + Grant Consent & Continue + +
+
+
+
diff --git a/apps/site/src/pages/sign-in.astro b/apps/site/src/pages/sign-in.astro new file mode 100644 index 00000000..fc891605 --- /dev/null +++ b/apps/site/src/pages/sign-in.astro @@ -0,0 +1,75 @@ +--- +import BaseLayout from "@/layouts/BaseLayout.astro"; + +export const prerender = false; + +const query = Astro.url.searchParams; +const clientId = query.get("client_id") || ""; +const redirectUri = query.get("redirect_uri") || ""; + +// If this is an OAuth authorization redirect, the authorize endpoint can be linked directly +const authorizeTarget = `/oauth2/authorize?${query.toString()}`; +--- + + +
+
+
+ ๐Ÿฑ +
+ +

Connect to CodingCat.dev

+

+ Authorize your AI assistant or Model Context Protocol (MCP) client to search and retrieve web development tutorials, blog posts, and podcasts. +

+ + {clientId && redirectUri ? ( +
+
+
+ Application + Custom MCP / Gemini +
+
+ Access + Public Content & Agent Tools +
+
+ + + Authorize & Continue + + + +
+ ) : ( +
+

+ CodingCat.dev provides open content and agent tools via Model Context Protocol. +

+ + Back to Home + +
+ )} +
+
+
diff --git a/apps/site/tests/tier1-features.test.mjs b/apps/site/tests/tier1-features.test.mjs index 6229167c..84d56981 100644 --- a/apps/site/tests/tier1-features.test.mjs +++ b/apps/site/tests/tier1-features.test.mjs @@ -1243,5 +1243,141 @@ describe("Tier 1 - Feature Coverage", () => { const data = await res.json(); assert.ok(data.client_id, "Must return client_id via alias"); }); + + it("7.13 /oauth2/authorize completes automated Account Linking redirect with code and state", async () => { + const oauth2Module = loadModule("src/pages/oauth2/[...slug].ts"); + const registeredClient = { + clientId: "test_gemini_client_id", + redirectUris: JSON.stringify([ + "https://oauth-redirect.googleusercontent.com/r/gemini-app", + ]), + disabled: 0, + }; + const verifications = new Map(); + const mockDb = { + prepare(sql) { + return { + bind(...args) { + return { + async first() { + if (sql.includes("FROM oauthClient")) { + return registeredClient; + } + return null; + }, + async run() { + if (sql.includes("INSERT INTO verification")) { + verifications.set(args[1], JSON.parse(args[2])); + } + return { success: true }; + }, + }; + }, + }; + }, + }; + + const request = new Request( + "https://codingcat.dev/oauth2/authorize?client_id=test_gemini_client_id&redirect_uri=https://oauth-redirect.googleusercontent.com/r/gemini-app&response_type=code&state=gemini_state_123", + { method: "GET" }, + ); + + const testLocals = { ...locals, db: mockDb }; + const res = await oauth2Module.ALL({ request, locals: testLocals }); + assert.equal(res.status, 302, "Must return 302 redirect"); + const location = res.headers.get("Location"); + assert.ok(location, "Must have Location header"); + const locUrl = new URL(location); + assert.equal( + locUrl.origin, + "https://oauth-redirect.googleusercontent.com", + ); + assert.equal(locUrl.pathname, "/r/gemini-app"); + assert.equal(locUrl.searchParams.get("state"), "gemini_state_123"); + assert.ok( + locUrl.searchParams.get("code"), + "Must contain authorization code", + ); + assert.equal(locUrl.searchParams.get("iss"), "https://codingcat.dev"); + }); + + it("7.14 /oauth2/token appends refresh_token for persistent Gemini Account Linking", async () => { + const oauth2Module = loadModule("src/pages/oauth2/[...slug].ts"); + const regReq = new Request("https://codingcat.dev/oauth2/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Google Gemini Token Test", + redirect_uris: [ + "https://oauth-redirect.googleusercontent.com/r/test", + ], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }), + }); + const regRes = await oauth2Module.ALL({ request: regReq, locals }); + const regData = await regRes.json(); + + const { hashToken, getAuth } = loadModule("src/lib/auth.ts"); + const auth = getAuth("https://codingcat.dev"); + + const ctx = await auth.$context; + const code = `test_code_refresh_${Date.now()}`; + const identifier = await hashToken(code); + + const user = await ctx.internalAdapter.createUser({ + email: "test_mcp@codingcat.dev", + emailVerified: true, + name: "Test MCP User", + createdAt: new Date(), + updatedAt: new Date(), + }); + const session = await ctx.internalAdapter.createSession(user.id); + + await ctx.internalAdapter.createVerificationValue({ + identifier, + value: JSON.stringify({ + type: "authorization_code", + query: { + client_id: regData.client_id, + redirect_uri: "https://oauth-redirect.googleusercontent.com/r/test", + response_type: "code", + scope: "read:content search mcp:tools agent", + state: "st123", + }, + userId: user.id, + sessionId: session.id, + authTime: Date.now(), + resource: ["https://codingcat.dev/mcp"], + }), + expiresAt: new Date(Date.now() + 600000), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const tokenReq = new Request("https://codingcat.dev/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + client_id: regData.client_id, + client_secret: regData.client_secret, + redirect_uri: "https://oauth-redirect.googleusercontent.com/r/test", + }), + }); + + const tokenRes = await oauth2Module.ALL({ request: tokenReq, locals }); + assert.equal(tokenRes.status, 200); + + const tokenData = await tokenRes.json(); + assert.ok(tokenData.access_token, "Must return access_token"); + assert.ok( + tokenData.refresh_token, + "Must return refresh_token for Google Account Linking", + ); + assert.equal(tokenData.token_type, "Bearer"); + }); }); });