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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user