From 7bc0ca806361f3ac88b4af9f508d025b590f336c Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Sat, 4 Apr 2026 11:17:04 -0500 Subject: [PATCH 1/2] Refresh access tokens in proxy instead of bouncing users to login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth callback already sets a 30-day refresh_token cookie, but refreshAccessToken() in lib/idp/client.ts had no call sites — so when the short-lived access token expired, users were redirected to the IDP login page even though a valid refresh token was sitting in their cookies. The proxy now decodes the access token's exp claim, attempts a refresh via the refresh_token cookie when the access token is within 60s of expiry, and updates both cookies (preserving refresh token rotation if the IDP returns a new one). If refresh fails, the stale access token is cleared and the user is redirected to /login as before. Effective session lifetime goes from ~1 hour (access token TTL) to 30 days, sliding if the IDP rotates refresh tokens. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/auth/token-refresh.test.ts | 63 +++++++++++++ lib/auth/token-refresh.ts | 37 ++++++++ proxy.test.ts | 162 +++++++++++++++++++++++++++++++++ proxy.ts | 74 ++++++++++++--- 4 files changed, 322 insertions(+), 14 deletions(-) create mode 100644 lib/auth/token-refresh.test.ts create mode 100644 lib/auth/token-refresh.ts create mode 100644 proxy.test.ts diff --git a/lib/auth/token-refresh.test.ts b/lib/auth/token-refresh.test.ts new file mode 100644 index 00000000..e02dfe65 --- /dev/null +++ b/lib/auth/token-refresh.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { decodeJwtExp, isTokenNearExpiry } from "./token-refresh"; + +function makeJwt(payload: Record): 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 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); + }); +}); diff --git a/lib/auth/token-refresh.ts b/lib/auth/token-refresh.ts new file mode 100644 index 00000000..19b25763 --- /dev/null +++ b/lib/auth/token-refresh.ts @@ -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; +} diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 00000000..1b2a8606 --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,162 @@ +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("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); + expect(res.cookies.get("refresh_token")).toBeUndefined(); + }); + + 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); + }); +}); diff --git a/proxy.ts b/proxy.ts index a2826d88..f808c9f6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { refreshAccessToken } from "@/lib/idp/client"; +import { isTokenNearExpiry } from "@/lib/auth/token-refresh"; /** * Routes that don't require authentication. @@ -12,6 +14,16 @@ const PUBLIC_ROUTES = [ "/api/me", // Returns null if not logged in, used by client components ]; +const REFRESH_BUFFER_SEC = 60; +const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60; // 30 days + +const sharedCookieOpts = { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", +}; + /** * Check if a pathname matches any of the public routes. */ @@ -21,7 +33,18 @@ function isPublicRoute(pathname: string): boolean { ); } -export function proxy(request: NextRequest) { +function redirectToLogin(request: NextRequest, pathname: string) { + // Use configured base URL when behind a reverse proxy, falling back to request origin for local dev. + const baseUrl = process.env.APP_BASE_URL ?? request.nextUrl.origin; + const loginUrl = new URL("/login", baseUrl); + // Preserve the original URL for redirect after login (except for home page) + if (pathname !== "/") { + loginUrl.searchParams.set("redirect", encodeURIComponent(pathname)); + } + return NextResponse.redirect(loginUrl); +} + +export async function proxy(request: NextRequest) { const { pathname } = request.nextUrl; // Allow public routes @@ -29,24 +52,47 @@ export function proxy(request: NextRequest) { return NextResponse.next(); } - // Check for auth token const token = request.cookies.get("token")?.value; + const refreshToken = request.cookies.get("refresh_token")?.value; + // No access token → redirect to login. if (!token) { - // Use configured base URL when behind a reverse proxy, falling back to request origin for local dev. - const baseUrl = process.env.APP_BASE_URL ?? request.nextUrl.origin; - const loginUrl = new URL("/login", baseUrl); - // Preserve the original URL for redirect after login (except for home page) - if (pathname !== "/") { - loginUrl.searchParams.set("redirect", encodeURIComponent(pathname)); - } - return NextResponse.redirect(loginUrl); + return redirectToLogin(request, pathname); } - // Token exists - allow the request - // Note: Admin checks are handled at the layout level (/app/admin/layout.tsx) - // since middleware can't easily make async DB calls to verify admin status - return NextResponse.next(); + // Access token is still valid → pass through. + if (!isTokenNearExpiry(token, Date.now(), REFRESH_BUFFER_SEC)) { + return NextResponse.next(); + } + + // Access token expired, no refresh token → redirect to login. + if (!refreshToken) { + return redirectToLogin(request, pathname); + } + + // Try to refresh the access token using the refresh token. + try { + const tokens = await refreshAccessToken(refreshToken); + const response = NextResponse.next(); + response.cookies.set("token", tokens.access_token, { + ...sharedCookieOpts, + maxAge: tokens.expires_in, + }); + // If the IDP rotated the refresh token, update that cookie too. + if (tokens.refresh_token) { + response.cookies.set("refresh_token", tokens.refresh_token, { + ...sharedCookieOpts, + maxAge: REFRESH_TOKEN_MAX_AGE, + }); + } + return response; + } catch { + // Refresh failed (stale refresh token, IDP down, etc.). Clear the + // expired access token and redirect to login. + const response = redirectToLogin(request, pathname); + response.cookies.set("token", "", { ...sharedCookieOpts, maxAge: 0 }); + return response; + } } export const config = { From 1990e78583537c4aa2a269964bc7823f5c93d1bc Mon Sep 17 00:00:00 2001 From: Ethan Swan Date: Sat, 4 Apr 2026 11:27:53 -0500 Subject: [PATCH 2/2] Address review findings on token refresh - Forward the new access token to downstream handlers on the refresh request itself via request.cookies.set + NextResponse.next({ request }). Without this, Server Components rendered during the refreshing request read the stale cookie and render as logged-out for one page load, even though the browser gets the new cookie on the next request. - Clear the refresh_token cookie on refresh failure. Leaving it set meant every subsequent navigation re-triggered the same failing IDP round-trip. - Validate that the refresh response actually contains a non-empty access_token and expires_in; treat missing/empty as a refresh failure rather than silently setting broken cookies. - Log refresh failures via the existing logger.warn so IDP outages are distinguishable from normal token expirations. Also expand test coverage: assert cookie security attributes (httpOnly, sameSite, path), verify the home-page redirect branch omits the redirect query param, cover string/null exp claims in decodeJwtExp. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/auth/token-refresh.test.ts | 9 ++++++ proxy.test.ts | 56 ++++++++++++++++++++++++++++++++++ proxy.ts | 26 +++++++++++++--- 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/lib/auth/token-refresh.test.ts b/lib/auth/token-refresh.test.ts index e02dfe65..5d03252d 100644 --- a/lib/auth/token-refresh.test.ts +++ b/lib/auth/token-refresh.test.ts @@ -25,6 +25,15 @@ describe("decodeJwtExp", () => { 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, diff --git a/proxy.test.ts b/proxy.test.ts index 1b2a8606..16ada222 100644 --- a/proxy.test.ts +++ b/proxy.test.ts @@ -64,6 +64,14 @@ describe("proxy: protected routes with no token", () => { 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")); @@ -115,9 +123,52 @@ describe("proxy: protected routes with expired token", () => { 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", @@ -158,5 +209,10 @@ describe("proxy: protected routes with expired token", () => { 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); }); }); diff --git a/proxy.ts b/proxy.ts index f808c9f6..bd2c4c28 100644 --- a/proxy.ts +++ b/proxy.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { refreshAccessToken } from "@/lib/idp/client"; import { isTokenNearExpiry } from "@/lib/auth/token-refresh"; +import { logger } from "@/lib/logger"; /** * Routes that don't require authentication. @@ -73,7 +74,14 @@ export async function proxy(request: NextRequest) { // Try to refresh the access token using the refresh token. try { const tokens = await refreshAccessToken(refreshToken); - const response = NextResponse.next(); + if (!tokens.access_token || !tokens.expires_in) { + throw new Error("IDP returned invalid token response"); + } + // Forward the new token to downstream handlers on THIS request — without + // this, Server Components rendered during this request would read the + // stale cookie and treat the user as logged out for one render. + request.cookies.set("token", tokens.access_token); + const response = NextResponse.next({ request }); response.cookies.set("token", tokens.access_token, { ...sharedCookieOpts, maxAge: tokens.expires_in, @@ -86,11 +94,21 @@ export async function proxy(request: NextRequest) { }); } return response; - } catch { - // Refresh failed (stale refresh token, IDP down, etc.). Clear the - // expired access token and redirect to login. + } catch (err) { + // Refresh failed (stale refresh token, IDP down, etc.). Log so we can + // distinguish normal expirations from IDP outages, clear both auth + // cookies (leaving the stale refresh token would cause every subsequent + // navigation to re-trigger the same failing refresh), and redirect. + logger.warn("Token refresh failed, redirecting to login", { + operation: "proxy.refreshAccessToken", + error: err instanceof Error ? err.message : String(err), + }); const response = redirectToLogin(request, pathname); response.cookies.set("token", "", { ...sharedCookieOpts, maxAge: 0 }); + response.cookies.set("refresh_token", "", { + ...sharedCookieOpts, + maxAge: 0, + }); return response; } }