diff --git a/apps/site/astro.config.mjs b/apps/site/astro.config.mjs index a2cff779..c2c8320c 100644 --- a/apps/site/astro.config.mjs +++ b/apps/site/astro.config.mjs @@ -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({ diff --git a/apps/site/migrations/0003_fix_oauth_client_scopes.sql b/apps/site/migrations/0003_fix_oauth_client_scopes.sql new file mode 100644 index 00000000..a1eeee63 --- /dev/null +++ b/apps/site/migrations/0003_fix_oauth_client_scopes.sql @@ -0,0 +1,5 @@ +UPDATE "oauthClient" +SET + "scopes" = '["openid","profile","email","offline_access","read:content","search","mcp:tools","agent"]', + "requirePKCE" = 0, + "skipConsent" = 1; diff --git a/apps/site/src/lib/auth.ts b/apps/site/src/lib/auth.ts index 155f37f9..5189a27c 100644 --- a/apps/site/src/lib/auth.ts +++ b/apps/site/src/lib/auth.ts @@ -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", @@ -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], }), ], diff --git a/apps/site/src/pages/.well-known/oauth-authorization-server/[...slug].ts b/apps/site/src/pages/.well-known/oauth-authorization-server/[...slug].ts new file mode 100644 index 00000000..0dbd861a --- /dev/null +++ b/apps/site/src/pages/.well-known/oauth-authorization-server/[...slug].ts @@ -0,0 +1 @@ +export { GET, prerender } from "../oauth-authorization-server"; diff --git a/apps/site/src/pages/.well-known/oauth-protected-resource/[...slug].ts b/apps/site/src/pages/.well-known/oauth-protected-resource/[...slug].ts new file mode 100644 index 00000000..2f2235b9 --- /dev/null +++ b/apps/site/src/pages/.well-known/oauth-protected-resource/[...slug].ts @@ -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", + }, + }); +}; diff --git a/apps/site/src/pages/.well-known/openid-configuration/[...slug].ts b/apps/site/src/pages/.well-known/openid-configuration/[...slug].ts new file mode 100644 index 00000000..6a0182c3 --- /dev/null +++ b/apps/site/src/pages/.well-known/openid-configuration/[...slug].ts @@ -0,0 +1 @@ +export { GET, prerender } from "../openid-configuration"; diff --git a/apps/site/src/pages/oauth/[...slug].ts b/apps/site/src/pages/oauth/[...slug].ts index 027abf13..048769b6 100644 --- a/apps/site/src/pages/oauth/[...slug].ts +++ b/apps/site/src/pages/oauth/[...slug].ts @@ -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, + }); }; diff --git a/apps/site/src/pages/oauth2/[...slug].ts b/apps/site/src/pages/oauth2/[...slug].ts index 8a9aa75c..b79c7a3b 100644 --- a/apps/site/src/pages/oauth2/[...slug].ts +++ b/apps/site/src/pages/oauth2/[...slug].ts @@ -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); @@ -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"); @@ -39,6 +58,7 @@ export const ALL: APIRoute = async ({ request, locals }) => { .first<{ clientId: string; redirectUris: string; + scopes?: string; disabled?: number; }>(); @@ -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); @@ -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 @@ -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(); } diff --git a/apps/site/tests/tier1-features.test.mjs b/apps/site/tests/tier1-features.test.mjs index 84d56981..2172a3b5 100644 --- a/apps/site/tests/tier1-features.test.mjs +++ b/apps/site/tests/tier1-features.test.mjs @@ -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"); + }); }); });