mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
MCP OAuth discovery fix + Supabase consent page (#6608)
This commit is contained in:
@@ -14,6 +14,7 @@ export const AUTH_ROUTES = [
|
||||
"/invite",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/oauth/consent",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+28
@@ -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">
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user