281adda27c
Login reached the backend (HTTP 200) but NextAuth returned CredentialsSignin.
Cause: authorize() read data.member.id/email/clubName/clubId, but the backend
LoginResponse is flat — { accessToken, refreshToken, expiresIn, role } with no
member object. Accessing data.member.id on undefined threw, so authorize()
returned null.
Decode the JWT payload to recover identity claims (sub=userId, email,
tenant_id=clubId) and use the flat top-level role. Adds a small decodeJwtPayload
helper (claims only, no signature verification needed here).
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
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<string, unknown> {
|
|
try {
|
|
const payload = token.split(".")[1]
|
|
const json = Buffer.from(payload, "base64url").toString("utf8")
|
|
return JSON.parse(json) as Record<string, unknown>
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
/** Helper: fetch with an AbortController timeout (default 5s) */
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit,
|
|
timeoutMs = 5000
|
|
): Promise<Response> {
|
|
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",
|
|
},
|
|
})
|