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
+14
View File
@@ -205,3 +205,17 @@ describe("createApp", () => {
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
});
});
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
const { createApp } = await import("./app");
mocks.serveWebDistStatic.mockImplementationOnce(async (_context: unknown, next: () => Promise<void>) => {
await next();
});
const response = await createApp().request(`http://localhost:3000${path}?sig=signed`);
expect(response.status).toBe(200);
expect(await response.text()).toBe("web");
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("cache-control")).toBe("no-store");
});
+8
View File
@@ -36,6 +36,14 @@ const getTrustedClient = (context: Context<ServerEnvironment>): string => {
export function createApp() {
const app = new Hono<ServerEnvironment>();
app.use("/auth/*", async (c, next) => {
await next();
c.header("Content-Security-Policy", "frame-ancestors 'none'");
c.header("X-Frame-Options", "DENY");
c.header("Referrer-Policy", "no-referrer");
c.header("Cache-Control", "no-store");
});
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
+131 -2
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
consent: vi.fn(),
continueOAuth: vi.fn(),
handler: vi.fn(),
env: {
SERVER_PORT: 3001,
@@ -14,6 +16,8 @@ vi.mock("@reactive-resume/auth/config", () => ({
auth: {
api: {
getSession: mocks.getSession,
oauth2Consent: mocks.consent,
oauth2Continue: mocks.continueOAuth,
},
handler: mocks.handler,
},
@@ -32,6 +36,23 @@ beforeEach(() => {
});
describe("handleAuth", () => {
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
"rejects non-object registration payload %j",
async (body) => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ message: "Invalid registration payload" });
expect(mocks.handler).not.toHaveBeenCalled();
},
);
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
const { handleAuth } = await import("./auth");
@@ -66,6 +87,57 @@ describe("handleAuth", () => {
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
it.each(["localhost", "127.0.0.1", "[::1]"])(
"infers native application type for exact %s loopback callbacks",
async (host) => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ redirect_uris: [`http://${host}:3210/callback`] }),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
await expect(forwarded.json()).resolves.toMatchObject({
application_type: "native",
token_endpoint_auth_method: "none",
});
},
);
it.each([
{ redirect_uris: ["https://example.com/callback"] },
{ redirect_uris: ["http://localhost.evil.example/callback"] },
{ redirect_uris: ["http://localhost:3210/callback"], application_type: "web" },
{ redirect_uris: ["http://localhost:3210/callback", "https://example.com/callback"] },
])("does not infer native for explicit web or non-loopback clients: %j", async (body) => {
const { handleAuth } = await import("./auth");
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect((await forwarded.json()).application_type).not.toBe("native");
});
it("preserves repeated resource indicators during authorization sanitization", async () => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request(
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
"http://localhost:3000",
"http://localhost:3000/mcp",
]);
});
});
describe("handleOAuth", () => {
@@ -91,7 +163,64 @@ describe("handleOAuth", () => {
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
expect(callbackUrl.searchParams.get("state")).toBe("abc");
expect(callbackUrl.searchParams.has("exp")).toBe(false);
expect(callbackUrl.searchParams.has("sig")).toBe(false);
expect(callbackUrl.searchParams.get("exp")).toBe("123");
expect(callbackUrl.searchParams.get("sig")).toBe("456");
});
it("continues signed authorization without approving consent on GET", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
);
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
expect(mocks.continueOAuth).toHaveBeenCalledWith(
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
);
expect(mocks.consent).not.toHaveBeenCalled();
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
});
it("preserves provider failures instead of issuing an authorization code", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
expect(response.status).toBe(400);
expect(response.headers.get("location")).toBeNull();
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
});
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
mocks.continueOAuth.mockResolvedValueOnce(
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
);
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
expect(response.status).toBe(302);
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
expect(response.headers.get("cache-control")).toBe("no-store");
expect(response.headers.get("content-type")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
});
});
describe("OAuth provider response validation", () => {
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
"fails closed for malformed provider response %j",
async (body) => {
const { handleOAuth } = await import("./auth");
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
expect(response.status).toBe(502);
expect(response.headers.get("location")).toBeNull();
expect(mocks.consent).not.toHaveBeenCalled();
},
);
});
+79 -86
View File
@@ -1,10 +1,6 @@
import crypto from "node:crypto";
import { eq } from "drizzle-orm";
import { APIError } from "better-auth/api";
import { auth } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import { oauthClient, verification } from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { generateId } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
const oauthAuthorizeSanitizedParams = [
@@ -19,8 +15,6 @@ const oauthAuthorizeSanitizedParams = [
"resource",
] as const;
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
if (request.method !== "GET") return request;
@@ -33,9 +27,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
.replace(/\s+/g, " ")
.trim();
const sanitizeParam = (key: string) => {
const value = url.searchParams.get(key);
if (!value) return;
url.searchParams.set(key, sanitizeValue(value));
const values = url.searchParams.getAll(key);
if (!values.length) return;
url.searchParams.delete(key);
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
};
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
@@ -56,6 +51,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
return new Request(url.toString(), request);
}
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
if (request.method !== "POST") return request;
@@ -66,11 +65,23 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
let body: Record<string, unknown>;
try {
body = await cloned.json();
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) return request;
body = payload;
} catch {
return request;
}
// MCP native clients often omit OIDC application_type. Infer it only for
// exact HTTP loopback callbacks; the provider still validates every URI.
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
const allLoopback = body.redirect_uris.every(
(uri: unknown) =>
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
);
if (allLoopback) body.application_type = "native";
}
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method = "none";
}
@@ -92,7 +103,11 @@ async function validateDynamicClientRegistrationRequest(request: Request): Promi
let body: Record<string, unknown>;
try {
body = await cloned.json();
const payload: unknown = await cloned.json();
if (!isRegistrationPayload(payload)) {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
body = payload;
} catch {
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
}
@@ -125,90 +140,68 @@ export async function handleAuth(request: Request) {
return auth.handler(finalRequest);
}
function generateCode() {
return crypto.randomBytes(32).toString("base64url");
}
function hashCode(code: string) {
return crypto.createHash("sha256").update(code).digest("base64url");
}
export async function handleOAuth(request: Request) {
try {
return await resumeOAuth(request);
} catch (error) {
// Before-hooks can throw even when the provider is called with asResponse.
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
throw error;
}
}
async function resumeOAuth(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
const url = new URL(request.url);
if (session?.user) {
const clientId = url.searchParams.get("client_id");
const redirectUri = url.searchParams.get("redirect_uri");
const state = url.searchParams.get("state");
const scope = url.searchParams.get("scope");
const codeChallenge = url.searchParams.get("code_challenge");
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
if (!clientId || !redirectUri) {
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
}
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
if (!client) {
return Response.json({ error: "invalid client" }, { status: 400 });
}
if (!client.redirectUris.includes(redirectUri)) {
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
}
const code = generateCode();
const hashedCode = hashCode(code);
const now = new Date();
const expiresAt = new Date(now.getTime() + 600_000);
await db.insert(verification).values({
id: generateId(),
identifier: hashedCode,
value: JSON.stringify({
type: "authorization_code",
query: {
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
},
userId: session.user.id,
sessionId: session.session.id,
authTime: new Date(session.session.createdAt).getTime(),
}),
expiresAt,
createdAt: now,
updatedAt: now,
});
const callbackUrl = new URL(redirectUri);
callbackUrl.searchParams.set("code", code);
if (state) callbackUrl.searchParams.set("state", state);
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
return new Response(null, {
status: 302,
headers: { Location: callbackUrl.toString() },
// Resume authorization without granting consent. The provider decides whether
// the user must sign in, explicitly approve a client, or reuse an existing grant.
// Its signed query must survive the login round trip byte-for-byte.
const response = await auth.api.oauth2Continue({
asResponse: true,
request,
headers: request.headers,
body: { postLogin: true, oauth_query: url.search.slice(1) },
});
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
if (!response.ok) return response;
const result: unknown = await response.json().catch(() => null);
if (
!result ||
typeof result !== "object" ||
!("url" in result) ||
typeof result.url !== "string" ||
!result.url ||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
!URL.canParse(result.url, env.APP_URL)
)
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
const headers = new Headers(response.headers);
headers.delete("content-type");
headers.delete("content-length");
const target = new URL(result.url, env.APP_URL);
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
}
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
return redirectToOAuthLogin(target, true, headers);
}
headers.set("Location", result.url);
return new Response(null, { status: 302, headers });
}
const loginUrl = new URL("/auth/login", env.APP_URL);
const oauthParams = new URLSearchParams();
for (const [key, value] of url.searchParams) {
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
oauthParams.set(key, value);
}
}
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
return redirectToOAuthLogin(url);
}
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
return new Response(null, {
status: 302,
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
headers,
});
}
@@ -0,0 +1,254 @@
import { createHash, randomBytes } from "node:crypto";
import { describe, expect, it, vi } from "vitest";
vi.mock("@reactive-resume/email/transport", () => ({ sendEmail: vi.fn() }));
// Run only against an explicitly supplied disposable database, after applying migrations.
const databaseURL = process.env.OAUTH_TEST_DATABASE_URL;
describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
it("registers public clients, resumes login, and exchanges a resource-bound PKCE code", async () => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
// Better Auth disables origin checks by default in test mode; exercise production behavior.
const { auth } = await import("@reactive-resume/auth/config");
(await auth.$context).skipOriginCheck = false;
const origin = process.env.APP_URL;
const redirectURI = "http://127.0.0.1:33921/callback";
const request = (path: string, body: object, cookie = "") =>
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
});
const registration = await handleAuth(
request("oauth2/register", { client_name: "OAuth integration", redirect_uris: [redirectURI] }),
);
expect(registration.status, await registration.clone().text()).toBe(201);
const client = await registration.json();
expect(client.token_endpoint_auth_method).toBe("none");
const deniedRegistration = await handleAuth(
request("oauth2/register", {
client_name: "Denied resource",
redirect_uris: [redirectURI],
resources: ["https://untrusted.example/mcp"],
}),
);
expect(deniedRegistration.status).toBe(400);
await expect(deniedRegistration.json()).resolves.toMatchObject({ error: "invalid_target" });
const verifier = randomBytes(32).toString("base64url");
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: redirectURI,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
resource: `${origin}/mcp`,
state: "opaque-state",
});
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
expect(authorize.status, await authorize.clone().text()).toBe(302);
const bridgeURL = authorize.headers.get("location");
expect(bridgeURL).toBeTruthy();
const login = await handleOAuth(new Request(new URL(bridgeURL ?? "", origin)));
const loginURL = new URL(login.headers.get("location") ?? "", origin);
const callbackURL = loginURL.searchParams.get("callbackURL");
expect(callbackURL).toContain("sig=");
expect(callbackURL).toContain("resource=");
const unique = randomBytes(6).toString("hex");
const signup = await handleAuth(
request("sign-up/email", {
name: "OAuth Test",
email: `oauth-${unique}@example.com`,
username: `oauth-${unique}`,
password: "password123",
}),
);
expect(signup.status, await signup.clone().text()).toBe(200);
const cookie = signup.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const tamperedURL = new URL(`${origin}${callbackURL}`);
tamperedURL.searchParams.set("state", "tampered");
const tampered = await handleOAuth(new Request(tamperedURL, { headers: { cookie } }));
expect(tampered.status).toBe(400);
await expect(tampered.json()).resolves.toMatchObject({ error: "invalid_signature" });
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
expect(callback.status, await callback.clone().text()).toBe(302);
const consentURL = new URL(callback.headers.get("location") ?? "", origin);
expect(consentURL.pathname).toBe("/auth/consent");
expect(consentURL.searchParams.has("code")).toBe(false);
const oauth_query = consentURL.search.slice(1);
const consents = async () => {
const response = await handleAuth(new Request(`${origin}/api/auth/oauth2/get-consents`, { headers: { cookie } }));
expect(response.status).toBe(200);
return response.json();
};
expect(await consents()).toEqual([]);
const silent = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}&prompt=none`, { headers: { cookie } }),
);
expect(new URL(silent.headers.get("location") ?? "").searchParams.get("error")).toBe("consent_required");
const tamperedConsentQuery = new URLSearchParams(oauth_query);
tamperedConsentQuery.set("scope", "openid profile email offline_access");
const tamperedConsent = await handleAuth(
request(
"oauth2/consent",
{
accept: true,
oauth_query: tamperedConsentQuery.toString(),
},
cookie,
),
);
expect(tamperedConsent.status).toBe(400);
expect(await consents()).toEqual([]);
const csrf = await handleAuth(
new Request(`${origin}/api/auth/oauth2/consent`, {
method: "POST",
headers: { cookie, origin: "https://untrusted.example", "content-type": "application/json" },
body: JSON.stringify({ accept: true, oauth_query }),
}),
);
expect(csrf.status).toBe(403);
const denied = await handleAuth(request("oauth2/consent", { accept: false, oauth_query }, cookie));
expect(denied.status, await denied.clone().text()).toBe(200);
const deniedURL = new URL((await denied.json()).url);
expect(deniedURL.searchParams.get("error")).toBe("access_denied");
expect(deniedURL.searchParams.get("state")).toBe("opaque-state");
expect(deniedURL.searchParams.has("code")).toBe(false);
expect(await consents()).toEqual([]);
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
expect(accepted.status, await accepted.clone().text()).toBe(200);
expect(await consents()).toHaveLength(1);
const codeURL = new URL((await accepted.json()).url);
expect(codeURL.origin).toBe(new URL(redirectURI).origin);
expect(codeURL.searchParams.get("state")).toBe("opaque-state");
const code = codeURL.searchParams.get("code");
expect(code).toBeTruthy();
const tokenRequest = () =>
new Request(`${origin}/api/auth/oauth2/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: client.client_id,
code: code ?? "",
redirect_uri: redirectURI,
code_verifier: verifier,
resource: `${origin}/mcp`,
}),
});
const tokenResponse = await handleAuth(tokenRequest());
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
const token = await tokenResponse.json();
expect(token.access_token).toBeTruthy();
expect(token.refresh_token).toBeTruthy();
const claims = JSON.parse(Buffer.from(token.access_token.split(".")[1], "base64url").toString());
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
expect((await handleAuth(tokenRequest())).status).toBe(400);
}, 30_000);
it.each(["login", "max-age", "create"])(
"requires fresh authentication for %s without looping",
async (mode) => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
const origin = process.env.APP_URL;
const cookieOf = (response: Response) =>
response.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const post = (path: string, body: object, cookie = "") =>
handleAuth(
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
}),
);
const unique = randomBytes(6).toString("hex");
const credentials = {
name: "Reauth Test",
email: `reauth-${unique}@example.com`,
username: `reauth-${unique}`,
password: "password123",
};
const existingSignup = await post("sign-up/email", credentials);
expect(existingSignup.status).toBe(200);
const oldCookie = cookieOf(existingSignup);
const registration = await post("oauth2/register", {
client_name: "Reauth integration",
redirect_uris: ["http://127.0.0.1:33921/callback"],
});
expect(registration.status).toBe(201);
const client = await registration.json();
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: "http://127.0.0.1:33921/callback",
response_type: "code",
scope: "openid profile",
resource: `${origin}/mcp`,
code_challenge: createHash("sha256").update(randomBytes(32)).digest("base64url"),
code_challenge_method: "S256",
...(mode === "max-age" ? { max_age: "0" } : { prompt: mode }),
});
const authorization = await handleAuth(
new Request(`${origin}/api/auth/oauth2/authorize?${query}`, { headers: { cookie: oldCookie } }),
);
expect(authorization.status).toBe(302);
const bridge = await handleOAuth(
new Request(new URL(authorization.headers.get("location") ?? "", origin), { headers: { cookie: oldCookie } }),
);
expect(bridge.status).toBe(302);
const loginURL = new URL(bridge.headers.get("location") ?? "", origin);
expect(loginURL.pathname).toBe(mode === "create" ? "/auth/register" : "/auth/login");
expect(loginURL.searchParams.get("reauthenticate")).toBe("true");
const callbackURL = new URL(loginURL.searchParams.get("callbackURL") ?? "", origin);
const oauth_query = callbackURL.search.slice(1);
const authenticated =
mode === "create"
? await post(
"sign-up/email",
{ ...credentials, email: `new-${unique}@example.com`, username: `new-${unique}` },
oldCookie,
)
: await post(
"sign-in/email",
{ email: credentials.email, password: credentials.password, oauth_query },
oldCookie,
);
expect(authenticated.status, await authenticated.clone().text()).toBe(200);
const newCookie = cookieOf(authenticated);
expect(newCookie).not.toBe(oldCookie);
const continuation =
mode === "create" ? await post("oauth2/continue", { created: true, oauth_query }, newCookie) : authenticated;
expect(continuation.status, await continuation.clone().text()).toBe(200);
const result = await continuation.json();
let target = new URL(result.url, origin);
if (target.pathname === "/api/auth/oauth") {
const response = await handleOAuth(new Request(target, { headers: { cookie: newCookie } }));
expect(response.status, await response.clone().text()).toBe(302);
target = new URL(response.headers.get("location") ?? "", origin);
}
expect(target.pathname).toBe("/auth/consent");
const accepted = await post("oauth2/consent", { accept: true, oauth_query: target.search.slice(1) }, newCookie);
expect(accepted.status, await accepted.clone().text()).toBe(200);
target = new URL((await accepted.json()).url, origin);
expect(target.origin).toBe("http://127.0.0.1:33921");
expect(target.searchParams.get("code")).toBeTruthy();
},
30_000,
);
});
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const events = vi.hoisted(() => [] as string[]);
vi.mock("./startup/checks", () => ({
runStartupChecks: async () => {
await Promise.resolve();
events.push("migrations complete");
},
}));
vi.mock("./http/app", () => {
events.push("auth imported");
return {
createApp: () => {
events.push("app created");
return { fetch: vi.fn() };
},
};
});
vi.mock("@hono/node-server", () => ({
serve: () => {
events.push("server listening");
},
}));
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 3001 } }));
afterEach(() => vi.restoreAllMocks());
describe("server startup", () => {
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
vi.spyOn(process, "on").mockReturnValue(process);
const entry = await import("./index");
expect(events).toEqual([]);
await entry.main();
expect(events).toEqual(["migrations complete", "auth imported", "app created", "server listening"]);
});
});
+5 -4
View File
@@ -1,14 +1,15 @@
import { pathToFileURL } from "node:url";
import { serve } from "@hono/node-server";
import { env } from "@reactive-resume/env/server";
import { createApp } from "./http/app";
import { runStartupChecks } from "./startup/checks";
export { createApp } from "./http/app";
async function main() {
export async function main() {
await runStartupChecks();
// OAuth resource seeding starts when auth is imported, so load the app only
// after migrations have created the provider tables.
const { createApp } = await import("./http/app");
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
// stray promise must not take the server down for everyone, so log and keep serving.
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
+6 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
// Spec generation reads procedure contracts without executing authentication. Keep the
// provider's resource seeding out of this unit test; real OAuth initialization is covered
// by the opt-in PostgreSQL integration suite after migrations run.
vi.mock("@reactive-resume/auth/config", () => ({ auth: {}, verifyOAuthToken: vi.fn() }));
type GeneratedSpecView = {
components?: { schemas?: Record<string, unknown> };
paths?: Record<
+5 -1
View File
@@ -2,8 +2,12 @@
"extends": ["//"],
"tags": ["app:server", "runtime:server", "role:adapter"],
"tasks": {
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
"test:ci": {
"cache": false
"cache": false,
"env": ["OAUTH_TEST_DATABASE_URL"]
}
}
}
+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));
},
});