Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fc2c85b
feat(syndication): add syndicate-post workflow and dispatch runner (#…
codercatdev Oct 10, 2026
6b80878
fix(syndication): update Hashnode tag shape to PublishPostTagInput (#…
codercatdev Oct 10, 2026
f01fad6
chore: merge origin/main into dev
codercatdev Oct 10, 2026
9f3e1d9
fix(syndication): update Hashnode coverImage input to string URL (#713)
codercatdev Oct 10, 2026
3c95e63
feat(seo): add VideoObject structured data and Gemini video storyboar…
codercatdev Oct 10, 2026
bf33f14
fix(video): upgrade to Gemini 2.5 Pro with native Google AI and OpenR…
codercatdev Oct 10, 2026
ab7c63e
chore: sync dev with main
codercatdev Oct 10, 2026
2b95c86
feat: complete Shorts story (/shorts shelf + kinetic captions + sync)…
codercatdev Oct 10, 2026
08a9794
chore: sync dev with main
codercatdev Oct 10, 2026
bc06604
Merge remote-tracking branch 'origin/main' into dev
codercatdev Oct 10, 2026
a145450
chore: remove legacy apps/web Next.js directory and revert Gemini/Sho…
codercatdev Oct 10, 2026
a24576d
Merge remote-tracking branch 'origin/main' into dev
codercatdev Oct 10, 2026
41938e2
feat(analytics): add Cloudflare Workers Analytics Engine edge telemet…
codercatdev Oct 10, 2026
a472065
Merge remote-tracking branch 'origin/main' into dev
codercatdev Oct 10, 2026
908eca9
feat(site): wire sponsorships and guest booking to FlipFeeds Creator …
codercatdev Oct 12, 2026
91c3aa0
fix(ci): deploy latest Worker version before updating secrets in depl…
codercatdev Oct 12, 2026
514245d
fix(site): defer analytics_engine_datasets binding until Analytics En…
codercatdev Oct 12, 2026
862120f
feat(auth): integrate Better Auth with Cloudflare D1 for MCP and OAut…
codercatdev Oct 13, 2026
3b7bebe
chore: sync dev with main
codercatdev Oct 13, 2026
609c2f8
fix(auth): handle automated MCP account linking and refresh token iss…
codercatdev Oct 13, 2026
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