From 47ca3669841577dfb890017fd0eb2459459b0b1d Mon Sep 17 00:00:00 2001 From: Claude VM Date: Mon, 18 May 2026 22:13:19 +0300 Subject: [PATCH] fix(auth): Authentik access_token refresh flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/core/auth/auth-options.ts | 80 +++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index fe2b9b5..e047ba8 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -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 { + 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).