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
+49
View File
@@ -234,6 +234,10 @@ msgstr "A web address is written without https://."
msgid "A4"
msgstr "A4"
#: src/features/auth/pages/consent.tsx
msgid "Access your account through the API, including reading and changing your resumes and job applications."
msgstr "Access your account through the API, including reading and changing your resumes and job applications."
#: src/routes/_home/-sections/features.tsx
msgid "Access your resumes and data programmatically using the API."
msgstr "Access your resumes and data programmatically using the API."
@@ -523,6 +527,10 @@ msgstr "Albanian"
msgid "All applications (including archived)"
msgstr "All applications (including archived)"
#: src/features/auth/pages/consent.tsx
msgid "Allow access"
msgstr "Allow access"
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx
msgid "Allow Public Access"
msgstr "Allow Public Access"
@@ -1093,6 +1101,10 @@ msgstr "Clear selection"
msgid "Click here to select a file to import"
msgstr "Click here to select a file to import"
#: src/features/auth/pages/consent.tsx
msgid "Client ID"
msgstr "Client ID"
#: src/routes/agent/-components/agent-chat.tsx
msgid "Close AI assistant"
msgstr "Close AI assistant"
@@ -1172,6 +1184,10 @@ msgstr "Confirm Password"
msgid "Connect"
msgstr "Connect"
#: src/features/auth/pages/consent.tsx
msgid "Connect an application"
msgstr "Connect an application"
#: src/features/ats-checker/ai-review/ai-review-card.tsx
msgid "Connect your own AI provider to get a review of the writing. The checks above need no provider and no account."
msgstr "Connect your own AI provider to get a review of the writing. The checks above need no provider and no account."
@@ -1295,6 +1311,10 @@ msgstr "Correct the year, or use \"Present\" for ongoing work."
msgid "Correct the year, or write \"Present\" for ongoing work."
msgstr "Correct the year, or write \"Present\" for ongoing work."
#: src/features/auth/pages/consent.tsx
msgid "Could not complete this connection. Restart the connection from your client and try again."
msgstr "Could not complete this connection. Restart the connection from your client and try again."
#: src/features/resume/export/use-resume-export.ts
msgid "Could not generate the DOCX. Please try again."
msgstr "Could not generate the DOCX. Please try again."
@@ -1722,6 +1742,7 @@ msgstr "Deleting your resume..."
msgid "Denied, waiting for the agent…"
msgstr "Denied, waiting for the agent…"
#: src/features/auth/pages/consent.tsx
#: src/routes/agent/-components/patch-approval-card.tsx
msgid "Deny"
msgstr "Deny"
@@ -2925,6 +2946,10 @@ msgstr "Kannada"
msgid "Keep a single address, so software does not have to guess which one to use."
msgstr "Keep a single address, so software does not have to guess which one to use."
#: src/features/auth/pages/consent.tsx
msgid "Keep access when you are not using the application."
msgstr "Keep access when you are not using the application."
#: src/features/ats-checker/messages.ts
msgid "Keep it under 2.5 MB, usually by removing or shrinking images."
msgstr "Keep it under 2.5 MB, usually by removing or shrinking images."
@@ -3158,6 +3183,10 @@ msgstr "Loading AI providers. Please try again in a moment."
msgid "Loading applications…"
msgstr "Loading applications…"
#: src/features/auth/pages/consent.tsx
msgid "Loading connection request..."
msgstr "Loading connection request..."
#: src/features/settings/integrations/components/ai-provider-picker.tsx
#: src/features/settings/integrations/components/ai-section.tsx
msgid "Loading providers…"
@@ -3630,6 +3659,10 @@ msgstr "One-Click Sign-In"
msgid "Ongoing Maintenance"
msgstr "Ongoing Maintenance"
#: src/features/auth/pages/consent.tsx
msgid "Only allow applications you trust. This application will be able to:"
msgstr "Only allow applications you trust. This application will be able to:"
#. Helper note explaining the keep-together limitation
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx
msgid "Only applies when the section fits on a single page."
@@ -4173,6 +4206,14 @@ msgstr "Read the Applying Custom Styles guide."
msgid "Read the resume"
msgstr "Read the resume"
#: src/features/auth/pages/consent.tsx
msgid "Read your email address."
msgstr "Read your email address."
#: src/features/auth/pages/consent.tsx
msgid "Read your profile information."
msgstr "Read your profile information."
#: src/features/ats-checker/messages.ts
msgid "Readability"
msgstr "Readability"
@@ -4881,6 +4922,10 @@ msgstr "Sign up"
msgid "Sign-in didn't complete"
msgstr "Sign-in didn't complete"
#: src/features/auth/pages/consent.tsx
msgid "Signed in as {email}"
msgstr "Signed in as {email}"
#: src/features/auth/components/social-auth.tsx
#: src/features/auth/pages/login.tsx
msgid "Signing in..."
@@ -5495,6 +5540,10 @@ msgstr "This check does not apply to this file."
msgid "This check only runs on resumes written in English."
msgstr "This check only runs on resumes written in English."
#: src/features/auth/pages/consent.tsx
msgid "This connection request is invalid or has expired."
msgstr "This connection request is invalid or has expired."
#: src/dialogs/api-key/create.tsx
msgid "This creates a new API key, which lets other programs read and change your resume data through the Reactive Resume API."
msgstr "This creates a new API key, which lets other programs read and change your resume data through the Reactive Resume API."
@@ -3,13 +3,14 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FingerprintIcon, GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { useRouter, useSearch } from "@tanstack/react-router";
import { Button } from "@reactive-resume/ui/components/button";
import { Skeleton } from "@reactive-resume/ui/components/skeleton";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { authClient } from "@/libs/auth/client";
import { orpc } from "@/libs/orpc/client";
import { getAuthRedirectOptions, getOAuthPasskeyOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
export function SocialAuth() {
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
@@ -48,10 +49,14 @@ type SocialAuthButtonsProps = {
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
const router = useRouter();
const { callbackURL } = useSearch({ from: "/auth" });
const runSignIn = async (fn: () => Promise<{ error: { message?: string } | null }>) => {
const runSignIn = async (
fn: () => Promise<{ data?: unknown; error: { message?: string } | null }>,
isPasskey = false,
) => {
const toastId = toast.add({ type: "loading", description: t`Signing in...` });
const { error } = await fn();
const { data, error } = await fn();
if (error) {
toast.add({
type: "error",
@@ -66,7 +71,9 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
return;
}
toast.close(toastId);
if (isOAuthRedirect(data)) return;
await router.invalidate();
if (isPasskey) void router.navigate(getAuthRedirectOptions(callbackURL));
};
return (
@@ -77,7 +84,8 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
runSignIn(() =>
authClient.signIn.social({
provider: "custom",
callbackURL: "/dashboard",
callbackURL: callbackURL ?? "/dashboard",
...getOAuthSignInOptions(callbackURL),
}),
)
}
@@ -89,7 +97,9 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
<Button
variant="secondary"
onClick={() => runSignIn(() => authClient.signIn.passkey({ autoFill: false }))}
onClick={() =>
runSignIn(() => authClient.signIn.passkey({ autoFill: false, ...getOAuthPasskeyOptions(callbackURL) }), true)
}
className={cn("hidden", "passkey" in providers && "inline-flex")}
>
<FingerprintIcon />
@@ -97,7 +107,15 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
</Button>
<Button
onClick={() => runSignIn(() => authClient.signIn.social({ provider: "google", callbackURL: "/dashboard" }))}
onClick={() =>
runSignIn(() =>
authClient.signIn.social({
provider: "google",
callbackURL: callbackURL ?? "/dashboard",
...getOAuthSignInOptions(callbackURL),
}),
)
}
className={cn(
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
"google" in providers && "inline-flex",
@@ -108,7 +126,15 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
</Button>
<Button
onClick={() => runSignIn(() => authClient.signIn.social({ provider: "github", callbackURL: "/dashboard" }))}
onClick={() =>
runSignIn(() =>
authClient.signIn.social({
provider: "github",
callbackURL: callbackURL ?? "/dashboard",
...getOAuthSignInOptions(callbackURL),
}),
)
}
className={cn(
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
"github" in providers && "inline-flex",
@@ -119,7 +145,15 @@ function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
</Button>
<Button
onClick={() => runSignIn(() => authClient.signIn.social({ provider: "linkedin", callbackURL: "/dashboard" }))}
onClick={() =>
runSignIn(() =>
authClient.signIn.social({
provider: "linkedin",
callbackURL: callbackURL ?? "/dashboard",
...getOAuthSignInOptions(callbackURL),
}),
)
}
className={cn(
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
"linkedin" in providers && "inline-flex",
@@ -0,0 +1,209 @@
// @vitest-environment happy-dom
import type { ComponentProps } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
const mocks = vi.hoisted(() => ({
callbackURL: "/api/auth/oauth?client_id=client&resource=https%3A%2F%2Fresume.example%2Fmcp&exp=123&sig=456",
navigate: vi.fn(),
invalidate: vi.fn(),
email: vi.fn(),
username: vi.fn(),
social: vi.fn(),
passkey: vi.fn(),
verifyTotp: vi.fn(),
verifyBackupCode: vi.fn(),
signup: vi.fn(),
continueOAuth: vi.fn(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { google: "Google", github: "GitHub", linkedin: "LinkedIn", custom: "SSO", passkey: true },
}),
}));
vi.mock("@tanstack/react-router", () => ({
useSearch: () => ({ callbackURL: mocks.callbackURL }),
useRouter: () => ({ navigate: mocks.navigate, invalidate: mocks.invalidate }),
useNavigate: () => mocks.navigate,
Link: ({ to, search: _search, ...props }: ComponentProps<"a"> & { to: string; search?: unknown }) => (
<a href={to} {...props} />
),
}));
vi.mock("@/libs/orpc/client", () => ({ orpc: { auth: { providers: { list: { queryOptions: () => ({}) } } } } }));
vi.mock("@/libs/auth/client", () => ({
authClient: {
signUp: { email: mocks.signup },
oauth2: { continue: mocks.continueOAuth },
twoFactor: { verifyTotp: mocks.verifyTotp, verifyBackupCode: mocks.verifyBackupCode },
signIn: { email: mocks.email, username: mocks.username, social: mocks.social, passkey: mocks.passkey },
},
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn(), close: vi.fn() } }));
import { LoginPage } from "./pages/login";
import { RegisterPage } from "./pages/register";
import { VerifyTwoFactorBackupPage, VerifyTwoFactorPage } from "./pages/verify-2fa";
beforeEach(() => {
vi.clearAllMocks();
mocks.callbackURL = "/api/auth/oauth?client_id=client&resource=https%3A%2F%2Fresume.example%2Fmcp&exp=123&sig=456";
i18n.loadAndActivate({ locale: "en-US", messages: {} });
mocks.invalidate.mockResolvedValue(undefined);
for (const signIn of [
mocks.email,
mocks.username,
mocks.social,
mocks.passkey,
mocks.verifyTotp,
mocks.verifyBackupCode,
mocks.signup,
mocks.continueOAuth,
])
signIn.mockResolvedValue({ data: {}, error: null });
});
afterEach(cleanup);
function renderLogin() {
return render(
<I18nProvider i18n={i18n}>
<LoginPage disableEmailAuth={false} disableSignups={false} />
</I18nProvider>,
);
}
function submitLogin(container: HTMLElement, identifier: string) {
const input = container.querySelector('input[name="identifier"]');
const password = container.querySelector('input[name="password"]');
const form = container.querySelector("form");
if (!input || !password || !form) throw new Error("Login form is missing");
fireEvent.change(input, { target: { value: identifier } });
fireEvent.change(password, { target: { value: "password123" } });
fireEvent.submit(form);
}
describe("OAuth callback after sign-in", () => {
it.each(["john@example.com", "john"])(
"resumes the signed server callback after email/username sign-in (%s)",
async (identifier) => {
const { container } = renderLogin();
submitLogin(container, identifier);
await waitFor(() =>
expect(mocks.navigate).toHaveBeenCalledWith({ href: mocks.callbackURL, reloadDocument: true, replace: true }),
);
},
);
it.each([
["Google", "google"],
["GitHub", "github"],
["LinkedIn", "linkedin"],
["SSO", "custom"],
])("passes the callback to %s sign-in", async (label, provider) => {
renderLogin();
fireEvent.click(screen.getByRole("button", { name: label }));
await waitFor(() =>
expect(mocks.social).toHaveBeenCalledWith({
provider,
callbackURL: mocks.callbackURL,
oauth_query: mocks.callbackURL.split("?")[1],
}),
);
});
it("resumes the callback after passkey sign-in", async () => {
renderLogin();
fireEvent.click(screen.getByRole("button", { name: "Passkey" }));
await waitFor(() =>
expect(mocks.navigate).toHaveBeenCalledWith({ href: mocks.callbackURL, reloadDocument: true, replace: true }),
);
});
it("carries the callback into two-factor verification", async () => {
mocks.email.mockResolvedValueOnce({ data: { twoFactorRedirect: true }, error: null });
const { container } = renderLogin();
submitLogin(container, "john@example.com");
await waitFor(() =>
expect(mocks.navigate).toHaveBeenCalledWith({
to: "/auth/verify-2fa",
search: { callbackURL: mocks.callbackURL },
replace: true,
}),
);
});
it("lets the provider finish its validated redirect after fresh authentication", async () => {
mocks.email.mockResolvedValueOnce({
data: { redirect: true, url: "http://127.0.0.1:1234/callback?code=code" },
error: null,
});
const { container } = renderLogin();
submitLogin(container, "john@example.com");
await waitFor(() =>
expect(mocks.email).toHaveBeenCalledWith(
expect.objectContaining({ oauth_query: mocks.callbackURL.split("?")[1] }),
),
);
expect(mocks.navigate).not.toHaveBeenCalled();
});
it("stays on login when credentials are rejected", async () => {
mocks.email.mockResolvedValueOnce({ data: null, error: { message: "Invalid credentials" } });
const { container } = renderLogin();
submitLogin(container, "john@example.com");
await waitFor(() => expect(mocks.email).toHaveBeenCalled());
expect(mocks.navigate).not.toHaveBeenCalled();
});
});
describe("OAuth callback after two-factor verification", () => {
it.each([false, true])("resumes the callback after verifying a code (backup: %s)", async (backup) => {
const { container } = render(
<I18nProvider i18n={i18n}>{backup ? <VerifyTwoFactorBackupPage /> : <VerifyTwoFactorPage />}</I18nProvider>,
);
const input = container.querySelector('input[name="code"]');
const form = container.querySelector("form");
if (!input || !form) throw new Error("Verification form is missing");
fireEvent.change(input, { target: { value: backup ? "abcde12345" : "123456" } });
fireEvent.submit(form);
await waitFor(() =>
expect(mocks.navigate).toHaveBeenCalledWith({ href: mocks.callbackURL, reloadDocument: true, replace: true }),
);
});
});
describe("OAuth account creation", () => {
it("continues a create prompt only after successful signup", async () => {
mocks.callbackURL += "&prompt=create";
mocks.signup.mockResolvedValueOnce({ data: { token: "session" }, error: null });
mocks.continueOAuth.mockResolvedValueOnce({
data: { redirect: true, url: "/api/auth/oauth?sig=next" },
error: null,
});
const { container } = render(
<I18nProvider i18n={i18n}>
<RegisterPage disableEmailAuth={false} />
</I18nProvider>,
);
for (const [name, value] of Object.entries({
name: "New User",
username: "newuser",
email: "new@example.com",
password: "password123",
})) {
const input = container.querySelector(`input[name="${name}"]`);
if (!input) throw new Error(`Missing ${name} field`);
fireEvent.change(input, { target: { value } });
}
const form = container.querySelector("form");
if (!form) throw new Error("Missing registration form");
fireEvent.submit(form);
await waitFor(() =>
expect(mocks.continueOAuth).toHaveBeenCalledWith({ created: true, oauth_query: mocks.callbackURL.split("?")[1] }),
);
expect(mocks.signup.mock.calls[0]?.[0]).not.toHaveProperty("oauth_query");
});
});
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({ publicClient: vi.fn(), consent: vi.fn() }));
vi.mock("@/libs/auth/client", () => ({ authClient: { oauth2: mocks } }));
const { OAuthConsentPage } = await import("./consent");
const oauthQuery =
"client_id=client&scope=openid+profile+email+offline_access&resource=one&resource=two&exp=123&sig=signed";
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => {
vi.resetAllMocks();
mocks.publicClient.mockResolvedValue({ data: { client_name: "Test client" } });
mocks.consent.mockResolvedValue({ data: { redirect: true, url: "http://localhost/callback?code=code" } });
});
function renderPage(query = oauthQuery) {
return render(
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })}>
<I18nProvider i18n={i18n}>
<OAuthConsentPage oauthQuery={query} email="owner@example.com" />
</I18nProvider>
</QueryClientProvider>,
);
}
it("shows the client and account permissions without granting access", async () => {
renderPage();
await screen.findByRole("button", { name: "Allow access" });
expect(screen.getByText("Test client")).toBeVisible();
expect(screen.getByText(/owner@example.com/)).toBeVisible();
expect(screen.getByText(/reading and changing your resumes and job applications/)).toBeVisible();
expect(screen.getByText("Read your profile information.")).toBeVisible();
expect(screen.getByText("Read your email address.")).toBeVisible();
expect(screen.getByText("Keep access when you are not using the application.")).toBeVisible();
expect(mocks.consent).not.toHaveBeenCalled();
});
it.each([true, false])("sends explicit accept=%s with the original signed query", async (accept) => {
renderPage();
await userEvent.click(await screen.findByRole("button", { name: accept ? "Allow access" : "Deny" }));
expect(mocks.consent).toHaveBeenCalledExactlyOnceWith({ accept, oauth_query: oauthQuery });
expect(screen.getByRole("button", { name: "Allow access" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Deny" })).toBeDisabled();
});
it.each(["", "client_id=client"])("offers no approval for an incomplete request %s", async (query) => {
renderPage(query);
expect(await screen.findByRole("alert")).toHaveTextContent("invalid or has expired");
expect(screen.queryByRole("button", { name: "Allow access" })).not.toBeInTheDocument();
expect(mocks.publicClient).not.toHaveBeenCalled();
expect(mocks.consent).not.toHaveBeenCalled();
});
it("offers no approval for an unknown or disabled client", async () => {
mocks.publicClient.mockResolvedValue({ error: { message: "not found" } });
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("invalid or has expired");
expect(screen.queryByRole("button", { name: "Allow access" })).not.toBeInTheDocument();
});
it.each([{}, { data: { redirect: true } }, { error: { message: "Invalid signature" } }])(
"keeps malformed or rejected approvals on screen %j",
async (response) => {
mocks.consent.mockResolvedValue(response);
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Allow access" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Restart the connection");
expect(screen.getByRole("button", { name: "Allow access" })).toBeEnabled();
},
);
it("recovers from a network error and blocks duplicate submissions", async () => {
mocks.consent.mockRejectedValueOnce(new Error("network"));
renderPage();
await userEvent.click(await screen.findByRole("button", { name: "Allow access" }));
await screen.findByRole("alert");
mocks.consent.mockImplementationOnce(() => new Promise(() => {}));
await userEvent.click(screen.getByRole("button", { name: "Allow access" }));
await userEvent.click(screen.getByRole("button", { name: "Deny" }));
await waitFor(() => expect(mocks.consent).toHaveBeenCalledTimes(2));
});
it("renders client supplied markup as text", async () => {
mocks.publicClient.mockResolvedValue({ data: { client_name: '<img src="https://untrusted.example/tracker">' } });
renderPage();
await screen.findByRole("button", { name: "Allow access" });
expect(screen.getByText(/<img src=/)).toBeVisible();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
@@ -0,0 +1,123 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { authClient } from "@/libs/auth/client";
import { isOAuthRedirect } from "../redirect";
type OAuthConsentPageProps = {
oauthQuery: string;
email: string;
};
export function OAuthConsentPage({ oauthQuery, email }: OAuthConsentPageProps) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string>();
const query = new URLSearchParams(oauthQuery);
const clientId = query.get("client_id") ?? "";
const scopes = new Set(query.get("scope")?.split(" ") ?? []);
const validRequest = !!clientId && query.has("sig");
const {
data: client,
isPending,
isError,
} = useQuery({
queryKey: ["oauth-client", clientId, oauthQuery],
enabled: validRequest,
retry: false,
queryFn: async () => {
const { data, error } = await authClient.oauth2.publicClient({ query: { client_id: clientId } });
if (error || !data) throw new Error(t`This connection request is invalid or has expired.`);
return data;
},
});
async function submit(accept: boolean) {
if (pending || !client || !validRequest) return;
setPending(true);
setError(undefined);
try {
// This is the only point that grants access: an explicit button press.
// Better Auth validates the signed request, session, and request origin.
const { data, error } = await authClient.oauth2.consent({ accept, oauth_query: oauthQuery });
if (error || !isOAuthRedirect(data)) {
setError(t`Could not complete this connection. Restart the connection from your client and try again.`);
setPending(false);
}
// Better Auth's redirect plugin follows a successful provider response.
} catch {
setError(t`Could not complete this connection. Restart the connection from your client and try again.`);
setPending(false);
}
}
return (
<>
<div className="space-y-2 text-center">
<h1 className="font-semibold text-2xl tracking-tight">
<Trans>Connect an application</Trans>
</h1>
<p className="wrap-anywhere text-muted-foreground text-sm">
<Trans>Signed in as {email}</Trans>
</p>
</div>
{!validRequest || isError ? (
<p role="alert">
<Trans>This connection request is invalid or has expired.</Trans>
</p>
) : isPending ? (
<p role="status">
<Trans>Loading connection request...</Trans>
</p>
) : client ? (
<div className="space-y-4">
<div className="wrap-anywhere space-y-1">
<p className="font-medium">{client.client_name || clientId}</p>
<p className="text-muted-foreground text-xs">
<Trans>Client ID</Trans>: {clientId}
</p>
</div>
<p className="text-sm">
<Trans>Only allow applications you trust. This application will be able to:</Trans>
</p>
<ul className="list-disc space-y-2 pl-5 text-sm">
<li>
<Trans>
Access your account through the API, including reading and changing your resumes and job applications.
</Trans>
</li>
{scopes.has("profile") && (
<li>
<Trans>Read your profile information.</Trans>
</li>
)}
{scopes.has("email") && (
<li>
<Trans>Read your email address.</Trans>
</li>
)}
{scopes.has("offline_access") && (
<li>
<Trans>Keep access when you are not using the application.</Trans>
</li>
)}
</ul>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<div className="flex gap-2">
<Button className="flex-1" variant="outline" disabled={pending} onClick={() => void submit(false)}>
<Trans>Deny</Trans>
</Button>
<Button className="flex-1" disabled={pending} onClick={() => void submit(true)}>
<Trans>Allow access</Trans>
</Button>
</div>
</div>
) : null}
</>
);
}
+24 -9
View File
@@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { useToggle } from "usehooks-ts";
import z from "zod";
@@ -14,6 +14,7 @@ import { authClient } from "@/libs/auth/client";
import { orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { SocialAuth } from "../components/social-auth";
import { getAuthRedirectOptions, getOAuthPasskeyOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
const formSchema = z.object({
identifier: z.string().trim().toLowerCase(),
@@ -27,6 +28,7 @@ type Props = {
export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
const router = useRouter();
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
const navigate = useNavigate();
const hasStartedConditionalPasskeyRef = useRef(false);
@@ -44,8 +46,16 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
const isEmail = value.identifier.includes("@");
const result = isEmail
? await authClient.signIn.email({ email: value.identifier, password: value.password })
: await authClient.signIn.username({ username: value.identifier, password: value.password });
? await authClient.signIn.email({
email: value.identifier,
password: value.password,
...getOAuthSignInOptions(callbackURL),
})
: await authClient.signIn.username({
username: value.identifier,
password: value.password,
...getOAuthSignInOptions(callbackURL),
});
if (result.error) {
toast.add({
@@ -69,13 +79,14 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
if (requiresTwoFactor) {
toast.close(toastId);
void navigate({ to: "/auth/verify-2fa", replace: true });
void navigate({ to: "/auth/verify-2fa", search: { callbackURL, reauthenticate }, replace: true });
return;
}
toast.close(toastId);
if (isOAuthRedirect(result.data)) return;
await router.invalidate();
void navigate({ to: "/dashboard", replace: true });
void navigate(getAuthRedirectOptions(callbackURL));
} catch {
toast.add({ type: "error", description: t`Failed to sign in. Please try again.`, id: toastId });
}
@@ -94,12 +105,16 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
void PublicKeyCredential.isConditionalMediationAvailable().then(async (isAvailable) => {
if (!isAvailable) return;
const { error } = await authClient.signIn.passkey({ autoFill: true });
if (error) return;
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
...getOAuthPasskeyOptions(callbackURL),
});
if (error || isOAuthRedirect(data)) return;
await router.invalidate();
void navigate(getAuthRedirectOptions(callbackURL));
});
}, [providers, router]);
}, [providers, router, navigate, callbackURL]);
return (
<>
@@ -117,7 +132,7 @@ export function LoginPage({ disableEmailAuth, disableSignups }: Props) {
nativeButton={false}
className="h-auto gap-1.5 px-1! py-0"
render={
<Link to="/auth/register">
<Link to="/auth/register" search={{ callbackURL, reauthenticate }}>
<Trans comment="Call-to-action link from login page to account registration page">
Create one now
</Trans>{" "}
+21 -6
View File
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, EyeIcon, EyeSlashIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Link, useSearch } from "@tanstack/react-router";
import { useState } from "react";
import { useToggle } from "usehooks-ts";
import z from "zod";
@@ -13,6 +13,7 @@ import { toast } from "@reactive-resume/ui/components/toast";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
import { SocialAuth } from "../components/social-auth";
import { getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
const formSchema = z.object({
name: z.string().min(3).max(64),
@@ -34,6 +35,7 @@ type Props = {
};
export function RegisterPage({ disableEmailAuth }: Props) {
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
const [submitted, setSubmitted] = useState(false);
const [showPassword, toggleShowPassword] = useToggle(false);
@@ -43,13 +45,16 @@ export function RegisterPage({ disableEmailAuth }: Props) {
onSubmit: async ({ value }) => {
const toastId = toast.add({ type: "loading", description: t`Signing up...` });
const { error } = await authClient.signUp.email({
const oauthOptions = getOAuthSignInOptions(callbackURL);
const createPrompt = new URLSearchParams(oauthOptions.oauth_query).get("prompt")?.split(" ").includes("create");
const { data, error } = await authClient.signUp.email({
name: value.name,
email: value.email,
password: value.password,
username: value.username,
displayUsername: value.username,
callbackURL: "/dashboard",
callbackURL: callbackURL ?? "/dashboard",
...(!createPrompt ? oauthOptions : {}),
});
if (error) {
@@ -66,6 +71,15 @@ export function RegisterPage({ disableEmailAuth }: Props) {
return;
}
if (isOAuthRedirect(data)) return;
if (createPrompt && oauthOptions.oauth_query) {
const continuation = await authClient.oauth2.continue({ created: true, oauth_query: oauthOptions.oauth_query });
if (continuation.error) {
toast.add({ type: "error", description: continuation.error.message, id: toastId });
return;
}
if (isOAuthRedirect(continuation.data)) return;
}
setSubmitted(true);
toast.close(toastId);
},
@@ -88,7 +102,7 @@ export function RegisterPage({ disableEmailAuth }: Props) {
nativeButton={false}
className="h-auto gap-1.5 px-1! py-0"
render={
<Link to="/auth/login">
<Link to="/auth/login" search={{ callbackURL, reauthenticate }}>
<Trans comment="Call-to-action link from registration page to login page">Sign in now</Trans>{" "}
<ArrowRightIcon />
</Link>
@@ -250,6 +264,7 @@ export function RegisterPage({ disableEmailAuth }: Props) {
}
function PostSignupScreen() {
const { callbackURL } = useSearch({ from: "/auth" });
return (
<>
<div className="space-y-1 text-center">
@@ -273,10 +288,10 @@ function PostSignupScreen() {
<Button
nativeButton={false}
render={
<Link to="/dashboard">
<a href={callbackURL ?? "/dashboard"}>
<Trans comment="Button label to continue to dashboard after successful registration">Continue</Trans>{" "}
<ArrowRightIcon />
</Link>
</a>
}
/>
</>
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
import { Link, useNavigate, useRouter } from "@tanstack/react-router";
import { Link, useNavigate, useRouter, useSearch } from "@tanstack/react-router";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
@@ -9,6 +9,7 @@ import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
import { getAuthRedirectOptions, getOAuthSignInOptions, isOAuthRedirect } from "../redirect";
const totpSchema = z.object({
code: z.string().length(6, "Code must be 6 digits"),
@@ -24,6 +25,7 @@ type TwoFactorVerificationPageProps = {
function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerificationPageProps) {
const router = useRouter();
const { callbackURL, reauthenticate } = useSearch({ from: "/auth" });
const navigate = useNavigate();
const form = useAppForm({
@@ -35,9 +37,9 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
description: backupCode ? t`Verifying backup code...` : t`Verifying code...`,
});
const code = backupCode ? `${value.code.slice(0, 5)}-${value.code.slice(5)}` : value.code;
const { error } = backupCode
? await authClient.twoFactor.verifyBackupCode({ code })
: await authClient.twoFactor.verifyTotp({ code });
const { data, error } = backupCode
? await authClient.twoFactor.verifyBackupCode({ code, ...getOAuthSignInOptions(callbackURL) })
: await authClient.twoFactor.verifyTotp({ code, ...getOAuthSignInOptions(callbackURL) });
if (error) {
toast.add({
@@ -59,8 +61,9 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
}
toast.close(toastId);
if (isOAuthRedirect(data)) return;
await router.invalidate();
void navigate({ to: "/dashboard", replace: true });
void navigate(getAuthRedirectOptions(callbackURL));
},
});
@@ -117,7 +120,7 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
className="flex-1"
nativeButton={false}
render={
<Link to={backupCode ? "/auth/verify-2fa" : "/auth/login"}>
<Link to={backupCode ? "/auth/verify-2fa" : "/auth/login"} search={{ callbackURL, reauthenticate }}>
<ArrowLeftIcon />
{backupCode ? (
<Trans comment="Secondary navigation button on backup-code verification screen">Go Back</Trans>
@@ -145,7 +148,7 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
nativeButton={false}
className="h-auto justify-self-center p-0 text-sm"
render={
<Link to="/auth/verify-2fa-backup">
<Link to="/auth/verify-2fa-backup" search={{ callbackURL, reauthenticate }}>
<Trans comment="Link to backup-code verification flow when authenticator app is unavailable">
Lost access to your authenticator?
</Trans>
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { authSearchSchema, getAuthRedirectOptions, getOAuthPasskeyOptions, getOAuthSignInOptions } from "./redirect";
describe("authentication callback", () => {
it.each([{}, { reauthenticate: true }])("accepts auth routes without a callback query: %j", (search) => {
expect(authSearchSchema.parse(search)).toEqual(search);
});
it("preserves a signed OAuth callback including repeated resources", () => {
const callbackURL =
"/api/auth/oauth?client_id=client&resource=https%3A%2F%2Fapp.example%2Fmcp&resource=https%3A%2F%2Fapp.example&exp=123&sig=signed";
expect(authSearchSchema.parse({ callbackURL })).toEqual({ callbackURL });
expect(getAuthRedirectOptions(callbackURL)).toEqual({ href: callbackURL, reloadDocument: true, replace: true });
});
it.each([
undefined,
123,
"https://evil.example",
"//evil.example",
"/\\evil.example",
"/%2f%2fevil.example",
"/%5cevil.example",
"/\tevil.example",
"javascript:alert(1)",
])("falls back for unsafe callback %s", (callbackURL) => {
expect(authSearchSchema.parse({ callbackURL }).callbackURL).toBeUndefined();
expect(getAuthRedirectOptions(callbackURL)).toEqual({ href: "/dashboard", reloadDocument: false, replace: true });
});
it("accepts application paths without interpreting nested URL query values", () => {
const callbackURL = "/dashboard?next=https%3A%2F%2Fexample.com";
expect(authSearchSchema.parse({ callbackURL }).callbackURL).toBe(callbackURL);
});
});
describe("signed OAuth sign-in context", () => {
it("forwards the original signed query without decoding or dropping repeated resources", () => {
const query = "resource=one&resource=two&prompt=login&exp=123&sig=signed";
expect(getOAuthSignInOptions(`/api/auth/oauth?${query}`)).toEqual({ oauth_query: query });
});
it.each(["https://evil.example/api/auth/oauth?sig=signed", "/dashboard?sig=signed", "/api/auth/oauth?prompt=login"])(
"does not attach OAuth context from %s",
(callbackURL) => {
expect(getOAuthSignInOptions(callbackURL)).toEqual({});
},
);
});
describe("passkey OAuth context", () => {
it("adds signed context to the actual WebAuthn verification request without dropping the assertion", () => {
const request = {
body: JSON.stringify({ response: { id: "credential", response: { signature: "webauthn-signature" } } }),
};
getOAuthPasskeyOptions("/api/auth/oauth?prompt=login&sig=signed").fetchOptions.onRequest(request);
expect(JSON.parse(request.body)).toEqual({
response: { id: "credential", response: { signature: "webauthn-signature" } },
oauth_query: "prompt=login&sig=signed",
});
});
});
+63
View File
@@ -0,0 +1,63 @@
import z from "zod";
// biome-ignore lint/suspicious/noControlCharactersInRegex: Reject URL parser whitespace and control-character normalization.
const unsafeCallbackCharacters = /[\\\u0000-\u0020\u007f]/;
function safeCallbackURL(value: unknown): string | undefined {
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return;
if (unsafeCallbackCharacters.test(value)) return;
try {
const pathname = decodeURIComponent(value.split(/[?#]/, 1)[0] ?? "");
if (pathname.startsWith("//") || unsafeCallbackCharacters.test(pathname)) return;
const url = new URL(value, "https://callback.invalid");
if (url.origin !== "https://callback.invalid") return;
return value;
} catch {
return;
}
}
export const authSearchSchema = z.object({
callbackURL: z.unknown().transform(safeCallbackURL).optional(),
reauthenticate: z.boolean().optional().catch(undefined),
});
export function getAuthRedirectOptions(value: unknown) {
const callbackURL = safeCallbackURL(value);
// OAuth callbacks are server endpoints; they must run through a document request.
return { href: callbackURL ?? "/dashboard", reloadDocument: callbackURL !== undefined, replace: true } as const;
}
export function getOAuthSignInOptions(callback: unknown) {
const callbackURL = safeCallbackURL(callback);
if (!callbackURL) return {};
const url = new URL(callbackURL, "https://callback.invalid");
if (url.pathname !== "/api/auth/oauth" || !url.searchParams.has("sig")) return {};
return { oauth_query: url.search.slice(1) };
}
export function isOAuthRedirect(data: unknown) {
return (
typeof data === "object" &&
data !== null &&
"redirect" in data &&
data.redirect === true &&
"url" in data &&
typeof data.url === "string"
);
}
export function getOAuthPasskeyOptions(callbackURL: unknown) {
const oauthOptions = getOAuthSignInOptions(callbackURL);
return {
fetchOptions: {
onRequest(context: { body?: unknown }) {
if (!oauthOptions.oauth_query) return;
// The passkey client forwards fetchOptions, but drops extra top-level fields.
const body: unknown = typeof context.body === "string" ? JSON.parse(context.body) : context.body;
if (body && typeof body === "object") context.body = JSON.stringify({ ...body, ...oauthOptions });
},
},
};
}
+9 -1
View File
@@ -6,6 +6,7 @@ import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resourc
import { passkeyClient } from "@better-auth/passkey/client";
import { adminClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { authSearchSchema } from "@/features/auth/redirect";
export const authClient = createAuthClient({
plugins: [
@@ -18,7 +19,14 @@ export const authClient = createAuthClient({
onTwoFactorRedirect() {
// Redirect to 2FA verification page
if (typeof window !== "undefined") {
window.location.href = "/auth/verify-2fa";
const { callbackURL, reauthenticate } = authSearchSchema.parse({
reauthenticate: new URLSearchParams(window.location.search).get("reauthenticate") === "true",
callbackURL: new URLSearchParams(window.location.search).get("callbackURL"),
});
const search = callbackURL
? `?${new URLSearchParams({ callbackURL, ...(reauthenticate ? { reauthenticate: "true" } : {}) })}`
: "";
window.location.href = `/auth/verify-2fa${search}`;
}
},
}),
+21
View File
@@ -20,6 +20,7 @@ import { Route as AgentIndexRouteImport } from "./routes/agent/index";
import { Route as AgentThreadIdRouteImport } from "./routes/agent/$threadId";
import { Route as AgentNewRouteImport } from "./routes/agent/new";
import { Route as AuthIndexRouteImport } from "./routes/auth/index";
import { Route as AuthConsentRouteImport } from "./routes/auth/consent";
import { Route as AuthErrorRouteImport } from "./routes/auth/error";
import { Route as AuthForgotPasswordRouteImport } from "./routes/auth/forgot-password";
import { Route as AuthLoginRouteImport } from "./routes/auth/login";
@@ -96,6 +97,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({
path: "/",
getParentRoute: () => AuthRouteRoute,
} as any);
const AuthConsentRoute = AuthConsentRouteImport.update({
id: "/consent",
path: "/consent",
getParentRoute: () => AuthRouteRoute,
} as any);
const AuthErrorRoute = AuthErrorRouteImport.update({
id: "/error",
path: "/error",
@@ -220,6 +226,7 @@ export interface FileRoutesByFullPath {
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/consent": typeof AuthConsentRoute;
"/auth/error": typeof AuthErrorRoute;
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
"/auth/login": typeof AuthLoginRoute;
@@ -248,6 +255,7 @@ export interface FileRoutesByTo {
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/consent": typeof AuthConsentRoute;
"/auth/error": typeof AuthErrorRoute;
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
"/auth/login": typeof AuthLoginRoute;
@@ -283,6 +291,7 @@ export interface FileRoutesById {
"/_home/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
"/agent/new": typeof AgentNewRoute;
"/auth/consent": typeof AuthConsentRoute;
"/auth/error": typeof AuthErrorRoute;
"/auth/forgot-password": typeof AuthForgotPasswordRoute;
"/auth/login": typeof AuthLoginRoute;
@@ -319,6 +328,7 @@ export interface FileRouteTypes {
| "/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/consent"
| "/auth/error"
| "/auth/forgot-password"
| "/auth/login"
@@ -347,6 +357,7 @@ export interface FileRouteTypes {
| "/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/consent"
| "/auth/error"
| "/auth/forgot-password"
| "/auth/login"
@@ -381,6 +392,7 @@ export interface FileRouteTypes {
| "/_home/ats-checker"
| "/agent/$threadId"
| "/agent/new"
| "/auth/consent"
| "/auth/error"
| "/auth/forgot-password"
| "/auth/login"
@@ -495,6 +507,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof AuthIndexRouteImport;
parentRoute: typeof AuthRouteRoute;
};
"/auth/consent": {
id: "/auth/consent";
path: "/consent";
fullPath: "/auth/consent";
preLoaderRoute: typeof AuthConsentRouteImport;
parentRoute: typeof AuthRouteRoute;
};
"/auth/error": {
id: "/auth/error";
path: "/error";
@@ -676,6 +695,7 @@ const AgentRouteRouteWithChildren = AgentRouteRoute._addFileChildren(
);
interface AuthRouteRouteChildren {
AuthConsentRoute: typeof AuthConsentRoute;
AuthErrorRoute: typeof AuthErrorRoute;
AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute;
AuthLoginRoute: typeof AuthLoginRoute;
@@ -688,6 +708,7 @@ interface AuthRouteRouteChildren {
}
const AuthRouteRouteChildren: AuthRouteRouteChildren = {
AuthConsentRoute: AuthConsentRoute,
AuthErrorRoute: AuthErrorRoute,
AuthForgotPasswordRoute: AuthForgotPasswordRoute,
AuthLoginRoute: AuthLoginRoute,
+26
View File
@@ -0,0 +1,26 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { OAuthConsentPage } from "@/features/auth/pages/consent";
export const Route = createFileRoute("/auth/consent")({
ssr: false,
beforeLoad: ({ context, location }) => {
if (!context.session) {
throw redirect({
to: "/auth/login",
search: {
callbackURL: `/api/auth/oauth${typeof window === "undefined" ? location.searchStr : window.location.search}`,
},
});
}
return { session: context.session };
},
component: RouteComponent,
});
function RouteComponent() {
const { session } = Route.useRouteContext();
// This route is client-only. TanStack serializes repeated parameters as JSON
// arrays in location.searchStr, invalidating the provider signature. Use the URL verbatim.
const oauthQuery = window.location.search.slice(1);
return <OAuthConsentPage key={oauthQuery} oauthQuery={oauthQuery} email={session.user.email} />;
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { getAuthRedirectOptions } from "@/features/auth/redirect";
export const Route = createFileRoute("/auth/")({
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
throw redirect({ to: "/auth/login", replace: true });
beforeLoad: ({ context, search }) => {
if (context.session && !search.reauthenticate) throw redirect(getAuthRedirectOptions(search.callbackURL));
throw redirect({ to: "/auth/login", search, replace: true });
},
});
+3 -2
View File
@@ -1,10 +1,11 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { LoginPage } from "@/features/auth/pages/login";
import { getAuthRedirectOptions } from "@/features/auth/redirect";
export const Route = createFileRoute("/auth/login")({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
beforeLoad: ({ context, search }) => {
if (context.session && !search.reauthenticate) throw redirect(getAuthRedirectOptions(search.callbackURL));
return { session: null };
},
});
+4 -3
View File
@@ -1,11 +1,12 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { RegisterPage } from "@/features/auth/pages/register";
import { getAuthRedirectOptions } from "@/features/auth/redirect";
export const Route = createFileRoute("/auth/register")({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", replace: true });
beforeLoad: ({ context, search }) => {
if (context.session && !search.reauthenticate) throw redirect(getAuthRedirectOptions(search.callbackURL));
if (context.flags.disableSignups) throw redirect({ to: "/auth/login", search, replace: true });
return { session: null };
},
});
+2
View File
@@ -1,9 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { AuthLayout } from "@/features/auth/layout";
import { authSearchSchema } from "@/features/auth/redirect";
import { createNoindexFollowMeta } from "@/libs/seo";
export const Route = createFileRoute("/auth")({
component: AuthLayout,
validateSearch: authSearchSchema,
head: () => ({
meta: [createNoindexFollowMeta()],
}),
@@ -1,9 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { VerifyTwoFactorBackupPage } from "@/features/auth/pages/verify-2fa";
import { getAuthRedirectOptions } from "@/features/auth/redirect";
export const Route = createFileRoute("/auth/verify-2fa-backup")({
component: VerifyTwoFactorBackupPage,
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
beforeLoad: ({ context, search }) => {
if (context.session && !search.reauthenticate) throw redirect(getAuthRedirectOptions(search.callbackURL));
},
});
+3 -2
View File
@@ -1,9 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { VerifyTwoFactorPage } from "@/features/auth/pages/verify-2fa";
import { getAuthRedirectOptions } from "@/features/auth/redirect";
export const Route = createFileRoute("/auth/verify-2fa")({
component: VerifyTwoFactorPage,
beforeLoad: ({ context }) => {
if (context.session) throw redirect({ to: "/dashboard", replace: true });
beforeLoad: ({ context, search }) => {
if (context.session && !search.reauthenticate) throw redirect(getAuthRedirectOptions(search.callbackURL));
},
});