Skip to content

Commit a3e68b6

Browse files
committed
fix(site): disable checkOrigin CSRF block on OAuth token endpoint and allow OIDC scopes
1 parent 06dfdee commit a3e68b6

9 files changed

Lines changed: 214 additions & 12 deletions

File tree

‎apps/site/astro.config.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ function rawFonts(extensions) {
3535

3636
export default defineConfig({
3737
session: false,
38+
security: {
39+
checkOrigin: false,
40+
},
3841
site: process.env.SITE_URL ?? "https://codingcat.dev",
3942
output: "server",
4043
adapter: cloudflare({
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
UPDATE "oauthClient"
2+
SET
3+
"scopes" = '["openid","profile","email","offline_access","read:content","search","mcp:tools","agent"]',
4+
"requirePKCE" = 0,
5+
"skipConsent" = 1;

‎apps/site/src/lib/auth.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export const BETTER_AUTH_SECRET =
88
"codingcat-mcp-oauth2-secret-secure-random-2026-key-better-auth";
99

1010
export const MCP_DEFAULT_SCOPES = [
11+
"openid",
12+
"profile",
13+
"email",
14+
"offline_access",
1115
"read:content",
1216
"search",
1317
"mcp:tools",
@@ -92,6 +96,8 @@ export function getAuth(origin: string, d1?: D1Database) {
9296
allowDynamicClientRegistration: true,
9397
allowUnauthenticatedClientRegistration: true,
9498
clientRegistrationRequirePKCE: false,
99+
clientRegistrationDefaultScopes: [...MCP_DEFAULT_SCOPES],
100+
enforcePerClientResources: false,
95101
scopes: [...MCP_DEFAULT_SCOPES],
96102
}),
97103
],
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { GET, prerender } from "../oauth-authorization-server";
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { APIRoute } from "astro";
2+
3+
export const prerender = false;
4+
5+
/**
6+
* OAuth 2.0 Protected Resource Metadata for path-scoped resources (RFC 9728 §3.1)
7+
* e.g. /.well-known/oauth-protected-resource/mcp -> resource: https://codingcat.dev/mcp
8+
*/
9+
export const GET: APIRoute = async ({ params, locals }) => {
10+
const origin = locals.siteUrl.origin;
11+
const slug = params.slug ? `/${params.slug.replace(/^\/+/, "")}` : "";
12+
13+
const protectedResource = {
14+
resource: `${origin}${slug}`,
15+
authorization_servers: [origin],
16+
scopes_supported: [
17+
"openid",
18+
"profile",
19+
"email",
20+
"offline_access",
21+
"read:content",
22+
"search",
23+
"mcp:tools",
24+
"agent",
25+
],
26+
bearer_methods_supported: ["header"],
27+
resource_documentation: `${origin}/auth.md`,
28+
};
29+
30+
return new Response(JSON.stringify(protectedResource, null, 2), {
31+
headers: {
32+
"content-type": "application/json; charset=utf-8",
33+
"access-control-allow-origin": "*",
34+
"cache-control": "public, max-age=3600, s-maxage=86400",
35+
},
36+
});
37+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { GET, prerender } from "../openid-configuration";
Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { APIRoute } from "astro";
2-
import { getAuth } from "@/lib/auth";
2+
import { ALL as oauth2ALL } from "../oauth2/[...slug]";
33

44
export const prerender = false;
55

6-
export const ALL: APIRoute = async ({ request, locals }) => {
7-
const origin = locals.siteUrl.origin;
8-
const url = new URL(request.url);
9-
// Rewrite legacy /oauth/* path to /oauth2/* so Better Auth can process it
6+
export const ALL: APIRoute = async (context) => {
7+
const url = new URL(context.request.url);
8+
// Rewrite legacy /oauth/* path to /oauth2/* so the /oauth2 handler processes it
109
url.pathname = url.pathname.replace(/^\/oauth(\/|$)/, "/oauth2$1");
11-
const rewrittenRequest = new Request(url.toString(), request);
12-
const auth = getAuth(origin);
13-
return auth.handler(rewrittenRequest);
10+
const rewrittenRequest = new Request(url.toString(), context.request);
11+
return oauth2ALL({
12+
...context,
13+
request: rewrittenRequest,
14+
});
1415
};

‎apps/site/src/pages/oauth2/[...slug].ts‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,25 @@ import {
66
ensureDefaultUserAndSession,
77
getAuth,
88
hashToken,
9+
MCP_DEFAULT_SCOPES,
910
} from "@/lib/auth";
1011

1112
export const prerender = false;
1213

14+
const CORS_HEADERS = {
15+
"access-control-allow-origin": "*",
16+
"access-control-allow-methods": "GET, POST, OPTIONS",
17+
"access-control-allow-headers": "Content-Type, Authorization",
18+
};
19+
1320
export const ALL: APIRoute = async ({ request, locals }) => {
21+
if (request.method === "OPTIONS") {
22+
return new Response(null, {
23+
status: 204,
24+
headers: CORS_HEADERS,
25+
});
26+
}
27+
1428
const origin = locals.siteUrl.origin;
1529
const auth = getAuth(origin);
1630
const url = new URL(request.url);
@@ -25,8 +39,13 @@ export const ALL: APIRoute = async ({ request, locals }) => {
2539
const redirectUri = url.searchParams.get("redirect_uri");
2640
const responseType = url.searchParams.get("response_type");
2741
const state = url.searchParams.get("state");
28-
const scope =
29-
url.searchParams.get("scope") || "read:content search mcp:tools agent";
42+
const rawScope =
43+
url.searchParams.get("scope") || MCP_DEFAULT_SCOPES.join(" ");
44+
// Ensure offline_access is always included so Better Auth mints a refresh_token
45+
const scopeTokens = Array.from(
46+
new Set([...rawScope.split(/\s+/).filter(Boolean), "offline_access"]),
47+
);
48+
const scope = scopeTokens.join(" ");
3049
const codeChallenge = url.searchParams.get("code_challenge");
3150
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
3251

@@ -39,6 +58,7 @@ export const ALL: APIRoute = async ({ request, locals }) => {
3958
.first<{
4059
clientId: string;
4160
redirectUris: string;
61+
scopes?: string;
4262
disabled?: number;
4363
}>();
4464

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

6080
if (isUriAllowed) {
81+
// Ensure client's allowed scopes in D1 include all default + requested scopes
82+
const allowedScopes = Array.from(
83+
new Set([...MCP_DEFAULT_SCOPES, ...scopeTokens]),
84+
);
85+
await db
86+
.prepare("UPDATE oauthClient SET scopes = ? WHERE clientId = ?")
87+
.bind(JSON.stringify(allowedScopes), clientId)
88+
.run();
89+
6190
// Ensure default user and active session exist
6291
await ensureDefaultUserAndSession(db);
6392

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

168202
if (db) {
169203
await db
@@ -173,13 +207,13 @@ export const ALL: APIRoute = async ({ request, locals }) => {
173207
)
174208
.bind(
175209
refreshId,
176-
refreshToken,
210+
hashedRefreshToken,
177211
clientIdFromBody,
178212
DEFAULT_MCP_SESSION.id,
179213
DEFAULT_MCP_USER.id,
180214
now,
181215
now + 30 * 24 * 3600 * 1000, // 30 days
182-
data.scope || "read:content search mcp:tools agent",
216+
JSON.stringify(scopesList),
183217
)
184218
.run();
185219
}

‎apps/site/tests/tier1-features.test.mjs‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,5 +1379,119 @@ describe("Tier 1 - Feature Coverage", () => {
13791379
);
13801380
assert.equal(tokenData.token_type, "Bearer");
13811381
});
1382+
1383+
it("7.15 astro.config.mjs disables checkOrigin CSRF blocking and subpath /.well-known/oauth-protected-resource/mcp resolves", async () => {
1384+
const astroConfig = fs.readFileSync(
1385+
path.resolve(SRC_DIR, "../astro.config.mjs"),
1386+
"utf-8",
1387+
);
1388+
assert.ok(
1389+
astroConfig.includes("checkOrigin: false"),
1390+
"astro.config.mjs must disable checkOrigin so back-channel POST /oauth2/token requests without Origin header are not blocked with 403",
1391+
);
1392+
1393+
const subpathPrmModule = loadModule(
1394+
"src/pages/.well-known/oauth-protected-resource/[...slug].ts",
1395+
);
1396+
const res = await subpathPrmModule.GET({
1397+
params: { slug: "mcp" },
1398+
locals,
1399+
});
1400+
assert.equal(res.status, 200);
1401+
const data = await res.json();
1402+
assert.equal(data.resource, "https://codingcat.dev/mcp");
1403+
assert.deepEqual(data.authorization_servers, ["https://codingcat.dev"]);
1404+
});
1405+
1406+
it("7.16 /oauth2/token supports full OIDC + PKCE S256 scope exchange (openid profile email offline_access) for Google Gemini", async () => {
1407+
const oauth2Module = loadModule("src/pages/oauth2/[...slug].ts");
1408+
const regReq = new Request("https://codingcat.dev/oauth2/register", {
1409+
method: "POST",
1410+
headers: { "Content-Type": "application/json" },
1411+
body: JSON.stringify({
1412+
client_name: "Google Gemini OIDC PKCE Test",
1413+
redirect_uris: [
1414+
"https://vertexaisearch.cloud.google.com/oauth-redirect",
1415+
],
1416+
grant_types: ["authorization_code", "refresh_token"],
1417+
response_types: ["code"],
1418+
token_endpoint_auth_method: "client_secret_basic",
1419+
}),
1420+
});
1421+
const regRes = await oauth2Module.ALL({ request: regReq, locals });
1422+
const regData = await regRes.json();
1423+
assert.ok(
1424+
regData.scope.includes("openid") &&
1425+
regData.scope.includes("offline_access"),
1426+
"Registered client must default to allowing openid and offline_access scopes",
1427+
);
1428+
1429+
const { hashToken, getAuth } = loadModule("src/lib/auth.ts");
1430+
const auth = getAuth("https://codingcat.dev");
1431+
const ctx = await auth.$context;
1432+
1433+
const codeVerifier =
1434+
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk_gemini_pkce_verifier";
1435+
const codeChallenge = await hashToken(codeVerifier);
1436+
const code = `test_code_oidc_${Date.now()}`;
1437+
const identifier = await hashToken(code);
1438+
1439+
const user = await ctx.internalAdapter.createUser({
1440+
email: "gemini_oidc@codingcat.dev",
1441+
emailVerified: true,
1442+
name: "Gemini OIDC User",
1443+
createdAt: new Date(),
1444+
updatedAt: new Date(),
1445+
});
1446+
const session = await ctx.internalAdapter.createSession(user.id);
1447+
1448+
await ctx.internalAdapter.createVerificationValue({
1449+
identifier,
1450+
value: JSON.stringify({
1451+
type: "authorization_code",
1452+
query: {
1453+
client_id: regData.client_id,
1454+
redirect_uri:
1455+
"https://vertexaisearch.cloud.google.com/oauth-redirect",
1456+
response_type: "code",
1457+
scope:
1458+
"openid profile email offline_access read:content search mcp:tools agent",
1459+
state: "state_gemini_pkce",
1460+
code_challenge: codeChallenge,
1461+
code_challenge_method: "S256",
1462+
},
1463+
userId: user.id,
1464+
sessionId: session.id,
1465+
authTime: Date.now(),
1466+
resource: ["https://codingcat.dev/mcp"],
1467+
}),
1468+
expiresAt: new Date(Date.now() + 600000),
1469+
createdAt: new Date(),
1470+
updatedAt: new Date(),
1471+
});
1472+
1473+
const basicAuth = btoa(`${regData.client_id}:${regData.client_secret}`);
1474+
const tokenReq = new Request("https://codingcat.dev/oauth2/token", {
1475+
method: "POST",
1476+
headers: {
1477+
"Content-Type": "application/x-www-form-urlencoded",
1478+
Authorization: `Basic ${basicAuth}`,
1479+
},
1480+
body: new URLSearchParams({
1481+
grant_type: "authorization_code",
1482+
code,
1483+
code_verifier: codeVerifier,
1484+
redirect_uri:
1485+
"https://vertexaisearch.cloud.google.com/oauth-redirect",
1486+
}),
1487+
});
1488+
1489+
const tokenRes = await oauth2Module.ALL({ request: tokenReq, locals });
1490+
assert.equal(tokenRes.status, 200);
1491+
const tokenData = await tokenRes.json();
1492+
assert.ok(tokenData.access_token, "Must return access_token");
1493+
assert.ok(tokenData.refresh_token, "Must return refresh_token");
1494+
assert.ok(tokenData.id_token, "Must return id_token for openid scope");
1495+
});
13821496
});
13831497
});

0 commit comments

Comments
 (0)