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

@@ -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": {

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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}>