fix: restore MCP OAuth registration and authorization (#3421)

* fix: align MCP OAuth provider schema and authorization flow

* test: isolate OpenAPI generation from OAuth initialization

* fix: accept auth routes without a callback query

* fix: require explicit OAuth consent and preserve signed requests

* test: verify OAuth audiences through real MCP initialization

* test(e2e): isolate OAuth token audience validation
This commit is contained in:
Amruth Pillai
2026-09-05 09:33:15 -07:00
committed by GitHub
parent bf71253ca4
commit fe9b59e111
35 changed files with 7837 additions and 144 deletions
+8 -4
View File
@@ -46,6 +46,9 @@ function resolveInternalBaseUrl(): string {
const internalBaseUrl = resolveInternalBaseUrl();
const oauthAudienceBase = authBaseUrl.replace(/\/$/, "");
// These identify the same account-wide API/MCP resource, not separate permission
// tiers. Protected-resource metadata advertises the root; MCP clients may also
// select the mounted endpoint or normalize either URI with a trailing slash.
const OAUTH_AUDIENCES = [
oauthAudienceBase,
`${oauthAudienceBase}/`,
@@ -301,11 +304,12 @@ const getAuthConfig = () => {
}),
oauthProvider({
loginPage: "/api/auth/oauth",
consentPage: "/api/auth/oauth",
validAudiences: OAUTH_AUDIENCES,
consentPage: "/auth/consent",
resources: OAUTH_AUDIENCES,
clientRegistrationDefaultResources: OAUTH_AUDIENCES,
allowDynamicClientRegistration: true,
// Required for MCP client onboarding (RFC 7591). Phishing vector is closed by the
// redirect_uri policy in the hooks.before middleware above and server auth preflight.
// Required for MCP client onboarding (RFC 7591). Redirect URI validation
// and explicit user consent protect access by dynamically registered clients.
allowUnauthenticatedClientRegistration: true,
rateLimit: oauthProviderRateLimit,
silenceWarnings: { oauthAuthServerConfig: true },
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { oauthProvider } from "@better-auth/oauth-provider";
import { getTableColumns, is, Table } from "drizzle-orm";
import * as dbSchema from "@reactive-resume/db/schema";
const plugin = oauthProvider({ loginPage: "/login", consentPage: "/consent" });
describe("OAuth provider persistence schema", () => {
it.each(Object.entries(plugin.schema))("provides every installed plugin field for %s", (modelName, model) => {
const table = Reflect.get(dbSchema, modelName);
expect(is(table, Table), `Missing table ${modelName}`).toBe(true);
if (!is(table, Table)) return;
const columns = getTableColumns(table);
for (const field of Object.keys(model.fields)) {
expect(columns, `${modelName}.${field}`).toHaveProperty(field);
}
});
});
+83
View File
@@ -250,6 +250,16 @@ export const jwks = pg.pgTable("jwks", {
export const oauthClient = pg.pgTable(
"oauth_client",
{
// Additive fields required by Better Auth 1.7 OAuth provider.
clientDiscoveryId: pg.text("client_discovery_id"),
clientCredentialsScopes: pg.text("client_credentials_scopes").array().default([]),
backchannelLogoutUri: pg.text("backchannel_logout_uri"),
backchannelLogoutSessionRequired: pg.boolean("backchannel_logout_session_required"),
applicationType: pg.text("application_type"),
jwks: pg.text("jwks"),
jwksUri: pg.text("jwks_uri"),
dpopBoundAccessTokens: pg.boolean("dpop_bound_access_tokens").default(false),
id: pg
.text("id")
.notNull()
@@ -294,6 +304,15 @@ export const oauthClient = pg.pgTable(
export const oauthRefreshToken = pg.pgTable(
"oauth_refresh_token",
{
// Additive fields required by Better Auth 1.7 OAuth provider.
authorizationCodeId: pg.text("authorization_code_id"),
resources: pg.text("resources").array(),
requestedUserInfoClaims: pg.text("requested_user_info_claims").array(),
rotatedAt: pg.timestamp("rotated_at", { withTimezone: true }),
rotationReplayResponse: pg.text("rotation_replay_response"),
rotationReplayExpiresAt: pg.timestamp("rotation_replay_expires_at", { withTimezone: true }),
confirmation: pg.jsonb("confirmation"),
id: pg
.text("id")
.notNull()
@@ -322,6 +341,13 @@ export const oauthRefreshToken = pg.pgTable(
export const oauthAccessToken = pg.pgTable(
"oauth_access_token",
{
// Additive fields required by Better Auth 1.7 OAuth provider.
authorizationCodeId: pg.text("authorization_code_id"),
resources: pg.text("resources").array(),
requestedUserInfoClaims: pg.text("requested_user_info_claims").array(),
revoked: pg.timestamp("revoked", { withTimezone: true }),
confirmation: pg.jsonb("confirmation"),
id: pg
.text("id")
.notNull()
@@ -346,6 +372,10 @@ export const oauthAccessToken = pg.pgTable(
export const oauthConsent = pg.pgTable(
"oauth_consent",
{
// Additive fields required by Better Auth 1.7 OAuth provider.
resources: pg.text("resources").array(),
requestedUserInfoClaims: pg.text("requested_user_info_claims").array(),
id: pg
.text("id")
.notNull()
@@ -366,3 +396,56 @@ export const oauthConsent = pg.pgTable(
},
(t) => [pg.index().on(t.userId, t.clientId)],
);
export const oauthResource = pg.pgTable("oauth_resource", {
id: pg
.text("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
identifier: pg.text("identifier").notNull().unique(),
name: pg.text("name").notNull(),
accessTokenTtl: pg.integer("access_token_ttl"),
refreshTokenTtl: pg.integer("refresh_token_ttl"),
signingAlgorithm: pg.text("signing_algorithm"),
signingKeyId: pg.text("signing_key_id"),
allowedScopes: pg.text("allowed_scopes").array(),
customClaims: pg.jsonb("custom_claims"),
dpopBoundAccessTokensRequired: pg.boolean("dpop_bound_access_tokens_required").default(false),
disabled: pg.boolean("disabled").default(false),
createdAt: pg.timestamp("created_at", { withTimezone: true }),
updatedAt: pg.timestamp("updated_at", { withTimezone: true }),
policyVersion: pg.integer("policy_version").default(1),
metadata: pg.jsonb("metadata"),
});
export const oauthClientResource = pg.pgTable(
"oauth_client_resource",
{
id: pg
.text("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
clientId: pg
.text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
resourceId: pg
.text("resource_id")
.notNull()
.references(() => oauthResource.identifier, { onDelete: "cascade" }),
metadata: pg.jsonb("metadata"),
createdAt: pg.timestamp("created_at", { withTimezone: true }),
},
(t) => [pg.index().on(t.clientId), pg.index().on(t.resourceId), pg.uniqueIndex().on(t.clientId, t.resourceId)],
);
export const oauthClientAssertion = pg.pgTable("oauth_client_assertion", {
id: pg
.text("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }).notNull(),
});