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.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);
|
||||||
|
|||||||
@@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
ui/src/api/schema.d.ts
vendored
8
ui/src/api/schema.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user