KEYCLOAK-3218 Support for max_age OIDC authRequest parameter and support for auth_time in IDToken

This commit is contained in:
mposolda 2016-07-07 17:04:32 +02:00
parent 7a161cc8bb
commit a7c9e71490
8 changed files with 245 additions and 3 deletions

View file

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

View file

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

View file

@ -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";

View file

@ -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);

View file

@ -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);

View file

@ -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));
}

View file

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

View file

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