import type { NextAuthOptions } from "next-auth"; import type { JWT } from "next-auth/jwt"; import AuthentikProvider from "next-auth/providers/authentik"; import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac"; /** * Refresh the Authentik access_token using the stored refresh_token. * Authentik provider pk=6 issues access tokens valid for 5 minutes; the * refresh token is good for 30 days. NextAuth's JWT cookie lasts longer * (30 days default), so without refresh the access_token in session goes * stale after 5 min → api.gis.ac responds invalid_token. * * Marks the JWT with token.error="RefreshAccessTokenError" on failure; * the client can react by forcing a re-login. * * **Concurrency:** Authentik rotates refresh_tokens — a refresh_token can * only be exchanged once. With parallel requests (typical for a map app — * map tiles + parcela.get + search all firing at once), each request * triggers its own jwt callback, each calls refresh, only the first wins * and the rest get `invalid_grant` → user is logged out for no good * reason. We dedupe in-memory by refresh_token key so concurrent callers * share a single Authentik request. */ const inflightRefreshes = new Map>(); async function refreshAuthentikToken(token: JWT): Promise { const refreshToken = token.refreshToken ? String(token.refreshToken) : ""; if (!refreshToken) { return { ...token, error: "RefreshAccessTokenError" }; } // Dedupe concurrent refreshes for the same refresh_token. const existing = inflightRefreshes.get(refreshToken); if (existing) { return existing.then((fresh) => ({ ...token, ...fresh })); } const promise = (async (): Promise => { try { const issuer = process.env.AUTHENTIK_ISSUER; const clientId = process.env.AUTHENTIK_CLIENT_ID; const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET; if (!issuer || !clientId || !clientSecret) { throw new Error("refresh_prerequisites_missing"); } // Authentik exposes the token endpoint at the SHARED path, not per // provider. The per-provider `{issuer}/token/` returns HTTP 405 with // an empty body — which then explodes our JSON.parse with // "Unexpected end of JSON input". The OIDC discovery doc at // {issuer}/.well-known/openid-configuration declares the correct // endpoint as `https://auth.beletage.ro/application/o/token/`. const issuerOrigin = new URL(issuer).origin; const url = `${issuerOrigin}/application/o/token/`; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret, }), cache: "no-store", signal: AbortSignal.timeout(8_000), }); const body = (await res.json()) as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string; }; if (!res.ok || !body.access_token) { console.warn("[auth] refresh failed:", res.status, body.error); return { ...token, error: "RefreshAccessTokenError", errorAt: Date.now() }; } console.log("[auth] refresh OK expires_in=%d", body.expires_in ?? 300); return { ...token, accessToken: body.access_token, accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000, refreshToken: body.refresh_token ?? refreshToken, error: undefined, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.warn("[auth] refresh error:", msg); return { ...token, error: "RefreshAccessTokenError", errorAt: Date.now() }; } finally { inflightRefreshes.delete(refreshToken); } })(); inflightRefreshes.set(refreshToken, promise); return promise; } export const authOptions: NextAuthOptions = { providers: [ AuthentikProvider({ clientId: process.env.AUTHENTIK_CLIENT_ID || "", clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || "", issuer: process.env.AUTHENTIK_ISSUER || "", authorization: { params: { scope: process.env.AUTHENTIK_SCOPES || "openid email profile enrichment", }, }, }), ], callbacks: { async jwt({ token, user, profile, account }) { // First sign-in: capture Authentik OIDC access_token + refresh_token // + expiry so we can forward to api.gis.ac and refresh before stale. if (account?.access_token) { token.accessToken = account.access_token; token.refreshToken = account.refresh_token; token.accessTokenExpires = account.expires_at ? account.expires_at * 1000 : Date.now() + 5 * 60 * 1000; } if (user) { token.id = user.id; } if (profile) { // Map Authentik groups/roles to our internal roles const groups = (profile as any).groups || []; let role = "user"; if (groups.includes("architools-admin")) role = "admin"; else if (groups.includes("architools-manager")) role = "manager"; token.role = role; // Map company based on groups let company = "group"; if (groups.includes("company-beletage")) company = "beletage"; else if (groups.includes("company-urban-switch")) company = "urban-switch"; else if (groups.includes("company-studii-de-teren")) company = "studii-de-teren"; token.company = company; } // Refresh the access_token when within 30s of expiry. Skip if we // never captured one (no refresh_token to use) or refresh recently // failed (avoid hot-loop — user must re-login). const exp = typeof token.accessTokenExpires === "number" ? token.accessTokenExpires : 0; const errorAt = typeof token.errorAt === "number" ? token.errorAt : 0; const cooldownDone = Date.now() - errorAt > 60_000; const blocked = token.error === "RefreshAccessTokenError" && !cooldownDone; const secLeft = Math.round((exp - Date.now()) / 1000); console.log( "[auth] jwt secLeft=%d hasAccess=%s hasRefresh=%s err=%s blocked=%s", secLeft, !!token.accessToken, !!token.refreshToken, token.error ?? "none", blocked, ); if ( token.accessToken && token.refreshToken && !blocked && Date.now() > exp - 30_000 ) { return refreshAuthentikToken(token); } return token; }, async session({ session, token }) { if (session.user) { (session.user as any).id = token.id; (session.user as any).role = token.role || "user"; (session.user as any).company = token.company || "group"; } (session as any).accessToken = token.accessToken; // Surface refresh failure so the client can force a re-login UX. if (token.error) (session as any).error = token.error; // Temporary diagnostic — confirm token state in session. (session as any).debug = { hasRefreshToken: !!token.refreshToken, accessTokenExpiresIn: typeof token.accessTokenExpires === "number" ? Math.round((token.accessTokenExpires - Date.now()) / 1000) : null, tokenError: token.error ?? null, }; // Faza C cutover flag — exposed on session so client components can // branch the same way server routes do (env-driven, evaluated per // request so flag flip + container restart picks up without rebuild). (session as any).useGisAc = useGisAcFlag(session.user?.email); return session; }, }, };