fix(auth): Authentik access_token refresh flow

Authentik provider pk=6 issues access tokens with 5-minute TTL but
NextAuth's JWT cookie lives 30 days. Without refresh, every api.gis.ac
call after the first 5 minutes returned 401 invalid_token — the exact
failure Marius hit on first Faza E pilot test.

Implementation:
- jwt callback captures account.refresh_token + account.expires_at on
  first sign-in alongside access_token.
- Before each jwt issuance, if access_token is within 30s of expiry
  and a refresh_token exists, POST to {issuer}/token/ with
  grant_type=refresh_token + client_id + client_secret. Update token
  with the new access_token + expiry + (rotated) refresh_token.
- On failure, set token.error="RefreshAccessTokenError" and stop
  trying (avoid hot-loop). Surfaced via session.error so client UI
  can prompt re-login.

AUTHENTIK_SCOPES updated in Infisical to include `offline_access` so
Authentik issues a refresh_token on first sign-in (standard OAuth2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-18 22:13:19 +03:00
parent e0610b0573
commit 47ca366984
+77 -3
View File
@@ -1,7 +1,61 @@
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.
*/
async function refreshAuthentikToken(token: JWT): 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 || !token.refreshToken) {
throw new Error("refresh_prerequisites_missing");
}
const url = `${issuer.replace(/\/$/, "")}/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: String(token.refreshToken),
client_id: clientId,
client_secret: clientSecret,
}),
cache: "no-store",
});
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" };
}
return {
...token,
accessToken: body.access_token,
accessTokenExpires: Date.now() + (body.expires_in ?? 300) * 1000,
refreshToken: body.refresh_token ?? token.refreshToken,
error: undefined,
};
} catch (err) {
console.warn("[auth] refresh error:", err);
return { ...token, error: "RefreshAccessTokenError" };
}
}
export const authOptions: NextAuthOptions = {
providers: [
AuthentikProvider({
@@ -19,11 +73,14 @@ export const authOptions: NextAuthOptions = {
],
callbacks: {
async jwt({ token, user, profile, account }) {
// First sign-in: capture Authentik OIDC access_token so api.gis.ac
// calls can forward it. Carries enrichment_scope + is_beletage_group
// claims (mapped server-side in Authentik from LDAP group Arhitecti).
// 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;
@@ -47,6 +104,21 @@ export const authOptions: NextAuthOptions = {
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;
if (
token.accessToken &&
token.refreshToken &&
token.error !== "RefreshAccessTokenError" &&
Date.now() > exp - 30_000
) {
return refreshAuthentikToken(token);
}
return token;
},
async session({ session, token }) {
@@ -56,6 +128,8 @@ export const authOptions: NextAuthOptions = {
(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;
// 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).