Files
cannamanage/cannamanage-frontend/src/lib/auth.ts
T
Patrick Plate 281adda27c
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled
fix(frontend): align NextAuth authorize() with flat backend LoginResponse
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).
2026-06-13 10:10:48 +02:00

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",
},
})