fix(frontend): align NextAuth authorize() with flat backend LoginResponse
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

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).
This commit is contained in:
Patrick Plate
2026-06-13 10:10:48 +02:00
parent dac884c4fe
commit 281adda27c
+22 -5
View File
@@ -2,6 +2,20 @@ 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,
@@ -42,14 +56,17 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
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: data.member.id,
email: data.member.email,
name: data.member.clubName,
role: data.member.role,
clubId: data.member.clubId,
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,