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 isAdmin?: boolean } accessToken?: string } interface JWT { clubs?: Record accessToken?: string isAdmin?: boolean } } // 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 || 'http://localhost:30808/realms/workclub' const issuerInternal = process.env.KEYCLOAK_ISSUER_INTERNAL || issuerPublic const oidcPublic = `${issuerPublic}/protocol/openid-connect` const oidcInternal = `${issuerInternal.replace(':8080', ':8081')}/protocol/openid-connect` export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ KeycloakProvider({ clientId: process.env.KEYCLOAK_CLIENT_ID || 'workclub-app', issuer: issuerPublic, authorization: { url: `${oidcPublic}/auth`, params: { scope: "openid email profile" }, }, token: `${oidcInternal}/token`, userinfo: `${oidcInternal}/userinfo`, jwks_endpoint: `${oidcInternal}/certs`, }) ], trustHost: true, cookies: { pkceCodeVerifier: { name: "authjs.pkce.code_verifier", options: { httpOnly: true, sameSite: "lax", path: "/", secure: false, }, }, state: { name: "authjs.state", options: { httpOnly: true, sameSite: "lax", path: "/", secure: false, }, }, }, debug: true, callbacks: { async jwt({ token, account }) { if (account && account.access_token) { // Add clubs claim from Keycloak access token token.clubs = (account as { clubs?: Record }).clubs || {} token.accessToken = account.access_token try { const payload = JSON.parse(Buffer.from((token.accessToken as string).split('.')[1], 'base64').toString()); const roles = (payload.realm_access?.roles as string[]) || []; token.isAdmin = roles.includes('admin'); } catch { token.isAdmin = false; } } return token }, async session({ session, token }) { // Expose clubs to client if (session.user) { session.user.clubs = token.clubs as Record | undefined session.user.isAdmin = token.isAdmin as boolean | undefined } session.accessToken = token.accessToken as string | undefined return session } } })