Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
06dfdee
chore: sync dev with main
codercatdev Oct 13, 2026
a3e68b6
fix(site): disable checkOrigin CSRF block on OAuth token endpoint and…
codercatdev Oct 13, 2026
ec0324b
Merge pull request #730 from CodingCatDev/fix/mcp-oauth-csrf-and-scopes
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
3 changes: 3 additions & 0 deletions apps/site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ function rawFonts(extensions) {

export default defineConfig({
session: false,
security: {
checkOrigin: false,
},
site: process.env.SITE_URL ?? "https://codingcat.dev",
output: "server",
adapter: cloudflare({
Expand Down
5 changes: 5 additions & 0 deletions apps/site/migrations/0003_fix_oauth_client_scopes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
UPDATE "oauthClient"
SET
"scopes" = '["openid","profile","email","offline_access","read:content","search","mcp:tools","agent"]',
"requirePKCE" = 0,
"skipConsent" = 1;
6 changes: 6 additions & 0 deletions apps/site/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const BETTER_AUTH_SECRET =
"codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth";

export const MCP_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"read:content",
"search",
"mcp:tools",
Expand Down Expand Up @@ -92,6 +96,8 @@ export function getAuth(origin: string, d1?: D1Database) {
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientRegistrationRequirePKCE: false,
clientRegistrationDefaultScopes: [...MCP_DEFAULT_SCOPES],
enforcePerClientResources: false,
scopes: [...MCP_DEFAULT_SCOPES],
}),
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET, prerender } from "../oauth-authorization-server";
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { APIRoute } from "astro";

export const prerender = false;

/**
* OAuth 2.0 Protected Resource Metadata for path-scoped resources (RFC 9728 §3.1)
* e.g. /.well-known/oauth-protected-resource/mcp -> resource: https://codingcat.dev/mcp
*/
export const GET: APIRoute = async ({ params, locals }) => {
const origin = locals.siteUrl.origin;
const slug = params.slug ? `/${params.slug.replace(/^\/+/, "")}` : "";

const protectedResource = {
resource: `${origin}${slug}`,
authorization_servers: [origin],
scopes_supported: [
"openid",
"profile",
"email",
"offline_access",
"read:content",
"search",
"mcp:tools",
"agent",
],
bearer_methods_supported: ["header"],
resource_documentation: `${origin}/auth.md`,
};

return new Response(JSON.stringify(protectedResource, null, 2), {
headers: {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"cache-control": "public, max-age=3600, s-maxage=86400",
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GET, prerender } from "../openid-configuration";
17 changes: 9 additions & 8 deletions apps/site/src/pages/oauth/[...slug].ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { APIRoute } from "astro";
import { getAuth } from "@/lib/auth";
import { ALL as oauth2ALL } from "../oauth2/[...slug]";

export const prerender = false;

export const ALL: APIRoute = async ({ request, locals }) => {
const origin = locals.siteUrl.origin;
const url = new URL(request.url);
// Rewrite legacy /oauth/* path to /oauth2/* so Better Auth can process it
export const ALL: APIRoute = async (context) => {
const url = new URL(context.request.url);
// Rewrite legacy /oauth/* path to /oauth2/* so the /oauth2 handler processes it
url.pathname = url.pathname.replace(/^\/oauth(\/|$)/, "/oauth2$1");
const rewrittenRequest = new Request(url.toString(), request);
const auth = getAuth(origin);
return auth.handler(rewrittenRequest);
const rewrittenRequest = new Request(url.toString(), context.request);
return oauth2ALL({
...context,
request: rewrittenRequest,
});
};
42 changes: 38 additions & 4 deletions apps/site/src/pages/oauth2/[...slug].ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,25 @@ import {
ensureDefaultUserAndSession,
getAuth,
hashToken,
MCP_DEFAULT_SCOPES,
} from "@/lib/auth";

export const prerender = false;

const CORS_HEADERS = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "Content-Type, Authorization",
};

export const ALL: APIRoute = async ({ request, locals }) => {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}

const origin = locals.siteUrl.origin;
const auth = getAuth(origin);
const url = new URL(request.url);
Expand All @@ -25,8 +39,13 @@ export const ALL: APIRoute = async ({ request, locals }) => {
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 rawScope =
url.searchParams.get("scope") || MCP_DEFAULT_SCOPES.join(" ");
// Ensure offline_access is always included so Better Auth mints a refresh_token
const scopeTokens = Array.from(
new Set([...rawScope.split(/\s+/).filter(Boolean), "offline_access"]),
);
const scope = scopeTokens.join(" ");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method");

Expand All @@ -39,6 +58,7 @@ export const ALL: APIRoute = async ({ request, locals }) => {
.first<{
clientId: string;
redirectUris: string;
scopes?: string;
disabled?: number;
}>();

Expand All @@ -58,6 +78,15 @@ export const ALL: APIRoute = async ({ request, locals }) => {
);

if (isUriAllowed) {
// Ensure client's allowed scopes in D1 include all default + requested scopes
const allowedScopes = Array.from(
new Set([...MCP_DEFAULT_SCOPES, ...scopeTokens]),
);
await db
.prepare("UPDATE oauthClient SET scopes = ? WHERE clientId = ?")
.bind(JSON.stringify(allowedScopes), clientId)
.run();

// Ensure default user and active session exist
await ensureDefaultUserAndSession(db);

Expand Down Expand Up @@ -162,8 +191,13 @@ export const ALL: APIRoute = async ({ request, locals }) => {
const refreshToken =
crypto.randomUUID().replace(/-/g, "") +
crypto.randomUUID().replace(/-/g, "");
const hashedRefreshToken = await hashToken(refreshToken);
const now = Date.now();
const refreshId = `rt_${now}_${Math.random().toString(36).slice(2, 8)}`;
const scopesList =
typeof data.scope === "string"
? data.scope.split(/\s+/).filter(Boolean)
: [...MCP_DEFAULT_SCOPES];

if (db) {
await db
Expand All @@ -173,13 +207,13 @@ export const ALL: APIRoute = async ({ request, locals }) => {
)
.bind(
refreshId,
refreshToken,
hashedRefreshToken,
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",
JSON.stringify(scopesList),
)
.run();
}
Expand Down
114 changes: 114 additions & 0 deletions apps/site/tests/tier1-features.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1379,5 +1379,119 @@ describe("Tier 1 - Feature Coverage", () => {
);
assert.equal(tokenData.token_type, "Bearer");
});

it("7.15 astro.config.mjs disables checkOrigin CSRF blocking and subpath /.well-known/oauth-protected-resource/mcp resolves", async () => {
const astroConfig = fs.readFileSync(
path.resolve(SRC_DIR, "../astro.config.mjs"),
"utf-8",
);
assert.ok(
astroConfig.includes("checkOrigin: false"),
"astro.config.mjs must disable checkOrigin so back-channel POST /oauth2/token requests without Origin header are not blocked with 403",
);

const subpathPrmModule = loadModule(
"src/pages/.well-known/oauth-protected-resource/[...slug].ts",
);
const res = await subpathPrmModule.GET({
params: { slug: "mcp" },
locals,
});
assert.equal(res.status, 200);
const data = await res.json();
assert.equal(data.resource, "https://codingcat.dev/mcp");
assert.deepEqual(data.authorization_servers, ["https://codingcat.dev"]);
});

it("7.16 /oauth2/token supports full OIDC + PKCE S256 scope exchange (openid profile email offline_access) for Google Gemini", 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 OIDC PKCE Test",
redirect_uris: [
"https://vertexaisearch.cloud.google.com/oauth-redirect",
],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_basic",
}),
});
const regRes = await oauth2Module.ALL({ request: regReq, locals });
const regData = await regRes.json();
assert.ok(
regData.scope.includes("openid") &&
regData.scope.includes("offline_access"),
"Registered client must default to allowing openid and offline_access scopes",
);

const { hashToken, getAuth } = loadModule("src/lib/auth.ts");
const auth = getAuth("https://codingcat.dev");
const ctx = await auth.$context;

const codeVerifier =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk_gemini_pkce_verifier";
const codeChallenge = await hashToken(codeVerifier);
const code = `test_code_oidc_${Date.now()}`;
const identifier = await hashToken(code);

const user = await ctx.internalAdapter.createUser({
email: "gemini_oidc@codingcat.dev",
emailVerified: true,
name: "Gemini OIDC 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://vertexaisearch.cloud.google.com/oauth-redirect",
response_type: "code",
scope:
"openid profile email offline_access read:content search mcp:tools agent",
state: "state_gemini_pkce",
code_challenge: codeChallenge,
code_challenge_method: "S256",
},
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 basicAuth = btoa(`${regData.client_id}:${regData.client_secret}`);
const tokenReq = new Request("https://codingcat.dev/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: codeVerifier,
redirect_uri:
"https://vertexaisearch.cloud.google.com/oauth-redirect",
}),
});

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");
assert.ok(tokenData.id_token, "Must return id_token for openid scope");
});
});
});
Loading