Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions lib/auth/token-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { decodeJwtExp, isTokenNearExpiry } from "./token-refresh";

function makeJwt(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
.toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.fake-signature`;
}

describe("decodeJwtExp", () => {
it("returns null for an empty string", () => {
expect(decodeJwtExp("")).toBeNull();
});

it("returns null for a token missing dot separators", () => {
expect(decodeJwtExp("notajwt")).toBeNull();
});

it("returns null when the payload is not valid base64 JSON", () => {
expect(decodeJwtExp("abc.###notbase64###.xyz")).toBeNull();
});

it("returns null when the payload has no exp claim", () => {
expect(decodeJwtExp(makeJwt({ sub: "u1" }))).toBeNull();
});

it("returns null when exp is a string (wrong type)", () => {
expect(decodeJwtExp(makeJwt({ exp: "1700000000" }))).toBeNull();
});

it("returns null when exp is null", () => {
expect(decodeJwtExp(makeJwt({ exp: null }))).toBeNull();
});


it("returns the exp claim (seconds) from a valid payload", () => {
expect(decodeJwtExp(makeJwt({ sub: "u1", exp: 1700000000 }))).toBe(
1700000000,
);
});
});

describe("isTokenNearExpiry", () => {
it("returns true when the token is malformed", () => {
expect(isTokenNearExpiry("notajwt", Date.now(), 60)).toBe(true);
});

it("returns true when the token has no exp claim", () => {
expect(isTokenNearExpiry(makeJwt({ sub: "u1" }), Date.now(), 60)).toBe(
true,
);
});

it("returns false when exp is comfortably beyond now + buffer", () => {
const nowMs = 1_700_000_000_000;
const expSec = 1_700_000_000 + 3600; // 1 hour in the future
expect(isTokenNearExpiry(makeJwt({ exp: expSec }), nowMs, 60)).toBe(false);
});

it("returns true when exp is within the buffer window", () => {
const nowMs = 1_700_000_000_000;
const expSec = 1_700_000_000 + 30; // 30s in future, buffer is 60
expect(isTokenNearExpiry(makeJwt({ exp: expSec }), nowMs, 60)).toBe(true);
});

it("returns true when exp is already in the past", () => {
const nowMs = 1_700_000_000_000;
const expSec = 1_700_000_000 - 100;
expect(isTokenNearExpiry(makeJwt({ exp: expSec }), nowMs, 60)).toBe(true);
});
});
37 changes: 37 additions & 0 deletions lib/auth/token-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Decodes the `exp` claim from a JWT payload without verifying the signature.
*
* Middleware uses this to decide whether to attempt a refresh — it does NOT
* trust the token. Full verification happens downstream via jose in
* lib/get-user.ts.
*
* Returns the exp claim (seconds since epoch) or null if the token is
* malformed or has no exp claim.
*/
export function decodeJwtExp(token: string): number | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const payload = JSON.parse(
Buffer.from(parts[1], "base64url").toString("utf8"),
);
if (typeof payload.exp !== "number") return null;
return payload.exp;
} catch {
return null;
}
}

/**
* Returns true if the token should be refreshed: either it can't be decoded,
* has no exp claim, or expires within `bufferSec` of `nowMs`.
*/
export function isTokenNearExpiry(
token: string,
nowMs: number,
bufferSec: number,
): boolean {
const expSec = decodeJwtExp(token);
if (expSec === null) return true;
return expSec * 1000 < nowMs + bufferSec * 1000;
}
218 changes: 218 additions & 0 deletions proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { NextRequest } from "next/server";

const { mockRefreshAccessToken } = vi.hoisted(() => ({
mockRefreshAccessToken: vi.fn(),
}));
vi.mock("@/lib/idp/client", () => ({
refreshAccessToken: mockRefreshAccessToken,
}));


const FRESH_JWT = (() => {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const body = Buffer.from(JSON.stringify({ exp: 4_000_000_000 })).toString(
"base64url",
);
return `${header}.${body}.sig`;
})();

const EXPIRED_JWT = (() => {
const header = Buffer.from(
JSON.stringify({ alg: "HS256", typ: "JWT" }),
).toString("base64url");
const body = Buffer.from(JSON.stringify({ exp: 1_000_000_000 })).toString(
"base64url",
);
return `${header}.${body}.sig`;
})();

function makeRequest(path: string, cookieHeader = ""): NextRequest {
return new NextRequest(`http://localhost${path}`, {
headers: cookieHeader ? { cookie: cookieHeader } : {},
});
}

afterEach(() => mockRefreshAccessToken.mockReset());

describe("proxy: public routes", () => {

it("passes /login through without touching the token", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/login"));
expect(res.status).toBe(200);
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
});

it("passes /oauth/callback through", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/oauth/callback"));
expect(res.status).toBe(200);
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
});
});

describe("proxy: protected routes with no token", () => {

it("redirects to /login when no token cookie exists", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/forecasts"));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
});

it("does not add a redirect query param when coming from the home page", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/"));
const url = new URL(res.headers.get("location") ?? "");
expect(url.pathname).toBe("/login");
expect(url.searchParams.get("redirect")).toBeNull();
});

it("preserves the original path in the redirect query string", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/forecasts"));
const url = new URL(res.headers.get("location") ?? "");
const redirect = url.searchParams.get("redirect") ?? "";
// proxy.ts double-encodes (encodeURIComponent + searchParams.set).
expect(decodeURIComponent(redirect)).toBe("/forecasts");
});
});

describe("proxy: protected routes with fresh token", () => {

it("passes the request through without calling refresh", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/forecasts", `token=${FRESH_JWT}`));
expect(res.status).toBe(200);
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
expect(res.cookies.get("token")).toBeUndefined();
});
});

describe("proxy: protected routes with expired token", () => {

it("redirects to /login when no refresh_token cookie exists", async () => {
const { proxy } = await import("./proxy");
const res = await proxy(makeRequest("/forecasts", `token=${EXPIRED_JWT}`));
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
});

it("sets a new token cookie when refresh succeeds", async () => {
mockRefreshAccessToken.mockResolvedValue({
access_token: "new-access",
token_type: "Bearer",
expires_in: 3600,
});

const { proxy } = await import("./proxy");
const res = await proxy(
makeRequest(
"/forecasts",
`token=${EXPIRED_JWT}; refresh_token=refresh-xyz`,
),
);

expect(mockRefreshAccessToken).toHaveBeenCalledWith("refresh-xyz");
expect(res.status).toBe(200);
const tokenCookie = res.cookies.get("token");
expect(tokenCookie?.value).toBe("new-access");
expect(tokenCookie?.maxAge).toBe(3600);
// Cookie security attributes — protect against accidental regressions.
expect(tokenCookie?.httpOnly).toBe(true);
expect(tokenCookie?.sameSite).toBe("lax");
expect(tokenCookie?.path).toBe("/");
expect(res.cookies.get("refresh_token")).toBeUndefined();
});

it("forwards the new token to downstream handlers on the same request", async () => {
mockRefreshAccessToken.mockResolvedValue({
access_token: "new-access",
token_type: "Bearer",
expires_in: 3600,
});

const { proxy } = await import("./proxy");
const req = makeRequest(
"/forecasts",
`token=${EXPIRED_JWT}; refresh_token=refresh-xyz`,
);
await proxy(req);

// Server components rendered on THIS request should see the new token,
// not the stale one the browser sent.
expect(req.cookies.get("token")?.value).toBe("new-access");
});

it("treats an empty access_token in the refresh response as failure", async () => {
mockRefreshAccessToken.mockResolvedValue({
access_token: "",
token_type: "Bearer",
expires_in: 3600,
});

const { proxy } = await import("./proxy");
const res = await proxy(
makeRequest(
"/forecasts",
`token=${EXPIRED_JWT}; refresh_token=refresh-xyz`,
),
);

expect(res.status).toBe(307);
expect(res.cookies.get("token")?.value).toBe("");
expect(res.cookies.get("token")?.maxAge).toBe(0);
});

it("updates both cookies when refresh response rotates the refresh_token", async () => {
mockRefreshAccessToken.mockResolvedValue({
access_token: "new-access",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "rotated-refresh",
});

const { proxy } = await import("./proxy");
const res = await proxy(
makeRequest(
"/forecasts",
`token=${EXPIRED_JWT}; refresh_token=refresh-xyz`,
),
);

expect(res.status).toBe(200);
expect(res.cookies.get("token")?.value).toBe("new-access");
const refreshCookie = res.cookies.get("refresh_token");
expect(refreshCookie?.value).toBe("rotated-refresh");
expect(refreshCookie?.maxAge).toBe(30 * 24 * 60 * 60);
});

it("redirects to /login and clears the token when refresh fails", async () => {
mockRefreshAccessToken.mockRejectedValue(new Error("refresh failed"));

const { proxy } = await import("./proxy");
const res = await proxy(
makeRequest(
"/forecasts",
`token=${EXPIRED_JWT}; refresh_token=refresh-xyz`,
),
);

expect(mockRefreshAccessToken).toHaveBeenCalled();
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("/login");
const tokenCookie = res.cookies.get("token");
expect(tokenCookie?.value).toBe("");
expect(tokenCookie?.maxAge).toBe(0);
// Stale refresh_token must also be cleared, otherwise every subsequent
// navigation triggers the same failing refresh call.
const refreshCookie = res.cookies.get("refresh_token");
expect(refreshCookie?.value).toBe("");
expect(refreshCookie?.maxAge).toBe(0);
});
});
Loading
Loading