MCP OAuth discovery fix + Supabase consent page (#6608)

This commit is contained in:
Anthony Stirling
2026-06-11 18:30:49 +01:00
committed by GitHub
parent 9e5fe2f4ca
commit 1d598d5caa
18 changed files with 1146 additions and 270 deletions
@@ -14,6 +14,7 @@ export const AUTH_ROUTES = [
"/invite",
"/forgot-password",
"/reset-password",
"/oauth/consent",
];
/**
@@ -30,6 +30,7 @@ interface McpAuthData {
issuerUri?: string;
jwksUri?: string;
resourceId?: string;
acceptedAudiences?: string[];
usernameClaim?: string;
requireExistingAccount?: boolean;
}
@@ -96,6 +97,7 @@ export default function AdminMcpSection() {
"mcp.auth.issuerUri": s.auth?.issuerUri ?? "",
"mcp.auth.jwksUri": s.auth?.jwksUri ?? "",
"mcp.auth.resourceId": s.auth?.resourceId ?? "",
"mcp.auth.acceptedAudiences": s.auth?.acceptedAudiences ?? [],
"mcp.auth.usernameClaim": s.auth?.usernameClaim ?? "sub",
"mcp.auth.requireExistingAccount":
s.auth?.requireExistingAccount ?? true,
@@ -269,6 +271,32 @@ export default function AdminMcpSection() {
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
<span>
{t(
"admin.settings.mcp.acceptedAudiences.label",
"Additional accepted audiences (optional)",
)}
</span>
<PendingBadge
show={isFieldPending("auth.acceptedAudiences")}
/>
</Group>
}
description={t(
"admin.settings.mcp.acceptedAudiences.description",
"Extra token audience values accepted besides the Resource ID (comma or space separated). Leave blank for strict RFC 8707. Needed for IdPs that cannot mint resource audiences - e.g. Supabase's OAuth server always issues aud=authenticated.",
)}
value={(settings.auth?.acceptedAudiences || []).join(" ")}
onChange={(e) =>
setAuth({ acceptedAudiences: parseOpList(e.target.value) })
}
placeholder="authenticated"
disabled={!settings.enabled}
/>
<TextInput
label={
<Group gap="xs">
+24 -4
View File
@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/utils/pathUtils";
import { AppProviders } from "@app/components/AppProviders";
import { setBaseUrl } from "@app/constants/app";
import type { AppConfig } from "@app/contexts/AppConfigContext";
@@ -11,6 +12,7 @@ import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import ResetPassword from "@app/routes/ResetPassword";
import OAuthConsent from "@app/routes/OAuthConsent";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
@@ -28,6 +30,25 @@ function handleConfigLoaded(config: AppConfig) {
if (config.baseUrl) setBaseUrl(config.baseUrl);
}
/**
* Onboarding and trial-expired modals must never cover auth-flow pages
* (login, signup, OAuth consent): they steal focus from the task the user
* was sent there to complete. Unmounting also stops their background polling.
*/
function NonAuthBootstraps() {
const location = useLocation();
if (isAuthRoute(location.pathname)) {
return null;
}
return (
<>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
</>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -35,14 +56,13 @@ export default function App() {
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
>
<AppLayout>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
<NonAuthBootstraps />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/auth/reset" element={<ResetPassword />} />
<Route path="/oauth/consent" element={<OAuthConsent />} />
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
+30 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { supabase, signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
@@ -47,6 +47,25 @@ export default function Login() {
}
}, []);
// Same-origin relative path to return to after login (e.g. the OAuth
// consent page). Same sanitization rules as AuthCallback's `next`.
const nextPath = useMemo(() => {
try {
const next = new URL(window.location.href).searchParams.get("next");
return next && next.startsWith("/") && !next.startsWith("//")
? next
: null;
} catch (_) {
return null;
}
}, []);
useEffect(() => {
if (session && !loading && nextPath) {
navigate(nextPath, { replace: true });
}
}, [session, loading, nextPath, navigate]);
const baseUrl = getBaseUrl();
// Set document meta
@@ -65,8 +84,11 @@ export default function Login() {
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// Show logged in state if authenticated
// Show logged in state if authenticated (unless bouncing back to `next`)
if (session && !loading) {
if (nextPath) {
return null;
}
return <LoggedInState />;
}
@@ -77,7 +99,9 @@ export default function Login() {
setIsSigningIn(true);
setError(null);
const redirectTo = absoluteWithBasePath("/auth/callback");
const redirectTo =
absoluteWithBasePath("/auth/callback") +
(nextPath ? `?next=${encodeURIComponent(nextPath)}` : "");
console.log(`[Login] Signing in with ${provider}`);
const oauthOptions: {
@@ -169,7 +193,9 @@ export default function Login() {
const { error } = await supabase.auth.signInWithOtp({
email: magicLinkEmail.trim(),
options: {
emailRedirectTo: absoluteWithBasePath("/auth/callback"),
emailRedirectTo:
absoluteWithBasePath("/auth/callback") +
(nextPath ? `?next=${encodeURIComponent(nextPath)}` : ""),
},
});
@@ -0,0 +1,410 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import { withBasePath } from "@app/constants/app";
import ErrorMessage from "@app/routes/login/ErrorMessage";
/**
* OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients
* such as Claude). Supabase redirects the user here as
* {SiteURL}/oauth/consent?authorization_id=X; this page loads the pending
* authorization, shows what the third-party app is asking for, and forwards
* the user's approve/deny decision back to Supabase, which then redirects to
* the requesting app with an authorization code (or an error).
*
* The installed supabase-js does not yet wrap these endpoints, so the GoTrue
* REST API is called directly with the user's session token.
*/
interface AuthorizationDetails {
authorization_id: string;
redirect_uri?: string;
scope?: string;
client?: {
id?: string;
name?: string;
logo_uri?: string;
};
}
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string;
const SUPABASE_KEY = import.meta.env
.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY as string;
async function gotrue(
path: string,
accessToken: string,
init?: RequestInit,
): Promise<Response> {
return fetch(`${SUPABASE_URL}/auth/v1/${path}`, {
...init,
headers: {
apikey: SUPABASE_KEY,
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(init?.headers || {}),
},
});
}
export default function OAuthConsent() {
const navigate = useNavigate();
const location = useLocation();
const { session, loading: sessionLoading, displayName } = useAuth();
const { t } = useTranslation();
const [details, setDetails] = useState<AuthorizationDetails | null>(null);
const [loadingDetails, setLoadingDetails] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deciding, setDeciding] = useState<"approve" | "deny" | null>(null);
const [redirecting, setRedirecting] = useState(false);
const authorizationId = useMemo(() => {
try {
return new URLSearchParams(location.search).get("authorization_id");
} catch (_) {
return null;
}
}, [location.search]);
useDocumentMeta({
title: `${t("oauthConsent.title", "Authorize access")} - Stirling PDF`,
});
// Load the pending authorization once a session is available.
useEffect(() => {
if (sessionLoading || !session || !authorizationId) {
return;
}
let cancelled = false;
(async () => {
try {
setLoadingDetails(true);
const response = await gotrue(
`oauth/authorizations/${encodeURIComponent(authorizationId)}`,
session.access_token,
);
const body = await response.json().catch(() => ({}));
if (cancelled) return;
if (!response.ok) {
console.error("[OAuthConsent] Failed to load authorization:", body);
setError(
t(
"oauthConsent.loadFailed",
"This authorization request is invalid or has expired. Close this tab and try connecting again from the app.",
),
);
} else {
setDetails(body as AuthorizationDetails);
}
} catch (err) {
console.error("[OAuthConsent] Unexpected error:", err);
if (!cancelled) {
setError(
t(
"oauthConsent.loadFailed",
"This authorization request is invalid or has expired. Close this tab and try connecting again from the app.",
),
);
}
} finally {
if (!cancelled) setLoadingDetails(false);
}
})();
return () => {
cancelled = true;
};
}, [sessionLoading, session, authorizationId, t]);
const decide = useCallback(
async (action: "approve" | "deny") => {
if (!session || !authorizationId || deciding) return;
try {
setDeciding(action);
setError(null);
const response = await gotrue(
`oauth/authorizations/${encodeURIComponent(authorizationId)}/consent`,
session.access_token,
{ method: "POST", body: JSON.stringify({ action }) },
);
const body = await response.json().catch(() => ({}));
if (!response.ok || !body.redirect_url) {
console.error("[OAuthConsent] Consent call failed:", body);
setError(
t(
"oauthConsent.decisionFailed",
"Could not submit your decision. Please try again.",
),
);
setDeciding(null);
return;
}
// Send the browser back to the requesting app with the code (or error).
setRedirecting(true);
window.location.assign(body.redirect_url as string);
} catch (err) {
console.error("[OAuthConsent] Unexpected error:", err);
setError(
t(
"oauthConsent.decisionFailed",
"Could not submit your decision. Please try again.",
),
);
setDeciding(null);
}
},
[session, authorizationId, deciding, t],
);
const appName =
details?.client?.name ||
t("oauthConsent.unknownApp", "A third-party application");
const scopes = (details?.scope || "")
.split(/\s+/)
.map((s) => s.trim())
.filter(Boolean);
const scopeDescription = (scope: string): string => {
switch (scope) {
case "openid":
return t("oauthConsent.scope.openid", "Confirm your identity");
case "email":
return t("oauthConsent.scope.email", "See your email address");
case "profile":
return t(
"oauthConsent.scope.profile",
"See your basic profile information",
);
case "phone":
return t("oauthConsent.scope.phone", "See your phone number");
default:
return scope;
}
};
const logoBlock = (
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
);
// Missing query parameter: nothing to act on.
if (!authorizationId) {
return (
<AuthLayout>
{logoBlock}
<ErrorMessage
error={t(
"oauthConsent.missingId",
"Missing authorization request. Start the connection from your app and try again.",
)}
/>
</AuthLayout>
);
}
// Not signed in: round-trip through login and come back here.
if (!sessionLoading && !session) {
const next = `${location.pathname}${location.search}`;
return (
<AuthLayout>
{logoBlock}
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
{t(
"oauthConsent.signInPrompt",
"Sign in to your Stirling PDF account to continue connecting the app.",
)}
</p>
<button
type="button"
className="oauth-button-fullwidth"
onClick={() => navigate(`/login?next=${encodeURIComponent(next)}`)}
>
{t("oauthConsent.signInButton", "Sign in to continue")}
</button>
</AuthLayout>
);
}
if (sessionLoading || loadingDetails || redirecting) {
return (
<AuthLayout>
{logoBlock}
<p style={{ textAlign: "center", color: "#6b7280" }}>
{redirecting
? t("oauthConsent.redirecting", "Returning you to the app...")
: t("oauthConsent.loading", "Loading authorization request...")}
</p>
</AuthLayout>
);
}
if (error && !details) {
return (
<AuthLayout>
{logoBlock}
<ErrorMessage error={error} />
</AuthLayout>
);
}
return (
<AuthLayout>
{logoBlock}
{/* AuthLayout forces light mode but text without explicit colors still
inherits dark-scheme values from the app CSS; pin them like the rest
of the auth pages do. */}
<h2
style={{
textAlign: "center",
fontSize: "1.25rem",
fontWeight: 700,
marginBottom: "0.5rem",
color: "#111827",
}}
>
{t("oauthConsent.title", "Authorize access")}
</h2>
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
{t("oauthConsent.requesting", {
app: appName,
defaultValue: `${appName} wants to access your Stirling PDF account`,
})}
</p>
{/* Be explicit about what connecting actually grants. The OAuth scopes
(openid/email) only cover identity; the real power is that the issued
token lets the app drive the MCP endpoint - i.e. run any Stirling PDF
tool as this user, audited as them and counted against their usage. */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
background: "#ffffff",
}}
>
<p
style={{
fontSize: "0.875rem",
fontWeight: 600,
margin: "0 0 0.5rem",
color: "#111827",
}}
>
{t("oauthConsent.scopesIntro", {
app: appName,
defaultValue: `This will allow ${appName} to:`,
})}
</p>
<ul
style={{
margin: 0,
paddingLeft: "1.25rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
{t("oauthConsent.access.tools", {
app: appName,
defaultValue: `Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents`,
})}
</li>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
{t("oauthConsent.access.actAsYou", {
app: appName,
defaultValue: `Act as you - everything ${appName} does runs under your account and counts towards your usage`,
})}
</li>
{scopes.map((scope) => (
<li key={scope} style={{ fontSize: "0.875rem", color: "#374151" }}>
{scopeDescription(scope)}
</li>
))}
</ul>
</div>
<ErrorMessage error={error} />
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<button
type="button"
disabled={deciding !== null}
onClick={() => decide("approve")}
style={{
width: "100%",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "none",
background: "#000000",
color: "#ffffff",
fontWeight: 700,
fontSize: "1rem",
cursor: deciding ? "default" : "pointer",
opacity: deciding && deciding !== "approve" ? 0.6 : 1,
}}
>
{deciding === "approve"
? t("oauthConsent.approving", "Allowing...")
: t("oauthConsent.approve", "Allow access")}
</button>
<button
type="button"
disabled={deciding !== null}
onClick={() => decide("deny")}
className="oauth-button-fullwidth"
>
{deciding === "deny"
? t("oauthConsent.denying", "Denying...")
: t("oauthConsent.deny", "Deny")}
</button>
</div>
{displayName && (
<p
style={{
textAlign: "center",
fontSize: "0.8125rem",
color: "#9ca3af",
marginTop: "1.25rem",
}}
>
{t("oauthConsent.signedInAs", {
name: displayName,
defaultValue: `Signed in as ${displayName}`,
})}
</p>
)}
</AuthLayout>
);
}
@@ -115,14 +115,13 @@ apiClient.interceptors.request.use(
},
);
// List of endpoints that don't require authentication
const publicEndpoints = [
"/api/v1/config/app-config",
"/api/v1/info/status",
"/api/v1/config/public-config",
"/api/v1/config/endpoints-availability",
"/api/v1/config/endpoint-enabled",
];
// Endpoints whose 401s must never trigger the hard redirect to /login. Prefix
// matching covers the whole public surface (/api/v1/config/* and /api/v1/info/*
// are permitAll on the backend), and crucially includes background pings like
// /api/v1/info/wau that fire before session hydration - without this they bounce
// the user off pages that manage their own auth state (e.g. /oauth/consent) and
// lose URL state.
const publicEndpoints = ["/api/v1/config/", "/api/v1/info/"];
// Share one in-flight refresh: Supabase rotates the refresh token on first
// use, so concurrent refreshSession() calls fail with "Already Used".
+7 -1
View File
@@ -25,7 +25,13 @@ export function normalizePath(pathname: string): string {
*/
export function isAuthRoute(pathname: string): boolean {
const p = normalizePath(pathname);
return p === "/login" || p === "/signup" || p === "/auth/callback";
return (
p === "/login" ||
p === "/signup" ||
p === "/auth/callback" ||
p === "/auth/reset" ||
p === "/oauth/consent"
);
}
/**