From 03ff9a381383a4fe00f02b2c11179a1d36292a5a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:16:52 +0200 Subject: [PATCH] feat: generic OIDC role extraction from access token The OIDC login flow now reads roles from the access_token (JWT) in addition to the id_token. This fixes role extraction with providers like Logto that put scopes/roles in access tokens rather than id_tokens. - Add audience and additionalScopes to OidcConfig for RFC 8707 resource indicator support and configurable extra scopes - OidcTokenExchanger decodes access_token with at+jwt-compatible processor, falls back to id_token if access_token is opaque or has no roles - syncOidcRoles preserves existing local roles when OIDC returns none - SPA includes resource and additionalScopes in authorization requests - Admin UI exposes new config fields Co-Authored-By: Claude Opus 4.6 (1M context) --- .../controller/OidcConfigAdminController.java | 4 +- .../app/dto/OidcAdminConfigRequest.java | 4 +- .../app/dto/OidcAdminConfigResponse.java | 8 +- .../app/dto/OidcPublicConfigResponse.java | 6 +- .../app/security/OidcAuthController.java | 16 +++- .../app/security/OidcTokenExchanger.java | 84 +++++++++++++++++-- .../server/core/security/OidcConfig.java | 8 +- ui/src/api/schema.d.ts | 8 ++ ui/src/auth/LoginPage.tsx | 17 +++- ui/src/auth/OidcCallback.tsx | 16 +++- ui/src/pages/Admin/OidcConfigPage.tsx | 24 +++++- 11 files changed, 173 insertions(+), 22 deletions(-) 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 053fd36c..4eb7a427 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 @@ -102,7 +102,9 @@ public class OidcConfigAdminController { request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"), request.autoSignup(), request.displayNameClaim() != null ? request.displayNameClaim() : "name", - request.userIdClaim() != null ? request.userIdClaim() : "sub" + request.userIdClaim() != null ? request.userIdClaim() : "sub", + request.audience() != null ? request.audience() : "", + request.additionalScopes() != null ? request.additionalScopes() : List.of() ); configRepository.save(config); 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 ee8ecb91..ee45669e 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 @@ -14,5 +14,7 @@ public record OidcAdminConfigRequest( List defaultRoles, boolean autoSignup, String displayNameClaim, - String userIdClaim + String userIdClaim, + String audience, + List additionalScopes ) {} 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 bf15b579..404c84ee 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 @@ -17,10 +17,12 @@ public record OidcAdminConfigResponse( List defaultRoles, boolean autoSignup, String displayNameClaim, - String userIdClaim + String userIdClaim, + String audience, + List additionalScopes ) { public static OidcAdminConfigResponse unconfigured() { - return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false, null, null); + return new OidcAdminConfigResponse(false, false, null, null, false, null, null, false, null, null, null, null); } public static OidcAdminConfigResponse from(OidcConfig config) { @@ -28,7 +30,7 @@ public record OidcAdminConfigResponse( true, config.enabled(), config.issuerUri(), config.clientId(), !config.clientSecret().isBlank(), config.rolesClaim(), config.defaultRoles(), config.autoSignup(), config.displayNameClaim(), - config.userIdClaim() + config.userIdClaim(), config.audience(), config.additionalScopes() ); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java index c96eca30..57994375 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/OidcPublicConfigResponse.java @@ -9,5 +9,9 @@ public record OidcPublicConfigResponse( @NotNull String clientId, @NotNull String authorizationEndpoint, @Schema(description = "Present if the provider supports RP-initiated logout") - String endSessionEndpoint + String endSessionEndpoint, + @Schema(description = "RFC 8707 resource indicator for the authorization request") + String resource, + @Schema(description = "Additional scopes to request beyond openid email profile") + java.util.List additionalScopes ) {} 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 e1b88c25..8c402280 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 @@ -98,7 +98,9 @@ public class OidcAuthController { oidc.issuerUri(), oidc.clientId(), tokenExchanger.getAuthorizationEndpoint(), - endSessionEndpoint + endSessionEndpoint, + oidc.audience() != null && !oidc.audience().isBlank() ? oidc.audience() : null, + oidc.additionalScopes() != null && !oidc.additionalScopes().isEmpty() ? oidc.additionalScopes() : null )); } catch (Exception e) { log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage()); @@ -172,6 +174,18 @@ public class OidcAuthController { } private void syncOidcRoles(String userId, List oidcRoles, OidcConfig config) { + // If OIDC returned no roles and user already has local roles, preserve them + if (oidcRoles.isEmpty()) { + Set current = rbacService.getEffectiveRolesForUser(userId).stream() + .filter(r -> SystemRole.isSystem(r.id())) + .map(RoleSummary::id) + .collect(Collectors.toSet()); + if (!current.isEmpty()) { + log.info("syncOidcRoles: userId={}, no OIDC roles, preserving existing roles: {}", userId, current); + return; + } + } + List roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles(); log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}", userId, oidcRoles, config.defaultRoles(), roleNames); 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 0ec91097..030461b8 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 @@ -46,6 +46,7 @@ public class OidcTokenExchanger { private volatile String cachedIssuerUri; private volatile OIDCProviderMetadata providerMetadata; private volatile ConfigurableJWTProcessor jwtProcessor; + private volatile ConfigurableJWTProcessor accessTokenProcessor; public OidcTokenExchanger(OidcConfigRepository configRepository, SecurityProperties securityProperties) { @@ -78,6 +79,9 @@ public class OidcTokenExchanger { grant ); + log.info("OIDC token exchange: tokenEndpoint={}, redirectUri={}, hasPkce={}, grantType={}", + metadata.getTokenEndpointURI(), redirectUri, codeVerifier != null && !codeVerifier.isBlank(), grant.getType()); + var httpRequest = tokenRequest.toHTTPRequest(); if (securityProperties.isOidcTlsSkipVerify()) { httpRequest.setSSLSocketFactory(InsecureTlsHelper.socketFactory()); @@ -86,15 +90,19 @@ public class OidcTokenExchanger { TokenResponse tokenResponse = TokenResponse.parse(httpRequest.send()); if (!tokenResponse.indicatesSuccess()) { - String error = tokenResponse.toErrorResponse().getErrorObject().getDescription(); - throw new IllegalStateException("OIDC token exchange failed: " + error); + var errorObj = tokenResponse.toErrorResponse().getErrorObject(); + log.error("OIDC token exchange failed: code={}, description={}, httpStatus={}", + errorObj.getCode(), errorObj.getDescription(), errorObj.getHTTPStatusCode()); + throw new IllegalStateException("OIDC token exchange failed: " + errorObj.getDescription()); } - String idTokenStr = tokenResponse.toSuccessResponse().toJSONObject() - .getAsString("id_token"); + var responseJson = tokenResponse.toSuccessResponse().toJSONObject(); + + String idTokenStr = responseJson.getAsString("id_token"); if (idTokenStr == null) { throw new IllegalStateException("OIDC response missing id_token"); } + String accessTokenStr = responseJson.getAsString("access_token"); JWTClaimsSet claims = getJwtProcessor(config.issuerUri()).process(idTokenStr, null); @@ -109,10 +117,29 @@ public class OidcTokenExchanger { String email = claims.getStringClaim("email"); String name = extractStringClaim(claims, config.displayNameClaim()); - List roles = extractRoles(claims, config.rolesClaim()); + // Try roles from access_token first (JWT providers like Logto, Keycloak), + // then fall back to id_token + List roles = Collections.emptyList(); + if (accessTokenStr != null && accessTokenStr.contains(".")) { + try { + String audience = config.audience() != null ? config.audience() : ""; + JWTClaimsSet atClaims = decodeAccessToken(accessTokenStr, config.issuerUri(), audience); + if (atClaims != null) { + roles = extractRoles(atClaims, config.rolesClaim()); + if (!roles.isEmpty()) { + log.info("OIDC roles from access_token: {}", roles); + } + } + } catch (Exception e) { + log.debug("Could not decode access_token as JWT: {}", e.getMessage()); + } + } + if (roles.isEmpty()) { + roles = extractRoles(claims, config.rolesClaim()); + } - log.info("OIDC user authenticated: id={}, email={}, rolesClaim='{}', extractedRoles={}, allClaims={}", - subject, email, config.rolesClaim(), roles, claims.getClaims().keySet()); + log.info("OIDC user authenticated: id={}, email={}, rolesClaim='{}', extractedRoles={}, idTokenClaims={}, hasAccessToken={}", + subject, email, config.rolesClaim(), roles, claims.getClaims().keySet(), accessTokenStr != null); return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr); } @@ -141,6 +168,7 @@ public class OidcTokenExchanger { synchronized (this) { providerMetadata = null; jwtProcessor = null; + accessTokenProcessor = null; cachedIssuerUri = null; log.info("OIDC provider cache invalidated"); } @@ -191,6 +219,48 @@ public class OidcTokenExchanger { return Collections.emptyList(); } + /** + * Decodes a JWT access token and validates its audience if configured. + * Returns the claims, or {@code null} if validation fails. + */ + private JWTClaimsSet decodeAccessToken(String jwt, String issuerUri, String audience) throws Exception { + ConfigurableJWTProcessor processor = getAccessTokenProcessor(issuerUri); + JWTClaimsSet claims = processor.process(jwt, null); + if (audience != null && !audience.isBlank()) { + java.util.List aud = claims.getAudience(); + if (aud == null || !aud.contains(audience)) { + log.debug("Access token audience {} does not match expected {}", aud, audience); + return null; + } + } + return claims; + } + + /** + * Returns a JWT processor that accepts {@code at+jwt} type headers (RFC 9068), + * suitable for validating OAuth2 access tokens. + */ + private ConfigurableJWTProcessor getAccessTokenProcessor(String issuerUri) throws Exception { + if (accessTokenProcessor == null) { + synchronized (this) { + if (accessTokenProcessor == null) { + OIDCProviderMetadata metadata = getProviderMetadata(issuerUri); + URL jwksUrl = metadata.getJWKSetURI().toURL(); + JWKSource jwkSource = OidcProviderHelper.buildJwkSource( + jwksUrl, securityProperties.isOidcTlsSkipVerify()); + var keySelector = new JWSVerificationKeySelector( + OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource); + ConfigurableJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(keySelector); + // Accept any JWT type header (at+jwt, JWT, etc.) + processor.setJWSTypeVerifier((type, ctx) -> { }); + accessTokenProcessor = processor; + } + } + } + return accessTokenProcessor; + } + private OIDCProviderMetadata getProviderMetadata(String issuerUri) throws Exception { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { synchronized (this) { 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 5a414bed..2180a59c 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 @@ -14,6 +14,8 @@ import java.util.List; * @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}) * @param userIdClaim dot-separated path to user identifier in the id_token (default {@code sub}); e.g. {@code email}, {@code preferred_username} + * @param audience RFC 8707 resource indicator — sent to SPA as {@code resource} param and used for access_token {@code aud} validation + * @param additionalScopes extra scopes the SPA should request beyond {@code openid email profile} */ public record OidcConfig( boolean enabled, @@ -24,9 +26,11 @@ public record OidcConfig( List defaultRoles, boolean autoSignup, String displayNameClaim, - String userIdClaim + String userIdClaim, + String audience, + List additionalScopes ) { public static OidcConfig disabled() { - return new OidcConfig(false, "", "", "", "roles", List.of("VIEWER"), true, "name", "sub"); + return new OidcConfig(false, "", "", "", "roles", List.of("VIEWER"), true, "name", "sub", "", List.of()); } } diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index e2dc2000..995085d5 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -1445,6 +1445,8 @@ export interface components { autoSignup?: boolean; displayNameClaim?: string; userIdClaim?: string; + audience?: string; + additionalScopes?: string[]; }; /** @description Error response */ ErrorResponse: { @@ -1462,6 +1464,8 @@ export interface components { autoSignup?: boolean; displayNameClaim?: string; userIdClaim?: string; + audience?: string; + additionalScopes?: string[]; }; UpdateGroupRequest: { name?: string; @@ -2029,6 +2033,10 @@ export interface components { authorizationEndpoint: string; /** @description Present if the provider supports RP-initiated logout */ endSessionEndpoint?: string; + /** @description RFC 8707 resource indicator for the authorization request */ + resource?: string; + /** @description Additional scopes to request beyond openid email profile */ + additionalScopes?: string[]; }; /** @description Agent instance summary with runtime metrics */ AgentInstanceResponse: { diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index 76c85abe..29a84fe6 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -10,6 +10,8 @@ import styles from './LoginPage.module.css'; interface OidcInfo { clientId: string; authorizationEndpoint: string; + resource?: string; + additionalScopes?: string[]; } /** Generate a random code_verifier for PKCE (RFC 7636). */ @@ -71,7 +73,12 @@ export function LoginPage() { api.GET('/auth/oidc/config') .then(({ data }) => { if (data?.authorizationEndpoint && data?.clientId) { - setOidc({ clientId: data.clientId, authorizationEndpoint: data.authorizationEndpoint }); + setOidc({ + clientId: data.clientId, + authorizationEndpoint: data.authorizationEndpoint, + resource: data.resource ?? undefined, + additionalScopes: data.additionalScopes ?? undefined, + }); if (data.endSessionEndpoint) { localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); } @@ -88,15 +95,17 @@ export function LoginPage() { const verifier = generateCodeVerifier(); sessionStorage.setItem('oidc-code-verifier', verifier); deriveCodeChallenge(verifier).then((challenge) => { + const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])]; const params = new URLSearchParams({ response_type: 'code', client_id: oidc.clientId, redirect_uri: redirectUri, - scope: 'openid email profile', + scope: scopes.join(' '), prompt: 'none', code_challenge: challenge, code_challenge_method: 'S256', }); + if (oidc.resource) params.set('resource', oidc.resource); window.location.href = `${oidc.authorizationEndpoint}?${params}`; }); } @@ -116,14 +125,16 @@ export function LoginPage() { const verifier = generateCodeVerifier(); sessionStorage.setItem('oidc-code-verifier', verifier); const challenge = await deriveCodeChallenge(verifier); + const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])]; const params = new URLSearchParams({ response_type: 'code', client_id: oidc.clientId, redirect_uri: redirectUri, - scope: 'openid email profile', + scope: scopes.join(' '), code_challenge: challenge, code_challenge_method: 'S256', }); + if (oidc.resource) params.set('resource', oidc.resource); window.location.href = `${oidc.authorizationEndpoint}?${params}`; }; diff --git a/ui/src/auth/OidcCallback.tsx b/ui/src/auth/OidcCallback.tsx index b4b6cfba..47c21ac1 100644 --- a/ui/src/auth/OidcCallback.tsx +++ b/ui/src/auth/OidcCallback.tsx @@ -27,15 +27,27 @@ export function OidcCallback() { // consent_required — retry without prompt=none so user can grant scopes if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) { sessionStorage.setItem('oidc-consent-retry', '1'); - api.GET('/auth/oidc/config').then(({ data }) => { + api.GET('/auth/oidc/config').then(async ({ data }) => { if (data?.authorizationEndpoint && data?.clientId) { const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const verifier = btoa(String.fromCharCode(...array)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + sessionStorage.setItem('oidc-code-verifier', verifier); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + const challenge = btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const scopes = ['openid', 'email', 'profile', ...(data.additionalScopes || [])]; const p = new URLSearchParams({ response_type: 'code', client_id: data.clientId, redirect_uri: redirectUri, - scope: 'openid email profile', + scope: scopes.join(' '), + code_challenge: challenge, + code_challenge_method: 'S256', }); + if (data.resource) p.set('resource', data.resource); window.location.href = `${data.authorizationEndpoint}?${p}`; } }).catch(() => { diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index 4f765297..56a5b0b7 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -16,6 +16,8 @@ interface OidcFormData { displayNameClaim: string; userIdClaim: string; defaultRoles: string[]; + audience: string; + additionalScopes: string[]; } const EMPTY_CONFIG: OidcFormData = { @@ -28,6 +30,8 @@ const EMPTY_CONFIG: OidcFormData = { displayNameClaim: 'name', userIdClaim: 'sub', defaultRoles: ['VIEWER'], + audience: '', + additionalScopes: [], }; export default function OidcConfigPage() { @@ -51,6 +55,8 @@ export default function OidcConfigPage() { displayNameClaim: data.displayNameClaim ?? 'name', userIdClaim: data.userIdClaim ?? 'sub', defaultRoles: data.defaultRoles ?? ['VIEWER'], + audience: (data as any).audience ?? '', + additionalScopes: (data as any).additionalScopes ?? [], })) .catch(() => setForm(EMPTY_CONFIG)); }, []); @@ -176,11 +182,27 @@ export default function OidcConfigPage() { onChange={(e) => update('clientSecret', e.target.value)} /> + + update('audience', e.target.value)} + /> + + + update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + /> +
Claim Mapping - +