Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/site/migrations/0002_default_mcp_account.sql
Original file line number Diff line number Diff line change
@@ -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';
89 changes: 78 additions & 11 deletions apps/site/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
Expand All @@ -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(),
Expand All @@ -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;
}
204 changes: 203 additions & 1 deletion apps/site/src/pages/oauth2/[...slug].ts
Original file line number Diff line number Diff line change
@@ -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<string, any>;
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);
};
47 changes: 47 additions & 0 deletions apps/site/src/pages/oauth2/consent.astro
Original file line number Diff line number Diff line change
@@ -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()}`;
---

<BaseLayout
title="Authorize Access — CodingCat.dev"
description="Authorize access to CodingCat.dev tools and content"
>
<div class="min-h-[70vh] flex items-center justify-center px-4 py-12">
<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">
<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">
🐱
</div>

<h1 class="text-2xl font-bold text-white mb-2">Consent &amp; Authorization</h1>
<p class="text-slate-400 text-sm mb-6 leading-relaxed">
Allow this application to interact with CodingCat.dev on your behalf.
</p>

<div class="space-y-4">
<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">
<div class="flex justify-between">
<span class="text-slate-400 font-medium">Scopes Requested</span>
<span class="text-purple-300">read:content, search, mcp:tools, agent</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400 font-medium">Resource</span>
<span>CodingCat.dev MCP Server</span>
</div>
</div>

<a
href={authorizeTarget}
id="consent-button"
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]"
>
Grant Consent &amp; Continue
</a>
</div>
</div>
</div>
</BaseLayout>
Loading
Loading