feat: generic OIDC role extraction from access token
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m48s
CI / docker (push) Successful in 1m1s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 10:16:52 +02:00
parent 95eb388283
commit 03ff9a3813
11 changed files with 173 additions and 22 deletions

View File

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

View File

@@ -14,5 +14,7 @@ public record OidcAdminConfigRequest(
List<String> defaultRoles,
boolean autoSignup,
String displayNameClaim,
String userIdClaim
String userIdClaim,
String audience,
List<String> additionalScopes
) {}

View File

@@ -17,10 +17,12 @@ public record OidcAdminConfigResponse(
List<String> defaultRoles,
boolean autoSignup,
String displayNameClaim,
String userIdClaim
String userIdClaim,
String audience,
List<String> 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()
);
}
}

View File

@@ -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<String> additionalScopes
) {}

View File

@@ -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<String> oidcRoles, OidcConfig config) {
// If OIDC returned no roles and user already has local roles, preserve them
if (oidcRoles.isEmpty()) {
Set<UUID> 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<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles();
log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}",
userId, oidcRoles, config.defaultRoles(), roleNames);

View File

@@ -46,6 +46,7 @@ public class OidcTokenExchanger {
private volatile String cachedIssuerUri;
private volatile OIDCProviderMetadata providerMetadata;
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
private volatile ConfigurableJWTProcessor<SecurityContext> 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<String> roles = extractRoles(claims, config.rolesClaim());
// Try roles from access_token first (JWT providers like Logto, Keycloak),
// then fall back to id_token
List<String> 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<SecurityContext> processor = getAccessTokenProcessor(issuerUri);
JWTClaimsSet claims = processor.process(jwt, null);
if (audience != null && !audience.isBlank()) {
java.util.List<String> 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<SecurityContext> getAccessTokenProcessor(String issuerUri) throws Exception {
if (accessTokenProcessor == null) {
synchronized (this) {
if (accessTokenProcessor == null) {
OIDCProviderMetadata metadata = getProviderMetadata(issuerUri);
URL jwksUrl = metadata.getJWKSetURI().toURL();
JWKSource<SecurityContext> jwkSource = OidcProviderHelper.buildJwkSource(
jwksUrl, securityProperties.isOidcTlsSkipVerify());
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
OidcProviderHelper.SUPPORTED_ALGORITHMS, jwkSource);
ConfigurableJWTProcessor<SecurityContext> 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) {