// config/authConfig.ts (or wherever your authOptions live) import { AuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; import { LOGIN_API_PATH, REFRESH_TOKEN_API_PATH } from "./api"; import { Session } from "next-auth"; import { JWT } from "next-auth/jwt"; /** Refresh access token this many ms before backend JWT `exp`. */ const ACCESS_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000; /** After a failed refresh, wait this long before trying again (avoids spam on session polls). */ const REFRESH_FAILURE_COOLDOWN_MS = 60 * 1000; function backendAccessTokenExpiresAtMs(accessToken: string | null | undefined): number | null { if (!accessToken) return null; try { const parts = accessToken.split("."); if (parts.length < 2) return null; const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); const json = Buffer.from(padded, "base64").toString("utf8"); const payload = JSON.parse(json) as { exp?: number }; return typeof payload.exp === "number" ? payload.exp * 1000 : null; } catch { return null; } } async function refreshAccessToken(token: JWT): Promise { const refreshToken = token.refreshToken; if (!refreshToken || !REFRESH_TOKEN_API_PATH || REFRESH_TOKEN_API_PATH === "undefined/refresh-token") { return { ...token, error: "RefreshAccessTokenError", refreshFailedAt: Date.now(), }; } try { const res = await fetch(REFRESH_TOKEN_API_PATH, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if (!res.ok) { return { ...token, error: "RefreshAccessTokenError", refreshFailedAt: Date.now(), }; } const data = (await res.json()) as { accessToken?: string; refreshToken?: string; }; if (!data.accessToken) { return { ...token, error: "RefreshAccessTokenError", refreshFailedAt: Date.now(), }; } return { ...token, accessToken: data.accessToken, refreshToken: data.refreshToken ?? refreshToken, error: undefined, refreshFailedAt: undefined, }; } catch { return { ...token, error: "RefreshAccessTokenError", refreshFailedAt: Date.now(), }; } } // Extend the built-in types declare module "next-auth" { interface Session { accessToken: string | null; refreshToken?: string; abilities: string[]; id?: string; /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ exp?: number; locale?: string; error?: string; } interface User { id?: string; accessToken: string | null; refreshToken?: string; abilities: string[]; locale?: string; } } declare module "next-auth/jwt" { interface JWT { id?: string; accessToken: string | null; refreshToken?: string; abilities: string[]; locale?: string; error?: string; /** Epoch ms of last failed refresh — used as cooldown to avoid hammering /refresh-token */ refreshFailedAt?: number; } } export const authOptions: AuthOptions = { debug: process.env.NODE_ENV === "development", providers: [ CredentialsProvider({ id: "credentials", name: "Credentials", credentials: { username: { label: "Username", type: "text" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { if (!credentials?.username || !credentials?.password) return null; const res = await fetch(LOGIN_API_PATH, { method: "POST", body: JSON.stringify(credentials), headers: { "Content-Type": "application/json" }, }); if (!res.ok) return null; const user = await res.json(); // Important: next-auth expects the user object returned here // to be serializable and contain the fields you want in token/session // Ensure your backend returns: { id, accessToken, abilities, ...other fields } if (user && user.abilities) { return user; // this will be passed to jwt callback as `user` } return null; }, }), ], pages: { signIn: "/login", }, callbacks: { // Persist custom fields into the JWT token; refresh backend access JWT near expiry async jwt({ token, user, trigger, session }) { // First sign-in: `user` is available if (user) { token.id = user.id ?? token.sub; // fallback to sub if no id token.accessToken = user.accessToken; token.refreshToken = user.refreshToken; token.abilities = user.abilities ?? []; token.error = undefined; token.refreshFailedAt = undefined; const loginLocale = (user as { locale?: string }).locale; if (loginLocale) { token.locale = loginLocale; } return token; } if (trigger === "update" && session && typeof session === "object") { if ("locale" in session) { const next = (session as { locale?: string }).locale; if (next === "zh" || next === "en") { token.locale = next; } } if ("accessToken" in session && typeof (session as { accessToken?: string }).accessToken === "string") { token.accessToken = (session as { accessToken: string }).accessToken; } if ("refreshToken" in session && typeof (session as { refreshToken?: string }).refreshToken === "string") { token.refreshToken = (session as { refreshToken: string }).refreshToken; } } const expiresAt = backendAccessTokenExpiresAtMs(token.accessToken); // Can't parse exp → leave as-is (avoid refresh thrash). Still-valid → keep. if (expiresAt === null || Date.now() < expiresAt - ACCESS_TOKEN_REFRESH_SKEW_MS) { return token; } // Cooldown after a failed refresh so session polls don't hammer /refresh-token if ( token.refreshFailedAt && Date.now() - token.refreshFailedAt < REFRESH_FAILURE_COOLDOWN_MS ) { return token; } // Access token expired or near expiry — refresh using refresh token if (token.refreshToken) { return refreshAccessToken(token); } return token; }, // Expose custom fields to the client session async session({ session, token }) { session.id = token.id as string | undefined; session.accessToken = token.accessToken as string | null; session.refreshToken = token.refreshToken as string | undefined; session.abilities = token.abilities as string[]; session.exp = token.exp as number | undefined; session.locale = token.locale as string | undefined; session.error = token.error as string | undefined; // Also add abilities to session.user for easier client-side access if (session.user) { session.user.abilities = token.abilities as string[]; } return session; }, }, }; export type SessionWithTokens = Session & { accessToken: string | null; refreshToken?: string; abilities: string[]; /** Backend / JWT subject — often numeric string or number */ id?: string | number; locale?: string; }; export default authOptions;