feat: add personal access tokens (#159)

* feat: add personal access tokens

* feat: replace MCP tokens with OAuth foundation

* fix: keep OAuth constants private in auth foundation

* fix: clean up mcp oauth branch scope

* fix: expose oauth metadata endpoints

* fix: trim mcp oauth config to non-default options

Drop OIDC scopes, the org-id JWT claim, and the openid-configuration
metadata endpoint since the MCP integration is OAuth-only and the org
gets resolved server-side. Also remove options that just duplicated
better-auth defaults.

* fix: drop redundant oauth metadata helpers

Remove `session.storeSessionInDatabase: true` since better-auth only
enforces it when secondaryStorage is configured. Inline the
`getHostedBaseUrlForOAuthMetadata` alias and skip the async
`getOAuthServerConfig()` call in the protected-resource metadata handler
— the issuer is just `baseURL` without a custom jwt.issuer override.

* docs: explain cache headers on mcp metadata response

* Use escaped file routes for OAuth metadata

* save
This commit is contained in:
Ben Senescu 2026-05-06 22:41:02 -04:00 committed by GitHub
parent fb265e3a3a
commit 6231424e88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 3178 additions and 12 deletions

View File

@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto";
import { betterAuth } from "better-auth";
import { baseAuthConfig } from "./src/lib/auth-config";
import { createBaseAuthConfig } from "./src/lib/auth-config";
const CLI_DEV_BASE_URL = "http://localhost:3000";
const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL,
baseURL: baseUrl,
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
...baseAuthConfig,
...createBaseAuthConfig(baseUrl),
});

View File

@ -0,0 +1,89 @@
CREATE TABLE `jwks` (
`id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer
);
--> statement-breakpoint
CREATE TABLE `oauth_access_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text,
`reference_id` text,
`refresh_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint
CREATE TABLE `oauth_client` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text,
`disabled` integer DEFAULT false,
`skip_consent` integer,
`enable_end_session` integer,
`subject_type` text,
`scopes` text,
`user_id` text,
`created_at` integer,
`updated_at` integer,
`name` text,
`uri` text,
`icon` text,
`contacts` text,
`tos` text,
`policy` text,
`software_id` text,
`software_version` text,
`software_statement` text,
`redirect_uris` text NOT NULL,
`post_logout_redirect_uris` text,
`token_endpoint_auth_method` text,
`grant_types` text,
`response_types` text,
`public` integer,
`type` text,
`require_pkce` integer,
`reference_id` text,
`metadata` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint
CREATE TABLE `oauth_consent` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`user_id` text,
`reference_id` text,
`scopes` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `oauth_refresh_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text NOT NULL,
`reference_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`revoked` integer,
`auth_time` integer,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,13 @@
"when": 1778031161783,
"tag": "0011_colorful_dark_beast",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1778113978173,
"tag": "0012_closed_impossible_man",
"breakpoints": true
}
]
}

View File

@ -11,6 +11,7 @@
"drizzle.config.ts",
// DB schema exports consumed via `import * as schema` / drizzle()
"src/db/index.ts",
"src/db/app.schema.ts",
"src/db/better-auth-schema.ts",
],
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],

View File

@ -49,6 +49,7 @@
}
},
"dependencies": {
"@better-auth/oauth-provider": "^1.5.5",
"@every-app/sdk": "^0.1.14",
"@tanstack/query-core": "^5.90.9",
"@tanstack/react-form": "^1.25.0",

22
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@better-auth/oauth-provider':
specifier: ^1.5.5
version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))
'@every-app/sdk':
specifier: ^0.1.14
version: 0.1.14(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(jose@6.1.3)(react@19.2.4)
@ -325,6 +328,15 @@ packages:
'@better-auth/utils': ^0.3.0
mongodb: ^6.0.0 || ^7.0.0
'@better-auth/oauth-provider@1.5.5':
resolution: {integrity: sha512-zH2uKtvd6406MysWCTBldPHTKCXEK8caMrNId03bh4ej4f2vU8+GfNGE+IyxARucHGI1T+Og7QrUgKAeA2jQUQ==}
peerDependencies:
'@better-auth/core': 1.5.5
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.5
better-call: 1.3.2
'@better-auth/prisma-adapter@1.5.5':
resolution: {integrity: sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==}
peerDependencies:
@ -4216,6 +4228,16 @@ snapshots:
'@better-auth/utils': 0.3.1
mongodb: 7.1.0
'@better-auth/oauth-provider@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
'@better-auth/utils': 0.3.1
'@better-fetch/fetch': 1.1.21
better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
better-call: 1.3.2(zod@4.3.6)
jose: 6.1.3
zod: 4.3.6
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
dependencies:
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)

View File

@ -151,18 +151,116 @@ export const invitation = sqliteTable(
],
);
export const jwks = sqliteTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
});
export const oauthClient = sqliteTable("oauth_client", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientSecret: text("client_secret"),
disabled: integer("disabled", { mode: "boolean" }).default(false),
skipConsent: integer("skip_consent", { mode: "boolean" }),
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
subjectType: text("subject_type"),
scopes: text("scopes", { mode: "json" }),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }),
name: text("name"),
uri: text("uri"),
icon: text("icon"),
contacts: text("contacts", { mode: "json" }),
tos: text("tos"),
policy: text("policy"),
softwareId: text("software_id"),
softwareVersion: text("software_version"),
softwareStatement: text("software_statement"),
redirectUris: text("redirect_uris", { mode: "json" }).notNull(),
postLogoutRedirectUris: text("post_logout_redirect_uris", { mode: "json" }),
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
grantTypes: text("grant_types", { mode: "json" }),
responseTypes: text("response_types", { mode: "json" }),
public: integer("public", { mode: "boolean" }),
type: text("type"),
requirePKCE: integer("require_pkce", { mode: "boolean" }),
referenceId: text("reference_id"),
metadata: text("metadata", { mode: "json" }),
});
export const oauthRefreshToken = sqliteTable("oauth_refresh_token", {
id: text("id").primaryKey(),
token: text("token").notNull(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: text("session_id").references(() => session.id, {
onDelete: "set null",
}),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
revoked: integer("revoked", { mode: "timestamp_ms" }),
authTime: integer("auth_time", { mode: "timestamp_ms" }),
scopes: text("scopes", { mode: "json" }).notNull(),
});
export const oauthAccessToken = sqliteTable("oauth_access_token", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: text("session_id").references(() => session.id, {
onDelete: "set null",
}),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
refreshId: text("refresh_id").references(() => oauthRefreshToken.id, {
onDelete: "cascade",
}),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
scopes: text("scopes", { mode: "json" }).notNull(),
});
export const oauthConsent = sqliteTable("oauth_consent", {
id: text("id").primaryKey(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
scopes: text("scopes", { mode: "json" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
members: many(member),
invitations: many(invitation),
oauthClients: many(oauthClient),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
oauthConsents: many(oauthConsent),
}));
export const sessionRelations = relations(session, ({ one }) => ({
export const sessionRelations = relations(session, ({ one, many }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
}));
export const accountRelations = relations(account, ({ one }) => ({
@ -198,3 +296,65 @@ export const invitationRelations = relations(invitation, ({ one }) => ({
references: [user.id],
}),
}));
export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({
user: one(user, {
fields: [oauthClient.userId],
references: [user.id],
}),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
oauthConsents: many(oauthConsent),
}));
export const oauthRefreshTokenRelations = relations(
oauthRefreshToken,
({ one, many }) => ({
oauthClient: one(oauthClient, {
fields: [oauthRefreshToken.clientId],
references: [oauthClient.clientId],
}),
session: one(session, {
fields: [oauthRefreshToken.sessionId],
references: [session.id],
}),
user: one(user, {
fields: [oauthRefreshToken.userId],
references: [user.id],
}),
oauthAccessTokens: many(oauthAccessToken),
}),
);
export const oauthAccessTokenRelations = relations(
oauthAccessToken,
({ one }) => ({
oauthClient: one(oauthClient, {
fields: [oauthAccessToken.clientId],
references: [oauthClient.clientId],
}),
session: one(session, {
fields: [oauthAccessToken.sessionId],
references: [session.id],
}),
user: one(user, {
fields: [oauthAccessToken.userId],
references: [user.id],
}),
oauthRefreshToken: one(oauthRefreshToken, {
fields: [oauthAccessToken.refreshId],
references: [oauthRefreshToken.id],
}),
}),
);
export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({
oauthClient: one(oauthClient, {
fields: [oauthConsent.clientId],
references: [oauthClient.clientId],
}),
user: one(user, {
fields: [oauthConsent.userId],
references: [user.id],
}),
}));

View File

@ -3,6 +3,7 @@ import {
inferAdditionalFields,
organizationClient,
} from "better-auth/client/plugins";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { userAdditionalFields } from "@/lib/auth-options";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
@ -11,6 +12,7 @@ export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [
organizationClient(),
oauthProviderClient(),
inferAdditionalFields({ user: userAdditionalFields }),
],
});

View File

@ -1,7 +1,29 @@
import { organization } from "better-auth/plugins";
import { oauthProvider } from "@better-auth/oauth-provider";
import { jwt, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options";
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
export const baseAuthConfig = {
...baseAuthOptions,
plugins: [organization()],
};
export function createBaseAuthConfig(baseUrl: string) {
const mcpResource = getMcpResource(baseUrl);
return {
...baseAuthOptions,
plugins: [
organization(),
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/oauth-consent",
signup: {
page: "/sign-up",
},
scopes: ["offline_access", MCP_SCOPE],
allowDynamicClientRegistration: true,
// TODO: drop once the MCP spec settles on a replacement for
// unauthenticated DCR — better-auth has flagged this option for removal.
allowUnauthenticatedClientRegistration: true,
validAudiences: [mcpResource],
}),
],
};
}

View File

@ -4,7 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import { db } from "@/db";
import { z } from "zod";
import { baseAuthConfig } from "@/lib/auth-config";
import { createBaseAuthConfig } from "@/lib/auth-config";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import {
sendHostedPasswordResetEmail,
@ -25,6 +25,7 @@ const hostedBaseUrlSchema = z
function createAuth() {
const baseUrl = getHostedBaseUrl();
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(baseUrl);
const auth = betterAuth({
baseURL: baseUrl,
@ -102,7 +103,7 @@ function getTrustedOrigins(baseUrl: string) {
return trustedOrigins;
}
function getHostedBaseUrl() {
export function getHostedBaseUrl() {
const baseUrl = env.BETTER_AUTH_URL?.trim();
if (!baseUrl) {

View File

@ -0,0 +1,6 @@
const MCP_RESOURCE_PATH = "/mcp";
export const MCP_SCOPE = "mcp";
export function getMcpResource(baseUrl: string) {
return new URL(MCP_RESOURCE_PATH, baseUrl).toString();
}

View File

@ -18,14 +18,17 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppBillingRouteImport } from './routes/_app/billing'
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
@ -83,6 +86,12 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
path: '/subscribe',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedOauthConsentRoute =
AuthenticatedOauthConsentRouteImport.update({
id: '/oauth-consent',
path: '/oauth-consent',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthSignUpRoute = AuthSignUpRouteImport.update({
id: '/sign-up',
path: '/sign-up',
@ -108,6 +117,12 @@ const AppBillingRoute = AppBillingRouteImport.update({
path: '/billing',
getParentRoute: () => AppRouteRoute,
} as any)
const DotwellKnownOauthAuthorizationServerRoute =
DotwellKnownOauthAuthorizationServerRouteImport.update({
id: '/.well-known/oauth-authorization-server',
path: '/.well-known/oauth-authorization-server',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$',
path: '/api/autumn/$',
@ -123,6 +138,12 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
path: '/help/dataforseo-api-key',
getParentRoute: () => AppRouteRoute,
} as any)
const DotwellKnownOauthProtectedResourceMcpRoute =
DotwellKnownOauthProtectedResourceMcpRouteImport.update({
id: '/.well-known/oauth-protected-resource/mcp',
path: '/.well-known/oauth-protected-resource/mcp',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
id: '/p/$projectId',
path: '/p/$projectId',
@ -213,13 +234,16 @@ export interface FileRoutesByFullPath {
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
'/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@ -243,12 +267,15 @@ export interface FileRoutesByTo {
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
'/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@ -274,14 +301,17 @@ export interface FileRoutesById {
'/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
'/_app/billing': typeof AppBillingRoute
'/_app/settings': typeof AppSettingsRoute
'/_app/support': typeof AppSupportRoute
'/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute
@ -307,13 +337,16 @@ export interface FileRouteTypes {
| '/forgot-password'
| '/reset-password'
| '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/billing'
| '/settings'
| '/support'
| '/sign-in'
| '/sign-up'
| '/oauth-consent'
| '/subscribe'
| '/p/$projectId'
| '/.well-known/oauth-protected-resource/mcp'
| '/help/dataforseo-api-key'
| '/api/auth/$'
| '/api/autumn/$'
@ -337,12 +370,15 @@ export interface FileRouteTypes {
| '/forgot-password'
| '/reset-password'
| '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/billing'
| '/settings'
| '/support'
| '/sign-in'
| '/sign-up'
| '/oauth-consent'
| '/subscribe'
| '/.well-known/oauth-protected-resource/mcp'
| '/help/dataforseo-api-key'
| '/api/auth/$'
| '/api/autumn/$'
@ -367,14 +403,17 @@ export interface FileRouteTypes {
| '/forgot-password'
| '/reset-password'
| '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/_app/billing'
| '/_app/settings'
| '/_app/support'
| '/_auth/sign-in'
| '/_auth/sign-up'
| '/_authenticated/oauth-consent'
| '/_authenticated/subscribe'
| '/_app/'
| '/_project/p/$projectId'
| '/.well-known/oauth-protected-resource/mcp'
| '/_app/help/dataforseo-api-key'
| '/api/auth/$'
| '/api/autumn/$'
@ -402,6 +441,8 @@ export interface RootRouteChildren {
ForgotPasswordRoute: typeof ForgotPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
}
@ -471,6 +512,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/oauth-consent': {
id: '/_authenticated/oauth-consent'
path: '/oauth-consent'
fullPath: '/oauth-consent'
preLoaderRoute: typeof AuthenticatedOauthConsentRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/sign-up': {
id: '/_auth/sign-up'
path: '/sign-up'
@ -506,6 +554,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppBillingRouteImport
parentRoute: typeof AppRouteRoute
}
'/.well-known/oauth-authorization-server': {
id: '/.well-known/oauth-authorization-server'
path: '/.well-known/oauth-authorization-server'
fullPath: '/.well-known/oauth-authorization-server'
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
parentRoute: typeof rootRouteImport
}
'/api/autumn/$': {
id: '/api/autumn/$'
path: '/api/autumn/$'
@ -527,6 +582,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
parentRoute: typeof AppRouteRoute
}
'/.well-known/oauth-protected-resource/mcp': {
id: '/.well-known/oauth-protected-resource/mcp'
path: '/.well-known/oauth-protected-resource/mcp'
fullPath: '/.well-known/oauth-protected-resource/mcp'
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport
parentRoute: typeof rootRouteImport
}
'/_project/p/$projectId': {
id: '/_project/p/$projectId'
path: '/p/$projectId'
@ -748,10 +810,12 @@ const AuthRouteChildren: AuthRouteChildren = {
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
}
@ -767,6 +831,10 @@ const rootRouteChildren: RootRouteChildren = {
ForgotPasswordRoute: ForgotPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute,
VerifyEmailRoute: VerifyEmailRoute,
DotwellKnownOauthAuthorizationServerRoute:
DotwellKnownOauthAuthorizationServerRoute,
DotwellKnownOauthProtectedResourceMcpRoute:
DotwellKnownOauthProtectedResourceMcpRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
}

View File

@ -0,0 +1,31 @@
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
import { isHostedAuthMode } from "@/lib/auth-mode";
function unavailableMetadataResponse() {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", { status: 404 });
}
return new Response("Missing Better Auth hosted configuration", {
status: 500,
});
}
export const Route = createFileRoute("/.well-known/oauth-authorization-server")(
{
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
return unavailableMetadataResponse();
}
return oauthProviderAuthServerMetadata(getAuth())(request);
},
},
},
},
);

View File

@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
import { isHostedAuthMode } from "@/lib/auth-mode";
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
function unavailableMetadataResponse() {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", { status: 404 });
}
return new Response("Missing Better Auth hosted configuration", {
status: 500,
});
}
export const Route = createFileRoute(
"/.well-known/oauth-protected-resource/mcp",
)({
server: {
handlers: {
GET: async () => {
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
return unavailableMetadataResponse();
}
const baseUrl = getHostedBaseUrl();
const authServerMetadata = await getAuth().api.getOAuthServerConfig();
const metadata = {
resource: getMcpResource(baseUrl),
authorization_servers: [authServerMetadata.issuer],
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP",
};
return new Response(JSON.stringify(metadata), {
headers: {
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
"Content-Type": "application/json",
},
});
},
},
},
});

View File

@ -0,0 +1,73 @@
import { createFileRoute } from "@tanstack/react-router";
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/_authenticated/oauth-consent")({
component: OAuthConsentPage,
});
function OAuthConsentPage() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function respond(accept: boolean) {
setError(null);
setIsSubmitting(true);
const { data, error: consentError } = await authClient.oauth2.consent({
accept,
});
if (consentError) {
setError(consentError.message ?? "Unable to complete authorization.");
setIsSubmitting(false);
return;
}
if (data?.redirect && data.url) {
window.location.assign(data.url);
return;
}
setError("Authorization response did not include a redirect URL.");
setIsSubmitting(false);
}
return (
<div className="w-full max-w-sm space-y-5">
<div className="text-center space-y-3">
<div className="mx-auto flex size-12 items-center justify-center rounded-lg bg-base-200">
<ShieldCheck className="size-6" />
</div>
<div>
<h1 className="text-xl font-semibold">Authorize MCP access</h1>
<p className="mt-2 text-sm text-base-content/70">
Allow this MCP client to access your OpenSEO workspace.
</p>
</div>
</div>
{error ? <p className="text-sm text-error">{error}</p> : null}
<div className="flex gap-2">
<button
type="button"
className="btn btn-ghost flex-1"
disabled={isSubmitting}
onClick={() => void respond(false)}
>
Deny
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={isSubmitting}
onClick={() => void respond(true)}
>
{isSubmitting ? "Authorizing..." : "Authorize"}
</button>
</div>
</div>
);
}

View File

@ -18,7 +18,9 @@ function AuthenticatedShellLayout() {
if (!session?.user?.id) {
void navigate({
to: "/sign-in",
search: { redirect: window.location.pathname },
search: {
redirect: `${window.location.pathname}${window.location.search}`,
},
});
}
}, [isPending, isHostedMode, session?.user?.id, navigate]);