fix(polar): lift invalid_grant to reauth and harden the credentials surface

classifyPolarError now lifts a 400 carrying invalid_grant on the token
endpoint from persistent to reauth_required, mirroring Oura via the shared
isOAuthInvalidGrant helper. A stale or replayed authorization code at connect
time now surfaces the reconnect prompt instead of silently parking after 24 h.

The BYO-credentials DELETE now audit-logs and parks the integration ledger at
disconnected for parity with the dedicated disconnect route, and the PUT
audit-logs the credential write. Refresh the connect-route comment that still
described Polar as env-only OAuth; it has resolved BYO-first since v1.17.1.

Add the invalid_grant classification test plus the previously-missing
callback (happy path, CSRF mismatch, cross-user, off-spec token), connect, and
credentials route tests.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:09:22 +02:00
1 parent 574b860eb4
commit 87e570abfe
7 files changed
+348 -5

No files matched your search

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
stateMatchesCookieMock,
verifySignedStateMock,
getSessionMock,
exchangeCodeMock,
registerUserMock,
getCredsMock,
markReconnectedMock,
} = vi.hoisted(() => ({
stateMatchesCookieMock: vi.fn(),
verifySignedStateMock: vi.fn(),
getSessionMock: vi.fn(),
exchangeCodeMock: vi.fn(),
registerUserMock: vi.fn(),
getCredsMock: vi.fn(),
markReconnectedMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
vi.mock("@/lib/polar/client", () => ({
exchangeCode: exchangeCodeMock,
registerUser: registerUserMock,
}));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: () => "polar_oauth_state",
stateMatchesCookie: stateMatchesCookieMock,
verifySignedState: verifySignedStateMock,
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: markReconnectedMock,
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(): Parameters<typeof GET>[0] {
return {
url: "https://app.example/api/polar/callback?code=abc&state=s",
cookies: { get: () => ({ value: "s" }) },
} as unknown as Parameters<typeof GET>[0];
}
const run = GET as unknown as (req: ReturnType<typeof makeReq>) => Promise<{
status: number;
headers: { get(k: string): string | null };
}>;
beforeEach(() => {
vi.clearAllMocks();
stateMatchesCookieMock.mockReturnValue(true);
verifySignedStateMock.mockReturnValue({ userId: "u1" });
getSessionMock.mockResolvedValue(null);
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 });
registerUserMock.mockResolvedValue(undefined);
});
describe("GET /api/polar/callback", () => {
it("completes the handshake and redirects to connected", async () => {
const res = await run(makeReq());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("polar=connected");
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar");
});
it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => {
stateMatchesCookieMock.mockReturnValue(false);
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=csrf1");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects a cross-user session completing another user's handshake", async () => {
getSessionMock.mockResolvedValue({ user: { id: "other" } });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=cross_user");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects an off-spec token body missing x_user_id", async () => {
exchangeCodeMock.mockResolvedValue({ access_token: "tok" });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=token");
expect(registerUserMock).not.toHaveBeenCalled();
});
});