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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -14,5 +14,7 @@ public record OidcAdminConfigRequest(
|
||||
List<String> defaultRoles,
|
||||
boolean autoSignup,
|
||||
String displayNameClaim,
|
||||
String userIdClaim
|
||||
String userIdClaim,
|
||||
String audience,
|
||||
List<String> additionalScopes
|
||||
) {}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user