Full OIDC logout with id_token_hint for provider session termination
Some checks failed
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 48s
CI / deploy (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 16:14:07 +01:00
parent 463cab1196
commit a6f94e8a70
7 changed files with 23 additions and 8 deletions

View File

@@ -7,5 +7,7 @@ import jakarta.validation.constraints.NotNull;
public record AuthTokenResponse( public record AuthTokenResponse(
@NotNull String accessToken, @NotNull String accessToken,
@NotNull String refreshToken, @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
) {} ) {}

View File

@@ -132,7 +132,7 @@ public class OidcAuthController {
String displayName = oidcUser.name() != null && !oidcUser.name().isBlank() String displayName = oidcUser.name() != null && !oidcUser.name().isBlank()
? oidcUser.name() : oidcUser.email(); ? 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) { } catch (ResponseStatusException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {

View File

@@ -57,7 +57,7 @@ public class OidcTokenExchanger {
this.configRepository = configRepository; this.configRepository = configRepository;
} }
public record OidcUserInfo(String subject, String email, String name, List<String> roles) {} public record OidcUserInfo(String subject, String email, String name, List<String> roles, String idToken) {}
/** /**
* Exchanges an authorization code for validated user info. * Exchanges an authorization code for validated user info.
@@ -100,7 +100,7 @@ public class OidcTokenExchanger {
List<String> roles = extractRoles(claims, config.rolesClaim()); List<String> roles = extractRoles(claims, config.rolesClaim());
log.info("OIDC user authenticated: sub={}, email={}", subject, email); 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);
} }
/** /**

View File

@@ -85,7 +85,7 @@ public class UiAuthController {
String refreshToken = jwtService.createRefreshToken(subject, "user", roles); String refreshToken = jwtService.createRefreshToken(subject, "user", roles);
log.info("UI user logged in: {}", request.username()); 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") @PostMapping("/refresh")
@@ -108,7 +108,7 @@ public class UiAuthController {
String displayName = userRepository.findById(result.subject()) String displayName = userRepository.findById(result.subject())
.map(UserInfo::displayName) .map(UserInfo::displayName)
.orElse(result.subject()); .orElse(result.subject());
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName)); return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {

View File

@@ -1602,6 +1602,10 @@
}, },
"displayName": { "displayName": {
"type": "string" "type": "string"
},
"idToken": {
"type": "string",
"description": "OIDC id_token for end-session logout (only present after OIDC login)"
} }
}, },
"required": [ "required": [

View File

@@ -595,6 +595,8 @@ export interface components {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
displayName: string; displayName: string;
/** @description OIDC id_token for end-session logout (only present after OIDC login) */
idToken?: string;
}; };
CallbackRequest: { CallbackRequest: {
code?: string; code?: string;

View File

@@ -66,6 +66,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
const { accessToken, refreshToken, displayName } = data; const { accessToken, refreshToken, displayName } = data;
localStorage.removeItem('cameleer-oidc-end-session'); localStorage.removeItem('cameleer-oidc-end-session');
localStorage.removeItem('cameleer-oidc-id-token');
const name = displayName ?? username; const name = displayName ?? username;
persistTokens(accessToken, refreshToken, name); persistTokens(accessToken, refreshToken, name);
set({ set({
@@ -93,9 +94,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (error || !data) { if (error || !data) {
throw new Error('OIDC login failed'); throw new Error('OIDC login failed');
} }
const { accessToken, refreshToken, displayName } = data; const { accessToken, refreshToken, displayName, idToken } = data;
const username = displayName ?? 'oidc-user'; const username = displayName ?? 'oidc-user';
persistTokens(accessToken, refreshToken, username); persistTokens(accessToken, refreshToken, username);
if (idToken) {
localStorage.setItem('cameleer-oidc-id-token', idToken);
}
set({ set({
accessToken, accessToken,
refreshToken, refreshToken,
@@ -137,8 +141,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
logout: () => { logout: () => {
const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session'); const endSessionEndpoint = localStorage.getItem('cameleer-oidc-end-session');
const idToken = localStorage.getItem('cameleer-oidc-id-token');
clearTokens(); clearTokens();
localStorage.removeItem('cameleer-oidc-end-session'); localStorage.removeItem('cameleer-oidc-end-session');
localStorage.removeItem('cameleer-oidc-id-token');
set({ set({
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
@@ -147,9 +153,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false, isAuthenticated: false,
error: null, error: null,
}); });
if (endSessionEndpoint) { if (endSessionEndpoint && idToken) {
const postLogoutRedirect = `${window.location.origin}/login`; const postLogoutRedirect = `${window.location.origin}/login`;
const params = new URLSearchParams({ const params = new URLSearchParams({
id_token_hint: idToken,
post_logout_redirect_uri: postLogoutRedirect, post_logout_redirect_uri: postLogoutRedirect,
}); });
window.location.href = `${endSessionEndpoint}?${params}`; window.location.href = `${endSessionEndpoint}?${params}`;