KEYCLOAK-3218 Support for max_age OIDC authRequest parameter and support for auth_time in IDToken
This commit is contained in:
parent
7a161cc8bb
commit
a7c9e71490
8 changed files with 245 additions and 3 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in a new issue