|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- // 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<JWT> {
- 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;
|