Add displayName to auth response and configurable display name claim for OIDC
- 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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -12,5 +12,6 @@ public record OidcAdminConfigRequest(
|
||||
String clientSecret,
|
||||
String rolesClaim,
|
||||
List<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
boolean autoSignup,
|
||||
String displayNameClaim
|
||||
) {}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS oidc_config (
|
||||
roles_claim String DEFAULT 'realm_access.roles',
|
||||
default_roles Array(LowCardinality(String)),
|
||||
auto_signup Bool DEFAULT true,
|
||||
display_name_claim String DEFAULT 'name',
|
||||
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
|
||||
) ENGINE = ReplacingMergeTree(updated_at)
|
||||
ORDER BY (config_id);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_config ADD COLUMN IF NOT EXISTS display_name_claim String DEFAULT 'name';
|
||||
@@ -9,9 +9,10 @@ import java.util.List;
|
||||
* @param issuerUri OIDC discovery issuer URL
|
||||
* @param clientId OAuth2 client ID
|
||||
* @param clientSecret OAuth2 client secret (stored server-side only)
|
||||
* @param rolesClaim dot-separated path to roles in the id_token (e.g. {@code realm_access.roles})
|
||||
* @param defaultRoles fallback roles for new users with no OIDC role claim
|
||||
* @param autoSignup whether new OIDC users are automatically created on first login
|
||||
* @param rolesClaim dot-separated path to roles in the id_token (e.g. {@code realm_access.roles})
|
||||
* @param defaultRoles fallback roles for new users with no OIDC role claim
|
||||
* @param autoSignup whether new OIDC users are automatically created on first login
|
||||
* @param displayNameClaim dot-separated path to display name in the id_token (e.g. {@code name}, {@code preferred_username})
|
||||
*/
|
||||
public record OidcConfig(
|
||||
boolean enabled,
|
||||
@@ -20,9 +21,10 @@ public record OidcConfig(
|
||||
String clientSecret,
|
||||
String rolesClaim,
|
||||
List<String> defaultRoles,
|
||||
boolean autoSignup
|
||||
boolean autoSignup,
|
||||
String displayNameClaim
|
||||
) {
|
||||
public static OidcConfig disabled() {
|
||||
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"), true);
|
||||
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"), true, "name");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS oidc_config (
|
||||
roles_claim String DEFAULT 'realm_access.roles',
|
||||
default_roles Array(LowCardinality(String)),
|
||||
auto_signup Bool DEFAULT true,
|
||||
display_name_claim String DEFAULT 'name',
|
||||
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
|
||||
) ENGINE = ReplacingMergeTree(updated_at)
|
||||
ORDER BY (config_id);
|
||||
|
||||
1
clickhouse/init/06-oidc-display-name-claim.sql
Normal file
1
clickhouse/init/06-oidc-display-name-claim.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_config ADD COLUMN IF NOT EXISTS display_name_claim String DEFAULT 'name';
|
||||
@@ -1393,6 +1393,9 @@
|
||||
},
|
||||
"autoSignup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"displayNameClaim": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1438,6 +1441,9 @@
|
||||
},
|
||||
"autoSignup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"displayNameClaim": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1593,11 +1599,15 @@
|
||||
},
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"accessToken",
|
||||
"refreshToken"
|
||||
"refreshToken",
|
||||
"displayName"
|
||||
]
|
||||
},
|
||||
"CallbackRequest": {
|
||||
|
||||
3
ui/src/api/schema.d.ts
vendored
3
ui/src/api/schema.d.ts
vendored
@@ -522,6 +522,7 @@ export interface components {
|
||||
rolesClaim?: string;
|
||||
defaultRoles?: string[];
|
||||
autoSignup?: boolean;
|
||||
displayNameClaim?: string;
|
||||
};
|
||||
/** @description Error response */
|
||||
ErrorResponse: {
|
||||
@@ -537,6 +538,7 @@ export interface components {
|
||||
rolesClaim?: string;
|
||||
defaultRoles?: string[];
|
||||
autoSignup?: boolean;
|
||||
displayNameClaim?: string;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
@@ -592,6 +594,7 @@ export interface components {
|
||||
AuthTokenResponse: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
displayName: string;
|
||||
};
|
||||
CallbackRequest: {
|
||||
code?: string;
|
||||
|
||||
@@ -64,13 +64,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (error || !data) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
const { accessToken, refreshToken } = data;
|
||||
const { accessToken, refreshToken, displayName } = data;
|
||||
localStorage.removeItem('cameleer-oidc-end-session');
|
||||
persistTokens(accessToken, refreshToken, username);
|
||||
const name = displayName ?? username;
|
||||
persistTokens(accessToken, refreshToken, name);
|
||||
set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
username,
|
||||
username: name,
|
||||
roles: parseRolesFromJwt(accessToken),
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
@@ -92,9 +93,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (error || !data) {
|
||||
throw new Error('OIDC login failed');
|
||||
}
|
||||
const { accessToken, refreshToken } = data;
|
||||
const payload = JSON.parse(atob(accessToken.split('.')[1]));
|
||||
const username = payload.sub ?? 'oidc-user';
|
||||
const { accessToken, refreshToken, displayName } = data;
|
||||
const username = displayName ?? 'oidc-user';
|
||||
persistTokens(accessToken, refreshToken, username);
|
||||
set({
|
||||
accessToken,
|
||||
@@ -120,11 +120,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
body: { refreshToken },
|
||||
});
|
||||
if (error || !data) return false;
|
||||
const username = get().username ?? '';
|
||||
const username = data.displayName ?? get().username ?? '';
|
||||
persistTokens(data.accessToken, data.refreshToken, username);
|
||||
set({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
username,
|
||||
roles: parseRolesFromJwt(data.accessToken),
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ interface FormData {
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
displayNameClaim: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormData = {
|
||||
@@ -27,6 +28,7 @@ const emptyForm: FormData = {
|
||||
clientSecret: '',
|
||||
rolesClaim: 'realm_access.roles',
|
||||
defaultRoles: ['VIEWER'],
|
||||
displayNameClaim: 'name',
|
||||
};
|
||||
|
||||
export function OidcAdminPage() {
|
||||
@@ -69,6 +71,7 @@ function OidcAdminForm() {
|
||||
clientSecret: '',
|
||||
rolesClaim: data.rolesClaim ?? 'realm_access.roles',
|
||||
defaultRoles: data.defaultRoles ?? ['VIEWER'],
|
||||
displayNameClaim: data.displayNameClaim ?? 'name',
|
||||
});
|
||||
setSecretTouched(false);
|
||||
} else {
|
||||
@@ -237,6 +240,20 @@ function OidcAdminForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Display Name Claim</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => updateField('displayNameClaim', e.target.value)}
|
||||
placeholder="name"
|
||||
/>
|
||||
<div className={styles.hint}>
|
||||
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label className={styles.label}>Default Roles</label>
|
||||
<div className={styles.tags}>
|
||||
|
||||
Reference in New Issue
Block a user