Files
ArchiTools/src/core/auth/auth-options.ts
T
Claude VM 162c8ed257 fix(auth): Authentik token endpoint is /application/o/token/ (shared)
THE bug behind every "data nu raman" / invalid_token incident this
session: refresh POSTed to `{issuer}/token/` = /application/o/architools/token/
which returns HTTP 405 + empty body. JSON.parse on the empty body
threw "Unexpected end of JSON input" → catch fired → token marked
RefreshAccessTokenError → 60s cooldown later, retry hit the same
broken URL → loop.

OIDC discovery at {issuer}/.well-known/openid-configuration confirms:
  "token_endpoint": "https://auth.beletage.ro/application/o/token/"

This is the SHARED endpoint, not per-provider. Hard-fix the URL by
constructing it from the issuer's origin.

Marius's currently-stuck session will auto-recover on next request
(cooldown expires, refresh fires against the corrected URL,
refresh_token still valid 30d).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:23:43 +03:00

201 lines
7.7 KiB
TypeScript

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<string, Promise<JWT>>();
async function refreshAuthentikToken(token: JWT): Promise<JWT> {
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<JWT> => {
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;
},
},
};