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
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