Skip to content

Commit b9637fe

Browse files
authored
fix: skip token-refresh redirect in database mode (#480)
1 parent 04a70f8 commit b9637fe

2 files changed

Lines changed: 128 additions & 10 deletions

File tree

src/lib/api-client.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
// Hoisted mocks — must be defined before any imports
4+
const mockRedirect = vi.hoisted(() => vi.fn());
5+
const mockGetSession = vi.hoisted(() => vi.fn());
6+
const mockGetAccessToken = vi.hoisted(() => vi.fn());
7+
const mockIsTokenNearExpiry = vi.hoisted(() => vi.fn());
8+
const mockIsDatabaseMode = vi.hoisted(() => ({ isDatabaseMode: false }));
9+
10+
vi.mock("next/navigation", () => ({ redirect: mockRedirect }));
11+
12+
vi.mock("next/headers", () => ({
13+
headers: vi.fn(() => Promise.resolve(new Headers({ "x-url": "/catalog" }))),
14+
cookies: vi.fn(() =>
15+
Promise.resolve({ get: vi.fn(), getAll: vi.fn(() => []) }),
16+
),
17+
}));
18+
19+
vi.mock("@/lib/auth/auth", () => ({
20+
auth: {
21+
api: { getSession: mockGetSession, getAccessToken: mockGetAccessToken },
22+
},
23+
}));
24+
25+
vi.mock("@/lib/auth/constants", () => ({
26+
OIDC_PROVIDER_ID: "oidc",
27+
}));
28+
29+
vi.mock("@/lib/auth/db", () => mockIsDatabaseMode);
30+
31+
vi.mock("@/lib/auth/utils", () => ({
32+
isTokenNearExpiry: mockIsTokenNearExpiry,
33+
}));
34+
35+
vi.mock("@/generated/client", () => ({
36+
createClient: vi.fn(() => ({})),
37+
createConfig: vi.fn(() => ({})),
38+
}));
39+
40+
vi.mock("@/generated/sdk.gen", () => ({}));
41+
42+
import { getAuthenticatedClient } from "./api-client";
43+
44+
const MOCK_SESSION = { user: { id: "user-123" } };
45+
const MOCK_ACCESS_TOKEN = "mock-access-token";
46+
47+
describe("getAuthenticatedClient", () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks();
50+
mockGetSession.mockResolvedValue(MOCK_SESSION);
51+
mockGetAccessToken.mockResolvedValue({ accessToken: MOCK_ACCESS_TOKEN });
52+
mockIsTokenNearExpiry.mockResolvedValue(false);
53+
mockIsDatabaseMode.isDatabaseMode = false;
54+
});
55+
56+
afterEach(() => {
57+
vi.restoreAllMocks();
58+
});
59+
60+
describe("database mode", () => {
61+
it("skips isTokenNearExpiry check and never redirects to token-refresh", async () => {
62+
mockIsDatabaseMode.isDatabaseMode = true;
63+
64+
await getAuthenticatedClient();
65+
66+
expect(mockIsTokenNearExpiry).not.toHaveBeenCalled();
67+
expect(mockRedirect).not.toHaveBeenCalledWith(
68+
expect.stringContaining("token-refresh"),
69+
);
70+
});
71+
72+
it("proceeds to return the API client", async () => {
73+
mockIsDatabaseMode.isDatabaseMode = true;
74+
75+
const client = await getAuthenticatedClient();
76+
77+
expect(client).toBeDefined();
78+
});
79+
});
80+
81+
describe("cookie mode", () => {
82+
it("redirects to token-refresh when token is near expiry", async () => {
83+
mockIsDatabaseMode.isDatabaseMode = false;
84+
mockIsTokenNearExpiry.mockResolvedValue(true);
85+
86+
await getAuthenticatedClient();
87+
88+
expect(mockRedirect).toHaveBeenCalledWith(
89+
"/api/auth/token-refresh?redirect=%2Fcatalog",
90+
);
91+
});
92+
93+
it("does not redirect when token is fresh", async () => {
94+
mockIsDatabaseMode.isDatabaseMode = false;
95+
mockIsTokenNearExpiry.mockResolvedValue(false);
96+
97+
await getAuthenticatedClient();
98+
99+
expect(mockRedirect).not.toHaveBeenCalledWith(
100+
expect.stringContaining("token-refresh"),
101+
);
102+
});
103+
});
104+
105+
it("redirects to /signin when no session", async () => {
106+
mockGetSession.mockResolvedValue(null);
107+
108+
await getAuthenticatedClient();
109+
110+
expect(mockRedirect).toHaveBeenCalledWith("/signin");
111+
});
112+
});

src/lib/api-client.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createClient, createConfig } from "@/generated/client";
1919
import * as apiServices from "@/generated/sdk.gen";
2020
import { auth } from "./auth/auth";
2121
import { OIDC_PROVIDER_ID } from "./auth/constants";
22+
import { isDatabaseMode } from "./auth/db";
2223
import { isTokenNearExpiry } from "./auth/utils";
2324

2425
const MOCK_SCENARIO_COOKIE = "mock-scenario";
@@ -74,16 +75,21 @@ export async function getAuthenticatedClient(accessToken?: string) {
7475
// the OIDC refresh and saves the rotated refresh token R2), then redirects
7576
// back. If the token is fresh, call getAccessToken() directly — Better Auth
7677
// won't refresh (its threshold is 5s), so no Set-Cookie is produced.
77-
const nearExpiry = await isTokenNearExpiry();
78-
79-
if (nearExpiry) {
80-
const currentPath = requestHeaders.get("x-url") || "/catalog";
81-
console.log(
82-
`[API Client] token near expiry, redirecting to token-refresh | path=${currentPath}`,
83-
);
84-
redirect(
85-
`/api/auth/token-refresh?redirect=${encodeURIComponent(currentPath)}`,
86-
);
78+
// In database mode, Better Auth stores and refreshes tokens directly in the DB —
79+
// no cookie write is needed, so we skip the Route Handler redirect entirely.
80+
// The cookie-based preemptive refresh is only needed in stateless (cookie) mode.
81+
if (!isDatabaseMode) {
82+
const nearExpiry = await isTokenNearExpiry();
83+
84+
if (nearExpiry) {
85+
const currentPath = requestHeaders.get("x-url") || "/catalog";
86+
console.log(
87+
`[API Client] token near expiry, redirecting to token-refresh | path=${currentPath}`,
88+
);
89+
redirect(
90+
`/api/auth/token-refresh?redirect=${encodeURIComponent(currentPath)}`,
91+
);
92+
}
8793
}
8894

8995
let tokenData: {

0 commit comments

Comments
 (0)