Skip to content

Commit

Permalink
Binding brokering OIDC user sessions with the issuer of the ID Token …
Browse files Browse the repository at this point in the history
…to avoid looking up sessions by iterating over all brokers in a realm

Closes keycloak#32091

Signed-off-by: Pedro Igor <[email protected]>
  • Loading branch information
pedroigor committed Sep 3, 2024
1 parent db7694e commit 94bf933
Show file tree
Hide file tree
Showing 12 changed files with 108 additions and 122 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public class LogoutAction extends AdminAction {
protected List<String> adapterSessionIds;
protected int notBefore;
protected List<String> keycloakSessionIds;
private String issuer;

public LogoutAction() {
}
Expand Down Expand Up @@ -64,4 +65,12 @@ public void setKeycloakSessionIds(List<String> keycloakSessionIds) {
public boolean validate() {
return LOGOUT.equals(action);
}

public void setIssuer(String issuer) {
this.issuer = issuer;
}

public String getIssuer() {
return issuer;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,11 @@ This is a non-browser-based logout that uses direct backchannel communication be
{project_name} sends a HTTP POST request containing a logout token to all clients logged into {project_name}. These
requests are sent to a registered backchannel logout URLs at {project_name} and are supposed to trigger a logout at client side.

{project_name} also supports backchannel logout when acting as a broker and integrating with third-party OpenID Providers. In this case, logout tokens
will be sent from these providers to {project_name} and, depending on the semantics of the logout token, a single session or all sessions
associated with a subject are going to be destroyed. You must set an `Issuer` in your broker configuration to be able to process
logout tokens from OpenID Providers.


[[_oidc-endpoints]]
==== {project_name} Server OIDC URI Endpoints
Expand Down Expand Up @@ -393,6 +398,7 @@ You can also find these endpoints under "OpenID Endpoint Configuration" in your
/realms/{realm-name}/protocol/openid-connect/ext/ciba/auth::
This is the URL endpoint for Client Initiated Backchannel Authentication Grant to obtain an auth_req_id that identifies the authentication request made by the client.
/realms/{realm-name}/protocol/openid-connect/logout/backchannel-logout::
This is the URL endpoint for performing backchannel logouts described in the OIDC specification.
This is the URL endpoint for performing backchannel logouts described in the OIDC specification. This endpoint is mainly targeted for
processing logout requests from OpenID Providers when {project_name} is acting as a broker.

In all of these replace _{realm-name}_ with the name of the realm.
Original file line number Diff line number Diff line change
Expand Up @@ -370,14 +370,13 @@ protected Stream<UserSessionModel> getUserSessionsStream(RealmModel realm, UserS
String[] idpAliasSessionId = predicate.getBrokerUserId().split("\\.");

Map<String, String> attributes = new HashMap<>();
attributes.put(UserModel.IDP_ALIAS, idpAliasSessionId[0]);
attributes.put(UserModel.IDP_USER_ID, idpAliasSessionId[1]);

UserProvider userProvider = session.getProvider(UserProvider.class);
UserModel userModel = userProvider.searchForUserStream(realm, attributes, 0, null).findFirst().orElse(null);
return userModel != null ?
persister.loadUserSessionsStream(realm, userModel, true, 0, null) :
Stream.empty();

return userProvider.searchForUserStream(realm, attributes, 0, null)
.flatMap((Function<UserModel, Stream<UserSessionModel>>) userModel -> persister.loadUserSessionsStream(realm, userModel, true, 0, null))
.filter(predicate.toModelPredicate());
}

throw new ModelException("For offline sessions, only lookup by userId and brokerUserId is supported");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,17 +329,15 @@ public String getId() {
String[] idpAliasSessionId = predicate.getBrokerUserId().split("\\.");

Map<String, String> attributes = new HashMap<>();
attributes.put(UserModel.IDP_ALIAS, idpAliasSessionId[0]);
attributes.put(UserModel.IDP_USER_ID, idpAliasSessionId[1]);

UserProvider userProvider = session.getProvider(UserProvider.class);
UserModel userModel = userProvider.searchForUserStream(realm, attributes, 0, null).findFirst().orElse(null);
return userModel != null ?
persister.loadUserSessionsStream(realm, userModel, offline, 0, null)
.filter(predicate.toModelPredicate())
.map(s -> (UserSessionModel) getUserSession(realm, s.getId(), s, offline))
.filter(Objects::nonNull) :
Stream.empty();

return userProvider.searchForUserStream(realm, attributes, 0, null)
.flatMap((Function<UserModel, Stream<UserSessionModel>>) userModel -> persister.loadUserSessionsStream(realm, userModel, offline, 0, null))
.filter(predicate.toModelPredicate())
.map(s -> (UserSessionModel) getUserSession(realm, s.getId(), s, offline))
.filter(Objects::nonNull);
}

if (predicate.getClient() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.util.Base64Url;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
Expand All @@ -42,6 +43,7 @@
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

/**
* @author <a href="mailto:[email protected]">Bill Burke</a>
Expand Down Expand Up @@ -94,7 +96,7 @@ public Response backchannelLogout(String input) {
if (!validateAction(action)) return Response.status(400).build();
if (action.getKeycloakSessionIds() != null) {
for (String sessionId : action.getKeycloakSessionIds()) {
String brokerSessionId = provider.getConfig().getAlias() + "." + sessionId;
String brokerSessionId = Base64Url.encode(action.getIssuer().getBytes(StandardCharsets.UTF_8)) + "." + sessionId;
UserSessionModel userSession = session.sessions().getUserSessionByBrokerSessionId(realm, brokerSessionId);
if (userSession != null
&& userSession.getState() != UserSessionModel.State.LOGGING_OUT
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class OAuth2IdentityProviderConfig extends IdentityProviderModel {
public static final String PKCE_METHOD = "pkceMethod";

public static final String JWT_X509_HEADERS_ENABLED = "jwtX509HeadersEnabled";
public static final String CLIENT_ID = "clientId";

public OAuth2IdentityProviderConfig(IdentityProviderModel model) {
super(model);
Expand Down Expand Up @@ -70,11 +71,11 @@ public void setUserInfoUrl(String userInfoUrl) {
}

public String getClientId() {
return getConfig().get("clientId");
return getConfig().get(CLIENT_ID);
}

public void setClientId(String clientId) {
getConfig().put("clientId", clientId);
getConfig().put(CLIENT_ID, clientId);
}

public String getClientAuthMethod() {
Expand All @@ -100,7 +101,7 @@ public String getDefaultScope() {
public void setDefaultScope(String defaultScope) {
getConfig().put("defaultScope", defaultScope);
}

public boolean isJWTAuthentication() {
if (getClientAuthMethod().equals(OIDCLoginProtocol.CLIENT_SECRET_JWT)
|| getClientAuthMethod().equals(OIDCLoginProtocol.PRIVATE_KEY_JWT)) {
Expand Down Expand Up @@ -152,7 +153,7 @@ public String setPkceMethod(String method) {
public String getClientAssertionSigningAlg() {
return getConfig().get("clientAssertionSigningAlg");
}

public void setClientAssertionSigningAlg(String signingAlg) {
getConfig().put("clientAssertionSigningAlg", signingAlg);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ public BrokeredIdentityContext getFederatedIdentity(String response) {

try {
BrokeredIdentityContext identity = extractIdentity(tokenResponse, accessToken, idToken);

if (!identity.getId().equals(idToken.getSubject())) {
throw new IdentityBrokerException("Mismatch between the subject in the id_token and the subject from the user_info endpoint");
}
Expand Down Expand Up @@ -428,7 +428,7 @@ public BrokeredIdentityContext getFederatedIdentity(String response) {
if (!getConfig().isDisableNonce()) {
identity.getContextData().put(BROKER_NONCE_PARAM, idToken.getOtherClaims().get(OIDCLoginProtocol.NONCE_PARAM));
}

if (getConfig().isStoreToken()) {
if (tokenResponse.getExpiresIn() > 0) {
long accessTokenExpiration = Time.currentTime() + tokenResponse.getExpiresIn();
Expand Down Expand Up @@ -469,6 +469,7 @@ protected boolean isAuthTimeExpired(JsonWebToken idToken, AuthenticationSessionM

protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException {
String id = idToken.getSubject();
String issuer = idToken.getIssuer();
BrokeredIdentityContext identity = new BrokeredIdentityContext(id, getConfig());
String name = (String) idToken.getOtherClaims().get(IDToken.NAME);
String givenName = (String)idToken.getOtherClaims().get(IDToken.GIVEN_NAME);
Expand Down Expand Up @@ -530,7 +531,7 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo

identity.setEmail(email);

identity.setBrokerUserId(getConfig().getAlias() + "." + id);
identity.setBrokerUserId(Base64Url.encode(issuer.getBytes(StandardCharsets.UTF_8)) + "." + id);

if (preferredUsername == null) {
preferredUsername = email;
Expand All @@ -542,11 +543,11 @@ protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenRespo

identity.setUsername(preferredUsername);
if (tokenResponse != null && tokenResponse.getSessionState() != null) {
identity.setBrokerSessionId(getConfig().getAlias() + "." + tokenResponse.getSessionState());
identity.setBrokerSessionId(Base64Url.encode(issuer.getBytes(StandardCharsets.UTF_8)) + "." + tokenResponse.getSessionState());
}
if (tokenResponse != null) identity.getContextData().put(FEDERATED_ACCESS_TOKEN_RESPONSE, tokenResponse);
if (tokenResponse != null) processAccessTokenResponse(identity, tokenResponse);

return identity;
}

Expand Down Expand Up @@ -707,7 +708,7 @@ protected JsonWebToken validateToken(String encodedToken, boolean ignoreAudience
if (!ignoreAudience && !token.hasAudience(getConfig().getClientId())) {
throw new IdentityBrokerException("Wrong audience from token.");
}

if (!ignoreAudience && (token.getIssuedFor() != null && !getConfig().getClientId().equals(token.getIssuedFor()))) {
throw new IdentityBrokerException("Token issued for does not match client id");
}
Expand Down Expand Up @@ -751,7 +752,7 @@ public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
String requestedIssuer = params == null ? null : params.getFirst(OAuth2Constants.SUBJECT_ISSUER);
if (requestedIssuer == null) requestedIssuer = issuer;
if (requestedIssuer.equals(getConfig().getAlias())) return true;

String trustedIssuers = getConfig().getIssuer();

if (trustedIssuers != null && trustedIssuers.length() > 0) {
Expand All @@ -763,7 +764,7 @@ public boolean isIssuer(String issuer, MultivaluedMap<String, String> params) {
}
}
}

return false;
}

Expand Down Expand Up @@ -802,22 +803,20 @@ protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event,
AbstractJsonUserAttributeMapper.storeUserProfileForMapper(identity, userInfo, getConfig().getAlias());

identity.setId(id);

if (givenName != null) {
identity.setFirstName(givenName);
}

if (familyName != null) {
identity.setLastName(familyName);
}

if (givenName == null && familyName == null) {
identity.setName(name);
}

identity.setEmail(email);

identity.setBrokerUserId(getConfig().getAlias() + "." + id);
identity.setEmail(email);

if (preferredUsername == null) {
preferredUsername = email;
Expand Down Expand Up @@ -943,7 +942,7 @@ protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) {
@Override
public void preprocessFederatedIdentity(KeycloakSession session, RealmModel realm, BrokeredIdentityContext context) {
AuthenticationSessionModel authenticationSession = session.getContext().getAuthenticationSession();

if (authenticationSession == null || getConfig().isDisableNonce()) {
// no interacting with the brokered OP, likely doing token exchanges or no nonce
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String USE_JWKS_URL = "useJwksUrl";
public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public static final String ISSUER = "issuer";

public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
Expand All @@ -49,10 +50,10 @@ public void setPrompt(String prompt) {
}

public String getIssuer() {
return getConfig().get("issuer");
return getConfig().get(ISSUER);
}
public void setIssuer(String issuer) {
getConfig().put("issuer", issuer);
getConfig().put(ISSUER, issuer);
}
public String getLogoutUrl() {
return getConfig().get("logoutUrl");
Expand Down
70 changes: 24 additions & 46 deletions services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@
import java.util.Collections;
import java.util.HashMap;
import org.jboss.logging.Logger;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.common.Profile.Feature;
import org.keycloak.http.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.TokenCategory;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.authenticators.util.AcrStore;
import org.keycloak.broker.oidc.OIDCIdentityProvider;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.common.VerificationException;
Expand All @@ -47,9 +48,8 @@
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.ClientScopeDecorator;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OrganizationModel;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
Expand Down Expand Up @@ -1440,22 +1440,33 @@ public boolean test(JsonWebToken token) {
}
}

public LogoutTokenValidationCode verifyLogoutToken(KeycloakSession session, RealmModel realm, String encodedLogoutToken) {
public LogoutTokenValidationCode verifyLogoutToken(KeycloakSession session, String encodedLogoutToken) {
Optional<LogoutToken> logoutTokenOptional = toLogoutToken(encodedLogoutToken);

if (!logoutTokenOptional.isPresent()) {
return LogoutTokenValidationCode.DECODE_TOKEN_FAILED;
}

LogoutToken logoutToken = logoutTokenOptional.get();
List<OIDCIdentityProvider> identityProviders = getOIDCIdentityProviders(realm, session).toList();
if (identityProviders.isEmpty()) {
return LogoutTokenValidationCode.COULD_NOT_FIND_IDP;
}
OIDCIdentityProvider identityProvider = session.identityProviders()
.getAllStream(Map.of(
OIDCIdentityProviderConfig.ISSUER, logoutToken.getIssuer()
), -1, -1)
.map(model -> {
IdentityProvider idp = IdentityBrokerService.getIdentityProviderFactory(session, model).create(session, model);

if (idp instanceof OIDCIdentityProvider) {
return (OIDCIdentityProvider) idp;
}

Stream<OIDCIdentityProvider> validOidcIdentityProviders =
validateLogoutTokenAgainstIdpProvider(identityProviders.stream(), encodedLogoutToken, logoutToken);
if (validOidcIdentityProviders.count() == 0) {
return LogoutTokenValidationCode.TOKEN_VERIFICATION_WITH_IDP_FAILED;
return null;
})
.filter(idp -> idp != null && idp.validateToken(encodedLogoutToken) != null)
.findAny()
.orElse(null);

if (identityProvider == null) {
return LogoutTokenValidationCode.COULD_NOT_FIND_IDP;
}

if (logoutToken.getSubject() == null && logoutToken.getSid() == null) {
Expand Down Expand Up @@ -1491,39 +1502,6 @@ public Optional<LogoutToken> toLogoutToken(String encodedLogoutToken) {
}


public Stream<OIDCIdentityProvider> getValidOIDCIdentityProvidersForBackchannelLogout(RealmModel realm, KeycloakSession session, String encodedLogoutToken, LogoutToken logoutToken) {
return validateLogoutTokenAgainstIdpProvider(getOIDCIdentityProviders(realm, session), encodedLogoutToken, logoutToken);
}


public Stream<OIDCIdentityProvider> validateLogoutTokenAgainstIdpProvider(Stream<OIDCIdentityProvider> oidcIdps, String encodedLogoutToken, LogoutToken logoutToken) {
return oidcIdps
.filter(oidcIdp -> oidcIdp.getConfig().getIssuer() != null)
.filter(oidcIdp -> oidcIdp.isIssuer(logoutToken.getIssuer(), null))
.filter(oidcIdp -> {
try {
oidcIdp.validateToken(encodedLogoutToken);
return true;
} catch (IdentityBrokerException e) {
logger.debugf("LogoutToken verification with identity provider failed", e.getMessage());
return false;
}
});
}

private Stream<OIDCIdentityProvider> getOIDCIdentityProviders(RealmModel realm, KeycloakSession session) {
try {
return session.identityProviders().getAllStream()
.map(idpModel ->
IdentityBrokerService.getIdentityProviderFactory(session, idpModel).create(session, idpModel))
.filter(OIDCIdentityProvider.class::isInstance)
.map(OIDCIdentityProvider.class::cast);
} catch (IdentityBrokerException e) {
logger.warnf("LogoutToken verification with identity provider failed", e.getMessage());
}
return Stream.empty();
}

private boolean checkLogoutTokenForEvents(LogoutToken logoutToken) {
for (String eventKey : logoutToken.getEvents().keySet()) {
if (TokenUtil.TOKEN_BACKCHANNEL_LOGOUT_EVENT.equals(eventKey)) {
Expand Down
Loading

0 comments on commit 94bf933

Please sign in to comment.