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.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
request.autoSignup(), request.autoSignup(),
request.displayNameClaim() != null ? request.displayNameClaim() : "name", 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); configRepository.save(config);

View File

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

View File

@@ -17,10 +17,12 @@ public record OidcAdminConfigResponse(
List<String> defaultRoles, List<String> defaultRoles,
boolean autoSignup, boolean autoSignup,
String displayNameClaim, String displayNameClaim,
String userIdClaim String userIdClaim,
String audience,
List<String> additionalScopes
) { ) {
public static OidcAdminConfigResponse unconfigured() { 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) { public static OidcAdminConfigResponse from(OidcConfig config) {
@@ -28,7 +30,7 @@ public record OidcAdminConfigResponse(
true, config.enabled(), config.issuerUri(), config.clientId(), true, config.enabled(), config.issuerUri(), config.clientId(),
!config.clientSecret().isBlank(), config.rolesClaim(), !config.clientSecret().isBlank(), config.rolesClaim(),
config.defaultRoles(), config.autoSignup(), config.displayNameClaim(), 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 clientId,
@NotNull String authorizationEndpoint, @NotNull String authorizationEndpoint,
@Schema(description = "Present if the provider supports RP-initiated logout") @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.issuerUri(),
oidc.clientId(), oidc.clientId(),
tokenExchanger.getAuthorizationEndpoint(), 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) { } catch (Exception e) {
log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage()); 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) { 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(); List<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles();
log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}", log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}",
userId, oidcRoles, config.defaultRoles(), roleNames); userId, oidcRoles, config.defaultRoles(), roleNames);

View File

@@ -46,6 +46,7 @@ public class OidcTokenExchanger {
private volatile String cachedIssuerUri; private volatile String cachedIssuerUri;
private volatile OIDCProviderMetadata providerMetadata; private volatile OIDCProviderMetadata providerMetadata;
private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor; private volatile ConfigurableJWTProcessor<SecurityContext> jwtProcessor;
private volatile ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
public OidcTokenExchanger(OidcConfigRepository configRepository, public OidcTokenExchanger(OidcConfigRepository configRepository,
SecurityProperties securityProperties) { SecurityProperties securityProperties) {
@@ -78,6 +79,9 @@ public class OidcTokenExchanger {
grant grant
); );
log.info("OIDC token exchange: tokenEndpoint={}, redirectUri={}, hasPkce={}, grantType={}",
metadata.getTokenEndpointURI(), redirectUri, codeVerifier != null && !codeVerifier.isBlank(), grant.getType());
var httpRequest = tokenRequest.toHTTPRequest(); var httpRequest = tokenRequest.toHTTPRequest();
if (securityProperties.isOidcTlsSkipVerify()) { if (securityProperties.isOidcTlsSkipVerify()) {
httpRequest.setSSLSocketFactory(InsecureTlsHelper.socketFactory()); httpRequest.setSSLSocketFactory(InsecureTlsHelper.socketFactory());
@@ -86,15 +90,19 @@ public class OidcTokenExchanger {
TokenResponse tokenResponse = TokenResponse.parse(httpRequest.send()); TokenResponse tokenResponse = TokenResponse.parse(httpRequest.send());
if (!tokenResponse.indicatesSuccess()) { if (!tokenResponse.indicatesSuccess()) {
String error = tokenResponse.toErrorResponse().getErrorObject().getDescription(); var errorObj = tokenResponse.toErrorResponse().getErrorObject();
throw new IllegalStateException("OIDC token exchange failed: " + error); 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() var responseJson = tokenResponse.toSuccessResponse().toJSONObject();
.getAsString("id_token");
String idTokenStr = responseJson.getAsString("id_token");
if (idTokenStr == null) { if (idTokenStr == null) {
throw new IllegalStateException("OIDC response missing id_token"); throw new IllegalStateException("OIDC response missing id_token");
} }
String accessTokenStr = responseJson.getAsString("access_token");
JWTClaimsSet claims = getJwtProcessor(config.issuerUri()).process(idTokenStr, null); JWTClaimsSet claims = getJwtProcessor(config.issuerUri()).process(idTokenStr, null);
@@ -109,10 +117,29 @@ public class OidcTokenExchanger {
String email = claims.getStringClaim("email"); String email = claims.getStringClaim("email");
String name = extractStringClaim(claims, config.displayNameClaim()); 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={}", log.info("OIDC user authenticated: id={}, email={}, rolesClaim='{}', extractedRoles={}, idTokenClaims={}, hasAccessToken={}",
subject, email, config.rolesClaim(), roles, claims.getClaims().keySet()); subject, email, config.rolesClaim(), roles, claims.getClaims().keySet(), accessTokenStr != null);
return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr); return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr);
} }
@@ -141,6 +168,7 @@ public class OidcTokenExchanger {
synchronized (this) { synchronized (this) {
providerMetadata = null; providerMetadata = null;
jwtProcessor = null; jwtProcessor = null;
accessTokenProcessor = null;
cachedIssuerUri = null; cachedIssuerUri = null;
log.info("OIDC provider cache invalidated"); log.info("OIDC provider cache invalidated");
} }
@@ -191,6 +219,48 @@ public class OidcTokenExchanger {
return Collections.emptyList(); 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 { private OIDCProviderMetadata getProviderMetadata(String issuerUri) throws Exception {
if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) { if (providerMetadata == null || !issuerUri.equals(cachedIssuerUri)) {
synchronized (this) { 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 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 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 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( public record OidcConfig(
boolean enabled, boolean enabled,
@@ -24,9 +26,11 @@ public record OidcConfig(
List<String> defaultRoles, List<String> defaultRoles,
boolean autoSignup, boolean autoSignup,
String displayNameClaim, String displayNameClaim,
String userIdClaim String userIdClaim,
String audience,
List<String> additionalScopes
) { ) {
public static OidcConfig disabled() { 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; autoSignup?: boolean;
displayNameClaim?: string; displayNameClaim?: string;
userIdClaim?: string; userIdClaim?: string;
audience?: string;
additionalScopes?: string[];
}; };
/** @description Error response */ /** @description Error response */
ErrorResponse: { ErrorResponse: {
@@ -1462,6 +1464,8 @@ export interface components {
autoSignup?: boolean; autoSignup?: boolean;
displayNameClaim?: string; displayNameClaim?: string;
userIdClaim?: string; userIdClaim?: string;
audience?: string;
additionalScopes?: string[];
}; };
UpdateGroupRequest: { UpdateGroupRequest: {
name?: string; name?: string;
@@ -2029,6 +2033,10 @@ export interface components {
authorizationEndpoint: string; authorizationEndpoint: string;
/** @description Present if the provider supports RP-initiated logout */ /** @description Present if the provider supports RP-initiated logout */
endSessionEndpoint?: string; 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 */ /** @description Agent instance summary with runtime metrics */
AgentInstanceResponse: { AgentInstanceResponse: {

View File

@@ -10,6 +10,8 @@ import styles from './LoginPage.module.css';
interface OidcInfo { interface OidcInfo {
clientId: string; clientId: string;
authorizationEndpoint: string; authorizationEndpoint: string;
resource?: string;
additionalScopes?: string[];
} }
/** Generate a random code_verifier for PKCE (RFC 7636). */ /** Generate a random code_verifier for PKCE (RFC 7636). */
@@ -71,7 +73,12 @@ export function LoginPage() {
api.GET('/auth/oidc/config') api.GET('/auth/oidc/config')
.then(({ data }) => { .then(({ data }) => {
if (data?.authorizationEndpoint && data?.clientId) { 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) { if (data.endSessionEndpoint) {
localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint); localStorage.setItem('cameleer-oidc-end-session', data.endSessionEndpoint);
} }
@@ -88,15 +95,17 @@ export function LoginPage() {
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier); sessionStorage.setItem('oidc-code-verifier', verifier);
deriveCodeChallenge(verifier).then((challenge) => { deriveCodeChallenge(verifier).then((challenge) => {
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: oidc.clientId, client_id: oidc.clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope: 'openid email profile', scope: scopes.join(' '),
prompt: 'none', prompt: 'none',
code_challenge: challenge, code_challenge: challenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`; window.location.href = `${oidc.authorizationEndpoint}?${params}`;
}); });
} }
@@ -116,14 +125,16 @@ export function LoginPage() {
const verifier = generateCodeVerifier(); const verifier = generateCodeVerifier();
sessionStorage.setItem('oidc-code-verifier', verifier); sessionStorage.setItem('oidc-code-verifier', verifier);
const challenge = await deriveCodeChallenge(verifier); const challenge = await deriveCodeChallenge(verifier);
const scopes = ['openid', 'email', 'profile', ...(oidc.additionalScopes || [])];
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: oidc.clientId, client_id: oidc.clientId,
redirect_uri: redirectUri, redirect_uri: redirectUri,
scope: 'openid email profile', scope: scopes.join(' '),
code_challenge: challenge, code_challenge: challenge,
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
if (oidc.resource) params.set('resource', oidc.resource);
window.location.href = `${oidc.authorizationEndpoint}?${params}`; 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 // consent_required — retry without prompt=none so user can grant scopes
if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) { if (errorParam === 'consent_required' && !sessionStorage.getItem('oidc-consent-retry')) {
sessionStorage.setItem('oidc-consent-retry', '1'); 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) { if (data?.authorizationEndpoint && data?.clientId) {
const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; 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({ const p = new URLSearchParams({
response_type: 'code', response_type: 'code',
client_id: data.clientId, client_id: data.clientId,
redirect_uri: redirectUri, 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}`; window.location.href = `${data.authorizationEndpoint}?${p}`;
} }
}).catch(() => { }).catch(() => {

View File

@@ -16,6 +16,8 @@ interface OidcFormData {
displayNameClaim: string; displayNameClaim: string;
userIdClaim: string; userIdClaim: string;
defaultRoles: string[]; defaultRoles: string[];
audience: string;
additionalScopes: string[];
} }
const EMPTY_CONFIG: OidcFormData = { const EMPTY_CONFIG: OidcFormData = {
@@ -28,6 +30,8 @@ const EMPTY_CONFIG: OidcFormData = {
displayNameClaim: 'name', displayNameClaim: 'name',
userIdClaim: 'sub', userIdClaim: 'sub',
defaultRoles: ['VIEWER'], defaultRoles: ['VIEWER'],
audience: '',
additionalScopes: [],
}; };
export default function OidcConfigPage() { export default function OidcConfigPage() {
@@ -51,6 +55,8 @@ export default function OidcConfigPage() {
displayNameClaim: data.displayNameClaim ?? 'name', displayNameClaim: data.displayNameClaim ?? 'name',
userIdClaim: data.userIdClaim ?? 'sub', userIdClaim: data.userIdClaim ?? 'sub',
defaultRoles: data.defaultRoles ?? ['VIEWER'], defaultRoles: data.defaultRoles ?? ['VIEWER'],
audience: (data as any).audience ?? '',
additionalScopes: (data as any).additionalScopes ?? [],
})) }))
.catch(() => setForm(EMPTY_CONFIG)); .catch(() => setForm(EMPTY_CONFIG));
}, []); }, []);
@@ -176,11 +182,27 @@ export default function OidcConfigPage() {
onChange={(e) => update('clientSecret', e.target.value)} onChange={(e) => update('clientSecret', e.target.value)}
/> />
</FormField> </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>
<section className={styles.section}> <section className={styles.section}>
<SectionHeader>Claim Mapping</SectionHeader> <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 <Input
id="roles-claim" id="roles-claim"
value={form.rolesClaim} value={form.rolesClaim}