keycloak-memoizeit

KEYCLOAK-3218 Support for max_age OIDC authRequest parameter

7/7/2016 12:04:32 PM

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;
+    }
+
+}