diff --git a/lib/auth/token-refresh.test.ts b/lib/auth/token-refresh.test.ts new file mode 100644 index 00000000..5d03252d --- /dev/null +++ b/lib/auth/token-refresh.test.ts @@ -0,0 +1,72 @@ +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 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); + }); +}); 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..16ada222 --- /dev/null +++ b/proxy.test.ts @@ -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); + }); +}); diff --git a/proxy.ts b/proxy.ts index a2826d88..bd2c4c28 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,5 +1,8 @@ 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. @@ -12,6 +15,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 +34,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 +53,64 @@ 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); + 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, + }); + // 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 (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; + } } export const config = {