diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java index df201708..de0d4a7c 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/OidcConfigAdminController.java @@ -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); 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 3bd088c2..477b946d 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 @@ -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 ) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java index 0b1222f5..09ffebcf 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigRequest.java @@ -12,5 +12,6 @@ public record OidcAdminConfigRequest( String clientSecret, String rolesClaim, List defaultRoles, - boolean autoSignup + boolean autoSignup, + String displayNameClaim ) {} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java index 20ad245d..eb5d1556 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcAdminConfigResponse.java @@ -15,17 +15,18 @@ public record OidcAdminConfigResponse( boolean clientSecretSet, String rolesClaim, List 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() ); } } 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 ee16939a..835dec7c 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 @@ -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) { 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 3e99c298..2804c502 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 @@ -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 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) 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 extractRoles(JWTClaimsSet claims, String claimPath) { try { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java index 72fbb9f0..ad48c345 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java @@ -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()); 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 e030c078..2024056b 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)); + 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) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java index 22143c24..92b08d54 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseOidcConfigRepository.java @@ -27,7 +27,7 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository { @Override public Optional find() { List 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") ); } } diff --git a/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql b/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql index 8f967733..35b4d896 100644 --- a/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql +++ b/cameleer3-server-app/src/main/resources/clickhouse/04-oidc-config.sql @@ -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); diff --git a/cameleer3-server-app/src/main/resources/clickhouse/06-oidc-display-name-claim.sql b/cameleer3-server-app/src/main/resources/clickhouse/06-oidc-display-name-claim.sql new file mode 100644 index 00000000..ef1870bd --- /dev/null +++ b/cameleer3-server-app/src/main/resources/clickhouse/06-oidc-display-name-claim.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_config ADD COLUMN IF NOT EXISTS display_name_claim String DEFAULT 'name'; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java index 4338f334..0c7654f6 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/OidcConfig.java @@ -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 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"); } } diff --git a/clickhouse/init/04-oidc-config.sql b/clickhouse/init/04-oidc-config.sql index 8f967733..35b4d896 100644 --- a/clickhouse/init/04-oidc-config.sql +++ b/clickhouse/init/04-oidc-config.sql @@ -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); diff --git a/clickhouse/init/06-oidc-display-name-claim.sql b/clickhouse/init/06-oidc-display-name-claim.sql new file mode 100644 index 00000000..ef1870bd --- /dev/null +++ b/clickhouse/init/06-oidc-display-name-claim.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_config ADD COLUMN IF NOT EXISTS display_name_claim String DEFAULT 'name'; diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index ce1d9645..dd36c8f7 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -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": { diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 2aa975f9..0154ac2f 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -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; diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts index 7d26ad76..c103d422 100644 --- a/ui/src/auth/auth-store.ts +++ b/ui/src/auth/auth-store.ts @@ -64,13 +64,14 @@ export const useAuthStore = create((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((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((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, }); diff --git a/ui/src/pages/admin/OidcAdminPage.tsx b/ui/src/pages/admin/OidcAdminPage.tsx index 2ded8387..675a3207 100644 --- a/ui/src/pages/admin/OidcAdminPage.tsx +++ b/ui/src/pages/admin/OidcAdminPage.tsx @@ -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() { +
+ + updateField('displayNameClaim', e.target.value)} + placeholder="name" + /> +
+ Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name) +
+
+