From a6f94e8a70cb16b46767eb797e9a33b4f4744728 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:14:07 +0100 Subject: [PATCH] Full OIDC logout with id_token_hint for provider session termination Return the OIDC id_token in the callback response so the frontend can store it and pass it as id_token_hint to the provider's end-session endpoint on logout. This lets Authentik (or any OIDC provider) honor the post_logout_redirect_uri and redirect back to the Cameleer login page instead of showing the provider's own logout page. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cameleer3/server/app/dto/AuthTokenResponse.java | 4 +++- .../server/app/security/OidcAuthController.java | 2 +- .../server/app/security/OidcTokenExchanger.java | 4 ++-- .../server/app/security/UiAuthController.java | 4 ++-- ui/src/api/openapi.json | 4 ++++ ui/src/api/schema.d.ts | 2 ++ ui/src/auth/auth-store.ts | 11 +++++++++-- 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java index 477b946d..fe327cf2 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/AuthTokenResponse.java @@ -7,5 +7,7 @@ import jakarta.validation.constraints.NotNull; public record AuthTokenResponse( @NotNull String accessToken, @NotNull String refreshToken, - @NotNull String displayName + @NotNull String displayName, + @Schema(description = "OIDC id_token for end-session logout (only present after OIDC login)") + String idToken ) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index 835dec7c..81394eaa 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -132,7 +132,7 @@ public class OidcAuthController { String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() ? oidcUser.name() : oidcUser.email(); - return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName)); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, oidcUser.idToken())); } catch (ResponseStatusException e) { throw e; } catch (Exception e) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java index 2804c502..61f47e21 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java @@ -57,7 +57,7 @@ public class OidcTokenExchanger { this.configRepository = configRepository; } - public record OidcUserInfo(String subject, String email, String name, List roles) {} + public record OidcUserInfo(String subject, String email, String name, List roles, String idToken) {} /** * Exchanges an authorization code for validated user info. @@ -100,7 +100,7 @@ public class OidcTokenExchanger { List roles = extractRoles(claims, config.rolesClaim()); log.info("OIDC user authenticated: sub={}, email={}", subject, email); - return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles); + return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr); } /** diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 2024056b..50a56486 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -85,7 +85,7 @@ public class UiAuthController { String refreshToken = jwtService.createRefreshToken(subject, "user", roles); log.info("UI user logged in: {}", request.username()); - return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username())); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, request.username(), null)); } @PostMapping("/refresh") @@ -108,7 +108,7 @@ public class UiAuthController { String displayName = userRepository.findById(result.subject()) .map(UserInfo::displayName) .orElse(result.subject()); - return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName)); + return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null)); } catch (ResponseStatusException e) { throw e; } catch (Exception e) { diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index dd36c8f7..efea3672 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -1602,6 +1602,10 @@ }, "displayName": { "type": "string" + }, + "idToken": { + "type": "string", + "description": "OIDC id_token for end-session logout (only present after OIDC login)" } }, "required": [ diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 0154ac2f..89bcbb89 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -595,6 +595,8 @@ export interface components { accessToken: string; refreshToken: string; displayName: string; + /** @description OIDC id_token for end-session logout (only present after OIDC login) */ + idToken?: string; }; CallbackRequest: { code?: string; diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index c103d422..f7b1dd01 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -66,6 +66,7 @@ export const useAuthStore = create((set, get) => ({ } const { accessToken, refreshToken, displayName } = data; localStorage.removeItem('cameleer-oidc-end-session'); + localStorage.removeItem('cameleer-oidc-id-token'); const name = displayName ?? username; persistTokens(accessToken, refreshToken, name); set({ @@ -93,9 +94,12 @@ export const useAuthStore = create((set, get) => ({ if (error || !data) { throw new Error('OIDC login failed'); } - const { accessToken, refreshToken, displayName } = data; + const { accessToken, refreshToken, displayName, idToken } = data; const username = displayName ?? 'oidc-user'; persistTokens(accessToken, refreshToken, username); + if (idToken) { + localStorage.setItem('cameleer-oidc-id-token', idToken); + } set({ accessToken, refreshToken, @@ -137,8 +141,10 @@ export const useAuthStore = create((set, get) => ({ logout: () => { const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session'); + const idToken = localStorage.getItem('cameleer-oidc-id-token'); clearTokens(); localStorage.removeItem('cameleer-oidc-end-session'); + localStorage.removeItem('cameleer-oidc-id-token'); set({ accessToken: null, refreshToken: null, @@ -147,9 +153,10 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: false, error: null, }); - if (endSessionEndpoint) { + if (endSessionEndpoint && idToken) { const postLogoutRedirect = `${window.location.origin}/login`; const params = new URLSearchParams({ + id_token_hint: idToken, post_logout_redirect_uri: postLogoutRedirect, }); window.location.href = `${endSessionEndpoint}?${params}`;