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 { NextAuthOptions } from "next-auth";
|
||||||
|
import type { JWT } from "next-auth/jwt";
|
||||||
import AuthentikProvider from "next-auth/providers/authentik";
|
import AuthentikProvider from "next-auth/providers/authentik";
|
||||||
import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac";
|
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 = {
|
export const authOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
AuthentikProvider({
|
AuthentikProvider({
|
||||||
@@ -19,11 +73,14 @@ export const authOptions: NextAuthOptions = {
|
|||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user, profile, account }) {
|
async jwt({ token, user, profile, account }) {
|
||||||
// First sign-in: capture Authentik OIDC access_token so api.gis.ac
|
// First sign-in: capture Authentik OIDC access_token + refresh_token
|
||||||
// calls can forward it. Carries enrichment_scope + is_beletage_group
|
// + expiry so we can forward to api.gis.ac and refresh before stale.
|
||||||
// claims (mapped server-side in Authentik from LDAP group Arhitecti).
|
|
||||||
if (account?.access_token) {
|
if (account?.access_token) {
|
||||||
token.accessToken = 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) {
|
if (user) {
|
||||||
token.id = user.id;
|
token.id = user.id;
|
||||||
@@ -47,6 +104,21 @@ export const authOptions: NextAuthOptions = {
|
|||||||
|
|
||||||
token.company = company;
|
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;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
@@ -56,6 +128,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
(session.user as any).company = token.company || "group";
|
(session.user as any).company = token.company || "group";
|
||||||
}
|
}
|
||||||
(session as any).accessToken = token.accessToken;
|
(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
|
// Faza C cutover flag — exposed on session so client components can
|
||||||
// branch the same way server routes do (env-driven, evaluated per
|
// branch the same way server routes do (env-driven, evaluated per
|
||||||
// request so flag flip + container restart picks up without rebuild).
|
// request so flag flip + container restart picks up without rebuild).
|
||||||
|
|||||||
Reference in New Issue
Block a user