2026-03-03 18:52:44 +01:00
|
|
|
import NextAuth from "next-auth"
|
|
|
|
|
import KeycloakProvider from "next-auth/providers/keycloak"
|
|
|
|
|
|
|
|
|
|
declare module "next-auth" {
|
|
|
|
|
interface Session {
|
|
|
|
|
user: {
|
|
|
|
|
id: string
|
|
|
|
|
name?: string | null
|
|
|
|
|
email?: string | null
|
|
|
|
|
image?: string | null
|
|
|
|
|
clubs?: Record<string, string>
|
2026-03-18 09:08:45 +01:00
|
|
|
isAdmin?: boolean
|
2026-03-03 18:52:44 +01:00
|
|
|
}
|
|
|
|
|
accessToken?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface JWT {
|
|
|
|
|
clubs?: Record<string, string>
|
|
|
|
|
accessToken?: string
|
2026-03-18 09:08:45 +01:00
|
|
|
isAdmin?: boolean
|
2026-03-03 18:52:44 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 14:21:03 +01:00
|
|
|
// In Docker, the Next.js server reaches Keycloak via internal hostname
|
|
|
|
|
// (keycloak:8080) but the browser uses localhost:8080. Explicit endpoint
|
|
|
|
|
// URLs bypass OIDC discovery, avoiding issuer mismatch validation errors.
|
|
|
|
|
const issuerPublic = process.env.KEYCLOAK_ISSUER!
|
|
|
|
|
const issuerInternal = process.env.KEYCLOAK_ISSUER_INTERNAL || issuerPublic
|
|
|
|
|
const oidcPublic = `${issuerPublic}/protocol/openid-connect`
|
|
|
|
|
const oidcInternal = `${issuerInternal}/protocol/openid-connect`
|
|
|
|
|
|
2026-03-03 18:52:44 +01:00
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
|
|
|
providers: [
|
|
|
|
|
KeycloakProvider({
|
|
|
|
|
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
|
|
|
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
2026-03-09 14:21:03 +01:00
|
|
|
issuer: issuerPublic,
|
|
|
|
|
authorization: {
|
|
|
|
|
url: `${oidcPublic}/auth`,
|
|
|
|
|
params: { scope: "openid email profile" },
|
|
|
|
|
},
|
|
|
|
|
token: `${oidcInternal}/token`,
|
|
|
|
|
userinfo: `${oidcInternal}/userinfo`,
|
2026-03-03 18:52:44 +01:00
|
|
|
})
|
|
|
|
|
],
|
|
|
|
|
callbacks: {
|
|
|
|
|
async jwt({ token, account }) {
|
2026-03-18 09:08:45 +01:00
|
|
|
if (account && account.access_token) {
|
2026-03-03 18:52:44 +01:00
|
|
|
// Add clubs claim from Keycloak access token
|
2026-03-18 09:08:45 +01:00
|
|
|
token.clubs = (account as any).clubs as Record<string, string> || {}
|
|
|
|
|
token.accessToken = account.access_token as string
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString());
|
|
|
|
|
const roles = payload.realm_access?.roles || [];
|
|
|
|
|
token.isAdmin = roles.includes('admin');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
token.isAdmin = false;
|
|
|
|
|
}
|
2026-03-03 18:52:44 +01:00
|
|
|
}
|
|
|
|
|
return token
|
|
|
|
|
},
|
|
|
|
|
async session({ session, token }) {
|
|
|
|
|
// Expose clubs to client
|
|
|
|
|
if (session.user) {
|
2026-03-18 09:08:45 +01:00
|
|
|
session.user.clubs = (token as any).clubs as Record<string, string> | undefined
|
|
|
|
|
session.user.isAdmin = (token as any).isAdmin as boolean | undefined
|
2026-03-03 18:52:44 +01:00
|
|
|
}
|
2026-03-18 09:08:45 +01:00
|
|
|
session.accessToken = (token as any).accessToken as string | undefined
|
2026-03-03 18:52:44 +01:00
|
|
|
return session
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|