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) {

View File

@@ -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<String> defaultRoles,
boolean autoSignup,
String displayNameClaim,
String userIdClaim
String userIdClaim,
String audience,
List<String> 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());
}
}

View File

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

View File

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

View File

@@ -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(() => {

View File

@@ -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)}
/>
</FormField>
<FormField label="Audience / API Resource" htmlFor="audience" hint="RFC 8707 resource indicator sent in the authorization request">
<Input
id="audience"
placeholder="https://api.example.com"
value={form.audience}
onChange={(e) => update('audience', e.target.value)}
/>
</FormField>
<FormField label="Additional Scopes" htmlFor="additional-scopes" hint="Extra scopes to request beyond openid email profile (comma-separated)">
<Input
id="additional-scopes"
placeholder="urn:scope:organizations, urn:scope:roles"
value={(form.additionalScopes || []).join(', ')}
onChange={(e) => update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
/>
</FormField>
</section>
<section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
<Input
id="roles-claim"
value={form.rolesClaim}