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:
parent
1281f28bb8
commit
de20830412
44 changed files with 1019 additions and 186 deletions
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|
@ -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);
|
||||
}
|
|
@ -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> {
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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());
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue