mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
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:
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user