162c8ed257
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>
201 lines
7.7 KiB
TypeScript
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;
|
|
},
|
|
},
|
|
};
|