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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.72.1",
"@better-auth/oauth-provider": "1.4.18",
"@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724",
"@linear/sdk": "^68.1.0",
"@modelcontextprotocol/sdk": "^1.25.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { auth } from "@superset/auth/server";

export const GET = oauthProviderAuthServerMetadata(auth);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
import { auth } from "@superset/auth/server";
import { oAuthDiscoveryMetadata } from "better-auth/plugins";

export const GET = oAuthDiscoveryMetadata(auth);
export const GET = oauthProviderAuthServerMetadata(auth);
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { protectedResourceHandler } from "mcp-handler";
import { env } from "@/env";

export const GET = protectedResourceHandler({
authServerUrls: [env.NEXT_PUBLIC_API_URL],
resourceUrl: env.NEXT_PUBLIC_API_URL,
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { auth } from "@superset/auth/server";
import { oAuthProtectedResourceMetadata } from "better-auth/plugins";
import { protectedResourceHandler } from "mcp-handler";
import { env } from "@/env";

export const GET = oAuthProtectedResourceMetadata(auth);
export const GET = protectedResourceHandler({
authServerUrls: [env.NEXT_PUBLIC_API_URL],
resourceUrl: env.NEXT_PUBLIC_API_URL,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@superset/auth/server";

export const GET = oauthProviderOpenIdConfigMetadata(auth);
4 changes: 4 additions & 0 deletions apps/api/src/app/.well-known/openid-configuration/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
import { auth } from "@superset/auth/server";

export const GET = oauthProviderOpenIdConfigMetadata(auth);
60 changes: 35 additions & 25 deletions apps/api/src/app/api/agent/[transport]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { auth } from "@superset/auth/server";
import { registerTools } from "@superset/mcp";
import type { McpContext } from "@superset/mcp/auth";
import { verifyAccessToken } from "better-auth/oauth2";
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { env } from "@/env";

async function verifyToken(req: Request, bearerToken?: string) {
// 1. Try session auth
// 1. Try session auth (for desktop/web app)
const session = await auth.api.getSession({ headers: req.headers });
if (session?.session) {
const extendedSession = session.session as {
Expand All @@ -28,35 +29,44 @@ async function verifyToken(req: Request, bearerToken?: string) {
};
}

// 2. Try OAuth bearer token
// 2. Try OAuth access token verification via JWKS
if (bearerToken) {
const mcpSession = await auth.api.getMcpSession({ headers: req.headers });
if (!mcpSession) return undefined;
try {
const payload = await verifyAccessToken(bearerToken, {
jwksUrl: `${env.NEXT_PUBLIC_API_URL}/api/auth/jwks`,
verifyOptions: {
issuer: env.NEXT_PUBLIC_API_URL,
audience: [env.NEXT_PUBLIC_API_URL, `${env.NEXT_PUBLIC_API_URL}/`],
},
});
if (!payload?.sub || !payload.organizationId) {
console.error(
"[mcp/auth] Access token missing sub or organizationId claim",
);
return undefined;
}

const scopes = Array.isArray(mcpSession.scopes)
? mcpSession.scopes
: (mcpSession.scopes?.split(" ") ?? []);
const scopes = Array.isArray(payload.scope)
? (payload.scope as string[])
: typeof payload.scope === "string"
? payload.scope.split(" ")
: [];

// Get organization from scope
const orgScope = scopes.find((s) => s.startsWith("organization:"));
const organizationId = orgScope?.split(":")[1];

if (!organizationId) {
console.error("[mcp/auth] OAuth token missing organization scope");
return {
token: bearerToken,
clientId: (payload.azp as string) ?? "mcp-client",
scopes,
extra: {
mcpContext: {
userId: payload.sub,
organizationId: payload.organizationId as string,
} satisfies McpContext,
},
};
} catch (error) {
console.error("[mcp/auth] Access token verification failed:", error);
return undefined;
}

return {
token: bearerToken,
clientId: mcpSession.clientId ?? "mcp-client",
scopes,
extra: {
mcpContext: {
userId: mcpSession.userId,
organizationId,
} satisfies McpContext,
},
};
}

return undefined;
Expand Down
11 changes: 2 additions & 9 deletions apps/docs/content/docs/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,7 @@ Add a `.mcp.json` to your project root. Claude Code auto-discovers this file and
"mcpServers": {
"superset": {
"type": "http",
"url": "https://api.superset.sh/api/agent/mcp",
"oauth": {
"clientId": "claude-code",
"authorizationUrl": "https://api.superset.sh/api/auth/mcp/authorize",
"tokenUrl": "https://api.superset.sh/api/auth/mcp/token",
"scopes": ["openid", "profile", "email"]
}
"url": "https://api.superset.sh/api/agent/mcp"
}
}
}
Expand Down Expand Up @@ -126,8 +120,7 @@ Add to your `opencode.json`:
"mcp": {
"superset": {
"type": "remote",
"url": "https://api.superset.sh/api/agent/mcp",
"enabled": true
"url": "https://api.superset.sh/api/agent/mcp"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { authClient } from "@superset/auth/client";
import { Button } from "@superset/ui/button";
import {
Select,
Expand All @@ -24,7 +25,6 @@ interface Organization {
}

interface ConsentFormProps {
consentCode: string;
clientId: string;
clientName?: string;
scopes: string[];
Expand Down Expand Up @@ -56,7 +56,6 @@ const SCOPE_DESCRIPTIONS: Record<
};

export function ConsentForm({
consentCode,
clientId,
clientName,
scopes,
Expand All @@ -74,63 +73,41 @@ export function ConsentForm({
const selectedOrg = organizations.find((o) => o.id === selectedOrgId);

const handleConsent = async (accept: boolean) => {
if (accept && !selectedOrgId) {
setError("Please select an organization");
return;
}

setIsLoading(true);
setError(null);

try {
// Add organization scope to verification value before consent
// (Better Auth's consent endpoint doesn't accept scope in body)
if (accept && selectedOrgId) {
const addScopeResponse = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/oauth/add-org-scope`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
consent_code: consentCode,
organizationId: selectedOrgId,
}),
},
);

if (!addScopeResponse.ok) {
const data = await addScopeResponse.json();
throw new Error(data.error || "Failed to set organization scope");
if (accept) {
const { error: setActiveError } =
await authClient.organization.setActive({
organizationId: selectedOrgId,
});
if (setActiveError) {
throw new Error(
setActiveError.message ?? "Failed to set organization",
);
}
Comment on lines +85 to 94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

authClient.organization.setActive is called before consent — verify this doesn't persist the org switch if the user later abandons or the consent call fails.

If setActive succeeds but the subsequent consent call throws, the user's active organization has already been changed server-side. On retry or navigating away, they may find themselves in a different org than expected. Consider calling setActive only after consent succeeds, or rolling back on failure.

🤖 Prompt for AI Agents
In `@apps/web/src/app/oauth/consent/components/ConsentForm/ConsentForm.tsx` around
lines 85 - 94, The current flow calls authClient.organization.setActive with
selectedOrgId before performing the consent request, which can persist an
unwanted org switch if the subsequent consent call fails; change the flow so
setActive is executed only after the consent call succeeds, or implement a
rollback: capture the previous active org id before calling setActive and, if
consent (the subsequent consent method) throws, revert by calling
authClient.organization.setActive with the saved previous org id and
log/propagate the original error. Ensure you reference
authClient.organization.setActive, selectedOrgId and the consent call when
updating the logic.

}

const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/auth/oauth2/consent`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
accept,
consent_code: consentCode,
}),
},
);

const data = await response.json();
const { data, error: consentError } = await authClient.oauth2.consent({
accept,
scope: accept ? scopes.join(" ") : undefined,
});

if (!response.ok) {
throw new Error(
data.error_description || data.message || "Failed to process consent",
);
if (consentError) {
throw new Error(consentError.message ?? "Failed to process consent");
}

const redirectUrl = data.redirectURI || data.redirectTo;
if (redirectUrl) {
window.location.href = redirectUrl;
if (data?.uri) {
window.location.href = data.uri;
}
Comment on lines +97 to 108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing setIsLoading(false) on the success path when data?.uri is falsy.

If authClient.oauth2.consent succeeds but data?.uri is undefined or empty, neither the redirect (line 95) nor the catch block executes. The button stays in the "Authorizing..." state permanently with no error shown.

Suggested fix
 		if (data?.uri) {
 			window.location.href = data.uri;
+		} else {
+			setError("No redirect URI received from server");
+			setIsLoading(false);
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data, error: consentError } = await authClient.oauth2.consent({
accept,
scope: accept ? scopes.join(" ") : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(
data.error_description || data.message || "Failed to process consent",
);
if (consentError) {
throw new Error(consentError.message ?? "Failed to process consent");
}
const redirectUrl = data.redirectURI || data.redirectTo;
if (redirectUrl) {
window.location.href = redirectUrl;
if (data?.uri) {
window.location.href = data.uri;
}
const { data, error: consentError } = await authClient.oauth2.consent({
accept,
scope: accept ? scopes.join(" ") : undefined,
});
if (consentError) {
throw new Error(consentError.message ?? "Failed to process consent");
}
if (data?.uri) {
window.location.href = data.uri;
} else {
setError("No redirect URI received from server");
setIsLoading(false);
}
🤖 Prompt for AI Agents
In `@apps/web/src/app/oauth/consent/components/ConsentForm/ConsentForm.tsx` around
lines 85 - 96, The success path after calling authClient.oauth2.consent
currently only redirects when data?.uri exists, leaving the UI stuck in loading
if uri is falsy; update the consent handling in ConsentForm (the
authClient.oauth2.consent call) to ensure setIsLoading(false) is called when
consent succeeds but data?.uri is undefined (and optionally surface an error or
throw to trigger existing error handling). Specifically, after checking
consentError and before/after the if (data?.uri) block, call setIsLoading(false)
(and if you prefer, set an error or throw new Error("No redirect URI returned")
to reuse the catch path) so the "Authorizing..." state is cleared.

} catch (err) {
console.error("Consent error:", err);
console.error("[oauth/consent] Error:", err);
setError(err instanceof Error ? err.message : "An error occurred");
setIsLoading(false);
}
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/app/oauth/consent/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@ import { api } from "@/trpc/server";
import { ConsentForm } from "./components/ConsentForm";

interface ConsentPageProps {
searchParams: Promise<{
consent_code?: string;
client_id?: string;
scope?: string;
}>;
searchParams: Promise<Record<string, string>>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n apps/web/src/app/oauth/consent/page.tsx | head -100

Repository: superset-sh/superset

Length of output: 2984


🌐 Web query:

Next.js 15 App Router page searchParams type definition

💡 Result:

In Next.js 15 (App Router), the page.tsx props are now Promises:

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const { slug } = await params
  const { q } = await searchParams
}
``` ([nextjs.org](https://nextjs.org/docs/app/api-reference/file-conventions/page))

If you want a **generated, route-aware type** (strict `params` keys, typed from the route literal), use the **global** helper:

```ts
export default async function Page(props: PageProps<'/blog/[slug]'>) {
  const { slug } = await props.params
  const query = await props.searchParams
}
``` ([nextjs.org](https://nextjs.org/docs/app/api-reference/file-conventions/page))

Docs note: in Next.js 15, `params` (Page/Layout) and `searchParams` (Page) are Promises. ([nextjs.org](https://nextjs.org/docs/messages/next-prerender-sync-params))

Citations:

- 1: https://nextjs.org/docs/app/api-reference/file-conventions/page
- 2: https://nextjs.org/docs/app/api-reference/file-conventions/page
- 3: https://nextjs.org/docs/messages/next-prerender-sync-params

---



</details>

**`searchParams` type is narrower than Next.js 15 actually provides.**

Next.js 15 App Router pages receive `searchParams` typed as `Promise<Record<string, string | string[] | undefined>>`. Using `Promise<Record<string, string>>` silently discards the possibility of array values (e.g., `?scope=a&scope=b`) and `undefined`. This can lead to runtime errors if a query param is repeated or absent — `params.client_id` is typed as `string` but could be `string[]` or `undefined`. Line 58's `scope?.split(" ")` would throw if `scope` were an array instead of a string.

<details>
<summary>Suggested fix</summary>

```diff
 interface ConsentPageProps {
-	searchParams: Promise<Record<string, string>>;
+	searchParams: Promise<Record<string, string | string[] | undefined>>;
 }

Then when extracting values, coerce to string:

const clientId = typeof params.client_id === "string" ? params.client_id : undefined;
const scope = typeof params.scope === "string" ? params.scope : undefined;
🤖 Prompt for AI Agents
In `@apps/web/src/app/oauth/consent/page.tsx` at line 11, Change the searchParams
type to match Next.js (Promise<Record<string, string | string[] | undefined>>)
and guard/coerce the extracted query values before using them: in the
handler/component where you read params.client_id and params.scope (and any
other param), check typeof === "string" and assign to local clientId/scope
variables (or undefined otherwise) so scope?.split(" ") and similar operations
never run on arrays or undefined.

}

export default async function ConsentPage({ searchParams }: ConsentPageProps) {
Expand All @@ -23,13 +19,14 @@ export default async function ConsentPage({ searchParams }: ConsentPageProps) {

if (!session) {
const params = await searchParams;
const returnUrl = `/oauth/consent?${new URLSearchParams(params as Record<string, string>).toString()}`;
const returnUrl = `/oauth/consent?${new URLSearchParams(params).toString()}`;
redirect(`/sign-in?redirect=${encodeURIComponent(returnUrl)}`);
}

const { consent_code, client_id, scope } = await searchParams;
const params = await searchParams;
const { client_id, scope } = params;

if (!consent_code || !client_id) {
if (!client_id) {
return (
<div className="relative flex min-h-screen flex-col">
<header className="container mx-auto px-6 py-6">
Expand Down Expand Up @@ -64,7 +61,7 @@ export default async function ConsentPage({ searchParams }: ConsentPageProps) {
const trpc = await api();
const userOrganizations = await trpc.user.myOrganizations.query();

const oauthApp = await db.query.oauthApplications.findFirst({
const oauthApp = await db.query.oauthClients.findFirst({
where: (table, { eq }) => eq(table.clientId, client_id),
columns: { name: true },
});
Expand All @@ -90,7 +87,6 @@ export default async function ConsentPage({ searchParams }: ConsentPageProps) {
</header>
<main className="flex flex-1 items-center justify-center">
<ConsentForm
consentCode={consent_code}
clientId={client_id}
clientName={oauthApp?.name ?? undefined}
scopes={scopes}
Expand Down
4 changes: 4 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"dependencies": {
"@better-auth/expo": "1.4.18",
"@better-auth/oauth-provider": "1.4.18",
"@better-auth/stripe": "1.4.18",
"@superset/db": "workspace:*",
"@superset/email": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { stripeClient } from "@better-auth/stripe/client";
import type { auth } from "@superset/auth/server";
import {
Expand All @@ -16,5 +17,6 @@ export const authClient = createAuthClient({
customSessionClient<typeof auth>(),
stripeClient({ subscription: true }),
apiKeyClient(),
oauthProviderClient(),
],
});
Loading