import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" /** * Decode a JWT payload (no signature verification — we only need the claims for * populating the session). Returns {} on any parse failure. */ function decodeJwtPayload(token: string): Record { try { const payload = token.split(".")[1] const json = Buffer.from(payload, "base64url").toString("utf8") return JSON.parse(json) as Record } catch { return {} } } /** Helper: fetch with an AbortController timeout (default 5s) */ async function fetchWithTimeout( url: string, options: RequestInit, timeoutMs = 5000 ): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const res = await fetch(url, { ...options, signal: controller.signal }) return res } finally { clearTimeout(timeout) } } export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, providers: [ Credentials({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(credentials) { try { const res = await fetchWithTimeout( `${process.env.BACKEND_URL}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: credentials.email, password: credentials.password, }), } ) if (!res.ok) return null // Backend LoginResponse is flat: { accessToken, refreshToken, expiresIn, role }. // Identity claims (sub=userId, email, tenant_id=clubId) live inside the JWT. const data = await res.json() const claims = decodeJwtPayload(data.accessToken) return { id: (claims.sub as string) ?? data.accessToken, email: (claims.email as string) ?? credentials.email, name: (claims.email as string) ?? credentials.email, role: data.role ?? (claims.role as string), clubId: (claims.tenant_id as string) ?? "", accessToken: data.accessToken, refreshToken: data.refreshToken, expiresAt: Date.now() + data.expiresIn * 1000, } } catch { // Backend unreachable or timeout — fail gracefully return null } }, }), ], callbacks: { async jwt({ token, user }) { // Initial sign-in: transfer user data to token if (user) { token.role = user.role token.clubId = user.clubId token.accessToken = user.accessToken token.refreshToken = user.refreshToken token.expiresAt = user.expiresAt } // Token refresh: if access token expired, use refresh token if (token.expiresAt && Date.now() > (token.expiresAt as number)) { try { const res = await fetchWithTimeout( `${process.env.BACKEND_URL}/api/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: token.refreshToken }), } ) if (res.ok) { const refreshed = await res.json() token.accessToken = refreshed.accessToken token.refreshToken = refreshed.refreshToken token.expiresAt = Date.now() + refreshed.expiresIn * 1000 } else { token.error = "RefreshTokenExpired" } } catch { token.error = "RefreshTokenError" } } return token }, async session({ session, token }) { session.user.role = token.role as string session.user.clubId = token.clubId as string session.error = token.error as string | undefined return session }, async redirect({ url, baseUrl }) { // Guard: url may be undefined during static generation if (!url) return baseUrl // Handle relative URLs if (url.startsWith("/")) return `${baseUrl}${url}` // Handle same-origin URLs try { if (new URL(url).origin === baseUrl) return url } catch { // Invalid URL — fall back to baseUrl } return baseUrl }, }, pages: { signIn: "/login", error: "/login", }, session: { strategy: "jwt", }, })