keycloak-memoizeit
Changes
services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java 40(+37 -3)
Details
diff --git a/core/src/main/java/org/keycloak/representations/IDToken.java b/core/src/main/java/org/keycloak/representations/IDToken.java
index f11b866..c5493f6 100755
--- a/core/src/main/java/org/keycloak/representations/IDToken.java
+++ b/core/src/main/java/org/keycloak/representations/IDToken.java
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
*/
public class IDToken extends JsonWebToken {
public static final String NONCE = "nonce";
+ public static final String AUTH_TIME = "auth_time";
public static final String SESSION_STATE = "session_state";
public static final String NAME = "name";
public static final String GIVEN_NAME = "given_name";
@@ -51,6 +52,9 @@ public class IDToken extends JsonWebToken {
@JsonProperty(NONCE)
protected String nonce;
+ @JsonProperty(AUTH_TIME)
+ protected int authTime;
+
@JsonProperty(SESSION_STATE)
protected String sessionState;
@@ -122,6 +126,14 @@ public class IDToken extends JsonWebToken {
this.nonce = nonce;
}
+ public int getAuthTime() {
+ return authTime;
+ }
+
+ public void setAuthTime(int authTime) {
+ this.authTime = authTime;
+ }
+
public String getSessionState() {
return sessionState;
}
diff --git a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java
index 7a94ea0..c7d6f25 100755
--- a/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java
+++ b/services/src/main/java/org/keycloak/authentication/authenticators/browser/CookieAuthenticator.java
@@ -19,9 +19,14 @@ package org.keycloak.authentication.authenticators.browser;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
+import org.keycloak.common.util.Time;
+import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
+import org.keycloak.services.ServicesLogger;
import org.keycloak.services.managers.AuthenticationManager;
/**
@@ -30,6 +35,8 @@ import org.keycloak.services.managers.AuthenticationManager;
*/
public class CookieAuthenticator implements Authenticator {
+ private static final ServicesLogger logger = ServicesLogger.ROOT_LOGGER;
+
@Override
public boolean requiresUser() {
return false;
@@ -42,9 +49,17 @@ public class CookieAuthenticator implements Authenticator {
if (authResult == null) {
context.attempted();
} else {
- context.setUser(authResult.getUser());
- context.attachUserSession(authResult.getSession());
- context.success();
+ // Cookie re-authentication is skipped if authTime is too old.
+ if (isAuthTimeExpired(authResult.getSession(), context.getClientSession())) {
+ context.attempted();
+ } else {
+ ClientSessionModel clientSession = context.getClientSession();
+ clientSession.setNote(AuthenticationManager.SKIP_AUTH_TIME_UPDATE, "true");
+
+ context.setUser(authResult.getUser());
+ context.attachUserSession(authResult.getSession());
+ context.success();
+ }
}
}
@@ -67,4 +82,23 @@ public class CookieAuthenticator implements Authenticator {
public void close() {
}
+
+ protected boolean isAuthTimeExpired(UserSessionModel userSession, ClientSessionModel clientSession) {
+ String authTime = userSession.getNote(AuthenticationManager.AUTH_TIME);
+ String maxAge = clientSession.getNote(OIDCLoginProtocol.MAX_AGE_PARAM);
+ if (maxAge == null) {
+ return false;
+ }
+
+ int authTimeInt = authTime==null ? 0 : Integer.parseInt(authTime);
+ int maxAgeInt = Integer.parseInt(maxAge);
+
+ if (authTimeInt + maxAgeInt < Time.currentTime()) {
+ logger.debugf("Authentication time is expired in CookieAuthenticator. userSession=%s, clientId=%s, maxAge=%d, authTime=%d", userSession.getId(),
+ clientSession.getClient().getId(), maxAgeInt, authTimeInt);
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
index 0122657..2c16b79 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java
@@ -117,6 +117,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
private String loginHint;
private String prompt;
private String nonce;
+ private String maxAge;
private String idpHint;
protected Map<String, String> additionalReqParams = new HashMap<>();
@@ -139,6 +140,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
prompt = params.getFirst(OIDCLoginProtocol.PROMPT_PARAM);
idpHint = params.getFirst(AdapterConstants.KC_IDP_HINT);
nonce = params.getFirst(OIDCLoginProtocol.NONCE_PARAM);
+ maxAge = params.getFirst(OIDCLoginProtocol.MAX_AGE_PARAM);
extractAdditionalReqParams(params);
@@ -309,6 +311,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
if (state != null) clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, state);
if (nonce != null) clientSession.setNote(OIDCLoginProtocol.NONCE_PARAM, nonce);
+ if (maxAge != null) clientSession.setNote(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
if (scope != null) clientSession.setNote(OIDCLoginProtocol.SCOPE_PARAM, scope);
if (loginHint != null) clientSession.setNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, loginHint);
if (prompt != null) clientSession.setNote(OIDCLoginProtocol.PROMPT_PARAM, prompt);
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
index 8bef72e..5fa727a 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java
@@ -56,6 +56,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String REDIRECT_URI_PARAM = "redirect_uri";
public static final String CLIENT_ID_PARAM = "client_id";
public static final String NONCE_PARAM = "nonce";
+ public static final String MAX_AGE_PARAM = "max_age";
public static final String PROMPT_PARAM = "prompt";
public static final String LOGIN_HINT_PARAM = "login_hint";
public static final String LOGOUT_REDIRECT_URI = "OIDC_LOGOUT_REDIRECT_URI";
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
index 2f05718..0950d4d 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@@ -518,6 +518,12 @@ public class TokenManager {
token.issuedFor(client.getClientId());
token.issuer(clientSession.getNote(OIDCLoginProtocol.ISSUER));
token.setNonce(clientSession.getNote(OIDCLoginProtocol.NONCE_PARAM));
+
+ String authTime = session.getNote(AuthenticationManager.AUTH_TIME);
+ if (authTime != null) {
+ token.setAuthTime(Integer.parseInt(authTime));
+ }
+
if (session != null) {
token.setSessionState(session.getId());
}
@@ -659,6 +665,7 @@ public class TokenManager {
idToken.issuedFor(accessToken.getIssuedFor());
idToken.issuer(accessToken.getIssuer());
idToken.setNonce(accessToken.getNonce());
+ idToken.setAuthTime(accessToken.getAuthTime());
idToken.setSessionState(accessToken.getSessionState());
idToken.expiration(accessToken.getExpiration());
transformIDToken(session, idToken, realm, client, userSession.getUser(), userSession, clientSession);
diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
index c2c774c..32f249f 100755
--- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
+++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java
@@ -61,6 +61,12 @@ import java.util.Set;
*/
public class AuthenticationManager {
public static final String END_AFTER_REQUIRED_ACTIONS = "END_AFTER_REQUIRED_ACTIONS";
+
+ // userSession note with authTime (time when authentication flow including requiredActions was finished)
+ public static final String AUTH_TIME = "AUTH_TIME";
+ // clientSession note with flag that authTime update should be skipped
+ public static final String SKIP_AUTH_TIME_UPDATE = "SKIP_AUTH_TIME_UPDATE";
+
protected static ServicesLogger logger = ServicesLogger.ROOT_LOGGER;
public static final String FORM_USERNAME = "username";
// used for auth login
@@ -403,6 +409,14 @@ public class AuthenticationManager {
createLoginCookie(session, realm, userSession.getUser(), userSession, uriInfo, clientConnection);
if (userSession.getState() != UserSessionModel.State.LOGGED_IN) userSession.setState(UserSessionModel.State.LOGGED_IN);
if (userSession.isRememberMe()) createRememberMeCookie(realm, userSession.getUser().getUsername(), uriInfo, clientConnection);
+
+ // Update userSession note with authTime. But just if flag SKIP_AUTH_TIME_UPDATE is not set
+ String skipAuthTimeUpdate = clientSession.getNote(SKIP_AUTH_TIME_UPDATE);
+ if (skipAuthTimeUpdate == null || !Boolean.parseBoolean(skipAuthTimeUpdate)) {
+ int authTime = Time.currentTime();
+ userSession.setNote(AUTH_TIME, String.valueOf(authTime));
+ }
+
return protocol.authenticated(userSession, new ClientSessionCode(realm, clientSession));
}
diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
index b4b9f40..c406e2e 100644
--- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
+++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java
@@ -41,14 +41,17 @@ import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.crypto.RSAProvider;
+import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.representations.JSONWebKeySet;
import org.keycloak.representations.AccessToken;
+import org.keycloak.representations.IDToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
+import org.keycloak.util.TokenUtil;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
@@ -94,6 +97,8 @@ public class OAuthClient {
private String clientSessionHost;
+ private String maxAge;
+
private Map<String, PublicKey> publicKeys = new HashMap<>();
public void init(Keycloak adminClient, WebDriver driver) {
@@ -109,6 +114,7 @@ public class OAuthClient {
uiLocales = null;
clientSessionState = null;
clientSessionHost = null;
+ maxAge = null;
}
public AuthorizationCodeResponse doLogin(String username, String password) {
@@ -415,6 +421,16 @@ public class OAuthClient {
}
}
+ public IDToken verifyIDToken(String token) {
+ try {
+ IDToken idToken = RSATokenVerifier.verifyToken(token, getRealmPublicKey(realm), baseUrl + "/realms/" + realm, true, false);
+ Assert.assertEquals(TokenUtil.TOKEN_TYPE_ID, idToken.getType());
+ return idToken;
+ } catch (VerificationException e) {
+ throw new RuntimeException("Failed to verify token", e);
+ }
+ }
+
public RefreshToken verifyRefreshToken(String refreshToken) {
try {
JWSInput jws = new JWSInput(refreshToken);
@@ -486,6 +502,9 @@ public class OAuthClient {
if (scope != null) {
b.queryParam(OAuth2Constants.SCOPE, scope);
}
+ if (maxAge != null) {
+ b.queryParam(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge);
+ }
return b.build(realm).toString();
}
@@ -574,6 +593,11 @@ public class OAuthClient {
return this;
}
+ public OAuthClient maxAge(String maxAge) {
+ this.maxAge = maxAge;
+ return this;
+ }
+
public String getRealm() {
return realm;
}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCAdvancedRequestParamsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCAdvancedRequestParamsTest.java
new file mode 100644
index 0000000..16117c9
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCAdvancedRequestParamsTest.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2016 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.oidc;
+
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.common.util.Time;
+import org.keycloak.events.Details;
+import org.keycloak.representations.IDToken;
+import org.keycloak.representations.idm.EventRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.testsuite.AbstractKeycloakTest;
+import org.keycloak.testsuite.Assert;
+import org.keycloak.testsuite.AssertEvents;
+import org.keycloak.testsuite.admin.AbstractAdminTest;
+import org.keycloak.testsuite.util.ClientManager;
+import org.keycloak.testsuite.util.OAuthClient;
+
+/**
+ * Test for supporting advanced parameters of OIDC specs (max_age, nonce, prompt, ...)
+ *
+ * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
+ */
+public class OIDCAdvancedRequestParamsTest extends AbstractKeycloakTest {
+
+ @Rule
+ public AssertEvents events = new AssertEvents(this);
+
+
+ @Override
+ public void beforeAbstractKeycloakTest() throws Exception {
+ super.beforeAbstractKeycloakTest();
+ }
+
+ @Before
+ public void clientConfiguration() {
+ ClientManager.realm(adminClient.realm("test")).clientId("test-app").directAccessGrant(true);
+ /*
+ * Configure the default client ID. Seems like OAuthClient is keeping the state of clientID
+ * For example: If some test case configure oauth.clientId("sample-public-client"), other tests
+ * will faile and the clientID will always be "sample-public-client
+ * @see AccessTokenTest#testAuthorizationNegotiateHeaderIgnored()
+ */
+ oauth.clientId("test-app");
+ oauth.maxAge(null);
+ }
+
+ @Override
+ public void addTestRealms(List<RealmRepresentation> testRealms) {
+ RealmRepresentation realm = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
+ testRealms.add(realm);
+ }
+
+ @Test
+ public void testMaxAge1() {
+ // Open login form and login successfully
+ oauth.doLogin("test-user@localhost", "password");
+ EventRepresentation loginEvent = events.expectLogin().assertEvent();
+
+ IDToken idToken = retrieveIDToken(loginEvent);
+
+ // Check that authTime is available and set to current time
+ int authTime = idToken.getAuthTime();
+ int currentTime = Time.currentTime();
+ Assert.assertTrue(authTime <= currentTime && authTime + 3 >= currentTime);
+
+ // Set time offset
+ setTimeOffset(10);
+
+ // Now open login form with maxAge=1
+ oauth.maxAge("1");
+
+ // Assert I need to login again through the login form
+ oauth.doLogin("test-user@localhost", "password");
+ loginEvent = events.expectLogin().assertEvent();
+
+ idToken = retrieveIDToken(loginEvent);
+
+ // Assert that authTime was updated
+ int authTimeUpdated = idToken.getAuthTime();
+ Assert.assertTrue(authTime + 10 <= authTimeUpdated);
+ }
+
+ @Test
+ public void testMaxAge10000() {
+ // Open login form and login successfully
+ oauth.doLogin("test-user@localhost", "password");
+ EventRepresentation loginEvent = events.expectLogin().assertEvent();
+
+ IDToken idToken = retrieveIDToken(loginEvent);
+
+ // Check that authTime is available and set to current time
+ int authTime = idToken.getAuthTime();
+ int currentTime = Time.currentTime();
+ Assert.assertTrue(authTime <= currentTime && authTime + 3 >= currentTime);
+
+ // Set time offset
+ setTimeOffset(10);
+
+ // Now open login form with maxAge=10000
+ oauth.maxAge("10000");
+
+ // Assert that I will be automatically logged through cookie
+ oauth.openLoginForm();
+ loginEvent = events.expectLogin().assertEvent();
+
+ idToken = retrieveIDToken(loginEvent);
+
+ // Assert that authTime is still the same
+ int authTimeUpdated = idToken.getAuthTime();
+ Assert.assertEquals(authTime, authTimeUpdated);
+ }
+
+ private IDToken retrieveIDToken(EventRepresentation loginEvent) {
+ String sessionId = loginEvent.getSessionId();
+ String codeId = loginEvent.getDetails().get(Details.CODE_ID);
+
+ String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+ OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password");
+
+ Assert.assertEquals(200, response.getStatusCode());
+ IDToken idToken = oauth.verifyIDToken(response.getIdToken());
+
+ events.expectCodeToToken(codeId, sessionId).assertEvent();
+ return idToken;
+ }
+
+}