Add displayName to auth response and configurable display name claim for OIDC
Some checks failed
CI / build (push) Successful in 1m11s
CI / docker (push) Successful in 49s
CI / deploy (push) Failing after 2m9s

- Add displayName field to AuthTokenResponse so the UI shows human-readable
  names instead of internal JWT subjects (e.g. user:oidc:<hash>)
- Add displayNameClaim to OIDC config (default: "name") allowing admins to
  configure which ID token claim contains the user's display name
- Support dot-separated claim paths (e.g. profile.display_name) like rolesClaim
- Add admin UI field for Display Name Claim on the OIDC config page
- ClickHouse migration: ALTER TABLE adds display_name_claim column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 16:09:24 +01:00
parent 6676e209c7
commit 463cab1196
18 changed files with 96 additions and 32 deletions

View File

@@ -88,7 +88,8 @@ public class OidcConfigAdminController {
clientSecret,
request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles",
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
request.autoSignup()
request.autoSignup(),
request.displayNameClaim() != null ? request.displayNameClaim() : "name"
);
configRepository.save(config);

View File

@@ -6,5 +6,6 @@ import jakarta.validation.constraints.NotNull;
@Schema(description = "JWT token pair")
public record AuthTokenResponse(
@NotNull String accessToken,
@NotNull String refreshToken
@NotNull String refreshToken,
@NotNull String displayName
) {}

View File

@@ -12,5 +12,6 @@ public record OidcAdminConfigRequest(
String clientSecret,
String rolesClaim,
List<String> defaultRoles,
boolean autoSignup
boolean autoSignup,
String displayNameClaim
) {}

View File

@@ -15,17 +15,18 @@ public record OidcAdminConfigResponse(
boolean clientSecretSet,
String rolesClaim,
List<String> defaultRoles,
boolean autoSignup
boolean autoSignup,
String displayNameClaim
) {
public static OidcAdminConfigResponse unconfigured() {
return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false);
return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false, null);
}
public static OidcAdminConfigResponse from(OidcConfig config) {
return new OidcAdminConfigResponse(
true, config.enabled(), config.issuerUri(), config.clientId(),
!config.clientSecret().isBlank(), config.rolesClaim(),
config.defaultRoles(), config.autoSignup()
config.defaultRoles(), config.autoSignup(), config.displayNameClaim()
);
}
}

View File

@@ -130,7 +130,9 @@ public class OidcAuthController {
String accessToken = jwtService.createAccessToken(userId, "user", roles);
String refreshToken = jwtService.createRefreshToken(userId, "user", roles);
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
String displayName = oidcUser.name() != null && !oidcUser.name().isBlank()
? oidcUser.name() : oidcUser.email();
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {

View File

@@ -95,10 +95,7 @@ public class OidcTokenExchanger {
String subject = claims.getSubject();
String email = claims.getStringClaim("email");
String name = claims.getStringClaim("name");
if (name == null) {
name = claims.getStringClaim("preferred_username");
}
String name = extractStringClaim(claims, config.displayNameClaim());
List<String> roles = extractRoles(claims, config.rolesClaim());
@@ -147,6 +144,24 @@ public class OidcTokenExchanger {
.orElseThrow(() -> new IllegalStateException("OIDC is not configured or disabled"));
}
@SuppressWarnings("unchecked")
private String extractStringClaim(JWTClaimsSet claims, String claimPath) {
if (claimPath == null || claimPath.isBlank()) {
return null;
}
try {
String[] parts = claimPath.split("\\.");
Object current = claims.getClaim(parts[0]);
for (int i = 1; i < parts.length && current instanceof Map; i++) {
current = ((Map<String, Object>) current).get(parts[i]);
}
return current instanceof String ? (String) current : null;
} catch (Exception e) {
log.debug("Could not extract string from claim path '{}': {}", claimPath, e.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
private List<String> extractRoles(JWTClaimsSet claims, String claimPath) {
try {

View File

@@ -74,7 +74,8 @@ public class SecurityBeanConfig {
envOidc.getClientSecret() != null ? envOidc.getClientSecret() : "",
envOidc.getRolesClaim(),
envOidc.getDefaultRoles(),
true
true,
"name"
);
configRepository.save(config);
log.info("OIDC config seeded from environment variables: issuer={}", envOidc.getIssuerUri());

View File

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

View File

@@ -27,7 +27,7 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
@Override
public Optional<OidcConfig> find() {
List<OidcConfig> results = jdbc.query(
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup "
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, display_name_claim "
+ "FROM oidc_config FINAL WHERE config_id = 'default'",
this::mapRow
);
@@ -37,15 +37,16 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
@Override
public void save(OidcConfig config) {
jdbc.update(
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, updated_at) "
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, display_name_claim, updated_at) "
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
config.enabled(),
config.issuerUri(),
config.clientId(),
config.clientSecret(),
config.rolesClaim(),
config.defaultRoles().toArray(new String[0]),
config.autoSignup()
config.autoSignup(),
config.displayNameClaim()
);
}
@@ -63,7 +64,8 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
rs.getString("client_secret"),
rs.getString("roles_claim"),
Arrays.asList(rolesArray),
rs.getBoolean("auto_signup")
rs.getBoolean("auto_signup"),
rs.getString("display_name_claim")
);
}
}