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
201 changes: 201 additions & 0 deletions apps/site/migrations/0001_better_auth.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
-- Migration: 0001_better_auth.sql
-- Better Auth core + OAuth 2.1 Provider + MCP schema for Cloudflare D1

CREATE TABLE IF NOT EXISTS "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL UNIQUE,
"emailVerified" integer NOT NULL DEFAULT 0,
"image" text,
"createdAt" integer NOT NULL,
"updatedAt" integer NOT NULL
);

CREATE TABLE IF NOT EXISTS "session" (
"id" text PRIMARY KEY NOT NULL,
"expiresAt" integer NOT NULL,
"token" text NOT NULL UNIQUE,
"createdAt" integer NOT NULL,
"updatedAt" integer NOT NULL,
"ipAddress" text,
"userAgent" text,
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS "session_userId_idx" ON "session" ("userId");

CREATE TABLE IF NOT EXISTS "account" (
"id" text PRIMARY KEY NOT NULL,
"accountId" text NOT NULL,
"providerId" text NOT NULL,
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"accessToken" text,
"refreshToken" text,
"idToken" text,
"accessTokenExpiresAt" integer,
"refreshTokenExpiresAt" integer,
"scope" text,
"password" text,
"createdAt" integer NOT NULL,
"updatedAt" integer NOT NULL
);

CREATE INDEX IF NOT EXISTS "account_userId_idx" ON "account" ("userId");

CREATE TABLE IF NOT EXISTS "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expiresAt" integer NOT NULL,
"createdAt" integer NOT NULL,
"updatedAt" integer NOT NULL
);

CREATE TABLE IF NOT EXISTS "jwks" (
"id" text PRIMARY KEY NOT NULL,
"publicKey" text NOT NULL,
"privateKey" text NOT NULL,
"createdAt" integer NOT NULL,
"expiresAt" integer,
"alg" text,
"crv" text
);

CREATE TABLE IF NOT EXISTS "oauthClient" (
"id" text PRIMARY KEY NOT NULL,
"clientId" text NOT NULL UNIQUE,
"clientSecret" text,
"clientDiscoveryId" text,
"disabled" integer DEFAULT 0,
"skipConsent" integer DEFAULT 0,
"enableEndSession" integer DEFAULT 0,
"subjectType" text,
"scopes" text,
"clientCredentialsScopes" text,
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
"createdAt" integer,
"updatedAt" integer,
"name" text,
"uri" text,
"icon" text,
"contacts" text,
"tos" text,
"policy" text,
"softwareId" text,
"softwareVersion" text,
"softwareStatement" text,
"redirectUris" text NOT NULL,
"postLogoutRedirectUris" text,
"backchannelLogoutUri" text,
"backchannelLogoutSessionRequired" integer,
"tokenEndpointAuthMethod" text,
"applicationType" text,
"jwks" text,
"jwksUri" text,
"grantTypes" text,
"responseTypes" text,
"requirePKCE" integer DEFAULT 1,
"dpopBoundAccessTokens" integer DEFAULT 0,
"referenceId" text,
"metadata" text
);

CREATE INDEX IF NOT EXISTS "oauthClient_userId_idx" ON "oauthClient" ("userId");

CREATE TABLE IF NOT EXISTS "oauthResource" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL UNIQUE,
"name" text NOT NULL,
"accessTokenTtl" integer,
"refreshTokenTtl" integer,
"signingAlgorithm" text,
"signingKeyId" text,
"allowedScopes" text,
"customClaims" text,
"dpopBoundAccessTokensRequired" integer DEFAULT 0,
"disabled" integer DEFAULT 0,
"createdAt" integer,
"updatedAt" integer,
"policyVersion" integer,
"metadata" text
);

CREATE TABLE IF NOT EXISTS "oauthClientResource" (
"id" text PRIMARY KEY NOT NULL,
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
"resourceId" text NOT NULL REFERENCES "oauthResource"("identifier") ON DELETE CASCADE,
"metadata" text,
"createdAt" integer
);

CREATE UNIQUE INDEX IF NOT EXISTS "oauthClientResource_clientId_resourceId_uidx" ON "oauthClientResource" ("clientId", "resourceId");

CREATE TABLE IF NOT EXISTS "oauthRefreshToken" (
"id" text PRIMARY KEY NOT NULL,
"token" text NOT NULL UNIQUE,
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
"sessionId" text REFERENCES "session"("id") ON DELETE SET NULL,
"userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"referenceId" text,
"authorizationCodeId" text,
"resources" text,
"requestedUserInfoClaims" text,
"expiresAt" integer NOT NULL,
"createdAt" integer NOT NULL,
"revoked" integer,
"rotatedAt" integer,
"rotationReplayResponse" text,
"rotationReplayExpiresAt" integer,
"authTime" integer,
"confirmation" text,
"scopes" text NOT NULL
);

CREATE INDEX IF NOT EXISTS "oauthRefreshToken_clientId_idx" ON "oauthRefreshToken" ("clientId");
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_userId_idx" ON "oauthRefreshToken" ("userId");
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_sessionId_idx" ON "oauthRefreshToken" ("sessionId");
CREATE INDEX IF NOT EXISTS "oauthRefreshToken_authorizationCodeId_idx" ON "oauthRefreshToken" ("authorizationCodeId");

CREATE TABLE IF NOT EXISTS "oauthAccessToken" (
"id" text PRIMARY KEY NOT NULL,
"token" text NOT NULL UNIQUE,
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
"sessionId" text REFERENCES "session"("id") ON DELETE SET NULL,
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
"referenceId" text,
"authorizationCodeId" text,
"resources" text,
"requestedUserInfoClaims" text,
"refreshId" text REFERENCES "oauthRefreshToken"("id") ON DELETE CASCADE,
"expiresAt" integer NOT NULL,
"createdAt" integer NOT NULL,
"revoked" integer,
"confirmation" text,
"scopes" text NOT NULL
);

CREATE INDEX IF NOT EXISTS "oauthAccessToken_clientId_idx" ON "oauthAccessToken" ("clientId");
CREATE INDEX IF NOT EXISTS "oauthAccessToken_userId_idx" ON "oauthAccessToken" ("userId");
CREATE INDEX IF NOT EXISTS "oauthAccessToken_sessionId_idx" ON "oauthAccessToken" ("sessionId");
CREATE INDEX IF NOT EXISTS "oauthAccessToken_authorizationCodeId_idx" ON "oauthAccessToken" ("authorizationCodeId");
CREATE INDEX IF NOT EXISTS "oauthAccessToken_refreshId_idx" ON "oauthAccessToken" ("refreshId");

CREATE TABLE IF NOT EXISTS "oauthConsent" (
"id" text PRIMARY KEY NOT NULL,
"clientId" text NOT NULL REFERENCES "oauthClient"("clientId") ON DELETE CASCADE,
"userId" text REFERENCES "user"("id") ON DELETE CASCADE,
"referenceId" text,
"resources" text,
"requestedUserInfoClaims" text,
"scopes" text NOT NULL,
"createdAt" integer NOT NULL,
"updatedAt" integer NOT NULL
);

CREATE INDEX IF NOT EXISTS "oauthConsent_clientId_idx" ON "oauthConsent" ("clientId");
CREATE INDEX IF NOT EXISTS "oauthConsent_userId_idx" ON "oauthConsent" ("userId");

CREATE TABLE IF NOT EXISTS "oauthClientAssertion" (
"id" text PRIMARY KEY NOT NULL,
"expiresAt" integer NOT NULL
);
3 changes: 3 additions & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"@ai-sdk/mcp": "^2.0.72",
"@astrojs/cloudflare": "^14.2.1",
"@astrojs/react": "^6.0.2",
"@better-auth/mcp": "^1.7.7",
"@better-auth/oauth-provider": "^1.7.7",
"@openrouter/ai-sdk-provider": "^3.1.0",
"@portabletext/to-html": "^6.0.0",
"@portabletext/types": "^4.0.2",
Expand All @@ -31,6 +33,7 @@
"ai": "^7.0.135",
"astro": "^7.2.2",
"astro-portabletext": "^0.13.0",
"better-auth": "^1.7.7",
"groq": "^6.9.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
Expand Down
39 changes: 39 additions & 0 deletions apps/site/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { env } from "cloudflare:workers";
import { mcp } from "@better-auth/mcp";
import { betterAuth } from "better-auth";
import { jwt } from "better-auth/plugins";

/**
* Creates or retrieves the Better Auth instance configured for
* Model Context Protocol (MCP) and OAuth 2.1 authorization server
* backed by Cloudflare D1.
*/
export function getAuth(origin: string, d1?: D1Database) {
const db = d1 ?? (env as unknown as { DB?: D1Database })?.DB;

return betterAuth({
baseURL: origin,
basePath: "",
...(db ? { database: db } : {}),
plugins: [
jwt(),
mcp({
resource: `${origin}/mcp`,
loginPage: "/sign-in",
consentPage: "/oauth2/consent",
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
scopes: [
"openid",
"profile",
"email",
"offline_access",
"read:content",
"search",
"mcp:tools",
"agent",
],
}),
],
});
}
2 changes: 1 addition & 1 deletion apps/site/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Always emit Link headers (RFC 8288) for AI agent discoverability
response.headers.set(
"Link",
`<${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"`,
`<${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"`,
);
response.headers.append("Vary", "Accept");

Expand Down
23 changes: 22 additions & 1 deletion apps/site/src/pages/.well-known/jwks.json.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import type { APIRoute } from "astro";
import { getAuth } from "@/lib/auth";

export const prerender = false;

/**
* JWKS (JSON Web Key Set) public keys endpoint
*/
export const GET: APIRoute = async () => {
export const GET: APIRoute = async ({ request, locals }) => {
const origin = locals.siteUrl.origin;
const auth = getAuth(origin);

try {
const req = new Request(`${origin}/jwks`, request);
const res = await auth.handler(req);
if (res.ok) {
const data = await res.json();
return new Response(JSON.stringify(data, null, 2), {
headers: {
"content-type": "application/json; charset=utf-8",
"access-control-allow-origin": "*",
"cache-control": "public, max-age=3600, s-maxage=86400",
},
});
}
} catch {
// Fallback
}

return new Response(JSON.stringify({ keys: [] }, null, 2), {
headers: {
"content-type": "application/json; charset=utf-8",
Expand Down
50 changes: 32 additions & 18 deletions apps/site/src/pages/.well-known/oauth-authorization-server.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import type { APIRoute } from "astro";
import { getAuth } from "@/lib/auth";

export const prerender = false;

/**
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
* with Auth.md agent_auth extension for autonomous AI agent discovery
* powered by Better Auth with Auth.md agent_auth extension for autonomous AI agent discovery
*/
export const GET: APIRoute = async ({ locals }) => {
export const GET: APIRoute = async ({ request, locals }) => {
const origin = locals.siteUrl.origin;
const auth = getAuth(origin);

let baseMeta: Record<string, any> = {};
try {
const req = new Request(
`${origin}/.well-known/oauth-authorization-server`,
request,
);
const baseRes = await auth.handler(req);
if (baseRes.ok) {
baseMeta = await baseRes.json();
}
} catch {
// Fallback if request is invoked offline/in tests
}

const metadata = {
...baseMeta,
issuer: origin,
authorization_endpoint: `${origin}/oauth/authorize`,
token_endpoint: `${origin}/oauth/token`,
registration_endpoint: `${origin}/oauth/register`,
jwks_uri: `${origin}/.well-known/jwks.json`,
response_types_supported: ["code", "token"],
grant_types_supported: [
"authorization_code",
"client_credentials",
"urn:ietf:params:oauth:grant-type:token-exchange",
],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"client_secret_post",
"none",
authorization_endpoint: `${origin}/oauth2/authorize`,
token_endpoint: `${origin}/oauth2/token`,
registration_endpoint: `${origin}/oauth2/register`,
jwks_uri: `${origin}/jwks`,
scopes_supported: [
"openid",
"profile",
"email",
"offline_access",
"read:content",
"search",
"mcp:tools",
"agent",
],
scopes_supported: ["read:content", "search", "mcp:tools", "agent"],
code_challenge_methods_supported: ["S256"],
agent_auth: {
skill: `${origin}/auth.md`,
register_uri: `${origin}/agent/auth`,
Expand Down
11 changes: 10 additions & 1 deletion apps/site/src/pages/.well-known/oauth-protected-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,16 @@ export const GET: APIRoute = async ({ locals }) => {
const protectedResource = {
resource: origin,
authorization_servers: [origin],
scopes_supported: ["read:content", "search", "mcp:tools", "agent"],
scopes_supported: [
"openid",
"profile",
"email",
"offline_access",
"read:content",
"search",
"mcp:tools",
"agent",
],
bearer_methods_supported: ["header"],
resource_documentation: `${origin}/auth.md`,
};
Expand Down
Loading
Loading