KEYCLOAK-9551 KEYCLOAK-16159 Make refresh_token generation for client_credentials optional. Support for revocation of access tokens.

Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Thomas Darimont 2019-07-31 17:50:04 +02:00 committed by Marek Posolda
parent 1281f28bb8
commit de20830412
44 changed files with 1019 additions and 186 deletions

View file

@ -52,7 +52,7 @@ public class TokenCallable implements Callable<String> {
@Override
public String call() {
if (clientToken == null) {
if (clientToken == null || clientToken.getRefreshToken() == null) {
if (userName == null || password == null) {
clientToken = obtainAccessToken();
} else {

View file

@ -94,6 +94,10 @@ public class TokenManager {
}
public synchronized AccessTokenResponse refreshToken() {
if (currentToken.getRefreshToken() == null) {
return grantToken();
}
Form form = new Form().param(GRANT_TYPE, REFRESH_TOKEN)
.param(REFRESH_TOKEN, currentToken.getRefreshToken());

View file

@ -18,6 +18,7 @@ package org.keycloak.client.admin.cli.commands;
import org.jboss.aesh.cl.Option;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.admin.cli.config.ConfigData;
import org.keycloak.client.admin.cli.config.ConfigHandler;
import org.keycloak.client.admin.cli.config.FileConfigHandler;
@ -264,6 +265,8 @@ public abstract class AbstractAuthOptionsCmd extends AbstractGlobalOptionsCmd {
rdata.setClientId(clientId);
if (secret != null)
rdata.setSecret(secret);
String grantTypeForAuthentication = user == null ? OAuth2Constants.CLIENT_CREDENTIALS : OAuth2Constants.PASSWORD;
rdata.setGrantTypeForAuthentication(grantTypeForAuthentication);
}
protected void checkUnsupportedOptions(String ... options) {

View file

@ -20,6 +20,7 @@ import org.jboss.aesh.cl.CommandDefinition;
import org.jboss.aesh.console.command.CommandException;
import org.jboss.aesh.console.command.CommandResult;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.admin.cli.config.ConfigData;
import org.keycloak.client.admin.cli.config.RealmConfigData;
import org.keycloak.client.admin.cli.util.AuthUtil;
@ -120,8 +121,10 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd {
boolean clientSet = clientId != null;
applyDefaultOptionValues();
String grantTypeForAuthentication = null;
if (user != null) {
grantTypeForAuthentication = OAuth2Constants.PASSWORD;
printErr("Logging into " + server + " as user " + user + " of realm " + realm);
// if user was set there needs to be a password so we can authenticate
@ -133,6 +136,7 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd {
secret = readSecret("Enter client secret: ", commandInvocation);
}
} else if (keystore != null || secret != null || clientSet) {
grantTypeForAuthentication = OAuth2Constants.CLIENT_CREDENTIALS;
printErr("Logging into " + server + " as " + "service-account-" + clientId + " of realm " + realm);
if (keystore == null) {
if (secret == null) {
@ -190,7 +194,7 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd {
Long sigExpiresAt = signedRequestToken == null ? null : System.currentTimeMillis() + sigLifetime * 1000;
// save tokens to config file
saveTokens(tokens, server, realm, clientId, signedRequestToken, sigExpiresAt, secret);
saveTokens(tokens, server, realm, clientId, signedRequestToken, sigExpiresAt, secret, grantTypeForAuthentication);
return CommandResult.SUCCESS;
}

View file

@ -39,6 +39,8 @@ public class RealmConfigData {
private String secret;
private String grantTypeForAuthentication;
private Long expiresAt;
private Long refreshExpiresAt;
@ -102,6 +104,14 @@ public class RealmConfigData {
this.secret = secret;
}
public String getGrantTypeForAuthentication() {
return grantTypeForAuthentication;
}
public void setGrantTypeForAuthentication(String grantTypeForAuthentication) {
this.grantTypeForAuthentication = grantTypeForAuthentication;
}
public Long getExpiresAt() {
return expiresAt;
}
@ -134,6 +144,7 @@ public class RealmConfigData {
refreshToken = source.refreshToken;
signingToken = source.signingToken;
secret = source.secret;
grantTypeForAuthentication = source.grantTypeForAuthentication;
expiresAt = source.expiresAt;
refreshExpiresAt = source.refreshExpiresAt;
sigExpiresAt = source.sigExpiresAt;
@ -164,6 +175,7 @@ public class RealmConfigData {
data.refreshToken = refreshToken;
data.signingToken = signingToken;
data.secret = secret;
data.grantTypeForAuthentication = grantTypeForAuthentication;
data.expiresAt = expiresAt;
data.refreshExpiresAt = refreshExpiresAt;
data.sigExpiresAt = sigExpiresAt;

View file

@ -62,7 +62,7 @@ public class AuthUtil {
// check refresh_token against expiry time
// if it's less than 5s to expiry, fail with credentials expired
if (realmConfig.getRefreshExpiresAt() - now < 5000) {
if (realmConfig.getRefreshExpiresAt() != null && realmConfig.getRefreshExpiresAt() - now < 5000) {
throw new RuntimeException("Session has expired. Login again with '" + OsUtil.CMD + " config credentials'");
}
@ -72,10 +72,15 @@ public class AuthUtil {
try {
String authorization = null;
StringBuilder body = new StringBuilder();
if (realmConfig.getRefreshToken() != null) {
body.append("grant_type=refresh_token")
.append("&refresh_token=").append(realmConfig.getRefreshToken());
} else {
body.append("grant_type=").append(realmConfig.getGrantTypeForAuthentication());
}
StringBuilder body = new StringBuilder("grant_type=refresh_token")
.append("&refresh_token=").append(realmConfig.getRefreshToken())
.append("&client_id=").append(urlencode(realmConfig.getClientId()));
body.append("&client_id=").append(urlencode(realmConfig.getClientId()));
if (realmConfig.getSigningToken() != null) {
body.append("&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
@ -94,7 +99,9 @@ public class AuthUtil {
realmData.setToken(token.getToken());
realmData.setRefreshToken(token.getRefreshToken());
realmData.setExpiresAt(currentTimeMillis() + token.getExpiresIn() * 1000);
realmData.setRefreshExpiresAt(currentTimeMillis() + token.getRefreshExpiresIn() * 1000);
if (token.getRefreshToken() != null) {
realmData.setRefreshExpiresAt(currentTimeMillis() + token.getRefreshExpiresIn() * 1000);
}
});
return token.getToken();

View file

@ -16,6 +16,7 @@
*/
package org.keycloak.client.admin.cli.util;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.admin.cli.config.ConfigData;
import org.keycloak.client.admin.cli.config.ConfigHandler;
import org.keycloak.client.admin.cli.config.ConfigUpdateOperation;
@ -44,7 +45,8 @@ public class ConfigUtil {
ConfigUtil.handler = handler;
}
public static void saveTokens(AccessTokenResponse tokens, String endpoint, String realm, String clientId, String signKey, Long sigExpiresAt, String secret) {
public static void saveTokens(AccessTokenResponse tokens, String endpoint, String realm, String clientId, String signKey, Long sigExpiresAt, String secret,
String grantTypeForAuthentication) {
handler.saveMergeConfig(config -> {
config.setServerUrl(endpoint);
config.setRealm(realm);
@ -55,10 +57,13 @@ public class ConfigUtil {
realmConfig.setSigningToken(signKey);
realmConfig.setSecret(secret);
realmConfig.setExpiresAt(System.currentTimeMillis() + tokens.getExpiresIn() * 1000);
realmConfig.setRefreshExpiresAt(tokens.getRefreshExpiresIn() == 0 ?
Long.MAX_VALUE : System.currentTimeMillis() + tokens.getRefreshExpiresIn() * 1000);
if (realmConfig.getRefreshToken() != null) {
realmConfig.setRefreshExpiresAt(tokens.getRefreshExpiresIn() == 0 ?
Long.MAX_VALUE : System.currentTimeMillis() + tokens.getRefreshExpiresIn() * 1000);
}
realmConfig.setSigExpiresAt(sigExpiresAt);
realmConfig.setClientId(clientId);
realmConfig.setGrantTypeForAuthentication(grantTypeForAuthentication);
});
}
@ -76,8 +81,12 @@ public class ConfigUtil {
}
public static boolean credentialsAvailable(ConfigData config) {
return config.getServerUrl() != null && (config.getExternalToken() != null || (config.getRealm() != null
&& config.sessionRealmConfigData() != null && config.sessionRealmConfigData().getRefreshToken() != null));
// Just supporting "client_credentials" grant type for the case when refresh token is missing
boolean credsAvailable = config.getServerUrl() != null && (config.getExternalToken() != null || (config.getRealm() != null
&& config.sessionRealmConfigData() != null &&
(config.sessionRealmConfigData().getRefreshToken() != null || (config.sessionRealmConfigData().getToken() != null && OAuth2Constants.CLIENT_CREDENTIALS.equals(config.sessionRealmConfigData().getGrantTypeForAuthentication())))
));
return credsAvailable;
}
public static ConfigData loadConfig() {

View file

@ -2,6 +2,7 @@ package org.keycloak.client.registration.cli.commands;
import org.jboss.aesh.cl.Option;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.registration.cli.config.ConfigData;
import org.keycloak.client.registration.cli.config.ConfigHandler;
import org.keycloak.client.registration.cli.config.FileConfigHandler;
@ -232,6 +233,8 @@ public abstract class AbstractAuthOptionsCmd extends AbstractGlobalOptionsCmd {
rdata.setClientId(clientId);
if (secret != null)
rdata.setSecret(secret);
String grantTypeForAuthentication = user == null ? OAuth2Constants.CLIENT_CREDENTIALS : OAuth2Constants.PASSWORD;
rdata.setGrantTypeForAuthentication(grantTypeForAuthentication);
}
protected void checkUnsupportedOptions(String ... options) {

View file

@ -5,6 +5,7 @@ import org.jboss.aesh.console.command.Command;
import org.jboss.aesh.console.command.CommandException;
import org.jboss.aesh.console.command.CommandResult;
import org.jboss.aesh.console.command.invocation.CommandInvocation;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.registration.cli.config.ConfigData;
import org.keycloak.client.registration.cli.config.RealmConfigData;
import org.keycloak.client.registration.cli.util.AuthUtil;
@ -104,8 +105,10 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd implements Comm
boolean clientSet = clientId != null;
applyDefaultOptionValues();
String grantTypeForAuthentication = null;
if (user != null) {
grantTypeForAuthentication = OAuth2Constants.PASSWORD;
printErr("Logging into " + server + " as user " + user + " of realm " + realm);
// if user was set there needs to be a password so we can authenticate
@ -117,6 +120,7 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd implements Comm
secret = readSecret("Enter client secret: ", commandInvocation);
}
} else if (keystore != null || secret != null || clientSet) {
grantTypeForAuthentication = OAuth2Constants.CLIENT_CREDENTIALS;
printErr("Logging into " + server + " as " + "service-account-" + clientId + " of realm " + realm);
if (keystore == null) {
if (secret == null) {
@ -174,7 +178,7 @@ public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd implements Comm
Long sigExpiresAt = signedRequestToken == null ? null : System.currentTimeMillis() + sigLifetime * 1000;
// save tokens to config file
saveTokens(tokens, server, realm, clientId, signedRequestToken, sigExpiresAt, secret);
saveTokens(tokens, server, realm, clientId, signedRequestToken, sigExpiresAt, secret, grantTypeForAuthentication);
return CommandResult.SUCCESS;
}

View file

@ -42,6 +42,8 @@ public class RealmConfigData {
private String secret;
private String grantTypeForAuthentication;
private Long expiresAt;
private Long refreshExpiresAt;
@ -125,6 +127,14 @@ public class RealmConfigData {
this.refreshExpiresAt = refreshExpiresAt;
}
public String getGrantTypeForAuthentication() {
return grantTypeForAuthentication;
}
public void setGrantTypeForAuthentication(String grantTypeForAuthentication) {
this.grantTypeForAuthentication = grantTypeForAuthentication;
}
public Long getSigExpiresAt() {
return sigExpiresAt;
}
@ -153,6 +163,7 @@ public class RealmConfigData {
refreshToken = source.refreshToken;
signingToken = source.signingToken;
secret = source.secret;
grantTypeForAuthentication = source.grantTypeForAuthentication;
expiresAt = source.expiresAt;
refreshExpiresAt = source.refreshExpiresAt;
sigExpiresAt = source.sigExpiresAt;
@ -210,6 +221,7 @@ public class RealmConfigData {
data.refreshToken = refreshToken;
data.signingToken = signingToken;
data.secret = secret;
data.grantTypeForAuthentication = grantTypeForAuthentication;
data.expiresAt = expiresAt;
data.refreshExpiresAt = refreshExpiresAt;
data.sigExpiresAt = sigExpiresAt;

View file

@ -61,7 +61,7 @@ public class AuthUtil {
// check refresh_token against expiry time
// if it's less than 5s to expiry, fail with credentials expired
if (realmConfig.getRefreshExpiresAt() - now < 5000) {
if (realmConfig.getRefreshExpiresAt() != null && realmConfig.getRefreshExpiresAt() - now < 5000) {
throw new RuntimeException("Session has expired. Login again with '" + OsUtil.CMD + " config credentials'");
}
@ -72,9 +72,15 @@ public class AuthUtil {
try {
String authorization = null;
StringBuilder body = new StringBuilder("grant_type=refresh_token")
.append("&refresh_token=").append(realmConfig.getRefreshToken())
.append("&client_id=").append(urlencode(realmConfig.getClientId()));
StringBuilder body = new StringBuilder();
if (realmConfig.getRefreshToken() != null) {
body.append("grant_type=refresh_token")
.append("&refresh_token=").append(realmConfig.getRefreshToken());
} else {
body.append("grant_type=").append(realmConfig.getGrantTypeForAuthentication());
}
body.append("&client_id=").append(urlencode(realmConfig.getClientId()));
if (realmConfig.getSigningToken() != null) {
body.append("&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
@ -93,7 +99,9 @@ public class AuthUtil {
realmData.setToken(token.getToken());
realmData.setRefreshToken(token.getRefreshToken());
realmData.setExpiresAt(currentTimeMillis() + token.getExpiresIn() * 1000);
realmData.setRefreshExpiresAt(currentTimeMillis() + token.getRefreshExpiresIn() * 1000);
if (token.getRefreshToken() != null) {
realmData.setRefreshExpiresAt(currentTimeMillis() + token.getRefreshExpiresIn() * 1000);
}
});
return token.getToken();

View file

@ -17,6 +17,7 @@
package org.keycloak.client.registration.cli.util;
import org.keycloak.OAuth2Constants;
import org.keycloak.client.registration.cli.config.ConfigData;
import org.keycloak.client.registration.cli.config.ConfigHandler;
import org.keycloak.client.registration.cli.config.ConfigUpdateOperation;
@ -52,7 +53,8 @@ public class ConfigUtil {
data.getClients().put(clientId, token == null ? "" : token);
}
public static void saveTokens(AccessTokenResponse tokens, String endpoint, String realm, String clientId, String signKey, Long sigExpiresAt, String secret) {
public static void saveTokens(AccessTokenResponse tokens, String endpoint, String realm, String clientId, String signKey, Long sigExpiresAt, String secret,
String grantTypeForAuthentication) {
handler.saveMergeConfig(config -> {
config.setServerUrl(endpoint);
config.setRealm(realm);
@ -63,10 +65,13 @@ public class ConfigUtil {
realmConfig.setSigningToken(signKey);
realmConfig.setSecret(secret);
realmConfig.setExpiresAt(System.currentTimeMillis() + tokens.getExpiresIn() * 1000);
realmConfig.setRefreshExpiresAt(tokens.getRefreshExpiresIn() == 0 ?
Long.MAX_VALUE : System.currentTimeMillis() + tokens.getRefreshExpiresIn() * 1000);
if (realmConfig.getRefreshToken() != null) {
realmConfig.setRefreshExpiresAt(tokens.getRefreshExpiresIn() == 0 ?
Long.MAX_VALUE : System.currentTimeMillis() + tokens.getRefreshExpiresIn() * 1000);
}
realmConfig.setSigExpiresAt(sigExpiresAt);
realmConfig.setClientId(clientId);
realmConfig.setGrantTypeForAuthentication(grantTypeForAuthentication);
});
}
@ -81,8 +86,11 @@ public class ConfigUtil {
}
public static boolean credentialsAvailable(ConfigData config) {
return config.getServerUrl() != null && config.getRealm() != null
&& config.sessionRealmConfigData() != null && config.sessionRealmConfigData().getRefreshToken() != null;
// Just supporting "client_credentials" grant type for the case when refresh token is missing
boolean credsAvailable = config.getServerUrl() != null && config.getRealm() != null
&& config.sessionRealmConfigData() != null &&
(config.sessionRealmConfigData().getRefreshToken() != null || (config.sessionRealmConfigData().getToken() != null && OAuth2Constants.CLIENT_CREDENTIALS.equals(config.sessionRealmConfigData().getGrantTypeForAuthentication())));
return credsAvailable;
}
public static ConfigData loadConfig() {

View file

@ -20,19 +20,14 @@ package org.keycloak.models.sessions.infinispan;
import java.util.UUID;
import java.util.function.Supplier;
import org.infinispan.Cache;
import org.infinispan.client.hotrod.Flag;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.commons.api.BasicCache;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.models.CodeToTokenStoreProvider;
import org.keycloak.models.CodeToTokenStoreProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
import org.keycloak.models.sessions.infinispan.util.InfinispanUtil;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -54,23 +49,7 @@ public class InfinispanCodeToTokenStoreProviderFactory implements CodeToTokenSto
if (codeCache == null) {
synchronized (this) {
if (codeCache == null) {
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
if (remoteCache != null) {
LOG.debugf("Having remote stores. Using remote cache '%s' for single-use cache of code", remoteCache.getName());
this.codeCache = () -> {
// Doing this way as flag is per invocation
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
};
} else {
LOG.debugf("Not having remote stores. Using normal cache '%s' for single-use cache of code", cache.getName());
this.codeCache = () -> {
return cache;
};
}
this.codeCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session);
}
}
}

View file

@ -52,28 +52,32 @@ public class InfinispanSingleUseTokenStoreProviderFactory implements SingleUseTo
if (tokenCache == null) {
synchronized (this) {
if (tokenCache == null) {
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
if (remoteCache != null) {
LOG.debugf("Having remote stores. Using remote cache '%s' for single-use cache of token", remoteCache.getName());
this.tokenCache = () -> {
// Doing this way as flag is per invocation
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
};
} else {
LOG.debugf("Not having remote stores. Using normal cache '%s' for single-use cache of token", cache.getName());
this.tokenCache = () -> {
return cache;
};
}
this.tokenCache = getActionTokenCache(session);
}
}
}
}
static Supplier getActionTokenCache(KeycloakSession session) {
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
if (remoteCache != null) {
LOG.debugf("Having remote stores. Using remote cache '%s' for single-use cache of token", remoteCache.getName());
return () -> {
// Doing this way as flag is per invocation
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
};
} else {
LOG.debugf("Not having remote stores. Using normal cache '%s' for single-use cache of token", cache.getName());
return () -> {
return cache;
};
}
}
@Override
public void init(Config.Scope config) {

View file

@ -0,0 +1,97 @@
/*
* Copyright 2020 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.models.sessions.infinispan;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import org.infinispan.client.hotrod.exceptions.HotRodClientException;
import org.infinispan.commons.api.BasicCache;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.TokenRevocationStoreProvider;
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class InfinispanTokenRevocationStoreProvider implements TokenRevocationStoreProvider {
public static final Logger logger = Logger.getLogger(InfinispanTokenRevocationStoreProvider.class);
private final Supplier<BasicCache<String, ActionTokenValueEntity>> tokenCache;
private final KeycloakSession session;
// Key in the data, which indicates that token is considered revoked
private final String REVOKED_KEY = "revoked";
public InfinispanTokenRevocationStoreProvider(KeycloakSession session, Supplier<BasicCache<String, ActionTokenValueEntity>> tokenCache) {
this.session = session;
this.tokenCache = tokenCache;
}
@Override
public void putRevokedToken(String tokenId, long lifespanSeconds) {
Map<String, String> data = Collections.singletonMap(REVOKED_KEY, "true");
ActionTokenValueEntity tokenValue = new ActionTokenValueEntity(data);
try {
BasicCache<String, ActionTokenValueEntity> cache = tokenCache.get();
cache.put(tokenId, tokenValue, lifespanSeconds + 1, TimeUnit.SECONDS);
} catch (HotRodClientException re) {
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
if (logger.isDebugEnabled()) {
logger.debugf(re, "Failed when adding revoked token %s", tokenId);
}
throw re;
}
}
@Override
public boolean isRevoked(String tokenId) {
try {
BasicCache<String, ActionTokenValueEntity> cache = tokenCache.get();
ActionTokenValueEntity existing = cache.get(tokenId);
if (existing == null) {
return false;
}
return existing.getNotes().containsKey(REVOKED_KEY);
} catch (HotRodClientException re) {
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
if (logger.isDebugEnabled()) {
logger.debugf(re, "Failed when trying to get revoked token %s", tokenId);
}
return false;
}
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright 2020 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.models.sessions.infinispan;
import java.util.function.Supplier;
import org.infinispan.commons.api.BasicCache;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.TokenRevocationStoreProvider;
import org.keycloak.models.TokenRevocationStoreProviderFactory;
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class InfinispanTokenRevocationStoreProviderFactory implements TokenRevocationStoreProviderFactory {
// Reuse "actionTokens" infinispan cache for now
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> tokenCache;
@Override
public TokenRevocationStoreProvider create(KeycloakSession session) {
lazyInit(session);
return new InfinispanTokenRevocationStoreProvider(session, tokenCache);
}
private void lazyInit(KeycloakSession session) {
if (tokenCache == null) {
synchronized (this) {
if (tokenCache == null) {
this.tokenCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session);
}
}
}
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "infinispan";
}
}

View file

@ -0,0 +1,19 @@
#
# Copyright 2020 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.
#
#
org.keycloak.models.sessions.infinispan.InfinispanTokenRevocationStoreProviderFactory

View file

@ -0,0 +1,50 @@
/*
* Copyright 2020 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.models;
import java.util.UUID;
import org.keycloak.provider.Provider;
/**
* Provides the cache for store revoked tokens.
*
* For now, it is separate provider as it is bit different use-case that existing providers like {@link CodeToTokenStoreProvider},
* {@link SingleUseTokenStoreProvider} and {@link ActionTokenStoreProvider}
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface TokenRevocationStoreProvider extends Provider {
/**
* Mark given token as revoked. Parameter "lifespanSeconds" is the time for which the token is considered revoked. After this time, it may be removed from this store,
* which means that {@link #isRevoked} method will return false. In reality, the token will usually still be invalid due the "expiration" claim on it, however
* that is out of scope of this provider.
*
* @param tokenId
* @oaran lifespanSeconds
*/
void putRevokedToken(String tokenId, long lifespanSeconds);
/**
* @param tokenId
* @return true if token exists in the store, which indicates that it is revoked.
*/
boolean isRevoked(String tokenId);
}

View file

@ -0,0 +1,26 @@
/*
* Copyright 2020 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.models;
import org.keycloak.provider.ProviderFactory;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public interface TokenRevocationStoreProviderFactory extends ProviderFactory<TokenRevocationStoreProvider> {
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2020 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.models;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class TokenRevocationStoreSpi implements Spi {
public static final String NAME = "tokenRevocationStore";
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return TokenRevocationStoreProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return TokenRevocationStoreProviderFactory.class;
}
}

View file

@ -25,6 +25,7 @@ org.keycloak.models.RoleSpi
org.keycloak.models.ActionTokenStoreSpi
org.keycloak.models.CodeToTokenStoreSpi
org.keycloak.models.SingleUseTokenStoreSpi
org.keycloak.models.TokenRevocationStoreSpi
org.keycloak.models.UserSessionSpi
org.keycloak.models.UserSpi
org.keycloak.models.session.UserSessionPersisterSpi

View file

@ -56,6 +56,8 @@ import org.keycloak.authorization.store.ResourceStore;
import org.keycloak.authorization.store.ScopeStore;
import org.keycloak.authorization.store.StoreFactory;
import org.keycloak.authorization.util.Tokens;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Base64Url;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -65,8 +67,10 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.UserSessionProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.TokenManager.AccessTokenResponseBuilder;
@ -279,10 +283,18 @@ public class AuthorizationTokenService {
AccessToken accessToken = identity.getAccessToken();
RealmModel realm = request.getRealm();
UserSessionProvider sessions = keycloakSession.sessions();
UserSessionModel userSessionModel = sessions.getUserSession(realm, accessToken.getSessionState());
UserSessionModel userSessionModel;
if (accessToken.getSessionState() == null) {
// Create temporary (request-scoped) transient session
UserModel user = TokenManager.lookupUserFromStatelessToken(keycloakSession, realm, accessToken);
userSessionModel = sessions.createUserSession(KeycloakModelUtils.generateId(), realm, user, user.getUsername(), request.getClientConnection().getRemoteAddr(),
ServiceAccountConstants.CLIENT_AUTH, false, null, null, UserSessionModel.SessionPersistenceState.TRANSIENT);
} else {
userSessionModel = sessions.getUserSession(realm, accessToken.getSessionState());
if (userSessionModel == null) {
userSessionModel = sessions.getOfflineUserSession(realm, accessToken.getSessionState());
if (userSessionModel == null) {
userSessionModel = sessions.getOfflineUserSession(realm, accessToken.getSessionState());
}
}
ClientModel client = realm.getClientByClientId(accessToken.getIssuedFor());
@ -316,8 +328,8 @@ public class AuthorizationTokenService {
TokenManager tokenManager = request.getTokenManager();
EventBuilder event = request.getEvent();
AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, keycloakSession, userSessionModel, clientSessionCtx)
.generateAccessToken()
.generateRefreshToken();
.generateAccessToken();
AccessToken rpt = responseBuilder.getAccessToken();
Authorization authorization = new Authorization();
@ -325,10 +337,16 @@ public class AuthorizationTokenService {
rpt.setAuthorization(authorization);
RefreshToken refreshToken = responseBuilder.getRefreshToken();
if (accessToken.getSessionState() == null) {
// Skip generating refresh token for accessToken without sessionState claim. This is "stateless" accessToken not pointing to any real persistent userSession
rpt.setSessionState(null);
} else {
responseBuilder.generateRefreshToken();
RefreshToken refreshToken = responseBuilder.getRefreshToken();
refreshToken.issuedFor(client.getClientId());
refreshToken.setAuthorization(authorization);
refreshToken.issuedFor(client.getClientId());
refreshToken.setAuthorization(authorization);
}
if (!rpt.hasAudience(targetClient.getClientId())) {
rpt.audience(targetClient.getClientId());
@ -700,13 +718,15 @@ public class AuthorizationTokenService {
private final EventBuilder event;
private final HttpRequest httpRequest;
private final Cors cors;
private final ClientConnection clientConnection;
public KeycloakAuthorizationRequest(AuthorizationProvider authorization, TokenManager tokenManager, EventBuilder event, HttpRequest request, Cors cors) {
public KeycloakAuthorizationRequest(AuthorizationProvider authorization, TokenManager tokenManager, EventBuilder event, HttpRequest request, Cors cors, ClientConnection clientConnection) {
this.authorization = authorization;
this.tokenManager = tokenManager;
this.event = event;
httpRequest = request;
this.cors = cors;
this.clientConnection = clientConnection;
}
TokenManager getTokenManager() {
@ -736,5 +756,9 @@ public class AuthorizationTokenService {
RealmModel getRealm() {
return getKeycloakSession().getContext().getRealm();
}
ClientConnection getClientConnection() {
return clientConnection;
}
}
}

View file

@ -154,7 +154,7 @@ public class KeycloakIdentity implements Identity {
clientUser = this.keycloakSession.users().getServiceAccount(clientModel);
}
UserModel userSession = getUserFromSessionState();
UserModel userSession = getUserFromToken();
this.resourceServer = clientUser != null && userSession.getId().equals(clientUser.getId());
@ -229,7 +229,7 @@ public class KeycloakIdentity implements Identity {
clientUser = this.keycloakSession.users().getServiceAccount(clientModel);
}
UserModel userSession = getUserFromSessionState();
UserModel userSession = getUserFromToken();
this.resourceServer = clientUser != null && userSession.getId().equals(clientUser.getId());
@ -276,7 +276,11 @@ public class KeycloakIdentity implements Identity {
return null;
}
private UserModel getUserFromSessionState() {
private UserModel getUserFromToken() {
if (accessToken.getSessionState() == null) {
return TokenManager.lookupUserFromStatelessToken(keycloakSession, realm, accessToken);
}
UserSessionProvider sessions = keycloakSession.sessions();
UserSessionModel userSession = sessions.getUserSession(realm, accessToken.getSessionState());

View file

@ -119,6 +119,20 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(OIDCConfigAttributes.USE_MTLS_HOK_TOKEN, val);
}
/**
* If true, then Client Credentials Grant generates refresh token and creates user session. This is not per specs, so it is false by default
* For the details @see https://tools.ietf.org/html/rfc6749#section-4.4.3
*/
public boolean isUseRefreshTokenForClientCredentialsGrant() {
String val = getAttribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "false");
return Boolean.parseBoolean(val);
}
public void setUseRefreshTokenForClientCredentialsGrant(boolean enable) {
String val = String.valueOf(enable);
setAttribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, val);
}
public String getTlsClientAuthSubjectDn() {
return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN);
}
@ -202,6 +216,14 @@ public class OIDCAdvancedConfigWrapper {
}
}
private String getAttribute(String attrKey, String defaultValue) {
String value = getAttribute(attrKey);
if (value == null) {
return defaultValue;
}
return value;
}
private void setAttribute(String attrKey, String attrValue) {
if (clientModel != null) {
if (attrValue != null) {

View file

@ -58,6 +58,8 @@ public final class OIDCConfigAttributes {
public static final String BACKCHANNEL_LOGOUT_REVOKE_OFFLINE_TOKENS = "backchannel.logout.revoke.offline.tokens";
public static final String USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT = "client_credentials.use_refresh_token";
private OIDCConfigAttributes() {
}

View file

@ -46,6 +46,7 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.models.TokenRevocationStoreProvider;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
@ -90,6 +91,7 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import static org.keycloak.representations.IDToken.NONCE;
import static org.keycloak.representations.IDToken.PHONE_NUMBER;
/**
* Stateless object that creates tokens and manages oauth access codes
@ -233,28 +235,43 @@ public class TokenManager {
return false;
}
boolean valid = false;
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId());
if (AuthenticationManager.isSessionValid(realm, userSession)) {
valid = isUserValid(session, realm, token, userSession);
} else {
userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId());
if (AuthenticationManager.isOfflineSessionValid(realm, userSession)) {
valid = isUserValid(session, realm, token, userSession);
}
TokenRevocationStoreProvider revocationStore = session.getProvider(TokenRevocationStoreProvider.class);
if (revocationStore.isRevoked(token.getId())) {
return false;
}
if (valid) {
userSession.setLastSessionRefresh(Time.currentTime());
boolean valid = false;
// Tokens without sessions are considered valid. Signature check and revocation check are sufficient checks for them
if (token.getSessionState() == null) {
UserModel user = lookupUserFromStatelessToken(session, realm, token);
valid = isUserValid(session, realm, token, user);
} else {
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId());
if (AuthenticationManager.isSessionValid(realm, userSession)) {
valid = isUserValid(session, realm, token, userSession.getUser());
} else {
userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId());
if (AuthenticationManager.isOfflineSessionValid(realm, userSession)) {
valid = isUserValid(session, realm, token, userSession.getUser());
}
}
if (valid && (token.getIssuedAt() + 1 < userSession.getStarted())) {
valid = false;
}
if (valid) {
userSession.setLastSessionRefresh(Time.currentTime());
}
}
return valid;
}
private boolean isUserValid(KeycloakSession session, RealmModel realm, AccessToken token, UserSessionModel userSession) {
UserModel user = userSession.getUser();
private boolean isUserValid(KeycloakSession session, RealmModel realm, AccessToken token, UserModel user) {
if (user == null) {
return false;
}
@ -268,13 +285,30 @@ public class TokenManager {
} catch (VerificationException e) {
return false;
}
if (token.getIssuedAt() + 1 < userSession.getStarted()) {
return false;
}
return true;
}
/**
* Lookup user from the "stateless" token. Stateless token is the token without sessionState filled (token doesn't belong to any userSession)
*/
public static UserModel lookupUserFromStatelessToken(KeycloakSession session, RealmModel realm, AccessToken token) {
// Try to lookup user based on "sub" claim. It should work for most cases with some rare exceptions (EG. OIDC "pairwise" subjects)
UserModel user = session.users().getUserById(token.getSubject(), realm);
if (user != null) {
return user;
}
// Fallback to lookup user based on username (preferred_username claim)
if (token.getPreferredUsername() != null) {
user = session.users().getUserByUsername(token.getPreferredUsername(), realm);
if (user != null) {
return user;
}
}
return user;
}
public RefreshResult refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient,
String encodedRefreshToken, EventBuilder event, HttpHeaders headers, HttpRequest request) throws OAuthErrorException {

View file

@ -717,10 +717,17 @@ public class TokenEndpoint {
authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, scope);
// TODO: This should create transient session by default - hence not persist userSession at all. However we should have compatibility switch for support
// persisting of userSession
// persisting of userSession by default
UserSessionModel.SessionPersistenceState sessionPersistenceState = UserSessionModel.SessionPersistenceState.PERSISTENT;
boolean useRefreshToken = OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshTokenForClientCredentialsGrant();
if (!useRefreshToken) {
// we don't want to store a session hence we mark it as transient, see KEYCLOAK-9551
sessionPersistenceState = UserSessionModel.SessionPersistenceState.TRANSIENT;
}
UserSessionModel userSession = session.sessions().createUserSession(authSession.getParentSession().getId(), realm, clientUser, clientUsername,
clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null, UserSessionModel.SessionPersistenceState.PERSISTENT);
clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null, sessionPersistenceState);
event.session(userSession);
AuthenticationManager.setClientScopesInSession(authSession);
@ -734,8 +741,14 @@ public class TokenEndpoint {
updateUserSessionFromClientAuth(userSession);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
.generateAccessToken()
.generateRefreshToken();
.generateAccessToken();
// Make refresh token generation optional, see KEYCLOAK-9551
if (useRefreshToken) {
responseBuilder = responseBuilder.generateRefreshToken();
} else {
responseBuilder.getAccessToken().setSessionState(null);
}
String scopeParam = clientSessionCtx.getClientSession().getNote(OAuth2Constants.SCOPE);
if (TokenUtil.isOIDCRequest(scopeParam)) {
@ -1262,7 +1275,8 @@ public class TokenEndpoint {
}
}
AuthorizationTokenService.KeycloakAuthorizationRequest authorizationRequest = new AuthorizationTokenService.KeycloakAuthorizationRequest(session.getProvider(AuthorizationProvider.class), tokenManager, event, this.request, cors);
AuthorizationTokenService.KeycloakAuthorizationRequest authorizationRequest = new AuthorizationTokenService.KeycloakAuthorizationRequest(session.getProvider(AuthorizationProvider.class),
tokenManager, event, this.request, cors, clientConnection);
authorizationRequest.setTicket(formParams.getFirst("ticket"));
authorizationRequest.setClaimToken(claimToken);

View file

@ -30,6 +30,7 @@ import javax.ws.rs.core.Response;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Time;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
@ -39,13 +40,14 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.TokenRevocationStoreProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.clientpolicy.DefaultClientPolicyManager;
import org.keycloak.services.clientpolicy.TokenRevokeContext;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.managers.UserSessionManager;
@ -75,7 +77,7 @@ public class TokenRevocationEndpoint {
private RealmModel realm;
private EventBuilder event;
private Cors cors;
private RefreshToken token;
private AccessToken token;
private UserModel user;
public TokenRevocationEndpoint(RealmModel realm, EventBuilder event) {
@ -105,11 +107,17 @@ public class TokenRevocationEndpoint {
checkToken();
checkIssuedFor();
checkUser();
revokeClient();
event.detail(Details.REVOKED_CLIENT, client.getClientId()).success();
if (TokenUtil.TOKEN_TYPE_REFRESH.equals(token.getType()) || TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType())) {
revokeClient();
event.detail(Details.REVOKED_CLIENT, client.getClientId());
} else {
revokeAccessToken();
event.detail(Details.TOKEN_ID, token.getId());
}
event.success();
session.getProvider(SecurityHeadersProvider.class).options().allowEmptyContentType();
return cors.builder(Response.ok()).build();
@ -153,14 +161,14 @@ public class TokenRevocationEndpoint {
Response.Status.BAD_REQUEST);
}
token = session.tokens().decode(encodedToken, RefreshToken.class);
token = session.tokens().decode(encodedToken, AccessToken.class);
if (token == null) {
event.error(Errors.INVALID_TOKEN);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.OK);
}
if (!(TokenUtil.TOKEN_TYPE_REFRESH.equals(token.getType()) || TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType()))) {
if (!(TokenUtil.TOKEN_TYPE_REFRESH.equals(token.getType()) || TokenUtil.TOKEN_TYPE_OFFLINE.equals(token.getType()) || TokenUtil.TOKEN_TYPE_BEARER.equals(token.getType()))) {
event.error(Errors.INVALID_TOKEN_TYPE);
throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_TOKEN_TYPE, "Unsupported token type",
Response.Status.BAD_REQUEST);
@ -182,21 +190,25 @@ public class TokenRevocationEndpoint {
}
private void checkUser() {
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm,
token.getSessionState(), false, client.getId());
if (userSession == null) {
userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true,
client.getId());
if (token.getSessionState() == null) {
user = TokenManager.lookupUserFromStatelessToken(session, realm, token);
} else {
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm,
token.getSessionState(), false, client.getId());
if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token",
Response.Status.OK);
}
}
userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true,
client.getId());
user = userSession.getUser();
if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_TOKEN, "Invalid token",
Response.Status.OK);
}
}
user = userSession.getUser();
}
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
@ -220,4 +232,11 @@ public class TokenRevocationEndpoint {
}
}
}
private void revokeAccessToken() {
TokenRevocationStoreProvider revocationStore = session.getProvider(TokenRevocationStoreProvider.class);
int currentTime = Time.currentTime();
long lifespanInSecs = Math.max(token.getExp() - currentTime, 10);
revocationStore.putRevokedToken(token.getId(), lifespanInSecs);
}
}

View file

@ -67,6 +67,7 @@ import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
@ -1305,23 +1306,24 @@ public class AuthenticationManager {
}
}
UserSessionModel userSession = session.sessions().getUserSession(realm, token.getSessionState());
UserSessionModel userSession = null;
UserModel user = null;
if (userSession != null) {
user = userSession.getUser();
if (user == null || !user.isEnabled()) {
logger.debug("Unknown user in identity token");
if (token.getSessionState() == null) {
user = TokenManager.lookupUserFromStatelessToken(session, realm, token);
if (!isUserValid(session, realm, user, token)) {
return null;
}
int userNotBefore = session.users().getNotBeforeOfUser(realm, user);
if (token.getIssuedAt() < userNotBefore) {
logger.debug("User notBefore newer than token");
return null;
} else {
userSession = session.sessions().getUserSession(realm, token.getSessionState());
if (userSession != null) {
user = userSession.getUser();
if (!isUserValid(session, realm, user, token)) {
return null;
}
}
}
if (!isSessionValid(realm, userSession)) {
if (token.getSessionState() != null && !isSessionValid(realm, userSession)) {
// Check if accessToken was for the offline session.
if (!isCookie) {
UserSessionModel offlineUserSession = session.sessions().getOfflineUserSession(realm, token.getSessionState());
@ -1345,6 +1347,21 @@ public class AuthenticationManager {
return null;
}
private static boolean isUserValid(KeycloakSession session, RealmModel realm, UserModel user, AccessToken token) {
if (user == null || !user.isEnabled()) {
logger.debug("Unknown user in identity token");
return false;
}
int userNotBefore = session.users().getNotBeforeOfUser(realm, user);
if (token.getIssuedAt() < userNotBefore) {
logger.debug("User notBefore newer than token");
return false;
}
return true;
}
public enum AuthenticationStatus {
SUCCESS, ACCOUNT_TEMPORARILY_DISABLED, ACCOUNT_DISABLED, ACTIONS_REQUIRED, INVALID_USER, INVALID_CREDENTIALS, MISSING_PASSWORD, MISSING_TOTP, FAILED
}

View file

@ -163,6 +163,9 @@ public class LinkedAccountsResource {
if (errorMessage != null) {
return ErrorResponse.error(errorMessage, Response.Status.BAD_REQUEST);
}
if (auth.getSession() == null) {
return ErrorResponse.error(Messages.SESSION_NOT_ACTIVE, Response.Status.BAD_REQUEST);
}
try {
String nonce = UUID.randomUUID().toString();

View file

@ -198,6 +198,7 @@ public class SessionResource {
}
private boolean isCurrentSession(UserSessionModel session) {
if (auth.getSession() == null) return false;
return session.getId().equals(auth.getSession().getId());
}

View file

@ -36,8 +36,10 @@ import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.ssl.SSLContexts;
import org.jboss.resteasy.client.jaxrs.ClientHttpEngine;
import org.jboss.resteasy.client.jaxrs.ClientHttpEngineBuilder43;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder;
import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.keycloak.models.Constants;
@ -59,6 +61,40 @@ public class AdminClientUtil {
}
public static Keycloak createAdminClient(boolean ignoreUnknownProperties, String authServerContextRoot, String realmName, String username, String password, String clientId, String clientSecret) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
ResteasyClient resteasyClient = createResteasyClient(ignoreUnknownProperties);
return KeycloakBuilder.builder()
.serverUrl(authServerContextRoot + "/auth")
.realm(realmName)
.username(username)
.password(password)
.clientId(clientId)
.clientSecret(clientSecret)
.resteasyClient(resteasyClient).build();
}
public static Keycloak createAdminClientWithClientCredentials(String realmName, String clientId, String clientSecret) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
boolean ignoreUnknownProperties = false;
ResteasyClient resteasyClient = createResteasyClient(ignoreUnknownProperties);
return KeycloakBuilder.builder()
.serverUrl(getAuthServerContextRoot() + "/auth")
.realm(realmName)
.grantType(OAuth2Constants.CLIENT_CREDENTIALS)
.clientId(clientId)
.clientSecret(clientSecret)
.resteasyClient(resteasyClient).build();
}
public static Keycloak createAdminClient() throws Exception {
return createAdminClient(false, getAuthServerContextRoot());
}
public static Keycloak createAdminClient(boolean ignoreUnknownProperties) throws Exception {
return createAdminClient(ignoreUnknownProperties, getAuthServerContextRoot());
}
private static ResteasyClient createResteasyClient(boolean ignoreUnknownProperties) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {
ResteasyClientBuilder resteasyClientBuilder = new ResteasyClientBuilder();
if ("true".equals(System.getProperty("auth.server.ssl.required"))) {
@ -81,26 +117,11 @@ public class AdminClientUtil {
}
resteasyClientBuilder
.hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.WILDCARD)
.connectionPoolSize(10)
.httpEngine(getCustomClientHttpEngine(resteasyClientBuilder, 1));
return KeycloakBuilder.builder()
.serverUrl(authServerContextRoot + "/auth")
.realm(realmName)
.username(username)
.password(password)
.clientId(clientId)
.clientSecret(clientSecret)
.resteasyClient(resteasyClientBuilder.build()).build();
}
.hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.WILDCARD)
.connectionPoolSize(10)
.httpEngine(getCustomClientHttpEngine(resteasyClientBuilder, 1));
public static Keycloak createAdminClient() throws Exception {
return createAdminClient(false, getAuthServerContextRoot());
}
public static Keycloak createAdminClient(boolean ignoreUnknownProperties) throws Exception {
return createAdminClient(ignoreUnknownProperties, getAuthServerContextRoot());
return resteasyClientBuilder.build();
}
private static SSLContext getSSLContextWithTrustore(File file, String password) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException {

View file

@ -0,0 +1,111 @@
/*
* Copyright 2020 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.admin;
import java.util.List;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
/**
* Test for the various "Advanced" scenarios of java admin-client
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class AdminClientTest extends AbstractKeycloakTest {
private static String userId;
private static String userName;
@Rule
public AssertEvents events = new AssertEvents(this);
@Rule
public ExpectedException expectedException = ExpectedException.none();
@Override
public void beforeAbstractKeycloakTest() throws Exception {
super.beforeAbstractKeycloakTest();
}
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmBuilder realm = RealmBuilder.create().name("test")
.privateKey("MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=")
.publicKey("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB")
.testEventListener();
ClientRepresentation enabledAppWithSkipRefreshToken = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("service-account-cl")
.secret("secret1")
.serviceAccountsEnabled(true)
.build();
realm.client(enabledAppWithSkipRefreshToken);
userId = KeycloakModelUtils.generateId();
userName = ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + enabledAppWithSkipRefreshToken.getClientId();
UserBuilder serviceAccountUser = UserBuilder.create()
.id(userId)
.username(userName)
.serviceAccountId(enabledAppWithSkipRefreshToken.getClientId())
.role(Constants.REALM_MANAGEMENT_CLIENT_ID, AdminRoles.REALM_ADMIN);
realm.user(serviceAccountUser);
UserBuilder defaultUser = UserBuilder.create()
.id(KeycloakModelUtils.generateId())
.username("test-user@localhost");
realm.user(defaultUser);
testRealms.add(realm.build());
}
@Test
public void clientCredentialsAuthSuccess() throws Exception {
try (Keycloak adminClient = AdminClientUtil.createAdminClientWithClientCredentials("test", "service-account-cl", "secret1")) {
// Check possible to load the realm
RealmRepresentation realm = adminClient.realm("test").toRepresentation();
Assert.assertEquals("test", realm.getRealm());
setTimeOffset(1000);
// Check still possible to load the realm after token expired
realm = adminClient.realm("test").toRepresentation();
Assert.assertEquals("test", realm.getRealm());
}
}
}

View file

@ -39,6 +39,7 @@ import org.keycloak.adapters.KeycloakDeploymentBuilder;
import org.keycloak.adapters.authentication.ClientCredentialsProviderUtils;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.AuthorizationResource;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientsResource;
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.authorization.client.AuthzClient;
@ -47,6 +48,7 @@ import org.keycloak.authorization.client.Configuration;
import org.keycloak.authorization.client.resource.ProtectionResource;
import org.keycloak.authorization.client.util.HttpResponseException;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
@ -59,6 +61,7 @@ import org.keycloak.representations.idm.authorization.PermissionRequest;
import org.keycloak.representations.idm.authorization.PermissionResponse;
import org.keycloak.representations.idm.authorization.ResourceRepresentation;
import org.keycloak.representations.idm.authorization.ResourceServerRepresentation;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.util.ClientBuilder;
@ -157,7 +160,27 @@ public class AuthzClientCredentialsTest extends AbstractAuthzTest {
}
@Test
public void testReusingAccessAndRefreshTokens() throws Exception {
public void testReusingAccessAndRefreshTokens_refreshDisabled() throws Exception {
testReusingAccessAndRefreshTokens(0);
}
@Test
public void testReusingAccessAndRefreshTokens_refreshEnabled() throws Exception {
// Use userSessions and refresh tokens
ClientResource client = ApiUtil.findClientByClientId(getAdminClient().realm("authz-test-session"), "resource-server-test");
ClientRepresentation clientRepresentation = ClientBuilder.edit(client.toRepresentation())
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true")
.build();
client.update(clientRepresentation);
testReusingAccessAndRefreshTokens( 1);
// Rollback configuration
clientRepresentation.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "false");
client.update(clientRepresentation);
}
private void testReusingAccessAndRefreshTokens(int expectedUserSessionsCount) throws Exception {
ClientsResource clients = getAdminClient().realm("authz-test-session").clients();
ClientRepresentation clientRepresentation = clients.findByClientId("resource-server-test").get(0);
List<UserSessionRepresentation> userSessions = clients.get(clientRepresentation.getId()).getUserSessions(-1, -1);
@ -169,7 +192,7 @@ public class AuthzClientCredentialsTest extends AbstractAuthzTest {
protection.resource().findByName("Default Resource");
userSessions = clients.get(clientRepresentation.getId()).getUserSessions(null, null);
assertEquals(1, userSessions.size());
assertEquals(expectedUserSessionsCount, userSessions.size());
Thread.sleep(2000);
protection = authzClient.protection();
@ -177,7 +200,7 @@ public class AuthzClientCredentialsTest extends AbstractAuthzTest {
userSessions = clients.get(clientRepresentation.getId()).getUserSessions(null, null);
assertEquals(1, userSessions.size());
assertEquals(expectedUserSessionsCount, userSessions.size());
}
@Test

View file

@ -2284,6 +2284,8 @@ public class EntitlementAPITest extends AbstractAuthzTest {
AuthorizationResponse response = authzClient.authorization().authorize(request);
assertNotNull(response.getToken());
// Refresh token should not be present
assertNull(response.getRefreshToken());
}
private void testRptRequestWithResourceName(String configFile) {

View file

@ -57,6 +57,7 @@ import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.KeyStoreConfig;
@ -148,6 +149,7 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
.id(KeycloakModelUtils.generateId())
.clientId("client1")
.attribute(JWTClientAuthenticator.CERTIFICATE_ATTR, "MIICnTCCAYUCBgFPPLDaTzANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdjbGllbnQxMB4XDTE1MDgxNzE3MjI0N1oXDTI1MDgxNzE3MjQyN1owEjEQMA4GA1UEAwwHY2xpZW50MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIUjjgv+V3s96O+Za9002Lp/trtGuHBeaeVL9dFKMKzO2MPqdRmHB4PqNlDdd28Rwf5Xn6iWdFpyUKOnI/yXDLhdcuFpR0sMNK/C9Lt+hSpPFLuzDqgtPgDotlMxiHIWDOZ7g9/gPYNXbNvjv8nSiyqoguoCQiiafW90bPHsiVLdP7ZIUwCcfi1qQm7FhxRJ1NiW5dvUkuCnnWEf0XR+Wzc5eC9EgB0taLFiPsSEIlWMm5xlahYyXkPdNOqZjiRnrTWm5Y4uk8ZcsD/KbPTf/7t7cQXipVaswgjdYi1kK2/zRwOhg1QwWFX/qmvdd+fLxV0R6VqRDhn7Qep2cxwMxLsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAKE6OA46sf20bz8LZPoiNsqRwBUDkaMGXfnob7s/hJZIIwDEx0IAQ3uKsG7q9wb+aA6s+v7S340zb2k3IxuhFaHaZpAd4CyR5cn1FHylbzoZ7rI/3ASqHDqpljdJaFqPH+m7nZWtyDvtZf+gkZ8OjsndwsSBK1d/jMZPp29qYbl1+XfO7RCp/jDqro/R3saYFaIFiEZPeKn1hUJn6BO48vxH1xspSu9FmlvDOEAOz4AuM58z4zRMP49GcFdCWr1wkonJUHaSptJaQwmBwLFUkCbE5I1ixGMb7mjEud6Y5jhfzJiZMo2U8RfcjNbrN0diZl3jB6LQIwESnhYSghaTjNQ==")
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true")
.authenticatorType(JWTClientAuthenticator.PROVIDER_ID)
.serviceAccountsEnabled(true)
.build();

View file

@ -136,6 +136,7 @@ public class OfflineTokenTest extends AbstractKeycloakTest {
.redirectUris(offlineClientAppUri)
.directAccessGrants()
.serviceAccountsEnabled(true)
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true")
.secret("secret1").build();
realm.client(app);

View file

@ -122,6 +122,7 @@ public class RefreshTokenTest extends AbstractKeycloakTest {
realmRepresentation.getClients().add(org.keycloak.testsuite.util.ClientBuilder.create()
.clientId("service-account-app")
.serviceAccount()
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true")
.secret("secret")
.build());

View file

@ -17,44 +17,62 @@
package org.keycloak.testsuite.oauth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.hamcrest.CoreMatchers;
import org.hamcrest.Matchers;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator;
import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.mappers.SHA256PairwiseSubMapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.TokenSignatureUtil;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.core.Response;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -85,13 +103,23 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
.testEventListener();
ClientRepresentation enabledApp = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("service-account-cl-refresh-on")
.secret("secret1")
.serviceAccountsEnabled(true)
.attribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, "true")
.build();
realm.client(enabledApp);
ClientRepresentation enabledAppWithSkipRefreshToken = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("service-account-cl")
.secret("secret1")
.serviceAccountsEnabled(true)
.build();
realm.client(enabledApp);
realm.client(enabledAppWithSkipRefreshToken);
ClientRepresentation disabledApp = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
@ -120,17 +148,24 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
@Test
public void clientCredentialsAuthSuccess() throws Exception {
oauth.clientId("service-account-cl");
oauth.clientId("service-account-cl-refresh-on");
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
assertEquals(200, response.getStatusCode());
// older clients which use client-credentials grant may create a refresh-token and session, see KEYCLOAK-9551.
List<Map<String, String>> clientSessionStats = getAdminClient().realm(oauth.getRealm()).getClientSessionStats();
assertThat(clientSessionStats, hasSize(1));
Map<String, String> sessionStats = clientSessionStats.get(0);
assertEquals(sessionStats.get("clientId"), oauth.getClientId());
// Refresh token is for backwards compatibility only. It won't be in client credentials by default
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
events.expectClientLogin()
.client("service-account-cl")
.client("service-account-cl-refresh-on")
.user(userId)
.session(accessToken.getSessionState())
.detail(Details.TOKEN_ID, accessToken.getId())
@ -140,7 +175,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals(accessToken.getSessionState(), refreshToken.getSessionState());
System.out.println("Access token other claims: " + accessToken.getOtherClaims());
Assert.assertEquals("service-account-cl", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
Assert.assertEquals("service-account-cl-refresh-on", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_ADDRESS));
Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_HOST));
@ -152,12 +187,14 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals(accessToken.getSessionState(), refreshedAccessToken.getSessionState());
assertEquals(accessToken.getSessionState(), refreshedRefreshToken.getSessionState());
events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl").assertEvent();
events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl-refresh-on").assertEvent();
}
// This is for the backwards compatibility only. By default, there won't be refresh token and hence there won't be availability for the logout
@Test
public void clientCredentialsLogout() throws Exception {
oauth.clientId("service-account-cl");
oauth.clientId("service-account-cl-refresh-on");
events.clear();
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
@ -167,7 +204,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
events.expectClientLogin()
.client("service-account-cl")
.client("service-account-cl-refresh-on")
.user(userId)
.session(accessToken.getSessionState())
.detail(Details.TOKEN_ID, accessToken.getId())
@ -179,7 +216,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
HttpResponse logoutResponse = oauth.doLogout(response.getRefreshToken(), "secret1");
assertEquals(204, logoutResponse.getStatusLine().getStatusCode());
events.expectLogout(accessToken.getSessionState())
.client("service-account-cl")
.client("service-account-cl-refresh-on")
.user(userId)
.removeDetail(Details.REDIRECT_URI)
.assertEvent();
@ -189,7 +226,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals("invalid_grant", response.getError());
events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState())
.client("service-account-cl")
.client("service-account-cl-refresh-on")
.user(userId)
.removeDetail(Details.TOKEN_ID)
.removeDetail(Details.UPDATED_REFRESH_TOKEN_ID)
@ -239,7 +276,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
@Test
public void changeClientIdTest() throws Exception {
ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl").renameTo("updated-client");
ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl-refresh-on").renameTo("updated-client");
oauth.clientId("updated-client");
@ -248,7 +285,6 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals(200, response.getStatusCode());
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
Assert.assertEquals("updated-client", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
// Username updated after client ID changed
@ -257,12 +293,11 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
.user(userId)
.session(accessToken.getSessionState())
.detail(Details.TOKEN_ID, accessToken.getId())
.detail(Details.REFRESH_TOKEN_ID, refreshToken.getId())
.detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "updated-client")
.assertEvent();
ClientManager.realm(adminClient.realm("test")).clientId("updated-client").renameTo("service-account-cl");
ClientManager.realm(adminClient.realm("test")).clientId("updated-client").renameTo("service-account-cl-refresh-on");
}
@ -281,14 +316,12 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
finally {
ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl").setServiceAccountsEnabled(true);
UserRepresentation user = ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl").getServiceAccountUser();
userId = user.getId();
userName = user.getUsername();
}
}
@Test
public void clientCredentialsAuthRequest_ClientES256_RealmPS256() throws Exception {
conductClientCredentialsAuthRequest(Algorithm.HS256, Algorithm.ES256, Algorithm.PS256);
conductClientCredentialsAuthRequestWithRefreshToken(Algorithm.HS256, Algorithm.ES256, Algorithm.PS256);
}
@Test
@ -310,21 +343,109 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
serviceAccount.update(representation);
}
private void conductClientCredentialsAuthRequest(String expectedRefreshAlg, String expectedAccessAlg, String realmTokenAlg) throws Exception {
/**
* See KEYCLOAK-9551
*/
@Test
public void clientCredentialsAuthSuccessWithoutRefreshToken_revokeToken() throws Exception {
String tokenString = clientCredentialsAuthSuccessWithoutRefreshTokenImpl();
AccessToken accessToken = oauth.verifyToken(tokenString);
// Revoke access token
CloseableHttpResponse response1 = oauth.doTokenRevoke(tokenString, "access_token", "secret1");
assertThat(response1, org.keycloak.testsuite.util.Matchers.statusCodeIsHC(Response.Status.OK));
response1.close();
events.expect(EventType.REVOKE_GRANT)
.client("service-account-cl")
.user(AssertEvents.isUUID())
.session(Matchers.isEmptyOrNullString())
.detail(Details.TOKEN_ID, accessToken.getId())
.assertEvent();
// Check that it is not possible to introspect token anymore
Assert.assertFalse(getIntrospectionResponse("service-account-cl", "secret1", tokenString));
// TODO: This would be better to be "INTROSPECT_TOKEN_ERROR"
events.expect(EventType.INTROSPECT_TOKEN)
.client("service-account-cl")
.user(Matchers.isEmptyOrNullString())
.session(Matchers.isEmptyOrNullString())
.assertEvent();
}
@Test
public void clientCredentialsAuthSuccessWithoutRefreshToken_pairWiseSubject() throws Exception {
// Add pairwise protocolMapper through admin REST endpoint
ProtocolMapperRepresentation pairwiseProtMapper = SHA256PairwiseSubMapper.createPairwiseMapper(null, null);
ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl")
.addRedirectUris(oauth.getRedirectUri())
.addProtocolMapper(pairwiseProtMapper);
clientCredentialsAuthSuccessWithoutRefreshTokenImpl();
ClientManager.realm(adminClient.realm("test")).clientId("service-account-cl").removeProtocolMapper(pairwiseProtMapper.getName());
}
// Returns accessToken string
private String clientCredentialsAuthSuccessWithoutRefreshTokenImpl() throws Exception {
oauth.clientId("service-account-cl");
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
assertEquals(200, response.getStatusCode());
String tokenString = response.getAccessToken();
Assert.assertNotNull("Access-Token should be present", tokenString);
AccessToken accessToken = oauth.verifyToken(tokenString);
Assert.assertNull(accessToken.getSessionState());
Assert.assertNull("Refresh-Token should not be present", response.getRefreshToken());
events.expectClientLogin()
.client("service-account-cl")
.user(AssertEvents.isUUID())
.session(AssertEvents.isUUID())
.detail(Details.TOKEN_ID, accessToken.getId())
.detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "service-account-cl")
.assertEvent();
// new clients which use client-credentials grant should NOT create a refresh-token or session, see KEYCLOAK-9551.
List<Map<String, String>> clientSessionStats = getAdminClient().realm(oauth.getRealm()).getClientSessionStats();
assertThat(clientSessionStats, empty());
// Check that token is possible to introspect
Assert.assertTrue(getIntrospectionResponse("service-account-cl", "secret1", tokenString));
events.expect(EventType.INTROSPECT_TOKEN)
.client("service-account-cl")
.user(AssertEvents.isUUID())
.user(Matchers.isEmptyOrNullString())
.session(Matchers.isEmptyOrNullString())
.assertEvent();
return tokenString;
}
private boolean getIntrospectionResponse(String clientId, String clientSecret, String tokenString) throws IOException {
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, clientSecret, tokenString);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(introspectionResponse);
return jsonNode.get("active").asBoolean();
}
private void conductClientCredentialsAuthRequestWithRefreshToken(String expectedRefreshAlg, String expectedAccessAlg, String realmTokenAlg) throws Exception {
try {
/// Realm Setting is used for ID Token Signature Algorithm
TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, realmTokenAlg);
TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "service-account-cl"), expectedAccessAlg);
clientCredentialsAuthSuccess(expectedRefreshAlg, expectedAccessAlg);
TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "service-account-cl-refresh-on"), expectedAccessAlg);
clientCredentialsAuthSuccessWithRefreshToken(expectedRefreshAlg, expectedAccessAlg);
} finally {
TokenSignatureUtil.changeRealmTokenSignatureProvider(adminClient, Algorithm.RS256);
TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "service-account-cl"), Algorithm.RS256);
TokenSignatureUtil.changeClientAccessTokenSignatureProvider(ApiUtil.findClientByClientId(adminClient.realm("test"), "service-account-cl-refresh-on"), Algorithm.RS256);
}
return;
}
private void clientCredentialsAuthSuccess(String expectedRefreshAlg, String expectedAccessAlg) throws Exception {
oauth.clientId("service-account-cl");
// Testing of refresh token is for backwards compatibility. By default, there won't be refresh token for the client credentials grant
private void clientCredentialsAuthSuccessWithRefreshToken(String expectedRefreshAlg, String expectedAccessAlg) throws Exception {
oauth.clientId("service-account-cl-refresh-on");
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest("secret1");
@ -344,7 +465,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertNull(header.getContentType());
events.expectClientLogin()
.client("service-account-cl")
.client("service-account-cl-refresh-on")
.user(userId)
.session(accessToken.getSessionState())
.detail(Details.TOKEN_ID, accessToken.getId())
@ -354,7 +475,7 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals(accessToken.getSessionState(), refreshToken.getSessionState());
System.out.println("Access token other claims: " + accessToken.getOtherClaims());
Assert.assertEquals("service-account-cl", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
Assert.assertEquals("service-account-cl-refresh-on", accessToken.getOtherClaims().get(ServiceAccountConstants.CLIENT_ID));
Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_ADDRESS));
Assert.assertTrue(accessToken.getOtherClaims().containsKey(ServiceAccountConstants.CLIENT_HOST));
@ -366,6 +487,6 @@ public class ServiceAccountTest extends AbstractKeycloakTest {
assertEquals(accessToken.getSessionState(), refreshedAccessToken.getSessionState());
assertEquals(accessToken.getSessionState(), refreshedRefreshToken.getSessionState());
events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl").assertEvent();
events.expectRefresh(refreshToken.getId(), refreshToken.getSessionState()).user(userId).client("service-account-cl-refresh-on").assertEvent();
}
}

View file

@ -17,8 +17,12 @@
package org.keycloak.testsuite.oauth;
import static org.junit.Assert.*;
import static org.keycloak.testsuite.admin.AbstractAdminTest.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
import java.io.IOException;
import java.util.List;
@ -120,9 +124,9 @@ public class TokenRevocationTest extends AbstractKeycloakTest {
isTokenEnabled(tokenResponse, "test-app");
CloseableHttpResponse response = oauth.doTokenRevoke(tokenResponse.getAccessToken(), "access_token", "password");
assertThat(response, Matchers.statusCodeIsHC(Status.BAD_REQUEST));
assertThat(response, Matchers.statusCodeIsHC(Status.OK));
isTokenEnabled(tokenResponse, "test-app");
isAccessTokenDisabled(tokenResponse.getAccessToken(), "test-app");
}
@Test
@ -222,14 +226,18 @@ public class TokenRevocationTest extends AbstractKeycloakTest {
}
private void isTokenDisabled(AccessTokenResponse tokenResponse, String clientId) throws IOException {
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, "password",
tokenResponse.getAccessToken());
TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class);
assertFalse(rep.isActive());
isAccessTokenDisabled(tokenResponse.getAccessToken(), clientId);
oauth.clientId(clientId);
OAuthClient.AccessTokenResponse tokenRefreshResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(),
"password");
assertEquals(Status.BAD_REQUEST.getStatusCode(), tokenRefreshResponse.getStatusCode());
}
private void isAccessTokenDisabled(String accessTokenString, String clientId) throws IOException {
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, "password",
accessTokenString);
TokenMetadataRepresentation rep = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class);
assertFalse(rep.isActive());
}
}

View file

@ -136,7 +136,7 @@ public class ClientManager {
clientResource.getScopeMappings().realmLevel().remove(Collections.singletonList(newRole));
}
public void addRedirectUris(String... redirectUris) {
public ClientManagerBuilder addRedirectUris(String... redirectUris) {
ClientRepresentation app = clientResource.toRepresentation();
if (app.getRedirectUris() == null) {
app.setRedirectUris(new LinkedList<String>());
@ -145,6 +145,7 @@ public class ClientManager {
app.getRedirectUris().add(redirectUri);
}
clientResource.update(app);
return this;
}
public void removeRedirectUris(String... redirectUris) {

View file

@ -403,6 +403,8 @@ oidc-compatibility-modes=OpenID Connect Compatibility Modes
oidc-compatibility-modes.tooltip=Expand this section to configure settings for backwards compatibility with older OpenID Connect / OAuth2 adapters. It is useful especially if your client uses older version of Keycloak / RH-SSO adapter.
exclude-session-state-from-auth-response=Exclude Session State From Authentication Response
exclude-session-state-from-auth-response.tooltip=If this is on, the parameter 'session_state' will not be included in OpenID Connect Authentication Response. It is useful if your client uses older OIDC / OAuth2 adapter, which does not support 'session_state' parameter.
use-refresh-token-for-client-credentials-grant=Use Refresh Tokens For Client Credentials Grant
use-refresh-token-for-client-credentials-grant.tooltip=If this is on, a refresh_token will be created and added to the token response if the client_credentials grant is used. The OAuth 2.0 RFC6749 Section 4.4.3 states that a refresh_token should not be generated when client_credentials grant is used. If this is off then no refresh_token will be generated and the associated user session will be removed.
# client import
import-client=Import Client

View file

@ -1289,6 +1289,13 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
}
}
var useRefreshToken = $scope.client.attributes["client_credentials.use_refresh_token"];
if (useRefreshToken === "true") {
$scope.useRefreshTokenForClientCredentialsGrant = true;
} else {
$scope.useRefreshTokenForClientCredentialsGrant = false;
}
if ($scope.client.attributes["display.on.consent.screen"]) {
if ($scope.client.attributes["display.on.consent.screen"] == "true") {
$scope.displayOnConsentScreen = true;
@ -1634,6 +1641,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientEdit.attributes["tls.client.certificate.bound.access.tokens"] = "false";
}
// KEYCLOAK-9551 Client Credentials Grant generates refresh token
// https://tools.ietf.org/html/rfc6749#section-4.4.3
if ($scope.useRefreshTokenForClientCredentialsGrant === true) {
$scope.clientEdit.attributes["client_credentials.use_refresh_token"] = "true";
} else {
$scope.clientEdit.attributes["client_credentials.use_refresh_token"] = "false";
}
if ($scope.displayOnConsentScreen == true) {
$scope.clientEdit.attributes["display.on.consent.screen"] = "true";
} else {

View file

@ -529,6 +529,13 @@
</div>
<kc-tooltip>{{:: 'exclude-session-state-from-auth-response.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="useRefreshTokenForClientCredentialsGrant">{{:: 'use-refresh-token-for-client-credentials-grant' | translate}}</label>
<div class="col-md-6">
<input ng-model="useRefreshTokenForClientCredentialsGrant" ng-click="switchChange()" name="useRefreshTokenForClientCredentialsGrant" id="useRefreshTokenForClientCredentialsGrant" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
<kc-tooltip>{{:: 'use-refresh-token-for-client-credentials-grant.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<fieldset>