Skip to content

Commit 862120f

Browse files
authored
feat(auth): integrate Better Auth with Cloudflare D1 for MCP and OAuth 2.1 Dynamic Client Registration (#726)
1 parent 514245d commit 862120f

15 files changed

Lines changed: 856 additions & 114 deletions
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
-- Migration: 0001_better_auth.sql
2+
-- Better Auth core + OAuth 2.1 Provider + MCP schema for Cloudflare D1
3+
4+
CREATE TABLE IF NOT EXISTS "user" (
5+
"id" text PRIMARY KEY NOT NULL,
6+
"name" text NOT NULL,
7+
"email" text NOT NULL UNIQUE,
8+
"emailVerified" integer NOT NULL DEFAULT 0,
9+
"image" text,
10+
"createdAt" integer NOT NULL,
11+
"updatedAt" integer NOT NULL
12+
);
13+
14+
CREATE TABLE IF NOT EXISTS "session" (
15+
"id" text PRIMARY KEY NOT NULL,
16+
"expiresAt" integer NOT NULL,
17+
"token" text NOT NULL UNIQUE,
18+
"createdAt" integer NOT NULL,
19+
"updatedAt" integer NOT NULL,
20+
"ipAddress" text,
21+
"userAgent" text,
22+
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
23+
);
24+
25+
CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");
26+
27+
CREATE TABLE IF NOT EXISTS "account" (
28+
"id" text PRIMARY KEY NOT NULL,
29+
"accountId" text NOT NULL,
30+
"providerId" text NOT NULL,
31+
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
32+
"accessToken" text,
33+
"refreshToken" text,
34+
"idToken" text,
35+
"accessTokenExpiresAt" integer,
36+
"refreshTokenExpiresAt" integer,
37+
"scope" text,
38+
"password" text,
39+
"createdAt" integer NOT NULL,
40+
"updatedAt" integer NOT NULL
41+
);
42+
43+
CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");
44+
45+
CREATE TABLE IF NOT EXISTS "verification" (
46+
"id" text PRIMARY KEY NOT NULL,
47+
"identifier" text NOT NULL,
48+
"value" text NOT NULL,
49+
"expiresAt" integer NOT NULL,
50+
"createdAt" integer NOT NULL,
51+
"updatedAt" integer NOT NULL
52+
);
53+
54+
CREATE TABLE IF NOT EXISTS "jwks" (
55+
"id" text PRIMARY KEY NOT NULL,
56+
"publicKey" text NOT NULL,
57+
"privateKey" text NOT NULL,
58+
"createdAt" integer NOT NULL,
59+
"expiresAt" integer,
60+
"alg" text,
61+
"crv" text
62+
);
63+
64+
CREATE TABLE IF NOT EXISTS "oauthClient" (
65+
"id" text PRIMARY KEY NOT NULL,
66+
"clientId" text NOT NULL UNIQUE,
67+
"clientSecret" text,
68+
"clientDiscoveryId" text,
69+
"disabled" integer DEFAULT 0,
70+
"skipConsent" integer DEFAULT 0,
71+
"enableEndSession" integer DEFAULT 0,
72+
"subjectType" text,
73+
"scopes" text,
74+
"clientCredentialsScopes" text,
75+
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
76+
"createdAt" integer,
77+
"updatedAt" integer,
78+
"name" text,
79+
"uri" text,
80+
"icon" text,
81+
"contacts" text,
82+
"tos" text,
83+
"policy" text,
84+
"softwareId" text,
85+
"softwareVersion" text,
86+
"softwareStatement" text,
87+
"redirectUris" text NOT NULL,
88+
"postLogoutRedirectUris" text,
89+
"backchannelLogoutUri" text,
90+
"backchannelLogoutSessionRequired" integer,
91+
"tokenEndpointAuthMethod" text,
92+
"applicationType" text,
93+
"jwks" text,
94+
"jwksUri" text,
95+
"grantTypes" text,
96+
"responseTypes" text,
97+
"requirePKCE" integer DEFAULT 1,
98+
"dpopBoundAccessTokens" integer DEFAULT 0,
99+
"referenceId" text,
100+
"metadata" text
101+
);
102+
103+
CREATE INDEX IF NOT EXISTS "oauthClient_userId_idx" ON "oauthClient" ("userId");
104+
105+
CREATE TABLE IF NOT EXISTS "oauthResource" (
106+
"id" text PRIMARY KEY NOT NULL,
107+
"identifier" text NOT NULL UNIQUE,
108+
"name" text NOT NULL,
109+
"accessTokenTtl" integer,
110+
"refreshTokenTtl" integer,
111+
"signingAlgorithm" text,
112+
"signingKeyId" text,
113+
"allowedScopes" text,
114+
"customClaims" text,
115+
"dpopBoundAccessTokensRequired" integer DEFAULT 0,
116+
"disabled" integer DEFAULT 0,
117+
"createdAt" integer,
118+
"updatedAt" integer,
119+
"policyVersion" integer,
120+
"metadata" text
121+
);
122+
123+
CREATE TABLE IF NOT EXISTS "oauthClientResource" (
124+
"id" text PRIMARY KEY NOT NULL,
125+
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
126+
"resourceId" text NOT NULL REFERENCES "oauthResource"("identifier") ON DELETE CASCADE,
127+
"metadata" text,
128+
"createdAt" integer
129+
);
130+
131+
CREATE UNIQUE INDEX IF NOT EXISTS "oauthClientResource_clientId_resourceId_uidx" ON "oauthClientResource" ("clientId", "resourceId");
132+
133+
CREATE TABLE IF NOT EXISTS "oauthRefreshToken" (
134+
"id" text PRIMARY KEY NOT NULL,
135+
"token" text NOT NULL UNIQUE,
136+
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
137+
"sessionId" text REFERENCES "session"("id") ON DELETE SET NULL,
138+
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
139+
"referenceId" text,
140+
"authorizationCodeId" text,
141+
"resources" text,
142+
"requestedUserInfoClaims" text,
143+
"expiresAt" integer NOT NULL,
144+
"createdAt" integer NOT NULL,
145+
"revoked" integer,
146+
"rotatedAt" integer,
147+
"rotationReplayResponse" text,
148+
"rotationReplayExpiresAt" integer,
149+
"authTime" integer,
150+
"confirmation" text,
151+
"scopes" text NOT NULL
152+
);
153+
154+
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_clientId_idx" ON "oauthRefreshToken" ("clientId");
155+
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_userId_idx" ON "oauthRefreshToken" ("userId");
156+
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_sessionId_idx" ON "oauthRefreshToken" ("sessionId");
157+
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_authorizationCodeId_idx" ON "oauthRefreshToken" ("authorizationCodeId");
158+
159+
CREATE TABLE IF NOT EXISTS "oauthAccessToken" (
160+
"id" text PRIMARY KEY NOT NULL,
161+
"token" text NOT NULL UNIQUE,
162+
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
163+
"sessionId" text REFERENCES "session"("id") ON DELETE SET NULL,
164+
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
165+
"referenceId" text,
166+
"authorizationCodeId" text,
167+
"resources" text,
168+
"requestedUserInfoClaims" text,
169+
"refreshId" text REFERENCES "oauthRefreshToken"("id") ON DELETE CASCADE,
170+
"expiresAt" integer NOT NULL,
171+
"createdAt" integer NOT NULL,
172+
"revoked" integer,
173+
"confirmation" text,
174+
"scopes" text NOT NULL
175+
);
176+
177+
CREATE INDEX IF NOT EXISTS "oauthAccessToken_clientId_idx" ON "oauthAccessToken" ("clientId");
178+
CREATE INDEX IF NOT EXISTS "oauthAccessToken_userId_idx" ON "oauthAccessToken" ("userId");
179+
CREATE INDEX IF NOT EXISTS "oauthAccessToken_sessionId_idx" ON "oauthAccessToken" ("sessionId");
180+
CREATE INDEX IF NOT EXISTS "oauthAccessToken_authorizationCodeId_idx" ON "oauthAccessToken" ("authorizationCodeId");
181+
CREATE INDEX IF NOT EXISTS "oauthAccessToken_refreshId_idx" ON "oauthAccessToken" ("refreshId");
182+
183+
CREATE TABLE IF NOT EXISTS "oauthConsent" (
184+
"id" text PRIMARY KEY NOT NULL,
185+
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
186+
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
187+
"referenceId" text,
188+
"resources" text,
189+
"requestedUserInfoClaims" text,
190+
"scopes" text NOT NULL,
191+
"createdAt" integer NOT NULL,
192+
"updatedAt" integer NOT NULL
193+
);
194+
195+
CREATE INDEX IF NOT EXISTS "oauthConsent_clientId_idx" ON "oauthConsent" ("clientId");
196+
CREATE INDEX IF NOT EXISTS "oauthConsent_userId_idx" ON "oauthConsent" ("userId");
197+
198+
CREATE TABLE IF NOT EXISTS "oauthClientAssertion" (
199+
"id" text PRIMARY KEY NOT NULL,
200+
"expiresAt" integer NOT NULL
201+
);

‎apps/site/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"@ai-sdk/mcp": "^2.0.72",
2222
"@astrojs/cloudflare": "^14.2.1",
2323
"@astrojs/react": "^6.0.2",
24+
"@better-auth/mcp": "^1.7.7",
25+
"@better-auth/oauth-provider": "^1.7.7",
2426
"@openrouter/ai-sdk-provider": "^3.1.0",
2527
"@portabletext/to-html": "^6.0.0",
2628
"@portabletext/types": "^4.0.2",
@@ -31,6 +33,7 @@
3133
"ai": "^7.0.135",
3234
"astro": "^7.2.2",
3335
"astro-portabletext": "^0.13.0",
36+
"better-auth": "^1.7.7",
3437
"groq": "^6.9.2",
3538
"react": "^19.2.6",
3639
"react-dom": "^19.2.6",

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { env } from "cloudflare:workers";
2+
import { mcp } from "@better-auth/mcp";
3+
import { betterAuth } from "better-auth";
4+
import { jwt } from "better-auth/plugins";
5+
6+
/**
7+
* Creates or retrieves the Better Auth instance configured for
8+
* Model Context Protocol (MCP) and OAuth 2.1 authorization server
9+
* backed by Cloudflare D1.
10+
*/
11+
export function getAuth(origin: string, d1?: D1Database) {
12+
const db = d1 ?? (env as unknown as { DB?: D1Database })?.DB;
13+
14+
return betterAuth({
15+
baseURL: origin,
16+
basePath: "",
17+
...(db ? { database: db } : {}),
18+
plugins: [
19+
jwt(),
20+
mcp({
21+
resource: `${origin}/mcp`,
22+
loginPage: "/sign-in",
23+
consentPage: "/oauth2/consent",
24+
allowDynamicClientRegistration: true,
25+
allowUnauthenticatedClientRegistration: true,
26+
scopes: [
27+
"openid",
28+
"profile",
29+
"email",
30+
"offline_access",
31+
"read:content",
32+
"search",
33+
"mcp:tools",
34+
"agent",
35+
],
36+
}),
37+
],
38+
});
39+
}

‎apps/site/src/middleware.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
100100
// Always emit Link headers (RFC 8288) for AI agent discoverability
101101
response.headers.set(
102102
"Link",
103-
`<${origin}/.well-known/api-catalog>; rel="api-catalog", <${origin}/.well-known/mcp/server-card.json>; rel="mcp-server-card", <${origin}/.well-known/agent-skills/index.json>; rel="agent-skills", <${origin}/.well-known/ai-catalog.json>; rel="ai-catalog", <${origin}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource", <${origin}/llms.txt>; rel="llms-txt", <${origin}/auth.md>; rel="auth-md", <${origin}/AGENTS.md>; rel="agent-guide", <${origin}/pricing.md>; rel="pricing"`,
103+
`<${origin}/.well-known/api-catalog>; rel="api-catalog", <${origin}/.well-known/mcp/server-card.json>; rel="mcp-server-card", <${origin}/.well-known/agent-skills/index.json>; rel="agent-skills", <${origin}/.well-known/ai-catalog.json>; rel="ai-catalog", <${origin}/.well-known/oauth-protected-resource>; rel="oauth-protected-resource", <${origin}/.well-known/oauth-authorization-server>; rel="oauth-authorization-server", <${origin}/llms.txt>; rel="llms-txt", <${origin}/auth.md>; rel="auth-md", <${origin}/AGENTS.md>; rel="agent-guide", <${origin}/pricing.md>; rel="pricing"`,
104104
);
105105
response.headers.append("Vary", "Accept");
106106

‎apps/site/src/pages/.well-known/jwks.json.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,32 @@
11
import type { APIRoute } from "astro";
2+
import { getAuth } from "@/lib/auth";
23

34
export const prerender = false;
45

56
/**
67
* JWKS (JSON Web Key Set) public keys endpoint
78
*/
8-
export const GET: APIRoute = async () => {
9+
export const GET: APIRoute = async ({ request, locals }) => {
10+
const origin = locals.siteUrl.origin;
11+
const auth = getAuth(origin);
12+
13+
try {
14+
const req = new Request(`${origin}/jwks`, request);
15+
const res = await auth.handler(req);
16+
if (res.ok) {
17+
const data = await res.json();
18+
return new Response(JSON.stringify(data, null, 2), {
19+
headers: {
20+
"content-type": "application/json; charset=utf-8",
21+
"access-control-allow-origin": "*",
22+
"cache-control": "public, max-age=3600, s-maxage=86400",
23+
},
24+
});
25+
}
26+
} catch {
27+
// Fallback
28+
}
29+
930
return new Response(JSON.stringify({ keys: [] }, null, 2), {
1031
headers: {
1132
"content-type": "application/json; charset=utf-8",

‎apps/site/src/pages/.well-known/oauth-authorization-server.ts‎

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,47 @@
11
import type { APIRoute } from "astro";
2+
import { getAuth } from "@/lib/auth";
23

34
export const prerender = false;
45

56
/**
67
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
7-
* with Auth.md agent_auth extension for autonomous AI agent discovery
8+
* powered by Better Auth with Auth.md agent_auth extension for autonomous AI agent discovery
89
*/
9-
export const GET: APIRoute = async ({ locals }) => {
10+
export const GET: APIRoute = async ({ request, locals }) => {
1011
const origin = locals.siteUrl.origin;
12+
const auth = getAuth(origin);
13+
14+
let baseMeta: Record<string, any> = {};
15+
try {
16+
const req = new Request(
17+
`${origin}/.well-known/oauth-authorization-server`,
18+
request,
19+
);
20+
const baseRes = await auth.handler(req);
21+
if (baseRes.ok) {
22+
baseMeta = await baseRes.json();
23+
}
24+
} catch {
25+
// Fallback if request is invoked offline/in tests
26+
}
1127

1228
const metadata = {
29+
...baseMeta,
1330
issuer: origin,
14-
authorization_endpoint: `${origin}/oauth/authorize`,
15-
token_endpoint: `${origin}/oauth/token`,
16-
registration_endpoint: `${origin}/oauth/register`,
17-
jwks_uri: `${origin}/.well-known/jwks.json`,
18-
response_types_supported: ["code", "token"],
19-
grant_types_supported: [
20-
"authorization_code",
21-
"client_credentials",
22-
"urn:ietf:params:oauth:grant-type:token-exchange",
23-
],
24-
token_endpoint_auth_methods_supported: [
25-
"client_secret_basic",
26-
"client_secret_post",
27-
"none",
31+
authorization_endpoint: `${origin}/oauth2/authorize`,
32+
token_endpoint: `${origin}/oauth2/token`,
33+
registration_endpoint: `${origin}/oauth2/register`,
34+
jwks_uri: `${origin}/jwks`,
35+
scopes_supported: [
36+
"openid",
37+
"profile",
38+
"email",
39+
"offline_access",
40+
"read:content",
41+
"search",
42+
"mcp:tools",
43+
"agent",
2844
],
29-
scopes_supported: ["read:content", "search", "mcp:tools", "agent"],
30-
code_challenge_methods_supported: ["S256"],
3145
agent_auth: {
3246
skill: `${origin}/auth.md`,
3347
register_uri: `${origin}/agent/auth`,

‎apps/site/src/pages/.well-known/oauth-protected-resource.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,16 @@ export const GET: APIRoute = async ({ locals }) => {
1111
const protectedResource = {
1212
resource: origin,
1313
authorization_servers: [origin],
14-
scopes_supported: ["read:content", "search", "mcp:tools", "agent"],
14+
scopes_supported: [
15+
"openid",
16+
"profile",
17+
"email",
18+
"offline_access",
19+
"read:content",
20+
"search",
21+
"mcp:tools",
22+
"agent",
23+
],
1524
bearer_methods_supported: ["header"],
1625
resource_documentation: `${origin}/auth.md`,
1726
};

0 commit comments

Comments
 (0)