Validate auth time when max_age is sent to brokered OPs

Closes #14146
This commit is contained in:
Pedro Igor 2022-09-01 12:17:55 -03:00
parent a0079b516b
commit 3518362002
6 changed files with 164 additions and 10 deletions

View file

@ -331,11 +331,6 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
uriBuilder.queryParam(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint);
}
String maxAge = request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.MAX_AGE_PARAM);
if (getConfig().isPassMaxAge() && maxAge != null) {
uriBuilder.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
}
if (getConfig().isUiLocales()) {
uriBuilder.queryParam(OIDCLoginProtocol.UI_LOCALES_PARAM, session.getContext().resolveLocale(null).toLanguageTag());
}

View file

@ -387,6 +387,20 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
JsonWebToken idToken = validateToken(encodedIdToken);
if (getConfig().isPassMaxAge()) {
AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession();
if (isAuthTimeExpired(idToken, authSession)) {
throw new IdentityBrokerException("User not re-authenticated by the target OpenID Provider");
}
Object authTime = idToken.getOtherClaims().get(IDToken.AUTH_TIME);
if (authTime != null) {
authSession.setClientNote(AuthenticationManager.AUTH_TIME_BROKER, authTime.toString());
}
}
try {
BrokeredIdentityContext identity = extractIdentity(tokenResponse, accessToken, idToken);
@ -411,6 +425,25 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
}
protected boolean isAuthTimeExpired(JsonWebToken idToken, AuthenticationSessionModel authSession) {
String maxAge = authSession.getClientNote(OIDCLoginProtocol.MAX_AGE_PARAM);
if (maxAge == null) {
return false;
}
String authTime = idToken.getOtherClaims().getOrDefault(IDToken.AUTH_TIME, "0").toString();
int authTimeInt = authTime == null ? 0 : Integer.parseInt(authTime);
int maxAgeInt = Integer.parseInt(maxAge);
if (authTimeInt + maxAgeInt < Time.currentTime()) {
logger.debugf("Invalid auth_time claim. User not re-authenticated by the target OP.");
return true;
}
return false;
}
private static final MediaType APPLICATION_JWT_TYPE = MediaType.valueOf("application/jwt");
protected BrokeredIdentityContext extractIdentity(AccessTokenResponse tokenResponse, String accessToken, JsonWebToken idToken) throws IOException {
@ -814,6 +847,12 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
authenticationSession.setClientNote(BROKER_NONCE_PARAM, nonce);
uriBuilder.queryParam(OIDCLoginProtocol.NONCE_PARAM, nonce);
String maxAge = request.getAuthenticationSession().getClientNote(OIDCLoginProtocol.MAX_AGE_PARAM);
if (getConfig().isPassMaxAge() && maxAge != null) {
uriBuilder.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
}
return uriBuilder;
}

View file

@ -138,6 +138,10 @@ public class AuthenticationManager {
// userSession note with authTime (time when authentication flow including requiredActions was finished)
public static final String AUTH_TIME = "AUTH_TIME";
// authSession client note set during brokering indicating the time when the authentication happened at the IdP
public static final String AUTH_TIME_BROKER = "AUTH_TIME_BROKER";
// clientSession note with flag that clientSession was authenticated through SSO cookie
public static final String SSO_AUTH = "SSO_AUTH";
@ -956,7 +960,7 @@ public class AuthenticationManager {
clientSession.setNote(SSO_AUTH, "true");
authSession.removeAuthNote(SSO_AUTH);
} else {
int authTime = Time.currentTime();
int authTime = Optional.ofNullable(authSession.getClientNote(AUTH_TIME_BROKER)).map(Integer::parseInt).orElse(Time.currentTime());
userSession.setNote(AUTH_TIME, String.valueOf(authTime));
clientSession.removeNote(SSO_AUTH);
}

View file

@ -0,0 +1,68 @@
/*
* Copyright 2022 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.broker.oidc;
import javax.ws.rs.core.UriBuilder;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProvider;
import org.keycloak.broker.oidc.KeycloakOIDCIdentityProviderFactory;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.sessions.AuthenticationSessionModel;
public class TestKeycloakOidcIdentityProviderFactory extends KeycloakOIDCIdentityProviderFactory {
public static final String ID = "test-keycloak-oidc";
public static final String IGNORE_MAX_AGE_PARAM = "ignore-max-age-param";
public static void setIgnoreMaxAgeParam(IdentityProviderRepresentation rep) {
rep.getConfig().put(IGNORE_MAX_AGE_PARAM, Boolean.TRUE.toString());
}
@Override
public String getId() {
return ID;
}
@Override
public KeycloakOIDCIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
return new KeycloakOIDCIdentityProvider(session, new OIDCIdentityProviderConfig(model)) {
@Override
protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) {
AuthenticationSessionModel authSession = request.getAuthenticationSession();
String maxAge = authSession.getClientNote(OIDCLoginProtocol.MAX_AGE_PARAM);
try {
if (isIgnoreMaxAgeParam()) {
authSession.removeClientNote(OIDCLoginProtocol.MAX_AGE_PARAM);
}
return super.createAuthorizationUrl(request);
} finally {
authSession.setClientNote(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
}
}
private boolean isIgnoreMaxAgeParam() {
return Boolean.parseBoolean(model.getConfig().getOrDefault(IGNORE_MAX_AGE_PARAM, Boolean.FALSE.toString()));
}
};
}
}

View file

@ -16,3 +16,4 @@
#
org.keycloak.testsuite.broker.oidc.LegacyIdIdentityProviderFactory
org.keycloak.testsuite.broker.oidc.TestKeycloakOidcIdentityProviderFactory

View file

@ -2,15 +2,17 @@ package org.keycloak.testsuite.broker;
import org.junit.Ignore;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.IdentityProviderSyncMode;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.broker.oidc.TestKeycloakOidcIdentityProviderFactory;
import java.util.Map;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_ALIAS;
import static org.keycloak.testsuite.broker.BrokerTestConstants.IDP_OIDC_PROVIDER_ID;
import static org.keycloak.testsuite.broker.BrokerTestTools.createIdentityProvider;
import static org.keycloak.testsuite.broker.BrokerTestTools.getConsumerRoot;
import static org.keycloak.testsuite.broker.BrokerTestTools.waitForPage;
@ -31,13 +33,13 @@ public class KcOidcBrokerPassMaxAgeTest extends AbstractBrokerTest {
@Override
public IdentityProviderRepresentation setUpIdentityProvider(IdentityProviderSyncMode syncMode) {
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, IDP_OIDC_PROVIDER_ID);
IdentityProviderRepresentation idp = createIdentityProvider(IDP_OIDC_ALIAS, TestKeycloakOidcIdentityProviderFactory.ID);
Map<String, String> config = idp.getConfig();
applyDefaultConfiguration(config, syncMode);
config.put(IdentityProviderModel.LOGIN_HINT, "false");
config.put(IdentityProviderModel.PASS_MAX_AGE, "true");
config.remove(OAuth2Constants.PROMPT);
return idp;
}
@ -92,4 +94,49 @@ public class KcOidcBrokerPassMaxAgeTest extends AbstractBrokerTest {
testSingleLogout();
}
@Test
public void testEnforceReAuthenticationWhenMaxAgeIsSet() {
// login as brokered user user, perform profile update on first broker login and logout user
loginUser();
testSingleLogout();
driver.navigate().to(getAccountUrl(getConsumerRoot(), bc.consumerRealmName()));
loginPage.clickSocial(bc.getIDPAlias());
waitForPage(driver, "sign in to", true);
Assert.assertTrue("Driver should be on the provider realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.providerRealmName() + "/"));
loginPage.login(bc.getUserLogin(), bc.getUserPassword());
accountUpdateProfilePage.assertCurrent();
IdentityProviderResource idpResource = realmsResouce().realm(bc.consumerRealmName()).identityProviders()
.get(bc.getIDPAlias());
IdentityProviderRepresentation idpRep = idpResource.toRepresentation();
TestKeycloakOidcIdentityProviderFactory.setIgnoreMaxAgeParam(idpRep);
idpResource.update(idpRep);
setTimeOffset(2);
// trigger re-auth with max_age while we are still authenticated
String loginUrlWithMaxAge = getLoginUrl(getConsumerRoot(), bc.consumerRealmName(), "account") + "&max_age=1";
driver.navigate().to(loginUrlWithMaxAge);
// we should now see the login page of the consumer
waitForPage(driver, "sign in to", true);
loginPage.assertCurrent(bc.consumerRealmName());
Assert.assertTrue("Driver should be on the consumer realm page right now",
driver.getCurrentUrl().contains("/auth/realms/" + bc.consumerRealmName() + "/protocol/openid-connect/auth"));
loginPage.clickSocial(bc.getIDPAlias());
// we should see the login page of the provider, since the max_age was propagated
waitForPage(driver, "sign in to", true);
loginPage.getError();
Assert.assertEquals("Unexpected error when authenticating with identity provider",
loginPage.getInstruction());
testSingleLogout();
}
}