From 901cc69ba31758543b90890ed124c49541831b5d Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Thu, 11 Jun 2026 09:21:15 +0200 Subject: [PATCH] fix(auth): silent session renewal + 401 auto-recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle sessions died and left a broken page: when the access token expired, nuxt-oidc-auth's automatic refresh had no refresh token to use — neither Authentik provider carried the offline_access scope mapping (and the operator never requested the scope), so the module cleared the session and every /api call 401'd until a manual F5 happened to re-auth through Authentik's still-alive SSO session. Fix 1: offline_access end to end — scope mapping attached to both live providers (and blueprints, prod + dev), operator now requests the scope. Sessions renew server-side for up to 30 days of activity (Redis store + pinned token key from earlier make the refresh tokens durable). Fix 2: client plugin in both apps — a 401 from /api sends the browser through /auth/oidc/login instead of leaving dead buttons; invisible when Authentik's session is alive, a clean sign-in screen when it isn't. Loop-guarded. Full sign-out behavior unchanged. --- apps/operator/nuxt.config.ts | 4 +++- apps/operator/plugins/auth-recover.client.ts | 20 +++++++++++++++++++ apps/portal/plugins/auth-recover.client.ts | 20 +++++++++++++++++++ .../blueprints/operator-application.yaml | 5 +++++ .../blueprints/operator-application.yaml | 3 +++ .../blueprints/portal-application.yaml | 3 +++ 6 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 apps/operator/plugins/auth-recover.client.ts create mode 100644 apps/portal/plugins/auth-recover.client.ts diff --git a/apps/operator/nuxt.config.ts b/apps/operator/nuxt.config.ts index 6de508d..b6fa77c 100644 --- a/apps/operator/nuxt.config.ts +++ b/apps/operator/nuxt.config.ts @@ -76,7 +76,9 @@ export default defineNuxtConfig({ logoutUrl: `${AUTH_URL}/application/o/${OPERATOR_OIDC_APP_SLUG}/end-session/`, openIdConfiguration: `${AUTH_URL}/application/o/${OPERATOR_OIDC_APP_SLUG}/.well-known/openid-configuration`, - scope: ['openid', 'profile', 'email', 'groups'], + // offline_access: refresh tokens for silent session renewal — without + // it, an expired access token kills the session (dead UI until F5). + scope: ['openid', 'profile', 'email', 'groups', 'offline_access'], userNameClaim: 'preferred_username', responseType: 'code', grantType: 'authorization_code', diff --git a/apps/operator/plugins/auth-recover.client.ts b/apps/operator/plugins/auth-recover.client.ts new file mode 100644 index 0000000..9159df7 --- /dev/null +++ b/apps/operator/plugins/auth-recover.client.ts @@ -0,0 +1,20 @@ +// Recover from a dead session instead of leaving a broken page: when any +// /api call returns 401, bounce the browser through the OIDC login route. +// With a live Authentik SSO session that round-trip is invisible (instant +// return to a fresh session); when Authentik's session is gone too, the +// user lands on the sign-in screen — never half-dead buttons. The +// timestamp guard prevents redirect loops if login can't restore a session. +export default defineNuxtPlugin(() => { + const KEY = 'auth-recover-at' + globalThis.$fetch = $fetch.create({ + onResponseError({ request, response }) { + const url = + typeof request === 'string' ? request : request instanceof Request ? request.url : String(request) + if (response.status !== 401 || !url.startsWith('/api/')) return + const last = Number(sessionStorage.getItem(KEY) ?? 0) + if (Date.now() - last < 30_000) return + sessionStorage.setItem(KEY, String(Date.now())) + window.location.href = '/auth/oidc/login' + }, + }) as typeof globalThis.$fetch +}) diff --git a/apps/portal/plugins/auth-recover.client.ts b/apps/portal/plugins/auth-recover.client.ts new file mode 100644 index 0000000..9159df7 --- /dev/null +++ b/apps/portal/plugins/auth-recover.client.ts @@ -0,0 +1,20 @@ +// Recover from a dead session instead of leaving a broken page: when any +// /api call returns 401, bounce the browser through the OIDC login route. +// With a live Authentik SSO session that round-trip is invisible (instant +// return to a fresh session); when Authentik's session is gone too, the +// user lands on the sign-in screen — never half-dead buttons. The +// timestamp guard prevents redirect loops if login can't restore a session. +export default defineNuxtPlugin(() => { + const KEY = 'auth-recover-at' + globalThis.$fetch = $fetch.create({ + onResponseError({ request, response }) { + const url = + typeof request === 'string' ? request : request instanceof Request ? request.url : String(request) + if (response.status !== 401 || !url.startsWith('/api/')) return + const last = Number(sessionStorage.getItem(KEY) ?? 0) + if (Date.now() - last < 30_000) return + sessionStorage.setItem(KEY, String(Date.now())) + window.location.href = '/auth/oidc/login' + }, + }) as typeof globalThis.$fetch +}) diff --git a/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml b/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml index c0d1be9..c6924f0 100644 --- a/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml +++ b/infrastructure/docker-compose/configs/authentik/blueprints/operator-application.yaml @@ -94,6 +94,11 @@ entries: authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"], ] + # offline_access -> refresh tokens for the apps' silent session renewal. + - !Find [ + authentik_providers_oauth2.scopemapping, + [managed, "goauthentik.io/providers/oauth2/scope-offline_access"], + ] sub_mode: hashed_user_id issuer_mode: per_provider diff --git a/infrastructure/production/fleet/authentik/blueprints/operator-application.yaml b/infrastructure/production/fleet/authentik/blueprints/operator-application.yaml index d6a6df6..46ba7c8 100644 --- a/infrastructure/production/fleet/authentik/blueprints/operator-application.yaml +++ b/infrastructure/production/fleet/authentik/blueprints/operator-application.yaml @@ -42,6 +42,9 @@ entries: - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]] - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]] - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]] + # offline_access -> Authentik issues refresh tokens, enabling the + # apps' silent session renewal (idle sessions died without it). + - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-offline_access"]] sub_mode: hashed_user_id issuer_mode: per_provider # Authentik 2026.5+ enforces a per-provider grant_types allowlist; an empty diff --git a/infrastructure/production/fleet/authentik/blueprints/portal-application.yaml b/infrastructure/production/fleet/authentik/blueprints/portal-application.yaml index 151e69c..07f435b 100644 --- a/infrastructure/production/fleet/authentik/blueprints/portal-application.yaml +++ b/infrastructure/production/fleet/authentik/blueprints/portal-application.yaml @@ -37,6 +37,9 @@ entries: - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-openid"]] - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-email"]] - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-profile"]] + # offline_access -> Authentik issues refresh tokens, enabling the + # apps' silent session renewal (idle sessions died without it). + - !Find [authentik_providers_oauth2.scopemapping, [managed, "goauthentik.io/providers/oauth2/scope-offline_access"]] sub_mode: hashed_user_id issuer_mode: per_provider # Authentik 2026.5+ enforces a per-provider grant_types allowlist; an empty