From de208304123d6360969c97f08d025d95ec4afd3d Mon Sep 17 00:00:00 2001 From: Thomas Darimont Date: Wed, 31 Jul 2019 17:50:04 +0200 Subject: [PATCH] KEYCLOAK-9551 KEYCLOAK-16159 Make refresh_token generation for client_credentials optional. Support for revocation of access tokens. Co-authored-by: mposolda --- .../client/util/TokenCallable.java | 2 +- .../admin/client/token/TokenManager.java | 4 + .../cli/commands/AbstractAuthOptionsCmd.java | 3 + .../cli/commands/ConfigCredentialsCmd.java | 6 +- .../admin/cli/config/RealmConfigData.java | 12 ++ .../client/admin/cli/util/AuthUtil.java | 17 +- .../client/admin/cli/util/ConfigUtil.java | 19 +- .../cli/commands/AbstractAuthOptionsCmd.java | 3 + .../cli/commands/ConfigCredentialsCmd.java | 6 +- .../cli/config/RealmConfigData.java | 12 ++ .../registration/cli/util/AuthUtil.java | 18 +- .../registration/cli/util/ConfigUtil.java | 18 +- ...nispanCodeToTokenStoreProviderFactory.java | 23 +-- ...panSingleUseTokenStoreProviderFactory.java | 38 ++-- ...nfinispanTokenRevocationStoreProvider.java | 97 ++++++++++ ...anTokenRevocationStoreProviderFactory.java | 76 ++++++++ ...models.TokenRevocationStoreProviderFactory | 19 ++ .../models/TokenRevocationStoreProvider.java | 50 +++++ .../TokenRevocationStoreProviderFactory.java | 26 +++ .../models/TokenRevocationStoreSpi.java | 52 ++++++ .../services/org.keycloak.provider.Spi | 1 + .../AuthorizationTokenService.java | 42 ++++- .../common/KeycloakIdentity.java | 10 +- .../oidc/OIDCAdvancedConfigWrapper.java | 22 +++ .../protocol/oidc/OIDCConfigAttributes.java | 2 + .../keycloak/protocol/oidc/TokenManager.java | 72 ++++++-- .../oidc/endpoints/TokenEndpoint.java | 26 ++- .../endpoints/TokenRevocationEndpoint.java | 59 ++++-- .../managers/AuthenticationManager.java | 39 ++-- .../account/LinkedAccountsResource.java | 3 + .../resources/account/SessionResource.java | 1 + .../testsuite/util/AdminClientUtil.java | 59 ++++-- .../testsuite/admin/AdminClientTest.java | 111 ++++++++++++ .../authz/AuthzClientCredentialsTest.java | 29 ++- .../testsuite/authz/EntitlementAPITest.java | 2 + .../oauth/ClientAuthSignedJWTTest.java | 2 + .../testsuite/oauth/OfflineTokenTest.java | 1 + .../testsuite/oauth/RefreshTokenTest.java | 1 + .../testsuite/oauth/ServiceAccountTest.java | 171 +++++++++++++++--- .../testsuite/oauth/TokenRevocationTest.java | 24 ++- .../testsuite/util/ClientManager.java | 3 +- .../messages/admin-messages_en.properties | 2 + .../admin/resources/js/controllers/clients.js | 15 ++ .../resources/partials/client-detail.html | 7 + 44 files changed, 1019 insertions(+), 186 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProviderFactory.java create mode 100644 model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.TokenRevocationStoreProviderFactory create mode 100644 server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreSpi.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java diff --git a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java index ffaa5926e24..997182df5df 100644 --- a/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java +++ b/authz/client/src/main/java/org/keycloak/authorization/client/util/TokenCallable.java @@ -52,7 +52,7 @@ public class TokenCallable implements Callable { @Override public String call() { - if (clientToken == null) { + if (clientToken == null || clientToken.getRefreshToken() == null) { if (userName == null || password == null) { clientToken = obtainAccessToken(); } else { diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java index bd231c89db7..8b0b46ab265 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/token/TokenManager.java @@ -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()); diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java index 6b48a2643fa..7afce2e2c56 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java @@ -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) { diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java index 95fe628def7..ecc7fd81b3b 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java @@ -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; } diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java index 2a8b163647c..ac6fe645270 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java @@ -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; diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java index c23757100dd..dcebc8f09ed 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java @@ -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(); diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java index af4d60b8a80..ff99eb328a5 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java @@ -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() { diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java index a00f88378c1..24b8ed846f9 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java @@ -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) { diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java index 31f7bd2aabc..3ce4a17c34e 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java @@ -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; } diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java index 58b34fa996b..6eade234340 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java @@ -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; diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java index 8752e60d134..821271f96d4 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java @@ -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(); diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java index 96996a2f962..8d1050f2f8d 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java @@ -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() { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProviderFactory.java index 3fa49f9ab3e..1e6504e089c 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanCodeToTokenStoreProviderFactory.java @@ -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 Marek Posolda @@ -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); } } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProviderFactory.java index f3099158fe5..e8a9fb8749b 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseTokenStoreProviderFactory.java @@ -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) { diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java new file mode 100644 index 00000000000..f48e48ef846 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProvider.java @@ -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 Marek Posolda + */ +public class InfinispanTokenRevocationStoreProvider implements TokenRevocationStoreProvider { + + public static final Logger logger = Logger.getLogger(InfinispanTokenRevocationStoreProvider.class); + + private final Supplier> 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> tokenCache) { + this.session = session; + this.tokenCache = tokenCache; + } + + + @Override + public void putRevokedToken(String tokenId, long lifespanSeconds) { + Map data = Collections.singletonMap(REVOKED_KEY, "true"); + ActionTokenValueEntity tokenValue = new ActionTokenValueEntity(data); + + try { + BasicCache 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 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() { + + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProviderFactory.java new file mode 100644 index 00000000000..48c847b322f --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanTokenRevocationStoreProviderFactory.java @@ -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 Marek Posolda + */ +public class InfinispanTokenRevocationStoreProviderFactory implements TokenRevocationStoreProviderFactory { + + // Reuse "actionTokens" infinispan cache for now + private volatile Supplier> 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"; + } + +} diff --git a/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.TokenRevocationStoreProviderFactory b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.TokenRevocationStoreProviderFactory new file mode 100644 index 00000000000..140d4ffd517 --- /dev/null +++ b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.TokenRevocationStoreProviderFactory @@ -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 \ No newline at end of file diff --git a/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProvider.java b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProvider.java new file mode 100644 index 00000000000..73765b24569 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProvider.java @@ -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 Marek Posolda + */ +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); +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProviderFactory.java new file mode 100644 index 00000000000..4f3d78fa677 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreProviderFactory.java @@ -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 Marek Posolda + */ +public interface TokenRevocationStoreProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreSpi.java b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreSpi.java new file mode 100644 index 00000000000..1a458ab1793 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/TokenRevocationStoreSpi.java @@ -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 Marek Posolda + */ +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 getProviderClass() { + return TokenRevocationStoreProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return TokenRevocationStoreProviderFactory.class; + } + +} diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 6dd7ec5b7dd..eb4a089f209 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -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 diff --git a/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java b/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java index b091be29aba..b41e17c0c9e 100644 --- a/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java +++ b/services/src/main/java/org/keycloak/authorization/authorization/AuthorizationTokenService.java @@ -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; + } } } diff --git a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java index 4f0c2cf793c..2ea51444138 100644 --- a/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java +++ b/services/src/main/java/org/keycloak/authorization/common/KeycloakIdentity.java @@ -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()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java index f74f626d2bb..b2323a88894 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -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) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index 50b41f92786..458b35a1b0c 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -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() { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 62a273a08e4..cd2f061e715 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -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 { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 6f50dc8351f..80d9b2f00e0 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -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); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java index 8f05c342b09..b0c7754103b 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenRevocationEndpoint.java @@ -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); + } } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index cec3eac4e1c..5629b56cab2 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -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 } diff --git a/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java b/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java index c3d48a337d5..92d1c704de9 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/LinkedAccountsResource.java @@ -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(); diff --git a/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java index c582d2fc157..46990a9d5b8 100755 --- a/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java +++ b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java @@ -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()); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java index c48a6dfb705..4eb2b2e91f2 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/AdminClientUtil.java @@ -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 { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java new file mode 100644 index 00000000000..5889bd5bb49 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/AdminClientTest.java @@ -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 Marek Posolda + */ +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 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()); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java index 30481c954d4..cd93a376701 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/AuthzClientCredentialsTest.java @@ -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 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 diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java index ce8665c4658..a6309d51f61 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/authz/EntitlementAPITest.java @@ -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) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java index f44c89bfb60..9d0a4cc1379 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ClientAuthSignedJWTTest.java @@ -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(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java index 08f56949dd0..71de550e00b 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OfflineTokenTest.java @@ -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); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 8c226d56a5b..4a77c5180e0 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -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()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java index 322dcd8ed52..013c588bc93 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/ServiceAccountTest.java @@ -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 Marek Posolda @@ -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> clientSessionStats = getAdminClient().realm(oauth.getRealm()).getClientSessionStats(); + assertThat(clientSessionStats, hasSize(1)); + Map 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> 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(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java index 180ee0bfd71..6c5012249dc 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenRevocationTest.java @@ -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()); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java index 4a3520fc73a..0848a227b08 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientManager.java @@ -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()); @@ -145,6 +145,7 @@ public class ClientManager { app.getRedirectUris().add(redirectUri); } clientResource.update(app); + return this; } public void removeRedirectUris(String... redirectUris) { diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 44fd4bf31c1..18e913f724d 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -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 diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index 989b839a778..731eb510a04 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -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 { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index b2ca4f80eaf..0a8e99680a8 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -529,6 +529,13 @@ {{:: 'exclude-session-state-from-auth-response.tooltip' | translate}} +
+ +
+ +
+ {{:: 'use-refresh-token-for-client-credentials-grant.tooltip' | translate}} +