KEYCLOAK-9563 Improve access token checks for userinfo endpoint
This commit is contained in:
parent
3ef338d392
commit
1d54f2ade3
3 changed files with 146 additions and 38 deletions
|
@ -22,8 +22,10 @@ import org.jboss.resteasy.spi.HttpRequest;
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.OAuthErrorException;
|
import org.keycloak.OAuthErrorException;
|
||||||
import org.keycloak.TokenCategory;
|
import org.keycloak.TokenCategory;
|
||||||
|
import org.keycloak.TokenVerifier;
|
||||||
import org.keycloak.cluster.ClusterProvider;
|
import org.keycloak.cluster.ClusterProvider;
|
||||||
import org.keycloak.common.ClientConnection;
|
import org.keycloak.common.ClientConnection;
|
||||||
|
import org.keycloak.common.VerificationException;
|
||||||
import org.keycloak.common.util.Time;
|
import org.keycloak.common.util.Time;
|
||||||
import org.keycloak.crypto.HashProvider;
|
import org.keycloak.crypto.HashProvider;
|
||||||
import org.keycloak.crypto.SignatureProvider;
|
import org.keycloak.crypto.SignatureProvider;
|
||||||
|
@ -56,6 +58,7 @@ import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.IDToken;
|
import org.keycloak.representations.IDToken;
|
||||||
|
import org.keycloak.representations.JsonWebToken;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
import org.keycloak.services.ErrorResponseException;
|
import org.keycloak.services.ErrorResponseException;
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
|
@ -161,16 +164,13 @@ public class TokenManager {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unmatching clients", "Unmatching clients");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unmatching clients", "Unmatching clients");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldToken.getIssuedAt() < client.getNotBefore()) {
|
try {
|
||||||
|
TokenVerifier.createWithoutSignature(oldToken)
|
||||||
|
.withChecks(NotBeforeCheck.forModel(client), NotBeforeCheck.forModel(session, realm, user))
|
||||||
|
.verify();
|
||||||
|
} catch (VerificationException e) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
|
||||||
}
|
}
|
||||||
if (oldToken.getIssuedAt() < realm.getNotBefore()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
|
|
||||||
}
|
|
||||||
if (oldToken.getIssuedAt() < session.users().getNotBeforeOfUser(realm, user)) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale token");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Setup clientScopes from refresh token to the context
|
// Setup clientScopes from refresh token to the context
|
||||||
String oldTokenScope = oldToken.getScope();
|
String oldTokenScope = oldToken.getScope();
|
||||||
|
@ -207,16 +207,16 @@ public class TokenManager {
|
||||||
* @throws OAuthErrorException
|
* @throws OAuthErrorException
|
||||||
*/
|
*/
|
||||||
public boolean checkTokenValidForIntrospection(KeycloakSession session, RealmModel realm, AccessToken token) throws OAuthErrorException {
|
public boolean checkTokenValidForIntrospection(KeycloakSession session, RealmModel realm, AccessToken token) throws OAuthErrorException {
|
||||||
if (!token.isActive()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.getIssuedAt() < realm.getNotBefore()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
ClientModel client = realm.getClientByClientId(token.getIssuedFor());
|
ClientModel client = realm.getClientByClientId(token.getIssuedFor());
|
||||||
if (client == null || !client.isEnabled() || token.getIssuedAt() < client.getNotBefore()) {
|
if (client == null || !client.isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TokenVerifier.createWithoutSignature(token)
|
||||||
|
.withChecks(NotBeforeCheck.forModel(client), TokenVerifier.IS_ACTIVE)
|
||||||
|
.verify();
|
||||||
|
} catch (VerificationException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,9 +248,14 @@ public class TokenManager {
|
||||||
if (!user.isEnabled()) {
|
if (!user.isEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (token.getIssuedAt() < session.users().getNotBeforeOfUser(realm, user)) {
|
try {
|
||||||
|
TokenVerifier.createWithoutSignature(token)
|
||||||
|
.withChecks(NotBeforeCheck.forModel(session ,realm, user))
|
||||||
|
.verify();
|
||||||
|
} catch (VerificationException e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
|
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -349,12 +354,12 @@ public class TokenManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkExpiration) {
|
if (checkExpiration) {
|
||||||
if (refreshToken.getExpiration() != 0 && refreshToken.isExpired()) {
|
try {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
|
TokenVerifier.createWithoutSignature(refreshToken)
|
||||||
}
|
.withChecks(NotBeforeCheck.forModel(realm), TokenVerifier.IS_ACTIVE)
|
||||||
|
.verify();
|
||||||
if (refreshToken.getIssuedAt() < realm.getNotBefore()) {
|
} catch (VerificationException e) {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token");
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -385,14 +390,12 @@ public class TokenManager {
|
||||||
|
|
||||||
public IDToken verifyIDToken(KeycloakSession session, RealmModel realm, String encodedIDToken) throws OAuthErrorException {
|
public IDToken verifyIDToken(KeycloakSession session, RealmModel realm, String encodedIDToken) throws OAuthErrorException {
|
||||||
IDToken idToken = session.tokens().decode(encodedIDToken, IDToken.class);
|
IDToken idToken = session.tokens().decode(encodedIDToken, IDToken.class);
|
||||||
if (idToken == null) {
|
try {
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken");
|
TokenVerifier.createWithoutSignature(idToken)
|
||||||
}
|
.withChecks(NotBeforeCheck.forModel(realm), TokenVerifier.IS_ACTIVE)
|
||||||
if (idToken.isExpired()) {
|
.verify();
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "IDToken expired");
|
} catch (VerificationException e) {
|
||||||
}
|
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, e.getMessage());
|
||||||
if (idToken.getIssuedAt() < realm.getNotBefore()) {
|
|
||||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale IDToken");
|
|
||||||
}
|
}
|
||||||
return idToken;
|
return idToken;
|
||||||
}
|
}
|
||||||
|
@ -881,4 +884,44 @@ public class TokenManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class NotBeforeCheck implements TokenVerifier.Predicate<JsonWebToken> {
|
||||||
|
|
||||||
|
private final int notBefore;
|
||||||
|
|
||||||
|
public NotBeforeCheck(int notBefore) {
|
||||||
|
this.notBefore = notBefore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean test(JsonWebToken t) throws VerificationException {
|
||||||
|
if (t.getIssuedAt() < notBefore) {
|
||||||
|
throw new VerificationException("Stale token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotBeforeCheck forModel(ClientModel clientModel) {
|
||||||
|
if (clientModel != null) {
|
||||||
|
|
||||||
|
int notBeforeClient = clientModel.getNotBefore();
|
||||||
|
int notBeforeRealm = clientModel.getRealm().getNotBefore();
|
||||||
|
|
||||||
|
int notBefore = (notBeforeClient == 0 ? notBeforeRealm : (notBeforeRealm == 0 ? notBeforeClient :
|
||||||
|
Math.min(notBeforeClient, notBeforeRealm)));
|
||||||
|
|
||||||
|
return new NotBeforeCheck(notBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NotBeforeCheck(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotBeforeCheck forModel(RealmModel realmModel) {
|
||||||
|
return new NotBeforeCheck(realmModel == null ? 0 : realmModel.getNotBefore());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NotBeforeCheck forModel(KeycloakSession session, RealmModel realmModel, UserModel userModel) {
|
||||||
|
return new NotBeforeCheck(session.users().getNotBeforeOfUser(realmModel, userModel));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ import org.keycloak.jose.jws.JWSBuilder;
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
import org.keycloak.models.ClientModel;
|
import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.ClientSessionContext;
|
import org.keycloak.models.ClientSessionContext;
|
||||||
|
import org.keycloak.protocol.oidc.TokenManager.NotBeforeCheck;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
|
@ -137,6 +138,7 @@ public class UserInfoEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessToken token;
|
AccessToken token;
|
||||||
|
ClientModel clientModel;
|
||||||
try {
|
try {
|
||||||
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class).withDefaultChecks()
|
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class).withDefaultChecks()
|
||||||
.realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
.realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
||||||
|
@ -145,17 +147,21 @@ public class UserInfoEndpoint {
|
||||||
verifier.verifierContext(verifierContext);
|
verifier.verifierContext(verifierContext);
|
||||||
|
|
||||||
token = verifier.verify().getToken();
|
token = verifier.verify().getToken();
|
||||||
|
|
||||||
|
clientModel = realm.getClientByClientId(token.getIssuedFor());
|
||||||
|
if (clientModel == null) {
|
||||||
|
event.error(Errors.CLIENT_NOT_FOUND);
|
||||||
|
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenVerifier.createWithoutSignature(token)
|
||||||
|
.withChecks(NotBeforeCheck.forModel(clientModel))
|
||||||
|
.verify();
|
||||||
} catch (VerificationException e) {
|
} catch (VerificationException e) {
|
||||||
event.error(Errors.INVALID_TOKEN);
|
event.error(Errors.INVALID_TOKEN);
|
||||||
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Token verification failed");
|
throw newUnauthorizedErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Token verification failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
ClientModel clientModel = realm.getClientByClientId(token.getIssuedFor());
|
|
||||||
if (clientModel == null) {
|
|
||||||
event.error(Errors.CLIENT_NOT_FOUND);
|
|
||||||
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientModel.getProtocol().equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
if (!clientModel.getProtocol().equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
||||||
event.error(Errors.INVALID_CLIENT);
|
event.error(Errors.INVALID_CLIENT);
|
||||||
throw new ErrorResponseException(Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST);
|
throw new ErrorResponseException(Errors.INVALID_CLIENT, "Wrong client protocol.", Response.Status.BAD_REQUEST);
|
||||||
|
|
|
@ -401,6 +401,65 @@ public class UserInfoTest extends AbstractKeycloakTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testNotBeforeTokens() {
|
||||||
|
Client client = ClientBuilder.newClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client);
|
||||||
|
|
||||||
|
int time = Time.currentTime() + 60;
|
||||||
|
|
||||||
|
RealmResource realm = adminClient.realm("test");
|
||||||
|
RealmRepresentation rep = realm.toRepresentation();
|
||||||
|
rep.setNotBefore(time);
|
||||||
|
realm.update(rep);
|
||||||
|
|
||||||
|
Response response = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, accessTokenResponse.getToken());
|
||||||
|
|
||||||
|
assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
|
||||||
|
|
||||||
|
response.close();
|
||||||
|
|
||||||
|
events.expect(EventType.USER_INFO_REQUEST_ERROR)
|
||||||
|
.error(Errors.INVALID_TOKEN)
|
||||||
|
.user(Matchers.nullValue(String.class))
|
||||||
|
.session(Matchers.nullValue(String.class))
|
||||||
|
.detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN)
|
||||||
|
.client((String) null)
|
||||||
|
.assertEvent();
|
||||||
|
|
||||||
|
events.clear();
|
||||||
|
rep.setNotBefore(0);
|
||||||
|
realm.update(rep);
|
||||||
|
|
||||||
|
// do the same with client's notBefore
|
||||||
|
ClientResource clientResource = realm.clients().get(realm.clients().findByClientId("test-app").get(0).getId());
|
||||||
|
ClientRepresentation clientRep = clientResource.toRepresentation();
|
||||||
|
clientRep.setNotBefore(time);
|
||||||
|
clientResource.update(clientRep);
|
||||||
|
|
||||||
|
response = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, accessTokenResponse.getToken());
|
||||||
|
|
||||||
|
assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus());
|
||||||
|
|
||||||
|
response.close();
|
||||||
|
|
||||||
|
events.expect(EventType.USER_INFO_REQUEST_ERROR)
|
||||||
|
.error(Errors.INVALID_TOKEN)
|
||||||
|
.user(Matchers.nullValue(String.class))
|
||||||
|
.session(Matchers.nullValue(String.class))
|
||||||
|
.detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN)
|
||||||
|
.client((String) null)
|
||||||
|
.assertEvent();
|
||||||
|
|
||||||
|
clientRep.setNotBefore(0);
|
||||||
|
clientResource.update(clientRep);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testSessionExpiredOfflineAccess() throws Exception {
|
public void testSessionExpiredOfflineAccess() throws Exception {
|
||||||
Client client = ClientBuilder.newClient();
|
Client client = ClientBuilder.newClient();
|
||||||
|
|
Loading…
Reference in a new issue