From 65c48a4183505718c433b332517711396123e378 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Thu, 29 Apr 2021 22:56:39 +0900 Subject: [PATCH] KEYCLOAK-12137 OpenID Connect Client Initiated Backchannel Authentication (CIBA) (#7679) * KEYCLOAK-12137 OpenID Connect Client Initiated Backchannel Authentication (CIBA) Co-authored-by: Andrii Murashkin Co-authored-by: Christophe Lannoy Co-authored-by: Pedro Igor Co-authored-by: mposolda --- .../java/org/keycloak/common/Profile.java | 3 +- .../java/org/keycloak/common/ProfileTest.java | 8 +- .../java/org/keycloak/OAuth2Constants.java | 4 + .../OIDCConfigurationRepresentation.java | 22 + .../OAuth2DeviceAuthorizationResponse.java | 10 +- .../idm/RealmRepresentation.java | 6 + .../oidc/OIDCClientRepresentation.java | 10 + .../java/org/keycloak/util/TokenUtil.java | 72 +- .../models/cache/infinispan/RealmAdapter.java | 6 + .../infinispan/entities/CachedRealm.java | 7 + .../org/keycloak/models/jpa/RealmAdapter.java | 5 + .../models/map/realm/AbstractRealmEntity.java | 7 +- .../models/map/realm/MapRealmAdapter.java | 5 + .../java/org/keycloak/events/EventType.java | 3 + .../models/utils/ModelToRepresentation.java | 9 + .../models/utils/RepresentationToModel.java | 16 + .../java/org/keycloak/models/CibaConfig.java | 164 ++ .../keycloak/models/OAuth2DeviceConfig.java | 16 +- .../java/org/keycloak/models/RealmModel.java | 4 + .../java/org/keycloak/utils/StringUtil.java | 29 + .../oidc/OIDCAdvancedConfigWrapper.java | 1 + .../protocol/oidc/OIDCLoginProtocol.java | 1 + .../protocol/oidc/OIDCWellKnownProvider.java | 10 +- .../oidc/endpoints/TokenEndpoint.java | 17 +- .../oidc/grants/ciba/CibaGrantType.java | 271 ++++ .../AuthenticationChannelProvider.java | 36 + .../AuthenticationChannelProviderFactory.java | 33 + .../channel/AuthenticationChannelRequest.java | 83 + .../AuthenticationChannelResponse.java | 52 + .../channel/AuthenticationChannelSpi.java | 48 + .../channel/CIBAAuthenticationRequest.java | 181 +++ .../HttpAuthenticationChannelProvider.java | 131 ++ ...pAuthenticationChannelProviderFactory.java | 55 + .../ciba/endpoints/AbstractCibaEndpoint.java | 85 + ...channelAuthenticationCallbackEndpoint.java | 160 ++ .../BackchannelAuthenticationEndpoint.java | 237 +++ .../ciba/endpoints/CibaRootEndpoint.java | 102 ++ .../ciba/resolvers/CIBALoginUserResolver.java | 78 + .../CIBALoginUserResolverFactory.java | 33 + .../resolvers/CIBALoginUserResolverSpi.java | 48 + .../DefaultCIBALoginUserResolver.java | 53 + .../DefaultCIBALoginUserResolverFactory.java | 51 + .../oidc/grants/device/DeviceGrantType.java | 11 +- .../device/endpoints/DeviceEndpoint.java | 2 +- .../DefaultKeycloakSessionFactory.java | 5 + .../oidc/DescriptionConverter.java | 40 + ...k.protocol.oidc.ext.OIDCExtProviderFactory | 3 +- ...annel.AuthenticationChannelProviderFactory | 1 + ...iba.resolvers.CIBALoginUserResolverFactory | 1 + .../services/org.keycloak.provider.Spi | 4 +- ...pAuthenticationChannelProviderFactory.java | 56 + .../rest/TestApplicationResourceProvider.java | 10 +- ...estApplicationResourceProviderFactory.java | 6 +- .../TestAuthenticationChannelRequest.java | 54 + ...stingOIDCEndpointsApplicationResource.java | 69 +- ...annel.AuthenticationChannelProviderFactory | 20 + .../arquillian/AuthServerTestEnricher.java | 7 + .../TestOIDCEndpointsApplicationResource.java | 19 + .../keycloak/testsuite/util/OAuthClient.java | 164 +- .../org/keycloak/testsuite/AssertEvents.java | 10 + .../testsuite/admin/PermissionsTest.java | 2 +- .../testsuite/admin/realm/RealmTest.java | 7 + .../keycloak/testsuite/client/CIBATest.java | 1393 +++++++++++++++++ .../client/OIDCClientRegistrationTest.java | 27 +- .../oidc/OIDCWellKnownProviderTest.java | 6 + .../base/src/test/resources/log4j.properties | 1 + .../resources/META-INF/keycloak-server.json | 1 + .../messages/admin-messages_en.properties | 11 + .../theme/base/admin/resources/js/app.js | 12 + .../admin/resources/js/controllers/clients.js | 15 + .../admin/resources/js/controllers/realm.js | 6 +- .../admin/resources/partials/ciba-policy.html | 62 + .../resources/partials/client-detail.html | 11 + .../templates/kc-tabs-authentication.html | 1 + 74 files changed, 4140 insertions(+), 69 deletions(-) create mode 100644 server-spi/src/main/java/org/keycloak/models/CibaConfig.java create mode 100644 server-spi/src/main/java/org/keycloak/utils/StringUtil.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelRequest.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelResponse.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelSpi.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/CIBAAuthenticationRequest.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/AbstractCibaEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/CibaRootEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolver.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverSpi.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolver.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolverFactory.java create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory create mode 100644 services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverFactory create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/TestHttpAuthenticationChannelProviderFactory.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/representation/TestAuthenticationChannelRequest.java create mode 100644 testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java create mode 100644 themes/src/main/resources/theme/base/admin/resources/partials/ciba-policy.html diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 0c2b623bdc..0f0d09e1af 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -54,7 +54,8 @@ public class Profile { TOKEN_EXCHANGE(Type.PREVIEW), UPLOAD_SCRIPTS(DEPRECATED), WEB_AUTHN(Type.DEFAULT, Type.PREVIEW), - CLIENT_POLICIES(Type.PREVIEW); + CLIENT_POLICIES(Type.PREVIEW), + CIBA(Type.PREVIEW); private Type typeProject; private Type typeProduct; diff --git a/common/src/test/java/org/keycloak/common/ProfileTest.java b/common/src/test/java/org/keycloak/common/ProfileTest.java index 5a4bf0d6d6..6ded20aecf 100644 --- a/common/src/test/java/org/keycloak/common/ProfileTest.java +++ b/common/src/test/java/org/keycloak/common/ProfileTest.java @@ -21,8 +21,8 @@ public class ProfileTest { @Test public void checkDefaultsKeycloak() { Assert.assertEquals("community", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CLIENT_POLICIES); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CLIENT_POLICIES); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); @@ -37,8 +37,8 @@ public class ProfileTest { Profile.init(); Assert.assertEquals("product", Profile.getName()); - assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES); + assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java index 37e1c241d2..1a8aaf6a1d 100755 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -128,7 +128,11 @@ public interface OAuth2Constants { String DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; String DEVICE_CODE = "device_code"; + String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; + String DISPLAY_CONSOLE = "console"; + String INTERVAL = "interval"; + String USER_CODE = "user_code"; } diff --git a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java index 1e8f4b9755..5a3e0040f4 100755 --- a/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java +++ b/core/src/main/java/org/keycloak/protocol/oidc/representations/OIDCConfigurationRepresentation.java @@ -145,6 +145,12 @@ public class OIDCConfigurationRepresentation { @JsonProperty("device_authorization_endpoint") private String deviceAuthorizationEndpoint; + @JsonProperty("backchannel_token_delivery_modes_supported") + private List backchannelTokenDeliveryModesSupported; + + @JsonProperty("backchannel_authentication_endpoint") + private String backchannelAuthenticationEndpoint; + protected Map otherClaims = new HashMap(); public String getIssuer() { @@ -439,6 +445,22 @@ public class OIDCConfigurationRepresentation { this.backchannelLogoutSupported = backchannelLogoutSupported; } + public List getBackchannelTokenDeliveryModesSupported() { + return backchannelTokenDeliveryModesSupported; + } + + public void setBackchannelTokenDeliveryModesSupported(List backchannelTokenDeliveryModesSupported) { + this.backchannelTokenDeliveryModesSupported = backchannelTokenDeliveryModesSupported; + } + + public String getBackchannelAuthenticationEndpoint() { + return backchannelAuthenticationEndpoint; + } + + public void setBackchannelAuthenticationEndpoint(String backchannelAuthenticationEndpoint) { + this.backchannelAuthenticationEndpoint = backchannelAuthenticationEndpoint; + } + @JsonAnyGetter public Map getOtherClaims() { return otherClaims; diff --git a/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java b/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java index d6cc9ac060..7c33b0fcc2 100755 --- a/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java +++ b/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java @@ -17,7 +17,11 @@ package org.keycloak.representations; +import static org.keycloak.OAuth2Constants.EXPIRES_IN; +import static org.keycloak.OAuth2Constants.INTERVAL; + import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.OAuth2Constants; import org.keycloak.common.Version; /** @@ -36,7 +40,7 @@ public class OAuth2DeviceAuthorizationResponse { /** * REQUIRED */ - @JsonProperty("user_code") + @JsonProperty(OAuth2Constants.USER_CODE) protected String userCode; /** @@ -54,13 +58,13 @@ public class OAuth2DeviceAuthorizationResponse { /** * REQUIRED */ - @JsonProperty("expires_in") + @JsonProperty(EXPIRES_IN) protected long expiresIn; /** * OPTIONAL */ - @JsonProperty("interval") + @JsonProperty(INTERVAL) protected long interval; public String getDeviceCode() { diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index ed2ca93b5c..5cd111fa07 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import org.keycloak.common.util.MultivaluedHashMap; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; @@ -1326,4 +1327,9 @@ public class RealmRepresentation { public Boolean isUserManagedAccessAllowed() { return userManagedAccessAllowed; } + + @JsonIgnore + public Map getAttributesOrEmpty() { + return (Map) (attributes == null ? Collections.emptyMap() : attributes); + } } diff --git a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java index 395453b28a..4435362e9f 100644 --- a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java @@ -125,6 +125,9 @@ public class OIDCClientRepresentation { private Boolean backchannel_logout_revoke_offline_tokens; + // OIDC CIBA + private String backchannel_token_delivery_mode; + public List getRedirectUris() { return redirect_uris; } @@ -487,4 +490,11 @@ public class OIDCClientRepresentation { this.tls_client_auth_subject_dn = tls_client_auth_subject_dn; } + public String getBackchannelTokenDeliveryMode() { + return backchannel_token_delivery_mode; + } + + public void setBackchannelTokenDeliveryMode(String backchannel_token_delivery_mode) { + this.backchannel_token_delivery_mode = backchannel_token_delivery_mode; + } } diff --git a/core/src/main/java/org/keycloak/util/TokenUtil.java b/core/src/main/java/org/keycloak/util/TokenUtil.java index 23506f81d7..80fc4053a5 100644 --- a/core/src/main/java/org/keycloak/util/TokenUtil.java +++ b/core/src/main/java/org/keycloak/util/TokenUtil.java @@ -135,31 +135,9 @@ public class TokenUtil { public static String jweDirectEncode(Key aesKey, Key hmacKey, JsonWebToken jwt) throws JWEException { - int keyLength = aesKey.getEncoded().length; - String encAlgorithm; - switch (keyLength) { - case 16: encAlgorithm = JWEConstants.A128CBC_HS256; - break; - case 24: encAlgorithm = JWEConstants.A192CBC_HS384; - break; - case 32: encAlgorithm = JWEConstants.A256CBC_HS512; - break; - default: throw new IllegalArgumentException("Bad size for Encryption key: " + aesKey + ". Valid sizes are 16, 24, 32."); - } - try { byte[] contentBytes = JsonSerialization.writeValueAsBytes(jwt); - - JWEHeader jweHeader = new JWEHeader(JWEConstants.DIR, encAlgorithm, null); - JWE jwe = new JWE() - .header(jweHeader) - .content(contentBytes); - - jwe.getKeyStorage() - .setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION) - .setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE); - - return jwe.encodeJwe(); + return jweDirectEncode(aesKey, hmacKey, contentBytes); } catch (IOException ioe) { throw new JWEException(ioe); } @@ -167,15 +145,9 @@ public class TokenUtil { public static T jweDirectVerifyAndDecode(Key aesKey, Key hmacKey, String jweStr, Class expectedClass) throws JWEException { - JWE jwe = new JWE(); - jwe.getKeyStorage() - .setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION) - .setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE); - - jwe.verifyAndDecodeJwe(jweStr); - + byte[] contentBytes = jweDirectVerifyAndDecode(aesKey, hmacKey, jweStr); try { - return JsonSerialization.readValue(jwe.getContent(), expectedClass); + return JsonSerialization.readValue(contentBytes, expectedClass); } catch (IOException ioe) { throw new JWEException(ioe); } @@ -211,4 +183,42 @@ public class TokenUtil { jwe.verifyAndDecodeJwe(encodedContent, algorithmProvider, encryptionProvider); return jwe.getContent(); } + + public static String jweDirectEncode(Key aesKey, Key hmacKey, byte[] contentBytes) throws JWEException { + int keyLength = aesKey.getEncoded().length; + String encAlgorithm; + switch (keyLength) { + case 16: encAlgorithm = JWEConstants.A128CBC_HS256; + break; + case 24: encAlgorithm = JWEConstants.A192CBC_HS384; + break; + case 32: encAlgorithm = JWEConstants.A256CBC_HS512; + break; + default: throw new IllegalArgumentException("Bad size for Encryption key: " + aesKey + ". Valid sizes are 16, 24, 32."); + } + + JWEHeader jweHeader = new JWEHeader(JWEConstants.DIR, encAlgorithm, null); + JWE jwe = new JWE() + .header(jweHeader) + .content(contentBytes); + + jwe.getKeyStorage() + .setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION) + .setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE); + + return jwe.encodeJwe(); + + } + + public static byte[] jweDirectVerifyAndDecode(Key aesKey, Key hmacKey, String jweStr) throws JWEException { + JWE jwe = new JWE(); + jwe.getKeyStorage() + .setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION) + .setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE); + + jwe.verifyAndDecodeJwe(jweStr); + + return jwe.getContent(); + + } } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index fb163a6093..b5f53821b4 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -653,6 +653,12 @@ public class RealmAdapter implements CachedRealmModel { return cached.getOAuth2DeviceConfig(modelSupplier); } + @Override + public CibaConfig getCibaPolicy() { + if (isUpdated()) return updated.getCibaPolicy(); + return cached.getCibaConfig(modelSupplier); + } + @Override public List getRequiredCredentials() { if (isUpdated()) return updated.getRequiredCredentials(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index 6641cc6c1d..5dc5f0fb7b 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -23,6 +23,7 @@ import org.keycloak.component.ComponentModel; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.GroupModel; @@ -101,6 +102,7 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int accessCodeLifespanUserAction; protected int accessCodeLifespanLogin; protected LazyLoader deviceConfig; + protected LazyLoader cibaConfig; protected int actionTokenGeneratedByAdminLifespan; protected int actionTokenGeneratedByUserLifespan; protected int notBefore; @@ -217,6 +219,7 @@ public class CachedRealm extends AbstractExtendableRevisioned { accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow(); accessCodeLifespan = model.getAccessCodeLifespan(); deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null); + cibaConfig = new DefaultLazyLoader<>(CibaConfig::new, null); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan(); @@ -497,6 +500,10 @@ public class CachedRealm extends AbstractExtendableRevisioned { return deviceConfig.get(modelSupplier); } + public CibaConfig getCibaConfig(Supplier modelSupplier) { + return cibaConfig.get(modelSupplier); + } + public int getActionTokenGeneratedByAdminLifespan() { return actionTokenGeneratedByAdminLifespan; } diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index fbfa6b5ace..1425441056 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -557,6 +557,11 @@ public class RealmAdapter implements RealmModel, JpaModel { return new OAuth2DeviceConfig(this); } + @Override + public CibaConfig getCibaPolicy() { + return new CibaConfig(this); + } + @Override public Map getUserActionTokenLifespans() { diff --git a/model/map/src/main/java/org/keycloak/models/map/realm/AbstractRealmEntity.java b/model/map/src/main/java/org/keycloak/models/map/realm/AbstractRealmEntity.java index 391a532e83..26614eaff9 100644 --- a/model/map/src/main/java/org/keycloak/models/map/realm/AbstractRealmEntity.java +++ b/model/map/src/main/java/org/keycloak/models/map/realm/AbstractRealmEntity.java @@ -97,9 +97,10 @@ public abstract class AbstractRealmEntity implements AbstractEntity { private String resetCredentialsFlow; private String clientAuthenticationFlow; private String dockerAuthenticationFlow; - private MapOTPPolicyEntity otpPolicy = MapOTPPolicyEntity.fromModel(OTPPolicy.DEFAULT_POLICY);; - private MapWebAuthnPolicyEntity webAuthnPolicy = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();; - private MapWebAuthnPolicyEntity webAuthnPolicyPasswordless = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();; + private MapOTPPolicyEntity otpPolicy = MapOTPPolicyEntity.fromModel(OTPPolicy.DEFAULT_POLICY); + private MapWebAuthnPolicyEntity webAuthnPolicy = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy(); + private MapWebAuthnPolicyEntity webAuthnPolicyPasswordless = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy(); + private Set eventsListeners = new HashSet<>(); private Set enabledEventTypes = new HashSet<>(); diff --git a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java index 00889efce5..2cfa0dc07a 100644 --- a/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java +++ b/model/map/src/main/java/org/keycloak/models/map/realm/MapRealmAdapter.java @@ -31,6 +31,7 @@ import org.keycloak.component.ComponentValidationException; import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientInitialAccessModel; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -1552,4 +1553,8 @@ public class MapRealmAdapter extends AbstractRealmModel implemen public String toString() { return String.format("%s@%08x", getId(), hashCode()); } + + public CibaConfig getCibaPolicy() { + return new CibaConfig(this); + } } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 302dcc1667..1148fdfa89 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -138,6 +138,9 @@ public enum EventType { OAUTH2_DEVICE_CODE_TO_TOKEN(true), OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR(true), + AUTHREQID_TO_TOKEN(true), + AUTHREQID_TO_TOKEN_ERROR(true), + PERMISSION_TOKEN(true), PERMISSION_TOKEN_ERROR(false), diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 351f6152fd..2adfe89baf 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -44,6 +44,7 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -399,6 +400,14 @@ public class ModelToRepresentation { rep.setWebAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(webAuthnPolicy.isAvoidSameAuthenticatorRegister()); rep.setWebAuthnPolicyPasswordlessAcceptableAaguids(webAuthnPolicy.getAcceptableAaguids()); + CibaConfig cibaPolicy = realm.getCibaPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, cibaPolicy.getBackchannelTokenDeliveryMode()); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(cibaPolicy.getExpiresIn())); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(cibaPolicy.getPoolingInterval())); + attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, cibaPolicy.getAuthRequestedUserHint()); + rep.setAttributes(attrMap); + if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().getAlias()); if (realm.getRegistrationFlow() != null) rep.setRegistrationFlow(realm.getRegistrationFlow().getAlias()); if (realm.getDirectGrantFlow() != null) rep.setDirectGrantFlow(realm.getDirectGrantFlow().getAlias()); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 4aa316ec3b..dc25a59c0a 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -65,6 +65,7 @@ import org.keycloak.models.AuthenticationExecutionModel; import org.keycloak.models.AuthenticationFlowModel; import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.BrowserSecurityHeaders; +import org.keycloak.models.CibaConfig; import org.keycloak.models.ClaimMask; import org.keycloak.models.ClientModel; import org.keycloak.models.ClientScopeModel; @@ -293,6 +294,8 @@ public class RepresentationToModel { webAuthnPolicy = getWebAuthnPolicyPasswordless(rep); newRealm.setWebAuthnPolicyPasswordless(webAuthnPolicy); + updateCibaSettings(rep, newRealm); + Map mappedFlows = importAuthenticationFlows(newRealm, rep); if (rep.getRequiredActions() != null) { for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) { @@ -1177,6 +1180,8 @@ public class RepresentationToModel { webAuthnPolicy = getWebAuthnPolicyPasswordless(rep); realm.setWebAuthnPolicyPasswordless(webAuthnPolicy); + updateCibaSettings(rep, realm); + if (rep.getSmtpServer() != null) { Map config = new HashMap(rep.getSmtpServer()); if (rep.getSmtpServer().containsKey("password") && ComponentRepresentation.SECRET_VALUE.equals(rep.getSmtpServer().get("password"))) { @@ -1217,6 +1222,17 @@ public class RepresentationToModel { if (rep.getDockerAuthenticationFlow() != null) { realm.setDockerAuthenticationFlow(realm.getFlowByAlias(rep.getDockerAuthenticationFlow())); } + + } + + private static void updateCibaSettings(RealmRepresentation rep, RealmModel realm) { + Map newAttributes = rep.getAttributesOrEmpty(); + CibaConfig cibaPolicy = realm.getCibaPolicy(); + + cibaPolicy.setBackchannelTokenDeliveryMode(newAttributes.get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE)); + cibaPolicy.setExpiresIn(newAttributes.get(CibaConfig.CIBA_EXPIRES_IN)); + cibaPolicy.setPoolingInterval(newAttributes.get(CibaConfig.CIBA_INTERVAL)); + cibaPolicy.setAuthRequestedUserHint(newAttributes.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT)); } // Basic realm stuff diff --git a/server-spi/src/main/java/org/keycloak/models/CibaConfig.java b/server-spi/src/main/java/org/keycloak/models/CibaConfig.java new file mode 100644 index 0000000000..406ba70957 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/CibaConfig.java @@ -0,0 +1,164 @@ +/* + * 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.io.Serializable; +import java.util.function.Supplier; + +import org.keycloak.utils.StringUtil; + +public class CibaConfig implements Serializable { + + // realm attribute names + public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE = "cibaBackchannelTokenDeliveryMode"; + public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT = "ciba.backchannel.token.delivery.mode"; + public static final String CIBA_EXPIRES_IN = "cibaExpiresIn"; + public static final String CIBA_INTERVAL = "cibaInterval"; + public static final String CIBA_AUTH_REQUESTED_USER_HINT = "cibaAuthRequestedUserHint"; + + // default value + public static final String DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE = "poll"; + public static final int DEFAULT_CIBA_POLICY_EXPIRES_IN = 120; + public static final int DEFAULT_CIBA_POLICY_INTERVAL = 5; + public static final String DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT = "login_hint"; + + private String backchannelTokenDeliveryMode = DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE; + private int expiresIn = DEFAULT_CIBA_POLICY_EXPIRES_IN; + private int poolingInterval = DEFAULT_CIBA_POLICY_INTERVAL; + private String authRequestedUserHint = DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT; + + // client attribute names + public static final String OIDC_CIBA_GRANT_ENABLED = "oidc.ciba.grant.enabled"; + + private transient Supplier realm; + + // Make sure setters are not called when calling this from constructor to avoid DB updates + private transient Supplier realmForWrite; + + public CibaConfig(RealmModel realm) { + this.realm = () -> realm; + + setBackchannelTokenDeliveryMode(realm.getAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE)); + + String expiresIn = realm.getAttribute(CIBA_EXPIRES_IN); + + if (StringUtil.isNotBlank(expiresIn)) { + setExpiresIn(Integer.parseInt(expiresIn)); + } + + String interval = realm.getAttribute(CIBA_INTERVAL); + + if (StringUtil.isNotBlank(interval)) { + setPoolingInterval(Integer.parseInt(interval)); + } + + setAuthRequestedUserHint(realm.getAttribute(CIBA_AUTH_REQUESTED_USER_HINT)); + + this.realmForWrite = () -> realm; + } + + public String getBackchannelTokenDeliveryMode(ClientModel client) { + String mode = client.getAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT); + if (StringUtil.isBlank(mode)) { + mode = getBackchannelTokenDeliveryMode(); + } + return mode; + } + + public String getBackchannelTokenDeliveryMode() { + return backchannelTokenDeliveryMode; + } + + public void setBackchannelTokenDeliveryMode(String mode) { + if (StringUtil.isBlank(mode)) { + mode = DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE; + } + this.backchannelTokenDeliveryMode = mode; + persistRealmAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, mode); + } + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(String expiresIn) { + if (expiresIn == null) { + setExpiresIn((Integer) null); + } else { + setExpiresIn(Integer.parseInt(expiresIn)); + } + } + + public void setExpiresIn(Integer expiresIn) { + if (expiresIn == null) { + expiresIn = DEFAULT_CIBA_POLICY_EXPIRES_IN; + } + this.expiresIn = expiresIn; + persistRealmAttribute(CIBA_EXPIRES_IN, expiresIn); + } + + public int getPoolingInterval() { + return poolingInterval; + } + + public void setPoolingInterval(String poolingInterval) { + if (poolingInterval == null) { + setPoolingInterval((Integer) null); + } else { + setPoolingInterval(Integer.parseInt(poolingInterval)); + } + } + + public void setPoolingInterval(Integer interval) { + if (interval == null) { + interval = DEFAULT_CIBA_POLICY_INTERVAL; + } + this.poolingInterval = interval; + persistRealmAttribute(CIBA_INTERVAL, interval); + } + + public String getAuthRequestedUserHint() { + return authRequestedUserHint; + } + + public void setAuthRequestedUserHint(String hint) { + if (hint == null) { + hint = DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT; + } + this.authRequestedUserHint = hint; + persistRealmAttribute(CIBA_AUTH_REQUESTED_USER_HINT, hint); + } + + public boolean isOIDCCIBAGrantEnabled(ClientModel client) { + String enabled = client.getAttribute(OIDC_CIBA_GRANT_ENABLED); + return Boolean.parseBoolean(enabled); + } + + private void persistRealmAttribute(String name, String value) { + RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get(); + if (realm != null) { + realm.setAttribute(name, value); + } + } + + private void persistRealmAttribute(String name, Integer value) { + RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get(); + if (realm != null) { + realm.setAttribute(name, value); + } + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceConfig.java b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceConfig.java index c33350ab12..1b51c08928 100644 --- a/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceConfig.java +++ b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceConfig.java @@ -43,6 +43,9 @@ public final class OAuth2DeviceConfig implements Serializable { private transient Supplier realm; + // Make sure setters are not called when calling this from constructor to avoid DB updates + private transient Supplier realmForWrite; + private int lifespan = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN; private int poolingInterval = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN; @@ -60,6 +63,8 @@ public final class OAuth2DeviceConfig implements Serializable { if (pooling != null && !pooling.trim().isEmpty()) { setOAuth2DevicePollingInterval(Integer.parseInt(pooling)); } + + this.realmForWrite = () -> realm; } public int getLifespan() { @@ -71,7 +76,7 @@ public final class OAuth2DeviceConfig implements Serializable { seconds = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN; } this.lifespan = seconds; - realm.get().setAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan); + persistRealmAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan); } public int getPoolingInterval() { @@ -86,7 +91,7 @@ public final class OAuth2DeviceConfig implements Serializable { RealmModel model = getRealm(); - model.setAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval); + persistRealmAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval); } public int getLifespan(ClientModel client) { @@ -123,4 +128,11 @@ public final class OAuth2DeviceConfig implements Serializable { return model; } + + private void persistRealmAttribute(String name, Integer value) { + RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get(); + if (realm != null) { + realm.setAttribute(name, value); + } + } } \ No newline at end of file diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index 20ee5ba78d..5148ad6d53 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -29,9 +29,11 @@ import org.keycloak.storage.client.ClientStorageProvider; import org.keycloak.storage.client.ClientStorageProviderModel; import org.keycloak.storage.role.RoleStorageProvider; import org.keycloak.storage.role.RoleStorageProviderModel; +import org.keycloak.utils.StringUtil; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -249,6 +251,8 @@ public interface RealmModel extends RoleContainerModel { OAuth2DeviceConfig getOAuth2DeviceConfig(); + CibaConfig getCibaPolicy(); + /** * This method will return a map with all the lifespans available * or an empty map, but never null. diff --git a/server-spi/src/main/java/org/keycloak/utils/StringUtil.java b/server-spi/src/main/java/org/keycloak/utils/StringUtil.java new file mode 100644 index 0000000000..054527c07e --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/utils/StringUtil.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021 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.utils; + +public class StringUtil { + + public static boolean isBlank(String str) { + return !(isNotBlank(str)); + } + + public static boolean isNotBlank(String str) { + return str != null && !"".equals(str.trim()); + } + +} \ No newline at end of file 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 e460f1ced5..76884b69d3 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc; import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator; import org.keycloak.jose.jws.Algorithm; +import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; import org.keycloak.representations.idm.ClientRepresentation; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index cd94d79a89..8466bd67fa 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -88,6 +88,7 @@ public class OIDCLoginProtocol implements LoginProtocol { public static final String UI_LOCALES_PARAM = OAuth2Constants.UI_LOCALES_PARAM; public static final String CLAIMS_PARAM = "claims"; public static final String ACR_PARAM = "acr_values"; + public static final String ID_TOKEN_HINT = "id_token_hint"; public static final String LOGOUT_REDIRECT_URI = "OIDC_LOGOUT_REDIRECT_URI"; public static final String ISSUER = "iss"; diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 433ec41fa9..3974811213 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -26,11 +26,13 @@ import org.keycloak.crypto.ClientSignatureVerifierProvider; import org.keycloak.crypto.ContentEncryptionProvider; import org.keycloak.crypto.SignatureProvider; import org.keycloak.jose.jws.Algorithm; +import org.keycloak.models.CibaConfig; import org.keycloak.models.ClientScopeModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.utils.OIDCResponseType; @@ -62,7 +64,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider { public static final List DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS, - OAuth2Constants.DEVICE_CODE_GRANT_TYPE); + OAuth2Constants.DEVICE_CODE_GRANT_TYPE, + OAuth2Constants.CIBA_GRANT_TYPE); public static final List DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token"); @@ -80,6 +83,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider { // KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange public static final List DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED = list(OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256); + public static final List DEFAULT_BACKCHANNEL_TOKEN_DELIVERY_MODES_SUPPORTED= list(CibaConfig.DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE); + private KeycloakSession session; public OIDCWellKnownProvider(KeycloakSession session) { @@ -167,6 +172,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider { config.setBackchannelLogoutSupported(true); config.setBackchannelLogoutSessionSupported(true); + config.setBackchannelTokenDeliveryModesSupported(DEFAULT_BACKCHANNEL_TOKEN_DELIVERY_MODES_SUPPORTED); + config.setBackchannelAuthenticationEndpoint(CibaGrantType.authorizationUrl(backendUriInfo.getBaseUriBuilder()).build(realm.getName()).toString()); + return config; } 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 93574acea9..13cd917c6e 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 @@ -62,6 +62,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.AuthenticationFlowResolver; import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocolFactory; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; import org.keycloak.protocol.oidc.grants.device.DeviceGrantType; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -149,7 +150,7 @@ public class TokenEndpoint { private Map clientAuthAttributes; private enum Action { - AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE + AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE, CIBA } // https://tools.ietf.org/html/rfc7636#section-4.2 @@ -231,6 +232,8 @@ public class TokenEndpoint { return permissionGrant(); case OAUTH2_DEVICE_CODE: return oauth2DeviceCodeToToken(); + case CIBA: + return cibaGrant(); } throw new RuntimeException("Unknown action " + action); @@ -305,6 +308,9 @@ public class TokenEndpoint { } else if (grantType.equals(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)) { event.event(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN); action = Action.OAUTH2_DEVICE_CODE; + } else if (grantType.equals(OAuth2Constants.CIBA_GRANT_TYPE)) { + event.event(EventType.AUTHREQID_TO_TOKEN); + action = Action.CIBA; } else { throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_GRANT_TYPE, "Unsupported " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST); @@ -449,10 +455,10 @@ public class TokenEndpoint { // Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce()); - return codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, true); + return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true); } - public Response codeOrDeviceCodeToToken(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx, + public Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx, String scopeParam, boolean code) { AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx); @@ -1392,6 +1398,11 @@ public class TokenEndpoint { return deviceGrantType.oauth2DeviceFlow(); } + public Response cibaGrant() { + CibaGrantType grantType = new CibaGrantType(formParams, client, session, this, realm, event, cors); + return grantType.cibaGrant(); + } + // https://tools.ietf.org/html/rfc7636#section-4.1 private boolean isValidPkceCodeVerifier(String codeVerifier) { if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java new file mode 100644 index 0000000000..3062fb0afe --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/CibaGrantType.java @@ -0,0 +1,271 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + +import org.jboss.logging.Logger; +import org.keycloak.OAuthErrorException; +import org.keycloak.authentication.AuthenticationProcessor; +import org.keycloak.common.Profile; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.AuthenticatedClientSessionModel; +import org.keycloak.models.ClientModel; +import org.keycloak.models.ClientScopeModel; +import org.keycloak.models.ClientSessionContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OAuth2DeviceCodeModel; +import org.keycloak.models.OAuth2DeviceTokenStoreProvider; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserConsentModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest; +import org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AuthenticationManager; +import org.keycloak.services.resources.Cors; +import org.keycloak.services.util.DefaultClientSessionContext; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.keycloak.utils.ProfileHelper; + +/** + * @author Pedro Igor + */ +public class CibaGrantType { + + private static final Logger logger = Logger.getLogger(CibaGrantType.class); + + public static final String IS_CONSENT_REQUIRED = "is_consent_required"; + public static final String LOGIN_HINT = "login_hint"; + public static final String LOGIN_HINT_TOKEN = "login_hint_token"; + public static final String BINDING_MESSAGE = "binding_message"; + public static final String AUTH_REQ_ID = "auth_req_id"; + public static final String CLIENT_NOTIFICATION_TOKEN = "client_notification_token"; + public static final String REQUESTED_EXPIRY = "requested_expiry"; + + public static UriBuilder authorizationUrl(UriBuilder baseUriBuilder) { + UriBuilder uriBuilder = OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder); + return uriBuilder.path(OIDCLoginProtocolService.class, "resolveExtension").resolveTemplate("extension", CibaRootEndpoint.PROVIDER_ID, false).path(CibaRootEndpoint.class, "authorize"); + } + + public static UriBuilder authenticationUrl(UriBuilder baseUriBuilder) { + UriBuilder uriBuilder = OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder); + return uriBuilder.path(OIDCLoginProtocolService.class, "resolveExtension").resolveTemplate("extension", CibaRootEndpoint.PROVIDER_ID, false).path(CibaRootEndpoint.class, "authenticate"); + } + + private final MultivaluedMap formParams; + private final ClientModel client; + private final KeycloakSession session; + private final TokenEndpoint tokenEndpoint; + private final RealmModel realm; + private final EventBuilder event; + private final Cors cors; + + public CibaGrantType(MultivaluedMap formParams, ClientModel client, KeycloakSession session, + TokenEndpoint tokenEndpoint, RealmModel realm, EventBuilder event, Cors cors) { + this.formParams = formParams; + this.client = client; + this.session = session; + this.tokenEndpoint = tokenEndpoint; + this.realm = realm; + this.event = event; + this.cors = cors; + } + + public Response cibaGrant() { + ProfileHelper.requireFeature(Profile.Feature.CIBA); + + if (!realm.getCibaPolicy().isOIDCCIBAGrantEnabled(client)) { + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, + "Client not allowed OIDC CIBA Grant", Response.Status.BAD_REQUEST); + } + + String jwe = formParams.getFirst(AUTH_REQ_ID); + + if (jwe == null) { + event.error(Errors.INVALID_CODE); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing parameter: " + AUTH_REQ_ID, Response.Status.BAD_REQUEST); + } + + logger.tracev("CIBA Grant :: authReqId = {0}", jwe); + + CIBAAuthenticationRequest request; + + try { + request = CIBAAuthenticationRequest.deserialize(session, jwe); + } catch (Exception e) { + logger.warnf("illegal format of auth_req_id : e.getMessage() = %s", e.getMessage()); + // Auth Req ID has not put onto cache, no need to remove Auth Req ID. + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid Auth Req ID", Response.Status.BAD_REQUEST); + } + + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + OAuth2DeviceCodeModel deviceCode = store.getByDeviceCode(realm, request.getId()); + + if (deviceCode == null) { + // Auth Req ID has not put onto cache, no need to remove Auth Req ID. + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid " + AUTH_REQ_ID, Response.Status.BAD_REQUEST); + } + + if (!request.getIssuedFor().equals(client.getClientId())) { + logDebug("invalid client.", request); + // the client sending this Auth Req ID does not match the client to which keycloak had issued Auth Req ID. + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "unauthorized client", Response.Status.BAD_REQUEST); + } + + if (deviceCode.isExpired()) { + logDebug("expired.", request); + throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "authentication timed out", Response.Status.BAD_REQUEST); + } + + if (!store.isPollingAllowed(deviceCode)) { + logDebug("pooling.", request); + throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "too early to access", Response.Status.BAD_REQUEST); + } + + if (deviceCode.isDenied()) { + logDebug("denied.", request); + throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "not authorized", Response.Status.FORBIDDEN); + } + + // get corresponding Authentication Channel Result entry + if (deviceCode.isPending()) { + logDebug("not yet authenticated by Authentication Device or auth_req_id has already been used to get tokens.", request); + throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is still pending as the end-user hasn't yet been authenticated.", Response.Status.BAD_REQUEST); + } + + UserSessionModel userSession = createUserSession(request); + UserModel user = userSession.getUser(); + + store.removeDeviceCode(realm, request.getId()); + + // Compute client scopes again from scope parameter. Check if user still has them granted + // (but in code-to-token request, it could just theoretically happen that they are not available) + String scopeParam = request.getScope(); + + if (!TokenManager + .verifyConsentStillAvailable(session, + user, client, TokenManager.getRequestedClientScopes(scopeParam, client))) { + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST); + } + + ClientSessionContext clientSessionCtx = DefaultClientSessionContext + .fromClientSessionAndClientScopes(userSession.getAuthenticatedClientSessionByClient(client.getId()), TokenManager.getRequestedClientScopes(scopeParam, client), session); + + return tokenEndpoint.createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true); + + } + + private UserSessionModel createUserSession(CIBAAuthenticationRequest request) { + RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm); + // here Client Model of CD(Consumption Device) needs to be used to bind its Client Session with User Session. + AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client); + + authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); + authSession.setAction(AuthenticatedClientSessionModel.Action.AUTHENTICATE.name()); + authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); + authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, request.getScope()); + + UserModel user = session.users().getUserById(realm, request.getSubject()); + + if (user == null) { + event.error(Errors.USERNAME_MISSING); + throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Could not identify user", Response.Status.BAD_REQUEST); + } + + if (!user.isEnabled()) { + event.error(Errors.USER_DISABLED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled", Response.Status.BAD_REQUEST); + } + + logger.debugf("CIBA Grant :: user model found. user.getId() = %s, user.getEmail() = %s, user.getUsername() = %s.", user.getId(), user.getEmail(), user.getUsername()); + + authSession.setAuthenticatedUser(user); + + if (user.getRequiredActionsStream().count() > 0) { + event.error(Errors.RESOLVE_REQUIRED_ACTIONS); + throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Account is not fully set up", Response.Status.BAD_REQUEST); + } + + AuthenticationManager.setClientScopesInSession(authSession); + + ClientSessionContext context = AuthenticationProcessor + .attachSession(authSession, null, session, realm, session.getContext().getConnection(), event); + UserSessionModel userSession = context.getClientSession().getUserSession(); + + if (userSession == null) { + event.error(Errors.USER_SESSION_NOT_FOUND); + throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "User session is not found", Response.Status.BAD_REQUEST); + } + + // authorization (consent) + UserConsentModel grantedConsent = session.users().getConsentByClient(realm, user.getId(), client.getId()); + if (grantedConsent == null) { + grantedConsent = new UserConsentModel(client); + session.users().addConsent(realm, user.getId(), grantedConsent); + if (logger.isTraceEnabled()) { + grantedConsent.getGrantedClientScopes().forEach(i->logger.tracef("CIBA Grant :: Consent granted. %s", i.getName())); + } + } + + boolean updateConsentRequired = false; + + for (String clientScopeId : authSession.getClientScopes()) { + ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId); + if (clientScope != null && !grantedConsent.isClientScopeGranted(clientScope) && clientScope.isDisplayOnConsentScreen()) { + grantedConsent.addGrantedClientScope(clientScope); + updateConsentRequired = true; + } + } + + if (updateConsentRequired) { + session.users().updateConsent(realm, user.getId(), grantedConsent); + if (logger.isTraceEnabled()) { + grantedConsent.getGrantedClientScopes().forEach(i->logger.tracef("CIBA Grant :: Consent updated. %s", i.getName())); + } + } + + event.detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED); + event.detail(Details.CODE_ID, userSession.getId()); + event.session(userSession.getId()); + event.user(user); + logger.debugf("Successfully verified Authe Req Id '%s'. User session: '%s', client: '%s'", request, userSession.getId(), client.getId()); + + return userSession; + } + + private static void logDebug(String message, CIBAAuthenticationRequest request) { + logger.debugf("CIBA Grant :: authentication channel %s clientId = %s, authResultId = %s", message, request.getIssuedFor(), request.getAuthResultId()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProvider.java new file mode 100644 index 0000000000..1d92f9adbc --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProvider.java @@ -0,0 +1,36 @@ +/* + * 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.protocol.oidc.grants.ciba.channel; + +import org.keycloak.provider.Provider; + +/** + * Provides the interface for requesting the authentication(AuthN) and authorization(AuthZ) by an authentication device (AD) to the external entity via Authentication Channel. + * This interface is for Client Initiated Backchannel Authentication(CIBA). + * + * @author Takashi Norimatsu + */ +public interface AuthenticationChannelProvider extends Provider { + + /** + * Request the authentication(AuthN) and authorization(AuthZ) by an authentication device (AD) to the external entity via Authentication Channel. + * @param request the representation of Authentication Request received on Backchannel Authentication Endpoint + * @param infoUsedByAuthenticator some value to help the AD to identify the user + * @return + */ + boolean requestAuthentication(CIBAAuthenticationRequest request, String infoUsedByAuthenticator); +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProviderFactory.java new file mode 100644 index 0000000000..fbba7f0565 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelProviderFactory.java @@ -0,0 +1,33 @@ +/* + * 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.protocol.oidc.grants.ciba.channel; + +import org.keycloak.common.Profile; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderFactory; + +/** + * @author Takashi Norimatsu + */ +public interface AuthenticationChannelProviderFactory extends ProviderFactory, + EnvironmentDependentProviderFactory { + + @Override + default boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.CIBA); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelRequest.java new file mode 100644 index 0000000000..41f2137c35 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelRequest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba.channel; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.keycloak.OAuth2Constants; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; + +/** + * @author Pedro Igor + */ +public class AuthenticationChannelRequest { + + @JsonProperty(CibaGrantType.BINDING_MESSAGE) + private String bindingMessage; + + @JsonProperty(CibaGrantType.LOGIN_HINT) + private String loginHint; + + @JsonProperty(CibaGrantType.IS_CONSENT_REQUIRED) + private Boolean consentRequired; + + @JsonProperty(OAuth2Constants.ACR_VALUES) + private String acrValues; + + private String scope; + + public void setBindingMessage(String bindingMessage) { + this.bindingMessage = bindingMessage; + } + + public String getBindingMessage() { + return bindingMessage; + } + + public void setLoginHint(String loginHint) { + this.loginHint = loginHint; + } + + public String getLoginHint() { + return loginHint; + } + + public void setConsentRequired(Boolean consentRequired) { + this.consentRequired = consentRequired; + } + + public Boolean getConsentRequired() { + return consentRequired; + } + + public String getAcrValues() { + return acrValues; + } + + public void setAcrValues(String acrValues) { + this.acrValues = acrValues; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getScope() { + return scope; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelResponse.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelResponse.java new file mode 100644 index 0000000000..45d772b80c --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelResponse.java @@ -0,0 +1,52 @@ +/* + * + * * Copyright 2021 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.protocol.oidc.grants.ciba.channel; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * @author Pedro Igor + */ +public class AuthenticationChannelResponse { + + public enum Status { + SUCCEED, + UNAUTHORIZED, + CANCELLED; + } + + private Status status; + + public AuthenticationChannelResponse() { + // for reflection + } + + public AuthenticationChannelResponse(Status status) { + this.status = status; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelSpi.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelSpi.java new file mode 100644 index 0000000000..0acf7550ab --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/AuthenticationChannelSpi.java @@ -0,0 +1,48 @@ +/* + * 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.protocol.oidc.grants.ciba.channel; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Takashi Norimatsu + */ +public class AuthenticationChannelSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "ciba-auth-channel"; + } + + @Override + public Class getProviderClass() { + return AuthenticationChannelProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return AuthenticationChannelProviderFactory.class; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/CIBAAuthenticationRequest.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/CIBAAuthenticationRequest.java new file mode 100644 index 0000000000..562361a103 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/CIBAAuthenticationRequest.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba.channel; + +import javax.crypto.SecretKey; +import java.io.UnsupportedEncodingException; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.keycloak.OAuth2Constants; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.KeyUse; +import org.keycloak.crypto.SignatureProvider; +import org.keycloak.crypto.SignatureSignerContext; +import org.keycloak.jose.jwe.JWEException; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.JsonWebToken; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.services.Urls; +import org.keycloak.util.TokenUtil; + +/** + *

Represents an authentication request sent by a consumption device (CD). + * + *

A authentication request can be serialized to a JWE so that it can be exchanged with authentication devices (AD) + * to communicate and authorize the authentication request made by consumption devices (CDs). + * + * @author Takashi Norimatsu + */ +public class CIBAAuthenticationRequest extends JsonWebToken { + + /** + * Deserialize the given {@code jwe} to a {@link CIBAAuthenticationRequest} instance. + * + * @param session the session + * @param jwe the authentication request in JWE format. + * @return the authentication request instance + * @throws Exception + */ + public static CIBAAuthenticationRequest deserialize(KeycloakSession session, String jwe) { + SecretKey aesKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.ENC, Algorithm.AES).getSecretKey(); + SecretKey hmacKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.SIG, Algorithm.HS256).getSecretKey(); + + try { + byte[] contentBytes = TokenUtil.jweDirectVerifyAndDecode(aesKey, hmacKey, jwe); + jwe = new String(contentBytes, "UTF-8"); + } catch (JWEException | UnsupportedEncodingException e) { + throw new RuntimeException("Error decoding auth_req_id.", e); + } + + return session.tokens().decode(jwe, CIBAAuthenticationRequest.class); + } + + public static final String SESSION_STATE = IDToken.SESSION_STATE; + public static final String AUTH_RESULT_ID = "auth_result_id"; + + @JsonProperty(OAuth2Constants.SCOPE) + protected String scope; + + @JsonProperty(AUTH_RESULT_ID) + protected String authResultId; + + @JsonProperty(CibaGrantType.BINDING_MESSAGE) + protected String bindingMessage; + + @JsonProperty(OAuth2Constants.ACR_VALUES) + protected String acrValues; + + @JsonIgnore + protected ClientModel client; + + @JsonIgnore + protected UserModel user; + + public CIBAAuthenticationRequest() { + // for reflection + } + + public CIBAAuthenticationRequest(KeycloakSession session, UserModel user, ClientModel client) { + id(KeycloakModelUtils.generateId()); + issuedNow(); + RealmModel realm = session.getContext().getRealm(); + issuer(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())); + audience(getIssuer()); + subject(user.getId()); + issuedFor(client.getClientId()); + setAuthResultId(KeycloakModelUtils.generateId()); + setClient(client); + setUser(user); + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getAuthResultId() { + return authResultId; + } + + public void setAuthResultId(String authResultId) { + this.authResultId = authResultId; + } + + public String getBindingMessage() { + return bindingMessage; + } + + public void setBindingMessage(String binding_message) { + this.bindingMessage = binding_message; + } + + public String getAcrValues() { + return acrValues; + } + + public void setAcrValues(String acrValues) { + this.acrValues = acrValues; + } + + /** + * Serializes this instance to a JWE. + * + * @param session the session + * @return the JWE + */ + public String serialize(KeycloakSession session) { + try { + SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, Algorithm.HS256); + SignatureSignerContext signer = signatureProvider.signer(); + String encodedJwt = new JWSBuilder().type("JWT").jsonContent(this).sign(signer); + SecretKey aesKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.ENC, Algorithm.AES).getSecretKey(); + SecretKey hmacKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.SIG, Algorithm.HS256).getSecretKey(); + + return TokenUtil.jweDirectEncode(aesKey, hmacKey, encodedJwt.getBytes("UTF-8")); + } catch (JWEException | UnsupportedEncodingException e) { + throw new RuntimeException("Error encoding auth_req_id.", e); + } + } + + public void setClient(ClientModel client) { + this.client = client; + } + + public ClientModel getClient() { + return client; + } + + public void setUser(UserModel user) { + this.user = user; + } + + public UserModel getUser() { + return user; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProvider.java new file mode 100644 index 0000000000..f68eb84d44 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProvider.java @@ -0,0 +1,131 @@ +/* + * 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.protocol.oidc.grants.ciba.channel; + +import java.io.IOException; +import java.util.Map; + +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response.Status; + +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.representations.AccessToken; +import org.keycloak.services.resources.Cors; +import org.keycloak.util.TokenUtil; + +/** + * @author Takashi Norimatsu + */ +public class HttpAuthenticationChannelProvider implements AuthenticationChannelProvider{ + + public static final String AUTHENTICATION_CHANNEL_ID = "authentication_channel_id"; + + protected KeycloakSession session; + protected MultivaluedMap formParams; + protected RealmModel realm; + protected Map clientAuthAttributes; + protected Cors cors; + protected final String httpAuthenticationChannelUri; + + public HttpAuthenticationChannelProvider(KeycloakSession session, String httpAuthenticationRequestUri) { + this.session = session; + this.realm = session.getContext().getRealm(); + this.httpAuthenticationChannelUri = httpAuthenticationRequestUri; + } + + @Override + public boolean requestAuthentication(CIBAAuthenticationRequest request, String infoUsedByAuthenticator) { + // Creates JWT formatted/JWS signed/JWE encrypted Authentication Channel ID by the same manner in creating auth_req_id. + // Authentication Channel ID binds Backchannel Authentication Request with Authentication by Authentication Device (AD). + // JWE serialized Authentication Channel ID works as a bearer token. It includes client_id + // that can be used on Authentication Channel Callback Endpoint to recognize the Consumption Device (CD) + // that sent Backchannel Authentication Request. + + // The following scopes should be displayed on AD: + // 1. scopes specified explicitly as query parameter in the authorization request + // 2. scopes specified implicitly as default client scope in keycloak + + checkAuthenticationChannel(); + + ClientModel client = request.getClient(); + + try { + AuthenticationChannelRequest channelRequest = new AuthenticationChannelRequest(); + + channelRequest.setScope(request.getScope()); + channelRequest.setBindingMessage(request.getBindingMessage()); + channelRequest.setLoginHint(infoUsedByAuthenticator); + channelRequest.setConsentRequired(client.isConsentRequired()); + channelRequest.setAcrValues(request.getAcrValues()); + + SimpleHttp simpleHttp = SimpleHttp.doPost(httpAuthenticationChannelUri, session) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) + .json(channelRequest) + .auth(createBearerToken(request, client)); + + int status = completeDecoupledAuthnRequest(simpleHttp, channelRequest).asStatus(); + + if (status == Status.CREATED.getStatusCode()) { + return true; + } + } catch (IOException ioe) { + throw new RuntimeException("Authentication Channel Access failed.", ioe); + } + + return false; + } + + private String createBearerToken(CIBAAuthenticationRequest request, ClientModel client) { + AccessToken bearerToken = new AccessToken(); + + bearerToken.type(TokenUtil.TOKEN_TYPE_BEARER); + bearerToken.issuer(request.getIssuer()); + bearerToken.id(request.getAuthResultId()); + bearerToken.issuedFor(client.getClientId()); + bearerToken.audience(request.getIssuer()); + bearerToken.exp(request.getExp()); + bearerToken.subject(request.getSubject()); + + return session.tokens().encode(bearerToken); + } + + protected void checkAuthenticationChannel() { + if (httpAuthenticationChannelUri == null) { + throw new RuntimeException("Authentication Channel Request URI not set properly."); + } + if (!httpAuthenticationChannelUri.startsWith("http://") && !httpAuthenticationChannelUri.startsWith("https://")) { + throw new RuntimeException("Authentication Channel Request URI not set properly."); + } + } + + /** + * Extension point to allow subclass to override this method in order to add data to post to decoupled server. + */ + protected SimpleHttp completeDecoupledAuthnRequest(SimpleHttp simpleHttp, AuthenticationChannelRequest channelRequest) { + return simpleHttp; + } + + @Override + public void close() { + + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProviderFactory.java new file mode 100644 index 0000000000..1eab575e90 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/channel/HttpAuthenticationChannelProviderFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba.channel; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * @author Takashi Norimatsu + */ +public class HttpAuthenticationChannelProviderFactory implements AuthenticationChannelProviderFactory { + + public static final String PROVIDER_ID = "ciba-http-auth-channel"; + + protected String httpAuthenticationChannelUri; + + @Override + public AuthenticationChannelProvider create(KeycloakSession session) { + return new HttpAuthenticationChannelProvider(session, httpAuthenticationChannelUri); + } + + @Override + public void init(Scope config) { + httpAuthenticationChannelUri = config.get("httpAuthenticationChannelUri"); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/AbstractCibaEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/AbstractCibaEndpoint.java new file mode 100644 index 0000000000..439f40f1dc --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/AbstractCibaEndpoint.java @@ -0,0 +1,85 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba.endpoints; + +import javax.ws.rs.core.Response; + +import org.keycloak.OAuthErrorException; +import org.keycloak.common.ClientConnection; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; +import org.keycloak.services.ErrorResponseException; + +/** + * @author Pedro Igor + */ +public abstract class AbstractCibaEndpoint { + + protected final KeycloakSession session; + protected final EventBuilder event; + protected final RealmModel realm; + + public AbstractCibaEndpoint(KeycloakSession session, EventBuilder event) { + this.session = session; + this.event = event; + realm = session.getContext().getRealm(); + } + + protected ClientModel authenticateClient() { + checkSsl(); + checkRealm(); + + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, null); + ClientModel client = clientAuth.getClient(); + + if (client.isBearerOnly()) { + throw new ErrorResponseException(OAuthErrorException.INVALID_CLIENT, "Bearer-only not allowed", Response.Status.BAD_REQUEST); + } + + if (!realm.getCibaPolicy().isOIDCCIBAGrantEnabled(client)) { + event.error(Errors.NOT_ALLOWED); + throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, + "Client not allowed OIDC CIBA Grant", Response.Status.BAD_REQUEST); + } + + event.client(client); + + return client; + } + + protected void checkSsl() { + ClientConnection clientConnection = session.getContext().getContextObject(ClientConnection.class); + RealmModel realm = session.getContext().getRealm(); + + if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN); + } + } + + protected void checkRealm() { + RealmModel realm = session.getContext().getRealm(); + + if (!realm.isEnabled()) { + throw new ErrorResponseException("access_denied", "Realm not enabled", Response.Status.FORBIDDEN); + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java new file mode 100644 index 0000000000..82a0e768b1 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationCallbackEndpoint.java @@ -0,0 +1,160 @@ +/* + * 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.protocol.oidc.grants.ciba.endpoints; + +import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.CANCELLED; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.jboss.resteasy.annotations.cache.NoCache; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.OAuthErrorException; +import org.keycloak.TokenVerifier; +import org.keycloak.events.Errors; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OAuth2DeviceCodeModel; +import org.keycloak.models.OAuth2DeviceTokenStoreProvider; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status; +import org.keycloak.representations.AccessToken; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.services.Urls; +import org.keycloak.services.managers.AppAuthManager; + +public class BackchannelAuthenticationCallbackEndpoint extends AbstractCibaEndpoint { + + @Context + private HttpRequest httpRequest; + + public BackchannelAuthenticationCallbackEndpoint(KeycloakSession session, EventBuilder event) { + super(session, event); + } + + @Path("/") + @POST + @NoCache + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response processAuthenticationChannelResult(AuthenticationChannelResponse response) { + event.event(EventType.LOGIN); + AccessToken bearerToken = verifyAuthenticationRequest(httpRequest.getHttpHeaders()); + Status status = response.getStatus(); + + if (status == null) { + event.error(Errors.INVALID_REQUEST); + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid authentication status", + Response.Status.BAD_REQUEST); + } + + switch (status) { + case SUCCEED: + approveRequest(bearerToken); + break; + case CANCELLED: + case UNAUTHORIZED: + denyRequest(bearerToken, status); + break; + } + + return Response.ok(MediaType.APPLICATION_JSON_TYPE).build(); + } + + private AccessToken verifyAuthenticationRequest(HttpHeaders headers) { + String rawBearerToken = AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(headers); + + if (rawBearerToken == null) { + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.UNAUTHORIZED); + } + + AccessToken bearerToken; + + try { + bearerToken = TokenVerifier.createWithoutSignature(session.tokens().decode(rawBearerToken, AccessToken.class)) + .withDefaultChecks() + .realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())) + .checkActive(true) + .audience(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName())) + .verify().getToken(); + } catch (Exception e) { + event.error(Errors.INVALID_TOKEN); + // authentication channel id format is invalid or it has already been used + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN); + } + + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + OAuth2DeviceCodeModel deviceCode = store.getByUserCode(realm, bearerToken.getId()); + + if (deviceCode == null) { + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN); + } + + if (!deviceCode.isPending()) { + cancelRequest(bearerToken.getId()); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN); + } + + ClientModel issuedFor = realm.getClientByClientId(bearerToken.getIssuedFor()); + + if (issuedFor == null || !issuedFor.isEnabled()) { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid token recipient", + Response.Status.BAD_REQUEST); + } + + if (!deviceCode.getClientId().equals(issuedFor.getClientId())) { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Token recipient mismatch", + Response.Status.BAD_REQUEST); + } + + event.client(issuedFor); + + return bearerToken; + } + + private void cancelRequest(String authResultId) { + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + OAuth2DeviceCodeModel userCode = store.getByUserCode(realm, authResultId); + store.removeDeviceCode(realm, userCode.getDeviceCode()); + store.removeUserCode(realm, authResultId); + } + + private void approveRequest(AccessToken authReqId) { + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + store.approve(realm, authReqId.getId(), "fake"); + } + + private void denyRequest(AccessToken authReqId, Status status) { + if (CANCELLED.equals(status)) { + event.error(Errors.NOT_ALLOWED); + } else { + event.error(Errors.CONSENT_DENIED); + } + + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + + store.deny(realm, authReqId.getId()); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java new file mode 100644 index 0000000000..7d17285fcc --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/BackchannelAuthenticationEndpoint.java @@ -0,0 +1,237 @@ +/* + * 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.protocol.oidc.grants.ciba.endpoints; + +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ID_TOKEN_HINT; +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM; + +import javax.ws.rs.Consumes; +import javax.ws.rs.POST; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import java.util.Collections; +import java.util.Optional; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jboss.resteasy.annotations.cache.NoCache; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.Profile; +import org.keycloak.common.util.Time; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.CibaConfig; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OAuth2DeviceCodeModel; +import org.keycloak.models.OAuth2DeviceTokenStoreProvider; +import org.keycloak.models.OAuth2DeviceUserCodeModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProvider; +import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest; +import org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolver; +import org.keycloak.services.ErrorResponseException; +import org.keycloak.util.JsonSerialization; +import org.keycloak.utils.ProfileHelper; + +public class BackchannelAuthenticationEndpoint extends AbstractCibaEndpoint { + + private final RealmModel realm; + + public BackchannelAuthenticationEndpoint(KeycloakSession session, EventBuilder event) { + super(session, event); + this.realm = session.getContext().getRealm(); + event.event(EventType.LOGIN); + } + + @POST + @NoCache + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public Response processGrantRequest(@Context HttpRequest httpRequest) { + CIBAAuthenticationRequest request = authorizeClient(httpRequest.getDecodedFormParameters()); + + try { + String authReqId = request.serialize(session); + AuthenticationChannelProvider provider = session.getProvider(AuthenticationChannelProvider.class); + + if (provider == null) { + throw new RuntimeException("Authentication Channel Provider not found."); + } + + CIBALoginUserResolver resolver = session.getProvider(CIBALoginUserResolver.class); + + if (resolver == null) { + throw new RuntimeException("CIBA Login User Resolver not setup properly."); + } + + UserModel user = request.getUser(); + + String infoUsedByAuthentication = resolver.getInfoUsedByAuthentication(user); + + if (provider.requestAuthentication(request, infoUsedByAuthentication)) { + CibaConfig cibaPolicy = realm.getCibaPolicy(); + int poolingInterval = cibaPolicy.getPoolingInterval(); + + storeAuthenticationRequest(request, cibaPolicy); + + ObjectNode response = JsonSerialization.createObjectNode(); + + response.put(CibaGrantType.AUTH_REQ_ID, authReqId) + .put(OAuth2Constants.EXPIRES_IN, cibaPolicy.getExpiresIn()); + + if (poolingInterval > 0) { + response.put(OAuth2Constants.INTERVAL, poolingInterval); + } + + return Response.ok(JsonSerialization.writeValueAsBytes(response)) + .build(); + } + } catch (Exception e) { + throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR, "Failed to send authentication request", Response.Status.SERVICE_UNAVAILABLE); + } + + throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR, "Unexpected response from authentication device", Response.Status.SERVICE_UNAVAILABLE); + } + + /** + * TODO: Leverage the device code storage for tracking authentication requests. Not sure if we need a specific storage, + * but probably make the {@link OAuth2DeviceTokenStoreProvider} more generic for ciba, device, or any other use case + * that relies on cross-references for unsolicited user authentication requests from devices. + */ + private void storeAuthenticationRequest(CIBAAuthenticationRequest request, CibaConfig cibaConfig) { + ClientModel client = request.getClient(); + int expiresIn = cibaConfig.getExpiresIn(); + int poolingInterval = cibaConfig.getPoolingInterval(); + + OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client, + request.getId(), request.getScope(), null, expiresIn, poolingInterval, + Collections.emptyMap()); + String authResultId = request.getAuthResultId(); + OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(), + authResultId); + + // To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code + int lifespanSeconds = expiresIn + poolingInterval + 10; + + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + + store.put(deviceCode, userCode, lifespanSeconds); + } + + private CIBAAuthenticationRequest authorizeClient(MultivaluedMap params) { + ClientModel client = authenticateClient(); + UserModel user = resolveUser(params, realm.getCibaPolicy().getAuthRequestedUserHint()); + + CIBAAuthenticationRequest request = new CIBAAuthenticationRequest(session, user, client); + + request.setClient(client); + + String scope = params.getFirst(OAuth2Constants.SCOPE); + + if (scope == null) + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : scope", + Response.Status.BAD_REQUEST); + + request.setScope(scope); + + // optional parameters + if (params.getFirst(CibaGrantType.BINDING_MESSAGE) != null) request.setBindingMessage(params.getFirst(CibaGrantType.BINDING_MESSAGE)); + if (params.getFirst(OAuth2Constants.ACR_VALUES) != null) request.setAcrValues(params.getFirst(OAuth2Constants.ACR_VALUES)); + + CibaConfig policy = realm.getCibaPolicy(); + + // create JWE encoded auth_req_id from Auth Req ID. + Integer expiresIn = policy.getExpiresIn(); + String requestedExpiry = params.getFirst(CibaGrantType.REQUESTED_EXPIRY); + + if (requestedExpiry != null) { + expiresIn = Integer.valueOf(requestedExpiry); + } + + request.exp(Time.currentTime() + expiresIn.longValue()); + + StringBuilder scopes = new StringBuilder(Optional.ofNullable(request.getScope()).orElse("")); + client.getClientScopes(true) + .forEach((key, value) -> { + if (value.isDisplayOnConsentScreen()) + scopes.append(" ").append(value.getName()); + }); + request.setScope(scopes.toString()); + + String clientNotificationToken = params.getFirst(CibaGrantType.CLIENT_NOTIFICATION_TOKEN); + + if (clientNotificationToken != null) { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, + "Ping and push modes not supported. Use poll mode instead.", Response.Status.BAD_REQUEST); + } + + String userCode = params.getFirst(OAuth2Constants.USER_CODE); + + if (userCode != null) { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User code not supported", + Response.Status.BAD_REQUEST); + } + + return request; + } + + private UserModel resolveUser(MultivaluedMap params, String authRequestedUserHint) { + CIBALoginUserResolver resolver = session.getProvider(CIBALoginUserResolver.class); + + if (resolver == null) { + throw new RuntimeException("CIBA Login User Resolver not setup properly."); + } + + String userHint; + UserModel user; + + if (authRequestedUserHint.equals(LOGIN_HINT_PARAM)) { + userHint = params.getFirst(LOGIN_HINT_PARAM); + if (userHint == null) + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : login_hint", + Response.Status.BAD_REQUEST); + user = resolver.getUserFromLoginHint(userHint); + } else if (authRequestedUserHint.equals(ID_TOKEN_HINT)) { + userHint = params.getFirst(ID_TOKEN_HINT); + if (userHint == null) + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : id_token_hint", + Response.Status.BAD_REQUEST); + user = resolver.getUserFromIdTokenHint(userHint); + } else if (authRequestedUserHint.equals(CibaGrantType.LOGIN_HINT_TOKEN)) { + userHint = params.getFirst(CibaGrantType.LOGIN_HINT_TOKEN); + if (userHint == null) + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : login_hint_token", + Response.Status.BAD_REQUEST); + user = resolver.getUserFromLoginHintToken(userHint); + } else { + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, + "invalid user hint", Response.Status.BAD_REQUEST); + } + + if (user == null || !user.isEnabled()) + throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "invalid user", Response.Status.BAD_REQUEST); + + return user; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/CibaRootEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/CibaRootEndpoint.java new file mode 100644 index 0000000000..746cf56dce --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/endpoints/CibaRootEndpoint.java @@ -0,0 +1,102 @@ +/* + * Copyright 2021 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.protocol.oidc.grants.ciba.endpoints; + +import javax.ws.rs.Path; + +import org.jboss.resteasy.spi.ResteasyProviderFactory; +import org.keycloak.common.Profile; +import org.keycloak.events.EventBuilder; +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.ext.OIDCExtProvider; +import org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +/** + * @author Pedro Igor + */ +public class CibaRootEndpoint implements OIDCExtProvider, OIDCExtProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "ciba"; + + private final KeycloakSession session; + private EventBuilder event; + + public CibaRootEndpoint() { + // for reflection + this(null); + } + + public CibaRootEndpoint(KeycloakSession session) { + this.session = session; + } + + /** + * The backchannel authentication endpoint used by consumption devices to obtain authorization from end-users. + * + * @return + */ + @Path("/auth") + public BackchannelAuthenticationEndpoint authorize() { + BackchannelAuthenticationEndpoint endpoint = new BackchannelAuthenticationEndpoint(session, event); + + ResteasyProviderFactory.getInstance().injectProperties(endpoint); + + return endpoint; + } + + /** + * The callback endpoint used by authentication devices to notify Keycloak about the end-user authentication status. + * + * @return + */ + @Path("/auth/callback") + public BackchannelAuthenticationCallbackEndpoint authenticate() { + BackchannelAuthenticationCallbackEndpoint endpoint = new BackchannelAuthenticationCallbackEndpoint(session, event); + + ResteasyProviderFactory.getInstance().injectProperties(endpoint); + + return endpoint; + } + + @Override + public OIDCExtProvider create(KeycloakSession session) { + return new CibaRootEndpoint(session); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public void setEvent(EventBuilder event) { + this.event = event; + } + + @Override + public void close() { + + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.CIBA); + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolver.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolver.java new file mode 100644 index 0000000000..c5e7d39d11 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolver.java @@ -0,0 +1,78 @@ +/* + * 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.protocol.oidc.grants.ciba.resolvers; + +import org.keycloak.models.UserModel; +import org.keycloak.provider.Provider; + +/** + * Provides the resolver that converts several types of receives login hint to its corresponding UserModel. + * Also converts between UserModel and the user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD. + * + * @author Takashi Norimatsu + */ +public interface CIBALoginUserResolver extends Provider { + + /** + * This method receives the login_hint parameter and returns its corresponding UserModel. + * + * @param loginHint + * @return UserModel + */ + default UserModel getUserFromLoginHint(String loginHint) { + return null; + } + + /** + * This method receives the login_hint_token parameter and returns its corresponding UserModel. + * + * @param loginHintToken + * @return UserModel + */ + default UserModel getUserFromLoginHintToken(String loginHintToken) { + return null; + } + + /** + * This method receives the id_token_hint parameter and returns its corresponding UserModel. + * + * @param idToken + * @return UserModel + */ + default UserModel getUserFromIdTokenHint(String idToken) { + return null; + } + + /** + * This method converts the UserModel to its corresponding user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD. + * + * @param user + * @return its corresponding user identifier + */ + default String getInfoUsedByAuthentication(UserModel user) { + return user.getUsername(); + } + + /** + * This method converts the user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD to the corresponding UserModel. + * + * @param info + * @return UserModel + */ + UserModel getUserFromInfoUsedByAuthentication(String info); + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverFactory.java new file mode 100644 index 0000000000..67f52a9b35 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverFactory.java @@ -0,0 +1,33 @@ +/* + * 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.protocol.oidc.grants.ciba.resolvers; + +import org.keycloak.common.Profile; +import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderFactory; + +/** + * @author Takashi Norimatsu + */ +public interface CIBALoginUserResolverFactory extends ProviderFactory, + EnvironmentDependentProviderFactory { + + @Override + default boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.CIBA); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverSpi.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverSpi.java new file mode 100644 index 0000000000..17a396102d --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/CIBALoginUserResolverSpi.java @@ -0,0 +1,48 @@ +/* + * 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.protocol.oidc.grants.ciba.resolvers; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Takashi Norimatsu + */ +public class CIBALoginUserResolverSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "ciba-login-user-resolver"; + } + + @Override + public Class getProviderClass() { + return CIBALoginUserResolver.class; + } + + @Override + public Class getProviderFactoryClass() { + return CIBALoginUserResolverFactory.class; + } + +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolver.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolver.java new file mode 100644 index 0000000000..a1ffd1b640 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolver.java @@ -0,0 +1,53 @@ +/* + * 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.protocol.oidc.grants.ciba.resolvers; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.KeycloakModelUtils; + +/** + * @author Takashi Norimatsu + */ +public class DefaultCIBALoginUserResolver implements CIBALoginUserResolver { + + private KeycloakSession session; + + public DefaultCIBALoginUserResolver(KeycloakSession session) { + this.session = session; + } + + @Override + public UserModel getUserFromLoginHint(String loginHint) { + return KeycloakModelUtils.findUserByNameOrEmail(session, session.getContext().getRealm(), loginHint); + } + + @Override + public String getInfoUsedByAuthentication(UserModel user) { + return user.getUsername(); + } + + @Override + public UserModel getUserFromInfoUsedByAuthentication(String info) { + return KeycloakModelUtils.findUserByNameOrEmail(session, session.getContext().getRealm(), info); + } + + @Override + public void close() { + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolverFactory.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolverFactory.java new file mode 100644 index 0000000000..9c879f53e7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/ciba/resolvers/DefaultCIBALoginUserResolverFactory.java @@ -0,0 +1,51 @@ +/* + * 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.protocol.oidc.grants.ciba.resolvers; + +import org.keycloak.Config.Scope; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * @author Takashi Norimatsu + */ +public class DefaultCIBALoginUserResolverFactory implements CIBALoginUserResolverFactory { + + public static final String PROVIDER_ID = "default-ciba-login-user-resolver"; + + @Override + public CIBALoginUserResolver create(KeycloakSession session) { + return new DefaultCIBALoginUserResolver(session); + } + + @Override + public void init(Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java index 5146e7787f..df4cf06210 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java @@ -19,7 +19,6 @@ package org.keycloak.protocol.oidc.grants.device; import static org.keycloak.protocol.oidc.OIDCLoginProtocolService.tokenServiceBaseUrl; -import org.jboss.resteasy.spi.HttpRequest; import org.keycloak.OAuth2Constants; import org.keycloak.OAuthErrorException; import org.keycloak.events.Details; @@ -38,28 +37,20 @@ import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.UserSessionModel; import org.keycloak.protocol.LoginProtocol; -import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint; -import org.keycloak.representations.AccessToken; -import org.keycloak.representations.AccessTokenResponse; import org.keycloak.services.CorsErrorResponseException; -import org.keycloak.services.Urls; import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.UserSessionCrossDCManager; import org.keycloak.services.resources.Cors; -import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.DefaultClientSessionContext; -import org.keycloak.services.util.MtlsHoKTokenUtil; import org.keycloak.sessions.AuthenticationSessionModel; -import org.keycloak.util.TokenUtil; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; @@ -285,6 +276,6 @@ public class DeviceGrantType { // Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce()); - return tokenEndpoint.codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, false); + return tokenEndpoint.createTokenResponse(user, userSession, clientSessionCtx, scopeParam, false); } } \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java index 4403b9df53..f7d6d78a11 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java @@ -259,7 +259,7 @@ public class DeviceEndpoint extends AuthorizationEndpointBase implements RealmRe } private Response processVerification(OAuth2DeviceCodeModel deviceCode, String userCode) { - int expiresIn = deviceCode.getExpiration() - Time.currentTime(); + long expiresIn = deviceCode.getExpiration() - Time.currentTime(); if (expiresIn < 0) { event.error(Errors.EXPIRED_OAUTH2_USER_CODE); return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE); diff --git a/services/src/main/java/org/keycloak/services/DefaultKeycloakSessionFactory.java b/services/src/main/java/org/keycloak/services/DefaultKeycloakSessionFactory.java index 75664975bd..a994c6e3b1 100755 --- a/services/src/main/java/org/keycloak/services/DefaultKeycloakSessionFactory.java +++ b/services/src/main/java/org/keycloak/services/DefaultKeycloakSessionFactory.java @@ -164,6 +164,8 @@ public class DefaultKeycloakSessionFactory implements KeycloakSessionFactory, Pr } factoriesMap = copy; + // need to update the default provider map + checkProvider(); boolean cfChanged = false; for (ProviderFactory factory : undeployed) { invalidate(ObjectType.PROVIDER_FACTORY, factory.getClass()); @@ -216,6 +218,9 @@ public class DefaultKeycloakSessionFactory implements KeycloakSessionFactory, Pr } protected void checkProvider() { + // make sure to recreated the default providers map + provider.clear(); + for (Spi spi : spis) { String defaultProvider = Config.getProvider(spi.getName()); if (defaultProvider != null) { diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java index d0e6f8dd6a..1678218dbb 100755 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -26,6 +26,7 @@ import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.Algorithm; +import org.keycloak.models.CibaConfig; import org.keycloak.models.KeycloakSession; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; @@ -42,17 +43,22 @@ import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.services.clientregistration.ClientRegistrationException; import org.keycloak.services.util.CertificateInfoHelper; import org.keycloak.util.JWKSUtils; +import org.keycloak.utils.StringUtil; import java.net.URI; import java.security.PublicKey; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import static org.keycloak.models.OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED; +import static org.keycloak.models.CibaConfig.OIDC_CIBA_GRANT_ENABLED; /** * @author Stian Thorgersen @@ -87,6 +93,7 @@ public class DescriptionConverter { if (oidcGrantTypes != null) { client.setDirectAccessGrantsEnabled(oidcGrantTypes.contains(OAuth2Constants.PASSWORD)); client.setServiceAccountsEnabled(oidcGrantTypes.contains(OAuth2Constants.CLIENT_CREDENTIALS)); + setOidcCibaGrantEnabled(client, oidcGrantTypes.contains(OAuth2Constants.CIBA_GRANT_TYPE)); } } catch (IllegalArgumentException iae) { throw new ClientRegistrationException(iae.getMessage(), iae); @@ -165,9 +172,31 @@ public class DescriptionConverter { configWrapper.setBackchannelLogoutRevokeOfflineTokens(clientOIDC.getBackchannelLogoutRevokeOfflineTokens()); } + String backchannelTokenDeliveryMode = clientOIDC.getBackchannelTokenDeliveryMode(); + if (backchannelTokenDeliveryMode != null) { + if(isSupportedBackchannelTokenDeliveryMode(backchannelTokenDeliveryMode)) { + Map attr = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>()); + attr.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT, backchannelTokenDeliveryMode); + client.setAttributes(attr); + } else { + throw new ClientRegistrationException("Unsupported requested backchannel_token_delivery_mode"); + } + } + return client; } + private static void setOidcCibaGrantEnabled(ClientRepresentation client, Boolean isEnabled) { + if (isEnabled == null) return; + Map attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>()); + attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, isEnabled.toString()); + client.setAttributes(attributes); + } + + private static boolean isSupportedBackchannelTokenDeliveryMode(String mode) { + if (mode.equals(CibaConfig.DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE)) return true; + return false; + } private static boolean setPublicKey(OIDCClientRepresentation clientOIDC, ClientRepresentation clientRep) { if (clientOIDC.getJwksUri() == null && clientOIDC.getJwks() == null) { @@ -270,6 +299,13 @@ public class DescriptionConverter { response.setBackchannelLogoutSessionRequired(config.isBackchannelLogoutSessionRequired()); response.setBackchannelLogoutSessionRequired(config.getBackchannelLogoutRevokeOfflineTokens()); + if (client.getAttributes() != null) { + String mode = client.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT); + if (StringUtil.isNotBlank(mode)) { + response.setBackchannelTokenDeliveryMode(mode); + } + } + List foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client); SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE; response.setSubjectType(subjectType.toString().toLowerCase()); @@ -318,6 +354,10 @@ public class DescriptionConverter { if (oauth2DeviceEnabled) { grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE); } + boolean oidcCibaEnabled = client.getAttributes() != null && Boolean.parseBoolean(client.getAttributes().get(OIDC_CIBA_GRANT_ENABLED)); + if (oidcCibaEnabled) { + grantTypes.add(OAuth2Constants.CIBA_GRANT_TYPE); + } if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) { grantTypes.add(OAuth2Constants.UMA_GRANT_TYPE); } diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory index 3c884ef838..3e94ed24f0 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory @@ -1 +1,2 @@ -org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory \ No newline at end of file +org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory +org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory new file mode 100644 index 0000000000..3685f84732 --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory @@ -0,0 +1 @@ +org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProviderFactory \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverFactory b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverFactory new file mode 100644 index 0000000000..e84f5a6b6c --- /dev/null +++ b/services/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverFactory @@ -0,0 +1 @@ +org.keycloak.protocol.oidc.grants.ciba.resolvers.DefaultCIBALoginUserResolverFactory \ No newline at end of file diff --git a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi index b43a16d810..88a33c6cec 100755 --- a/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/services/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -23,4 +23,6 @@ org.keycloak.authentication.actiontoken.ActionTokenHandlerSpi org.keycloak.services.x509.X509ClientCertificateLookupSpi org.keycloak.protocol.oidc.ext.OIDCExtSPI org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi -org.keycloak.encoding.ResourceEncodingSpi \ No newline at end of file +org.keycloak.encoding.ResourceEncodingSpi +org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelSpi +org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi \ No newline at end of file diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/TestHttpAuthenticationChannelProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/TestHttpAuthenticationChannelProviderFactory.java new file mode 100644 index 0000000000..f31fbe3c2c --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/authentication/TestHttpAuthenticationChannelProviderFactory.java @@ -0,0 +1,56 @@ +/* + * + * * Copyright 2021 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.authentication; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProvider; +import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProvider; +import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProviderFactory; +import org.keycloak.testsuite.util.ServerURLs; + +/** + * @author Pedro Igor + */ +public class TestHttpAuthenticationChannelProviderFactory extends HttpAuthenticationChannelProviderFactory { + + private static final String TEST_HTTP_AUTH_CHANNEL = + String.format("%s://%s:%s/auth/realms/master/app/oidc-client-endpoints/request-authentication-channel", + ServerURLs.AUTH_SERVER_SCHEME, ServerURLs.AUTH_SERVER_HOST, ServerURLs.AUTH_SERVER_PORT); + + @Override + public AuthenticationChannelProvider create(KeycloakSession session) { + return new HttpAuthenticationChannelProvider(session, TEST_HTTP_AUTH_CHANNEL); + } + + @Override + public int order() { + return 100; + } + + @Override + public String getId() { + return "test-http-auth-channel"; + } + + @Override + public boolean isSupported() { + return true; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java index 2269d11dcc..be10d1d719 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProvider.java @@ -30,6 +30,7 @@ import org.keycloak.representations.adapters.action.PushNotBeforeAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource; import org.keycloak.utils.MediaType; @@ -45,6 +46,7 @@ import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; /** @@ -61,6 +63,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { private final BlockingQueue adminTestAvailabilityAction; private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData; + private final ConcurrentMap authenticationChannelRequests; + @Context HttpRequest request; @@ -68,13 +72,15 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { BlockingQueue backChannelLogoutTokens, BlockingQueue adminPushNotBeforeActions, BlockingQueue adminTestAvailabilityAction, - TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) { + TestApplicationResourceProviderFactory.OIDCClientData oidcClientData, + ConcurrentMap authenticationChannelRequests) { this.session = session; this.adminLogoutActions = adminLogoutActions; this.backChannelLogoutTokens = backChannelLogoutTokens; this.adminPushNotBeforeActions = adminPushNotBeforeActions; this.adminTestAvailabilityAction = adminTestAvailabilityAction; this.oidcClientData = oidcClientData; + this.authenticationChannelRequests = authenticationChannelRequests; } @POST @@ -227,7 +233,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider { @Path("/oidc-client-endpoints") public TestingOIDCEndpointsApplicationResource getTestingOIDCClientEndpoints() { - return new TestingOIDCEndpointsApplicationResource(oidcClientData); + return new TestingOIDCEndpointsApplicationResource(oidcClientData, authenticationChannelRequests); } @Override diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java index 2cc23f44c3..4c976efbe3 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/TestApplicationResourceProviderFactory.java @@ -30,10 +30,13 @@ import org.keycloak.representations.adapters.action.PushNotBeforeAction; import org.keycloak.representations.adapters.action.TestAvailabilityAction; import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProviderFactory; +import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; import java.security.KeyPair; import java.util.List; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.LinkedBlockingDeque; /** @@ -47,11 +50,12 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv private BlockingQueue testAvailabilityActions = new LinkedBlockingDeque<>(); private final OIDCClientData oidcClientData = new OIDCClientData(); + private ConcurrentMap authenticationChannelRequests = new ConcurrentHashMap(); @Override public RealmResourceProvider create(KeycloakSession session) { TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions, - backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData); + backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests); ResteasyProviderFactory.getInstance().injectProperties(provider); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/representation/TestAuthenticationChannelRequest.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/representation/TestAuthenticationChannelRequest.java new file mode 100644 index 0000000000..3fa193004a --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/representation/TestAuthenticationChannelRequest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021 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.rest.representation; + +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest; + +/** + * @author Pedro Igor + */ +public class TestAuthenticationChannelRequest { + + private String bearerToken; + private AuthenticationChannelRequest request; + + public TestAuthenticationChannelRequest() { + // for reflection + } + + public TestAuthenticationChannelRequest(AuthenticationChannelRequest request, String bearerToken) { + setBearerToken(bearerToken); + setRequest(request); + } + + public void setBearerToken(String bearerToken) { + this.bearerToken = bearerToken; + } + + public String getBearerToken() { + return bearerToken; + } + + public void setRequest(AuthenticationChannelRequest request) { + this.request = request; + } + + public AuthenticationChannelRequest getRequest() { + return request; + } +} diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java index b3cd9888fd..2939e36929 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/rest/resource/TestingOIDCEndpointsApplicationResource.java @@ -19,6 +19,8 @@ package org.keycloak.testsuite.rest.resource; import org.jboss.resteasy.annotations.cache.NoCache; import javax.ws.rs.BadRequestException; +import javax.ws.rs.Consumes; + import org.keycloak.OAuth2Constants; import org.keycloak.common.util.Base64; import org.keycloak.common.util.Base64Url; @@ -37,19 +39,32 @@ import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.jose.jws.JWSInput; +import org.keycloak.jose.jws.JWSInputException; import org.keycloak.models.Constants; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest; +import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProvider; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessToken; import org.keycloak.representations.JsonWebToken; +import org.keycloak.services.managers.AppAuthManager; import org.keycloak.testsuite.rest.TestApplicationResourceProviderFactory; +import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; import org.keycloak.util.JsonSerialization; import com.fasterxml.jackson.annotation.JsonProperty; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; import java.io.IOException; import java.security.InvalidAlgorithmParameterException; @@ -63,6 +78,10 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; /** * @author Marek Posolda @@ -73,9 +92,13 @@ public class TestingOIDCEndpointsApplicationResource { public static final String PUBLIC_KEY = "publicKey"; private final TestApplicationResourceProviderFactory.OIDCClientData clientData; + private final ConcurrentMap authenticationChannelRequests; - public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) { + + public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData, + ConcurrentMap authenticationChannelRequests) { this.clientData = oidcClientData; + this.authenticationChannelRequests = authenticationChannelRequests; } @GET @@ -490,6 +513,50 @@ public class TestingOIDCEndpointsApplicationResource { public void setAction(String action) { this.action = action; } + } + @POST + @Path("/request-authentication-channel") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public Response requestAuthenticationChannel(@Context HttpHeaders headers, AuthenticationChannelRequest request) { + String rawBearerToken = AppAuthManager.extractAuthorizationHeaderToken(headers); + AccessToken bearerToken; + + try { + bearerToken = new JWSInput(rawBearerToken).readJsonContent(AccessToken.class); + } catch (JWSInputException e) { + throw new RuntimeException("Failed to parse bearer token", e); + } + + // required + String authenticationChannelId = bearerToken.getId(); + if (authenticationChannelId == null) throw new BadRequestException("missing parameter : " + HttpAuthenticationChannelProvider.AUTHENTICATION_CHANNEL_ID); + + String loginHint = request.getLoginHint(); + if (loginHint == null) throw new BadRequestException("missing parameter : " + CibaGrantType.LOGIN_HINT); + + if (request.getConsentRequired() == null) + throw new BadRequestException("missing parameter : " + CibaGrantType.IS_CONSENT_REQUIRED); + + String scope = request.getScope(); + if (scope == null) throw new BadRequestException("missing parameter : " + OAuth2Constants.SCOPE); + + // optional + // for testing purpose + if (request.getBindingMessage() != null && request.getBindingMessage().equals("GODOWN")) throw new BadRequestException("intentional error : GODOWN"); + + authenticationChannelRequests.put(request.getBindingMessage(), new TestAuthenticationChannelRequest(request, rawBearerToken)); + + return Response.status(Status.CREATED).build(); + } + + @GET + @Path("/get-authentication-channel") + @Produces(MediaType.APPLICATION_JSON) + @NoCache + public TestAuthenticationChannelRequest getAuthenticationChannel(@QueryParam("bindingMessage") String bindingMessage) { + return authenticationChannelRequests.get(bindingMessage); } } diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory new file mode 100644 index 0000000000..87e6f7d720 --- /dev/null +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/resources/META-INF/services/org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProviderFactory @@ -0,0 +1,20 @@ +# +# /* +# * Copyright 2021 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.testsuite.authentication.TestHttpAuthenticationChannelProviderFactory \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java index c049f7f274..a6e6896980 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/AuthServerTestEnricher.java @@ -182,6 +182,13 @@ public class AuthServerTestEnricher { return removeDefaultPorts(String.format("%s://%s:%s", "http", host, httpPort)); } + public static String getHttpsAuthServerContextRoot() { + String host = System.getProperty("auth.server.host", "localhost"); + int httpPort = Integer.parseInt(System.getProperty("auth.server.https.port")); // property must be set + + return removeDefaultPorts(String.format("%s://%s:%s", "https", host, httpPort)); + } + public static String getAuthServerBrowserContextRoot() throws MalformedURLException { return getAuthServerBrowserContextRoot(new URL(getAuthServerContextRoot())); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java index e0f4dcbfa2..9e50678c62 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/client/resources/TestOIDCEndpointsApplicationResource.java @@ -17,13 +17,20 @@ package org.keycloak.testsuite.client.resources; +import org.jboss.resteasy.annotations.cache.NoCache; import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; +import javax.ws.rs.Consumes; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + import java.util.List; import java.util.Map; @@ -80,4 +87,16 @@ public interface TestOIDCEndpointsApplicationResource { @Produces(MediaType.APPLICATION_JSON) List getSectorIdentifierRedirectUris(); + @POST + @Path("/request-authentication-channel") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + @NoCache + Response requestAuthenticationChannel(final MultivaluedMap request); + + @GET + @Path("/get-authentication-channel") + @Produces(MediaType.APPLICATION_JSON) + TestAuthenticationChannelRequest getAuthenticationChannel(@QueryParam("bindingMessage") String bindingMessage); + } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 9b64fc7b56..83f1b57427 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -17,6 +17,9 @@ package org.keycloak.testsuite.util; +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM; +import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.AUTH_REQ_ID; +import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.BINDING_MESSAGE; import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.oauth2DeviceAuthUrl; import static org.keycloak.testsuite.admin.Users.getPasswordOf; import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; @@ -34,6 +37,8 @@ import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpOptions; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.URLEncodedUtils; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.message.BasicNameValuePair; @@ -59,9 +64,10 @@ import org.keycloak.jose.jwk.JWKParser; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; -import org.keycloak.protocol.oidc.grants.device.DeviceGrantType; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.representations.AccessToken; @@ -705,6 +711,75 @@ public class OAuthClient { } } + public AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequest(String clientId, String clientSecret, String userid, String bindingMessage, String acrValues) throws Exception { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + HttpPost post = new HttpPost(getBackchannelAuthenticationUrl()); + + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + + List parameters = new LinkedList<>(); + if (userid != null) parameters.add(new BasicNameValuePair(LOGIN_HINT_PARAM, userid)); + if (bindingMessage != null) parameters.add(new BasicNameValuePair(BINDING_MESSAGE, bindingMessage)); + if (acrValues != null) parameters.add(new BasicNameValuePair(OAuth2Constants.ACR_VALUES, acrValues)); + if (scope != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID + " " + scope)); + } else { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AuthenticationRequestAcknowledgement(client.execute(post)); + } + } + + public int doAuthenticationChannelCallback(String requestToken, AuthenticationChannelResponse.Status authStatus) throws Exception { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + HttpPost post = new HttpPost(getAuthenticationChannelCallbackUrl()); + + String authorization = TokenUtil.TOKEN_TYPE_BEARER + " " + requestToken; + post.setHeader("Authorization", authorization); + + post.setEntity(new StringEntity(JsonSerialization.writeValueAsString(new AuthenticationChannelResponse(authStatus)), ContentType.APPLICATION_JSON)); + + return client.execute(post).getStatusLine().getStatusCode(); + } + } + + public AccessTokenResponse doBackchannelAuthenticationTokenRequest(String clientSecret, String authReqId) throws Exception { + return doBackchannelAuthenticationTokenRequest(this.clientId, clientSecret, authReqId); + } + + public AccessTokenResponse doBackchannelAuthenticationTokenRequest(String clientId, String clientSecret, String authReqId) throws Exception { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + HttpPost post = new HttpPost(getBackchannelAuthenticationTokenRequestUrl()); + + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE)); + parameters.add(new BasicNameValuePair(AUTH_REQ_ID, authReqId)); + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AccessTokenResponse(client.execute(post)); + } + } + // KEYCLOAK-6771 Certificate Bound Token public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) { @@ -1215,6 +1290,21 @@ public class OAuthClient { return b.build(realm).toString(); } + public String getBackchannelAuthenticationUrl() { + UriBuilder b = CibaGrantType.authorizationUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + + public String getAuthenticationChannelCallbackUrl() { + UriBuilder b = CibaGrantType.authenticationUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + + public String getBackchannelAuthenticationTokenRequestUrl() { + UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + public OAuthClient baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; @@ -1437,6 +1527,78 @@ public class OAuthClient { } } + public static class AuthenticationRequestAcknowledgement { + private int statusCode; + private Map headers; + + private String authReqId; + private int expiresIn; + private int interval = -1; + + private String error; + private String errorDescription; + + public AuthenticationRequestAcknowledgement(CloseableHttpResponse response) throws Exception { + try { + statusCode = response.getStatusLine().getStatusCode(); + + headers = new HashMap<>(); + + for (Header h : response.getAllHeaders()) { + headers.put(h.getName(), h.getValue()); + } + + Header[] contentTypeHeaders = response.getHeaders("Content-Type"); + String contentType = (contentTypeHeaders != null && contentTypeHeaders.length > 0) ? contentTypeHeaders[0].getValue() : null; + if (!"application/json".equals(contentType)) { + Assert.fail("Invalid content type. Status: " + statusCode + ", contentType: " + contentType); + } + + String s = IOUtils.toString(response.getEntity().getContent(), "UTF-8"); + Map responseJson = JsonSerialization.readValue(s, Map.class); + if (statusCode == 200) { + authReqId = (String) responseJson.get("auth_req_id"); + expiresIn = (Integer) responseJson.get("expires_in"); + if (responseJson.containsKey("interval")) interval = (Integer) responseJson.get("interval"); + } else { + error = (String) responseJson.get(OAuth2Constants.ERROR); + errorDescription = responseJson.containsKey(OAuth2Constants.ERROR_DESCRIPTION) ? (String) responseJson.get(OAuth2Constants.ERROR_DESCRIPTION) : null; + } + } finally { + response.close(); + } + } + + public int getStatusCode() { + return statusCode; + } + + public Map getHeaders() { + return headers; + } + + public String getAuthReqId() { + return authReqId; + } + + public int getExpiresIn() { + return expiresIn; + } + + public int getInterval() { + return interval; + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + + } + public static class AccessTokenResponse { private int statusCode; diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java index 61643fdebb..4ef2bf046c 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -199,6 +199,16 @@ public class AssertEvents implements TestRule { return expect(event).client("account"); } + public ExpectedEvent expectAuthReqIdToToken(String codeId, String sessionId) { + return expect(EventType.AUTHREQID_TO_TOKEN) + .detail(Details.CODE_ID, codeId) + .detail(Details.TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) + .session(isUUID()); + } + public ExpectedEvent expect(EventType event) { return new ExpectedEvent() .realm(defaultRealmId()) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java index 37097dd6ea..b7b358f93e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/PermissionsTest.java @@ -1958,7 +1958,7 @@ public class PermissionsTest extends AbstractKeycloakTest { } private void assertGettersEmpty(RealmRepresentation rep) { - assertGettersEmpty(rep, "getRealm"); + assertGettersEmpty(rep, "getRealm", "getAttributesOrEmpty"); } private void assertGettersEmpty(ClientRepresentation rep) { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java index d1cfe7d3f3..be649a67f6 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/realm/RealmTest.java @@ -30,6 +30,7 @@ import org.keycloak.events.EventType; import org.keycloak.events.admin.OperationType; import org.keycloak.events.admin.ResourceType; import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory; +import org.keycloak.models.CibaConfig; import org.keycloak.models.Constants; import org.keycloak.models.OAuth2DeviceConfig; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -179,6 +180,12 @@ public class RealmTest extends AbstractAdminTest { try { RealmRepresentation rep2 = adminClient.realm("attributes").toRepresentation(); + if (rep2.getAttributes() != null) { + Arrays.asList(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, + CibaConfig.CIBA_EXPIRES_IN, + CibaConfig.CIBA_INTERVAL, + CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT).stream().forEach(i -> rep2.getAttributes().remove(i)); + } Map attributes = rep2.getAttributes(); assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()), diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java new file mode 100644 index 0000000000..d5c4af54ed --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/CIBATest.java @@ -0,0 +1,1393 @@ +/* + * 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.client; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.containsString; + +import javax.ws.rs.core.Response.Status; + +import org.apache.http.client.methods.CloseableHttpResponse; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; +import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.CANCELLED; +import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.SUCCEED; +import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.UNAUTHORIZED; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; + +import org.hamcrest.CoreMatchers; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.admin.client.resource.ClientResource; +import org.keycloak.client.registration.Auth; +import org.keycloak.client.registration.ClientRegistration; +import org.keycloak.client.registration.ClientRegistrationException; +import org.keycloak.common.Profile; +import org.keycloak.events.Details; +import org.keycloak.events.Errors; +import org.keycloak.events.EventType; +import org.keycloak.models.CibaConfig; +import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest; +import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; +import org.keycloak.representations.idm.ClientInitialAccessPresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.representations.oidc.TokenMetadataRepresentation; +import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; +import org.keycloak.testsuite.AssertEvents; +import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource; +import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest; +import org.keycloak.testsuite.util.InfinispanTestTimeServiceRule; +import org.keycloak.testsuite.util.KeycloakModelUtils; +import org.keycloak.testsuite.util.Matchers; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.ServerURLs; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.testsuite.util.OAuthClient.AuthenticationRequestAcknowledgement; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * @author Takashi Norimatsu + */ +@EnableFeature(value = Profile.Feature.CIBA, skipRestart = true) +@AuthServerContainerExclude({REMOTE, QUARKUS}) +public class CIBATest extends AbstractTestRealmKeycloakTest { + + private final String SECOND_TEST_CLIENT_NAME = "test-second-client"; + private final String SECOND_TEST_CLIENT_SECRET = "passwort-test-second-client"; + private static final String ERR_MSG_CLIENT_REG_FAIL = "Failed to send request"; + + private ClientRegistration reg; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Rule + public InfinispanTestTimeServiceRule ispnTestTimeService = new InfinispanTestTimeServiceRule(this); + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + + UserRepresentation user = UserBuilder.create() + .username("nutzername-schwarz") + .email("schwarz@test.example.com") + .enabled(true) + .password("passwort-schwarz") + .addRoles("user", "offline_access") + .build(); + testRealm.getUsers().add(user); + + user = UserBuilder.create() + .username("nutzername-rot") + .email("rot@test.example.com") + .enabled(true) + .password("passwort-rot") + .addRoles("user", "offline_access") + .build(); + testRealm.getUsers().add(user); + + user = UserBuilder.create() + .username("nutzername-gelb") + .email("gelb@test.example.com") + .enabled(true) + .password("passwort-gelb") + .addRoles("user", "offline_access") + .build(); + testRealm.getUsers().add(user); + + user = UserBuilder.create() + .username("nutzername-deaktiviert") + .email("deaktiviert@test.example.com") + .enabled(false) + .password("passwort-deaktiviert") + .addRoles("user", "offline_access") + .build(); + testRealm.getUsers().add(user); + + ClientRepresentation confApp = KeycloakModelUtils.createClient(testRealm, SECOND_TEST_CLIENT_NAME); + confApp.setSecret(SECOND_TEST_CLIENT_SECRET); + confApp.setServiceAccountsEnabled(Boolean.TRUE); + } + + @Before + public void before() throws Exception { + // get initial access token for Dynamic Client Registration with authentication + reg = ClientRegistration.create().url(suiteContext.getAuthServerInfo().getContextRoot() + "/auth", TEST_REALM_NAME).build(); + ClientInitialAccessPresentation token = adminClient.realm(TEST_REALM_NAME).clientInitialAccess().create(new ClientInitialAccessCreatePresentation(0, 10)); + reg.auth(Auth.token(token)); + } + + @After + public void after() throws Exception { + reg.close(); + } + + private String cibaBackchannelTokenDeliveryMode; + private Integer cibaExpiresIn; + private Integer cibaInterval; + private String cibaAuthRequestedUserHint; + + private final String TEST_REALM_NAME = "test"; + private final String TEST_CLIENT_NAME = "test-app"; + private final String TEST_CLIENT_PASSWORD = "password"; + + @Test + public void testAttackerClientUseVictimAuthReqIdAttack() throws Exception { + ClientResource victimClientResource = null; + ClientResource attackerClientResource = null; + ClientRepresentation victimClientRep = null; + ClientRepresentation attackerClientRep = null; + try { + final String username = "nutzername-gelb"; + final String victimClientName = "test-app-scope"; + final String attackerClientName = TEST_CLIENT_NAME; + final String victimClientPassword = "password"; + final String attackerClientPassword = TEST_CLIENT_PASSWORD; + String victimClientAuthReqId = null; + victimClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), victimClientName); + victimClientRep = victimClientResource.toRepresentation(); + prepareCIBASettings(victimClientResource, victimClientRep); + attackerClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), attackerClientName); + attackerClientRep = attackerClientResource.toRepresentation(); + prepareCIBASettings(attackerClientResource, attackerClientRep); + + // victim client Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(victimClientName, victimClientPassword, username, "asdfghjkl"); + victimClientAuthReqId = response.getAuthReqId(); + + // victim client Authentication Channel Request + TestAuthenticationChannelRequest victimClientAuthenticationChannelReq = doAuthenticationChannelRequest("asdfghjkl"); + + // victim client Authentication Channel completed + doAuthenticationChannelCallback(victimClientAuthenticationChannelReq); + + // attacker client Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(attackerClientName, attackerClientPassword, victimClientAuthReqId); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.INVALID_GRANT))); + Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("unauthorized client"))); + } finally { + revertCIBASettings(victimClientResource, victimClientRep); + revertCIBASettings(attackerClientResource, attackerClientRep); + } + } + + // This tests that client should *not* be allowed to do whole CIBA flow by himself without any interaction from the user + @Test + public void testAttackerClientUseAuthReqIdInCallbackEndpoint() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // client sends Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // This request should not ever pass. Client should not be allowed to send the successfull "approve" request to the BackchannelAuthenticationCallbackEndpoint + // with using the "authReqId" as a bearer token + int statusCode = oauth.doAuthenticationChannelCallback(response.getAuthReqId(), SUCCEED); + Assert.assertThat(statusCode, is(equalTo(403))); + + // client sends TokenRequest - This should not pass and should return 400 + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.AUTHORIZATION_PENDING))); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testAuthenticationChannelUnexpectedError() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String signal = "GODOWN"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, signal, null); + Assert.assertThat(response.getStatusCode(), is(equalTo(503))); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.INVALID_GRANT))); + Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("Invalid Auth Req ID"))); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testBackchannelAuthnReqWithDeactivatedUser() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-deaktiviert"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, null, "acr2"); + Assert.assertThat(response.getStatusCode(), is(equalTo(400))); + Assert.assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testBackchannelAuthnReqWithUnknownUser() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "Unbekannt"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage, "urn:mace:incommon:iap:silver urn:mace:incommon:iap:gold"); + Assert.assertThat(response.getStatusCode(), is(equalTo(400))); + Assert.assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testBackchannelAuthnReqWithoutLoginHint() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = null; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage, "ACR1"); + Assert.assertThat(response.getStatusCode(), is(equalTo(400))); + Assert.assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testLoginHintTokenRequiredButNotSend() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-schwarz"; + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, CibaGrantType.LOGIN_HINT_TOKEN); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, null, null); + Assert.assertThat(response.getStatusCode(), is(equalTo(400))); + Assert.assertThat(response.getError(), is(OAuthErrorException.INVALID_REQUEST)); + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + @Ignore("Should never happen because the AD does not send any information about the user but only the status of the authentication") + public void testDifferentUserAuthenticated() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String usernameToBeAuthenticated = "nutzername-rot"; + final String usernameAuthenticated = "nutzername-gelb"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, usernameToBeAuthenticated, bindingMessage); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest(bindingMessage); + Assert.assertThat(authenticationChannelReq.getRequest().getScope(), is(containsString(OAuth2Constants.OFFLINE_ACCESS))); + + // different user Authentication Channel completed +// oauth.doAuthenticationChannelCallback(SECOND_TEST_CLIENT_NAME, SECOND_TEST_CLIENT_SECRET, usernameAuthenticated, authenticationChannelReq.getBearerToken(), SUCCEEDED); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.INVALID_GRANT)); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testTokenRevocation() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest(bindingMessage); + Assert.assertThat(authenticationChannelReq.getRequest().getScope(), is(containsString(OAuth2Constants.OFFLINE_ACCESS))); + + // user Authentication Channel completed + EventRepresentation loginEvent = doAuthenticationChannelCallback(authenticationChannelReq); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String userId = loginEvent.getUserId(); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequest(username, response.getAuthReqId()); + + // token introspection + String tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // token refresh + tokenRes = doRefreshTokenRequest(tokenRes.getRefreshToken(), username, sessionId, true); + + // token introspection after token refresh + tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // revoke by refresh token + EventRepresentation logoutEvent = doTokenRevokeByRefreshToken(tokenRes.getRefreshToken(), sessionId, userId, true); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testChangeInterval() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String firstUsername = "nutzername-schwarz"; + final String secondUsername = "nutzername-rot"; + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // first user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, firstUsername, "lbies8e"); + Assert.assertThat(response.getInterval(), is(equalTo(5))); + // dequeue user Authentication Channel Request by first user to revert the initial setting of the queue + doAuthenticationChannelRequest("lbies8e"); + + // set interval + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(1200)); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(10)); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // first user Token Request + // second user Backchannel Authentication Request + response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, secondUsername, "Keb9eser"); + Assert.assertThat(response.getInterval(), is(equalTo(10))); + // dequeue user Authentication Channel Request by second user to revert the initial setting of the queue + doAuthenticationChannelRequest("Keb9eser"); + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + public void testAccessThrottling() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(3)); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest(bindingMessage); + Assert.assertThat(authenticationChannelReq.getRequest().getBindingMessage(), is(equalTo(bindingMessage))); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.AUTHORIZATION_PENDING)); // 10+5+5 sec + + tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.SLOW_DOWN)); // 10+5+5 sec + + tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.SLOW_DOWN)); // 10+5+5+5 sec + + // user Authentication Channel completed + EventRepresentation loginEvent = doAuthenticationChannelCallback(authenticationChannelReq); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String userId = loginEvent.getUserId(); + + setTimeOffset(3); + + tokenRes = doBackchannelAuthenticationTokenRequest(username, response.getAuthReqId()); + + // token introspection + String tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // token refresh + tokenRes = doRefreshTokenRequest(tokenRes.getRefreshToken(), username, sessionId, false); + + // token introspection after token refresh + tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // revoke by refresh token + EventRepresentation logoutEvent = doTokenRevokeByRefreshToken(tokenRes.getRefreshToken(), sessionId, userId, false); + + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + public void testTokenRequestAfterIntervalButNotYetAuthenticated() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(5)); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // user Token Request but not yet user being authenticated + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.AUTHORIZATION_PENDING)); + + // user Token Request but not yet user being authenticated + tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.SLOW_DOWN)); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest(bindingMessage); + Assert.assertThat(authenticationChannelReq.getRequest().getBindingMessage(), is(equalTo(bindingMessage))); + + // user Authentication Channel completed + EventRepresentation loginEvent = doAuthenticationChannelCallback(authenticationChannelReq); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String userId = loginEvent.getUserId(); + + setTimeOffset(5); + + // user Token Request again + tokenRes = doBackchannelAuthenticationTokenRequest(username, response.getAuthReqId()); + + // token introspection + String tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // token refresh + tokenRes = doRefreshTokenRequest(tokenRes.getRefreshToken(), username, sessionId, false); + + // token introspection after token refresh + tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // revoke by refresh token + EventRepresentation logoutEvent = doTokenRevokeByRefreshToken(tokenRes.getRefreshToken(), sessionId, userId, false); + + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + public void testCIBAPolicy() { + try { + // null input - default values used + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, null); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, null); + attrMap.put(CibaConfig.CIBA_INTERVAL, null); + attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, null); + rep.setAttributes(attrMap); + testRealm().update(rep); + + rep = testRealm().toRepresentation(); + attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + Assert.assertThat(attrMap.get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE), is(equalTo("poll"))); + Assert.assertThat(Integer.parseInt(attrMap.get(CibaConfig.CIBA_EXPIRES_IN)), is(equalTo(120))); + Assert.assertThat(Integer.parseInt(attrMap.get(CibaConfig.CIBA_INTERVAL)), is(equalTo(5))); + Assert.assertThat(attrMap.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT), is(equalTo("login_hint"))); + + // valid input + rep = backupCIBAPolicy(); + attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, "poll"); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(736)); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(7)); + attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, "login_hint"); + rep.setAttributes(attrMap); + testRealm().update(rep); + + rep = testRealm().toRepresentation(); + Assert.assertThat(attrMap.get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE), is(equalTo("poll"))); + Assert.assertThat(Integer.parseInt(attrMap.get(CibaConfig.CIBA_EXPIRES_IN)), is(equalTo(736))); + Assert.assertThat(Integer.parseInt(attrMap.get(CibaConfig.CIBA_INTERVAL)), is(equalTo(7))); + Assert.assertThat(attrMap.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT), is(equalTo("login_hint"))); + } finally { + restoreCIBAPolicy(); + } + } + + @Test + public void testBackchannelAuthenticationFlow() throws Exception { + testBackchannelAuthenticationFlow(false); + } + + @Test + public void testBackchannelAuthenticationFlowOfflineAccess() throws Exception { + testBackchannelAuthenticationFlow(true); + } + + @Test + public void testMultipleUsersBackchannelAuthenticationFlows() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String firstUsername = "nutzername-schwarz"; + final String secondUsername = "nutzername-rot"; + String firstUserAuthReqId = null; + String secondUserAuthReqId = null; + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // first user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, firstUsername, "Pjb9eD8w"); + firstUserAuthReqId = response.getAuthReqId(); + + // first user Authentication Channel Request + TestAuthenticationChannelRequest firstUserAuthenticationChannelReq = doAuthenticationChannelRequest("Pjb9eD8w"); + + // first user Authentication Channel completed + EventRepresentation firstUserloginEvent = doAuthenticationChannelCallback(firstUserAuthenticationChannelReq); + String firstUserSessionId = firstUserloginEvent.getSessionId(); + String firstUserSessionCodeId = firstUserloginEvent.getDetails().get(Details.CODE_ID); + + // second user Backchannel Authentication Request + response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, secondUsername, "dEkg8vDdsl"); + secondUserAuthReqId = response.getAuthReqId(); + + // second user Authentication Channel Request + TestAuthenticationChannelRequest secondUserAuthenticationChannelReq = doAuthenticationChannelRequest("dEkg8vDdsl"); + + // second user Authentication Channel completed + EventRepresentation secondUserloginEvent = doAuthenticationChannelCallback(secondUserAuthenticationChannelReq); + String secondUserSessionId = secondUserloginEvent.getSessionId(); + String secondUserSessionCodeId = secondUserloginEvent.getDetails().get(Details.CODE_ID); + + // second user Token Request + OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequest(secondUsername, secondUserAuthReqId); + + // first user Token Request + tokenRes = doBackchannelAuthenticationTokenRequest(firstUsername, firstUserAuthReqId); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testExplicitConsentRequiredBackchannelAuthenticationFlows() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-gelb"; + final String clientName = "third-party"; // see testrealm.json : "consentRequired": true + final String clientPassword = "password"; + String clientAuthReqId = null; + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), clientName); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // client Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(clientName, clientPassword, username, "asdfghjkl"); + clientAuthReqId = response.getAuthReqId(); + + // client Authentication Channel Request + TestAuthenticationChannelRequest clientAuthenticationChannelReq = doAuthenticationChannelRequest("asdfghjkl"); + Assert.assertTrue(clientAuthenticationChannelReq.getRequest().getConsentRequired()); + Assert.assertThat(clientAuthenticationChannelReq.getRequest().getScope(), is(containsString(OAuth2Constants.SCOPE_OPENID))); + Assert.assertThat(clientAuthenticationChannelReq.getRequest().getScope(), is(containsString("email"))); + Assert.assertThat(clientAuthenticationChannelReq.getRequest().getScope(), is(containsString("profile"))); + Assert.assertThat(clientAuthenticationChannelReq.getRequest().getScope(), is(containsString("roles"))); + + // client Authentication Channel completed + EventRepresentation clientloginEvent = doAuthenticationChannelCallback(clientAuthenticationChannelReq); + String clientSessionId = clientloginEvent.getSessionId(); + String clientSessionCodeId = clientloginEvent.getDetails().get(Details.CODE_ID); + + // client Token Request + OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequest(clientName, clientPassword, username, clientAuthReqId); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testMultipleClientsBackchannelAuthenticationFlows() throws Exception { + ClientResource firstClientResource = null; + ClientResource secondClientResource = null; + ClientRepresentation firstClientRep = null; + ClientRepresentation secondClientRep = null; + try { + final String username = "nutzername-gelb"; + final String firstClientName = "test-app-scope"; // see testrealm.json + final String secondClientName = TEST_CLIENT_NAME; + final String firstClientPassword = "password"; + final String secondClientPassword = TEST_CLIENT_PASSWORD; + String firstClientAuthReqId = null; + String secondClientAuthReqId = null; + firstClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), firstClientName); + firstClientRep = firstClientResource.toRepresentation(); + prepareCIBASettings(firstClientResource, firstClientRep); + secondClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), secondClientName); + secondClientRep = secondClientResource.toRepresentation(); + prepareCIBASettings(secondClientResource, secondClientRep); + + // first client Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(firstClientName, firstClientPassword, username, "asdfghjkl"); + firstClientAuthReqId = response.getAuthReqId(); + + // first client Authentication Channel Request + TestAuthenticationChannelRequest firstClientAuthenticationChannelReq = doAuthenticationChannelRequest("asdfghjkl"); + + // first client Authentication Channel completed + EventRepresentation firstClientloginEvent = doAuthenticationChannelCallback(firstClientAuthenticationChannelReq); + String firstClientSessionId = null; + String firstClientSessionCodeId = null; + + // second client Backchannel Authentication Request + response = doBackchannelAuthenticationRequest(secondClientName, secondClientPassword, username, "qwertyui"); + secondClientAuthReqId = response.getAuthReqId(); + + // second client Authentication Channel Request + TestAuthenticationChannelRequest secondClientAuthenticationChannelReq = doAuthenticationChannelRequest("qwertyui"); + + // second client Authentication Channel completed + EventRepresentation secondClientloginEvent = doAuthenticationChannelCallback(secondClientAuthenticationChannelReq); + String secondClientSessionId = null; + String secondClientSessionCodeId = null; + + // second client Token Request + OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequest(secondClientName, secondClientPassword, username, secondClientAuthReqId); + + // first client Token Request + tokenRes = doBackchannelAuthenticationTokenRequest(firstClientName, firstClientPassword, username, firstClientAuthReqId); + + } finally { + revertCIBASettings(firstClientResource, firstClientRep); + revertCIBASettings(secondClientResource, secondClientRep); + } + } + + @Test + public void testRequestTokenBeforeAuthenticationNotCompleted() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "kvoDKw98"); + + // user Token Request before Authentication Channel completion + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.AUTHORIZATION_PENDING)); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("kvoDKw98"); + + // user Authentication Channel completed + doAuthenticationChannelCallback(authenticationChannelReq); + + setTimeOffset(6); + + // user Token Request after Authentication Channel completion + tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(200))); + + IDToken idToken = oauth.verifyIDToken(tokenRes.getIdToken()); + Assert.assertThat(idToken.getPreferredUsername(), is(equalTo(username))); + + AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken()); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testRequestTokenAfterAuthReqIdExpired() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(60)); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "obkes8dke1"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("obkes8dke1"); + + // user Authentication Channel completed + doAuthenticationChannelCallback(authenticationChannelReq); + + setTimeOffset(70); + + // user Token Request before Authentication Channel completion + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.EXPIRED_TOKEN)); + + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + public void testCallbackAfterAuthenticationRequestExpired() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + RealmRepresentation rep = backupCIBAPolicy(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(60)); + rep.setAttributes(attrMap); + testRealm().update(rep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "3FIekcs9"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("3FIekcs9"); + + setTimeOffset(70); + + int statusCode = oauth.doAuthenticationChannelCallback(authenticationChannelReq.getBearerToken(), SUCCEED); + Assert.assertThat(statusCode, is(equalTo(Status.FORBIDDEN.getStatusCode()))); + events.expect(EventType.LOGIN_ERROR).clearDetails().client((String) null).error(Errors.INVALID_TOKEN).user((String)null).session(CoreMatchers.nullValue(String.class)).assertEvent(); + } finally { + revertCIBASettings(clientResource, clientRep); + restoreCIBAPolicy(); + } + } + + @Test + public void testDuplicatedTokenRequestWithSameAuthReqId() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-gelb"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "kciwje86"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("kciwje86"); + + // user Authentication Channel completed + doAuthenticationChannelCallback(authenticationChannelReq); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(200))); + + IDToken idToken = oauth.verifyIDToken(tokenRes.getIdToken()); + Assert.assertThat(idToken.getPreferredUsername(), is(equalTo(username))); + + AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken()); + + // duplicate user Token Request + tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.INVALID_GRANT)); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testOtherClientSendTokenRequest() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "ldkq366"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("ldkq366"); + + // user Authentication Channel completed + doAuthenticationChannelCallback(authenticationChannelReq); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(SECOND_TEST_CLIENT_NAME, SECOND_TEST_CLIENT_SECRET, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.INVALID_GRANT)); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testAuthenticationChannelUnauthorized() throws Exception { + testAuthenticationChannelErrorCase(Status.OK, Status.FORBIDDEN, UNAUTHORIZED, OAuthErrorException.ACCESS_DENIED, Errors.CONSENT_DENIED); + } + + @Test + public void testAuthenticationChannelCancelled() throws Exception { + testAuthenticationChannelErrorCase(Status.OK, Status.FORBIDDEN, CANCELLED, OAuthErrorException.ACCESS_DENIED, Errors.NOT_ALLOWED); + } + + @Test + public void testAuthenticationChannelUnknown() throws Exception { + testAuthenticationChannelErrorCase(Status.BAD_REQUEST, Status.BAD_REQUEST, null, OAuthErrorException.AUTHORIZATION_PENDING, Errors.INVALID_REQUEST); + } + + @Test + public void testInvalidConsumptionDeviceRegistration() throws Exception { + try { + createClientDynamically("invalid-CIBA-CD", (OIDCClientRepresentation clientRep) -> { + clientRep.setBackchannelTokenDeliveryMode("pushpush"); + }); + fail(); + } catch (ClientRegistrationException e) { + assertEquals(ERR_MSG_CLIENT_REG_FAIL, e.getMessage()); + } + } + + @Test + public void testCibaGrantDeactivated() throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings with ciba grant deactivated + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + Map attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>()); + attributes.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT, "poll"); + attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, null); + clientRep.setAttributes(attributes); + clientResource.update(clientRep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "gilwekDe3", "acr2"); + Assert.assertThat(response.getStatusCode(), is(equalTo(400))); + Assert.assertThat(response.getError(), is(OAuthErrorException.INVALID_GRANT)); + Assert.assertThat(response.getErrorDescription(), is("Client not allowed OIDC CIBA Grant")); + + // activate ciba grant + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + attributes = clientRep.getAttributes(); + attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, Boolean.TRUE.toString()); + clientRep.setAttributes(attributes); + clientResource.update(clientRep); + + // user Backchannel Authentication Request + response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "Fkb4T3s"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("Fkb4T3s"); + + // user Authentication Channel completed + doAuthenticationChannelCallback(authenticationChannelReq); + + // deactivate ciba grant + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + attributes = clientRep.getAttributes(); + attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, Boolean.FALSE.toString()); + clientRep.setAttributes(attributes); + clientResource.update(clientRep); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(SECOND_TEST_CLIENT_NAME, SECOND_TEST_CLIENT_SECRET, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(OAuthErrorException.INVALID_GRANT)); + Assert.assertThat(tokenRes.getErrorDescription(), is("Client not allowed OIDC CIBA Grant")); + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + @Test + public void testCibaGrantSettingByDynamicClientRegistration() throws Exception { + String clientId = createClientDynamically("valid-CIBA-CD", (OIDCClientRepresentation clientRep) -> { + }); + + OIDCClientRepresentation rep = getClientDynamically(clientId); + Assert.assertTrue(!rep.getGrantTypes().contains(OAuth2Constants.CIBA_GRANT_TYPE)); + + updateClientDynamically(clientId, (OIDCClientRepresentation clientRep) -> { + List grantTypes = Optional.ofNullable(clientRep.getGrantTypes()).orElse(new ArrayList<>()); + grantTypes.add(OAuth2Constants.CIBA_GRANT_TYPE); + clientRep.setGrantTypes(grantTypes); + }); + + rep = getClientDynamically(clientId); + Assert.assertTrue(rep.getGrantTypes().contains(OAuth2Constants.CIBA_GRANT_TYPE)); + } + + private String createClientDynamically(String clientName, Consumer op) throws ClientRegistrationException { + OIDCClientRepresentation clientRep = new OIDCClientRepresentation(); + clientRep.setClientName(clientName); + clientRep.setClientUri(ServerURLs.getAuthServerContextRoot()); + clientRep.setRedirectUris(Collections.singletonList(ServerURLs.getAuthServerContextRoot() + "/auth/realms/master/app/auth")); + op.accept(clientRep); + OIDCClientRepresentation response = reg.oidc().create(clientRep); + reg.auth(Auth.token(response)); + // registered components will be removed automatically when a test method finishes regardless of its success or failure. + String clientId = response.getClientId(); + testContext.getOrCreateCleanup(TEST_REALM_NAME).addClientUuid(clientId); + return clientId; + } + + private OIDCClientRepresentation getClientDynamically(String clientId) throws ClientRegistrationException { + return reg.oidc().get(clientId); + } + + protected void updateClientDynamically(String clientId, Consumer op) throws ClientRegistrationException { + OIDCClientRepresentation clientRep = reg.oidc().get(clientId); + op.accept(clientRep); + OIDCClientRepresentation response = reg.oidc().update(clientRep); + reg.auth(Auth.token(response)); + } + + private void testAuthenticationChannelErrorCase(Status statusCallback, Status statusTokenEndpont, AuthenticationChannelResponse.Status authStatus, String error, String errorEvent) throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, "kwq26rfjs73"); + + // user Authentication Channel Request + TestAuthenticationChannelRequest authenticationChannelReq = doAuthenticationChannelRequest("kwq26rfjs73"); + + // user Authentication Channel completed + doAuthenticationChannelCallbackError(statusCallback, TEST_CLIENT_NAME, authenticationChannelReq, authStatus, username, errorEvent); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(TEST_CLIENT_PASSWORD, response.getAuthReqId()); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(statusTokenEndpont.getStatusCode()))); + Assert.assertThat(tokenRes.getError(), is(error)); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } + + private void prepareCIBASettings(ClientResource clientResource, ClientRepresentation clientRep) { + Map attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>()); + attributes.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT, "poll"); + attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, Boolean.TRUE.toString()); + clientRep.setAttributes(attributes); + clientResource.update(clientRep); + } + + private void revertCIBASettings(ClientResource clientResource, ClientRepresentation clientRep) { + Map attributes = Optional.ofNullable(clientRep.getAttributes()).orElse(new HashMap<>()); + attributes.remove(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT); + clientRep.setAttributes(attributes); + clientResource.update(clientRep); + } + + private RealmRepresentation backupCIBAPolicy() { + RealmRepresentation rep = testRealm().toRepresentation(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + cibaBackchannelTokenDeliveryMode = attrMap.get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE); + cibaExpiresIn = Integer.parseInt(attrMap.get(CibaConfig.CIBA_EXPIRES_IN)); + cibaInterval = Integer.parseInt(attrMap.get(CibaConfig.CIBA_INTERVAL)); + cibaAuthRequestedUserHint = attrMap.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT); + return rep; + } + + private void restoreCIBAPolicy() { + RealmRepresentation rep = testRealm().toRepresentation(); + Map attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, cibaBackchannelTokenDeliveryMode); + attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(cibaExpiresIn)); + attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(cibaInterval)); + attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, cibaAuthRequestedUserHint); + rep.setAttributes(attrMap); + testRealm().update(rep); + } + + private AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequest(String clientId, String clientSecret, String username, String bindingMessage) throws Exception { + AuthenticationRequestAcknowledgement response = oauth.doBackchannelAuthenticationRequest(clientId, clientSecret, username, bindingMessage, null); + Assert.assertThat(response.getStatusCode(), is(equalTo(200))); + Assert.assertNotNull(response.getAuthReqId()); + return response; + } + + private TestAuthenticationChannelRequest doAuthenticationChannelRequest(String bindingMessage) { + // get Authentication Channel Request keycloak has done on Backchannel Authentication Endpoint from the FIFO queue of testing Authentication Channel Request API + TestOIDCEndpointsApplicationResource oidcClientEndpointsResource = testingClient.testApp().oidcClientEndpoints(); + TestAuthenticationChannelRequest authenticationChannelReq = oidcClientEndpointsResource.getAuthenticationChannel(bindingMessage); + return authenticationChannelReq; + } + + private EventRepresentation doAuthenticationChannelCallback(TestAuthenticationChannelRequest request) throws Exception { + int statusCode = oauth.doAuthenticationChannelCallback(request.getBearerToken(), SUCCEED); + Assert.assertThat(statusCode, is(equalTo(200))); + // check login event : ignore user id and other details except for username + EventRepresentation representation = new EventRepresentation(); + + representation.setDetails(Collections.emptyMap()); + + return representation; + } + + private EventRepresentation doAuthenticationChannelCallbackError(Status status, String clientId, TestAuthenticationChannelRequest authenticationChannelReq, AuthenticationChannelResponse.Status authStatus, String username, String error) throws Exception { + int statusCode = oauth.doAuthenticationChannelCallback(authenticationChannelReq.getBearerToken(), authStatus); + Assert.assertThat(statusCode, is(equalTo(status.getStatusCode()))); + return events.expect(EventType.LOGIN_ERROR).clearDetails().client(clientId).error(error).user((String)null).session(CoreMatchers.nullValue(String.class)).assertEvent(); + } + + private OAuthClient.AccessTokenResponse doBackchannelAuthenticationTokenRequest(String username, String authReqId) throws Exception { + return doBackchannelAuthenticationTokenRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, authReqId); + } + + private OAuthClient.AccessTokenResponse doBackchannelAuthenticationTokenRequest(String clientId, String clientSecret, String username, String authReqId) throws Exception { + OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(clientId, clientSecret, authReqId); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(200))); + EventRepresentation event = events.expectAuthReqIdToToken(null, null).clearDetails().user(AssertEvents.isUUID()).client(clientId).assertEvent(); + + AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken()); + Assert.assertThat(accessToken.getIssuedFor(), is(equalTo(clientId))); + + RefreshToken refreshToken = oauth.parseRefreshToken(tokenRes.getRefreshToken()); + Assert.assertThat(refreshToken.getIssuedFor(), is(equalTo(clientId))); + Assert.assertThat(refreshToken.getAudience()[0], is(equalTo(refreshToken.getIssuer()))); + + IDToken idToken = oauth.verifyIDToken(tokenRes.getIdToken()); + Assert.assertThat(idToken.getPreferredUsername(), is(equalTo(username))); + Assert.assertThat(idToken.getIssuedFor(), is(equalTo(clientId))); + Assert.assertThat(idToken.getAudience()[0], is(equalTo(idToken.getIssuedFor()))); + + return tokenRes; + } + + private String doIntrospectAccessTokenWithClientCredential(OAuthClient.AccessTokenResponse tokenRes, String username) throws IOException { + String tokenResponse = oauth.introspectAccessTokenWithClientCredential(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, tokenRes.getAccessToken()); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(tokenResponse); + Assert.assertThat(jsonNode.get("active").asBoolean(), is(equalTo(true))); + Assert.assertThat(jsonNode.get("username").asText(), is(equalTo(username))); + Assert.assertThat(jsonNode.get("client_id").asText(), is(equalTo(TEST_CLIENT_NAME))); + TokenMetadataRepresentation rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class); + Assert.assertThat(rep.isActive(), is(equalTo(true))); + Assert.assertThat(rep.getClientId(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(rep.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + events.expect(EventType.INTROSPECT_TOKEN).user((String)null).clearDetails().assertEvent(); + + tokenResponse = oauth.introspectAccessTokenWithClientCredential(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, tokenRes.getRefreshToken()); + jsonNode = objectMapper.readTree(tokenResponse); + Assert.assertThat(jsonNode.get("active").asBoolean(), is(equalTo(true))); + Assert.assertThat(jsonNode.get("client_id").asText(), is(equalTo(TEST_CLIENT_NAME))); + rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class); + Assert.assertThat(rep.isActive(), is(equalTo(true))); + Assert.assertThat(rep.getClientId(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(rep.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(rep.getAudience()[0], is(equalTo(rep.getIssuer()))); + events.expect(EventType.INTROSPECT_TOKEN).user((String)null).clearDetails().assertEvent(); + + tokenResponse = oauth.introspectAccessTokenWithClientCredential(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, tokenRes.getIdToken()); + jsonNode = objectMapper.readTree(tokenResponse); + Assert.assertThat(jsonNode.get("active").asBoolean(), is(equalTo(true))); + Assert.assertThat(jsonNode.get("client_id").asText(), is(equalTo(TEST_CLIENT_NAME))); + rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class); + Assert.assertThat(rep.isActive(), is(equalTo(true))); + Assert.assertThat(rep.getUserName(), is(equalTo(username))); + Assert.assertThat(rep.getClientId(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(rep.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(rep.getPreferredUsername(), is(equalTo(username))); + Assert.assertThat(rep.getAudience()[0], is(equalTo(rep.getIssuedFor()))); + events.expect(EventType.INTROSPECT_TOKEN).user((String)null).clearDetails().assertEvent(); + + return tokenResponse; + } + + private OAuthClient.AccessTokenResponse doRefreshTokenRequest(String oldRefreshToken, String username, String sessionId, boolean isOfflineAccess) { + OAuthClient.AccessTokenResponse tokenRes = oauth.doRefreshTokenRequest(oldRefreshToken, TEST_CLIENT_PASSWORD); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(200))); + + AccessToken accessToken = oauth.verifyToken(tokenRes.getAccessToken()); + Assert.assertThat(accessToken.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(accessToken.getExp().longValue(), is(equalTo(accessToken.getIat().longValue() + tokenRes.getExpiresIn()))); + + RefreshToken refreshToken = oauth.parseRefreshToken(tokenRes.getRefreshToken()); + Assert.assertThat(refreshToken.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(refreshToken.getAudience()[0], is(equalTo(refreshToken.getIssuer()))); + if(!isOfflineAccess) Assert.assertThat(refreshToken.getExp().longValue(), is(equalTo(refreshToken.getIat().longValue() + tokenRes.getRefreshExpiresIn()))); + + IDToken idToken = oauth.verifyIDToken(tokenRes.getIdToken()); + Assert.assertThat(idToken.getPreferredUsername(), is(equalTo(username))); + Assert.assertThat(idToken.getIssuedFor(), is(equalTo(TEST_CLIENT_NAME))); + Assert.assertThat(idToken.getAudience()[0], is(equalTo(idToken.getIssuedFor()))); + Assert.assertThat(idToken.getExp().longValue(), is(equalTo(idToken.getIat().longValue() + tokenRes.getExpiresIn()))); + + events.expectRefresh(tokenRes.getRefreshToken(), sessionId).session(CoreMatchers.notNullValue(String.class)).user(AssertEvents.isUUID()).clearDetails().assertEvent(); + + return tokenRes; + } + + private EventRepresentation doLogoutByRefreshToken(String refreshToken, String sessionId, String userId, boolean isOfflineAccess) throws IOException{ + try (CloseableHttpResponse res = oauth.doLogout(refreshToken, TEST_CLIENT_PASSWORD)) { + assertThat(res, Matchers.statusCodeIsHC(Status.NO_CONTENT)); + } + + // confirm logged out + OAuthClient.AccessTokenResponse tokenRes = oauth.doRefreshTokenRequest(refreshToken, TEST_CLIENT_PASSWORD); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.INVALID_GRANT))); + if (isOfflineAccess) Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("Offline user session not found"))); + else Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("Session not active"))); + + return events.expectLogout(sessionId).client(TEST_CLIENT_NAME).user(AssertEvents.isUUID()).session(AssertEvents.isUUID()).clearDetails().assertEvent(); + } + + private EventRepresentation doTokenRevokeByRefreshToken(String refreshToken, String sessionId, String userId, boolean isOfflineAccess) throws IOException{ + try (CloseableHttpResponse res = oauth.doTokenRevoke(refreshToken, "refresh_token", TEST_CLIENT_PASSWORD)) { + assertThat(res, Matchers.statusCodeIsHC(Status.OK)); + } + + // confirm revocation + OAuthClient.AccessTokenResponse tokenRes = oauth.doRefreshTokenRequest(refreshToken, TEST_CLIENT_PASSWORD); + Assert.assertThat(tokenRes.getStatusCode(), is(equalTo(400))); + Assert.assertThat(tokenRes.getError(), is(equalTo(OAuthErrorException.INVALID_GRANT))); + if (isOfflineAccess) Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("Offline user session not found"))); + else Assert.assertThat(tokenRes.getErrorDescription(), is(equalTo("Session not active"))); + + return events.expect(EventType.REVOKE_GRANT).clearDetails().client(TEST_CLIENT_NAME).user(AssertEvents.isUUID()).assertEvent(); + } + + private void testBackchannelAuthenticationFlow(boolean isOfflineAccess) throws Exception { + ClientResource clientResource = null; + ClientRepresentation clientRep = null; + try { + final String username = "nutzername-rot"; + final String bindingMessage = "BASTION"; + + // prepare CIBA settings + clientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), TEST_CLIENT_NAME); + clientRep = clientResource.toRepresentation(); + prepareCIBASettings(clientResource, clientRep); + if(isOfflineAccess) oauth.scope(OAuth2Constants.OFFLINE_ACCESS); + + // user Backchannel Authentication Request + AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(TEST_CLIENT_NAME, TEST_CLIENT_PASSWORD, username, bindingMessage); + + // user Authentication Channel Request + TestAuthenticationChannelRequest testRequest = doAuthenticationChannelRequest(bindingMessage); + AuthenticationChannelRequest authenticationChannelReq = testRequest.getRequest(); + Assert.assertThat(authenticationChannelReq.getBindingMessage(), is(equalTo(bindingMessage))); + if (isOfflineAccess) Assert.assertThat(authenticationChannelReq.getScope(), is(containsString(OAuth2Constants.OFFLINE_ACCESS))); + Assert.assertThat(authenticationChannelReq.getScope(), is(containsString(OAuth2Constants.SCOPE_OPENID))); + + // user Authentication Channel completed + EventRepresentation loginEvent = doAuthenticationChannelCallback(testRequest); + String sessionId = loginEvent.getSessionId(); + String codeId = loginEvent.getDetails().get(Details.CODE_ID); + String userId = loginEvent.getUserId(); + + // user Token Request + OAuthClient.AccessTokenResponse tokenRes = doBackchannelAuthenticationTokenRequest(username, response.getAuthReqId()); + + // token introspection + String tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // token refresh + tokenRes = doRefreshTokenRequest(tokenRes.getRefreshToken(), username, sessionId, isOfflineAccess); + + // token introspection after token refresh + tokenResponse = doIntrospectAccessTokenWithClientCredential(tokenRes, username); + + // logout by refresh token + EventRepresentation logoutEvent = doLogoutByRefreshToken(tokenRes.getRefreshToken(), sessionId, userId, isOfflineAccess); + + } finally { + revertCIBASettings(clientResource, clientRep); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java index d55adc33d0..13b84dfa14 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/OIDCClientRegistrationTest.java @@ -31,6 +31,7 @@ import org.keycloak.common.util.CollectionUtil; import org.keycloak.events.Errors; import org.keycloak.jose.jwe.JWEConstants; import org.keycloak.jose.jws.Algorithm; +import org.keycloak.models.CibaConfig; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.utils.OIDCResponseType; @@ -49,7 +50,6 @@ import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.keycloak.testsuite.auth.page.AuthRealm.TEST; @@ -61,6 +61,7 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { private static final String PRIVATE_KEY = "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y="; private static final String PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB"; + private static final String ERR_MSG_CLIENT_REG_FAIL = "Failed to send request"; @Override public void addTestRealms(List testRealms) { @@ -383,6 +384,30 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest { } } + @Test + public void testCIBASettings() throws Exception { + OIDCClientRepresentation clientRep = null; + OIDCClientRepresentation response = null; + clientRep = createRep(); + clientRep.setBackchannelTokenDeliveryMode("poll"); + + response = reg.oidc().create(clientRep); + Assert.assertEquals("poll", response.getBackchannelTokenDeliveryMode()); + + // Test Keycloak representation + ClientRepresentation kcClient = getClient(response.getClientId()); + Assert.assertEquals("poll", kcClient.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT)); + + // update + clientRep.setBackchannelTokenDeliveryMode("ping"); + try { + reg.oidc().create(clientRep); + fail(); + } catch (ClientRegistrationException e) { + assertEquals(ERR_MSG_CLIENT_REG_FAIL, e.getMessage()); + } + } + @Test public void testOIDCEndpointCreateWithSamlClient() throws Exception { ClientsResource clientsResource = adminClient.realm(TEST).clients(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java index 463428c523..535cfa2694 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/OIDCWellKnownProviderTest.java @@ -110,6 +110,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { assertEquals(oidcConfig.getUserinfoEndpoint(), OIDCLoginProtocolService.userInfoUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build("test").toString()); assertEquals(oidcConfig.getJwksUri(), oauth.getCertsUrl("test")); + String registrationUri = UriBuilder .fromUri(OAuthClient.AUTH_SERVER_ROOT) .path(RealmsResource.class) @@ -168,6 +169,11 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2 Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens()); + // CIBA + assertEquals(oidcConfig.getBackchannelAuthenticationEndpoint(), oauth.getBackchannelAuthenticationUrl()); + assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.CIBA_GRANT_TYPE); + Assert.assertNames(oidcConfig.getBackchannelTokenDeliveryModesSupported(), "poll"); + Assert.assertTrue(oidcConfig.getBackchannelLogoutSupported()); Assert.assertTrue(oidcConfig.getBackchannelLogoutSessionSupported()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/log4j.properties b/testsuite/integration-arquillian/tests/base/src/test/resources/log4j.properties index eff5b25d71..02f1982044 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/log4j.properties +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/log4j.properties @@ -80,6 +80,7 @@ log4j.logger.org.keycloak.services.clientregistration.policy=debug # log4j.logger.org.keycloak.services.clientpolicy=trace # log4j.logger.org.keycloak.testsuite.clientpolicy=trace +# log4j.logger.org.keycloak.protocol.ciba=trace #log4j.logger.org.keycloak.authentication=debug diff --git a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json index d1ec21b82a..c66523adda 100755 --- a/testsuite/utils/src/main/resources/META-INF/keycloak-server.json +++ b/testsuite/utils/src/main/resources/META-INF/keycloak-server.json @@ -156,4 +156,5 @@ "certificateChainLength": 1 } } + } 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 ce9a857cbf..ccae25041a 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 @@ -331,6 +331,8 @@ service-accounts-enabled=Service Accounts Enabled service-accounts-enabled.tooltip=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client. oauth2-device-authorization-grant-enabled=OAuth 2.0 Device Authorization Grant Enabled oauth2-device-authorization-grant-enabled.tooltip=This enables support for OAuth 2.0 Device Authorization Grant, which means that client is an application on device that has limited input capabilities or lack a suitable browser. +oidc-ciba-grant-enabled=OIDC CIBA Grant Enabled +oidc-ciba-grant-enabled.tooltip=This enables support for OIDC CIBA Grant, which means that the user is authenticated via some external authentication device instead of the user's browser. include-authnstatement=Include AuthnStatement include-authnstatement.tooltip=Should a statement specifying the method and timestamp be included in login responses? include-onetimeuse-condition=Include OneTimeUse Condition @@ -1277,6 +1279,15 @@ manage-webauthn-authenticator=Manage WebAuthn Authenticator public-key-credential-id=Public Key Credential ID public-key-credential-aaguid=Public Key Credential AAGUID public-key-credential-label=Public Key Credential Label +ciba-policy=CIBA Policy +ciba-backchannel-tokendelivery-mode=Backchannel Token Delivery Mode +ciba-backchannel-tokendelivery-mode.tooltip=Specifies how the CD(Consumption Device) gets the authentication result and related tokens. +ciba-expires-in=Expires In +ciba-expires-in.tooltip=The expiration time of the "auth_req_id" in seconds since the authentication request was received. +ciba-interval=Interval +ciba-interval.tooltip=The minimum amount of time in seconds that the CD(Consumption Device) must wait between polling requests to the token endpoint. +ciba-auth-requested-user-hint=Authentication Requested User Hint +ciba-auth-requested-user-hint.tooltip=The way of identifying the end-user for whom authentication is being requested. admin-events=Admin Events admin-events.tooltip=Displays saved admin events for the realm. Events are related to admin account, for example a realm creation. To enable persisted events go to config. login-events=Login Events diff --git a/themes/src/main/resources/theme/base/admin/resources/js/app.js b/themes/src/main/resources/theme/base/admin/resources/js/app.js index 3d27ddcddc..81dc3d97bc 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/app.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/app.js @@ -2079,6 +2079,18 @@ module.config([ '$routeProvider', function($routeProvider) { }, controller : 'RealmWebAuthnPasswordlessPolicyCtrl' }) + .when('/realms/:realm/authentication/ciba-policy', { + templateUrl : resourceUrl + '/partials/ciba-policy.html', + resolve : { + realm : function(RealmLoader) { + return RealmLoader(); + }, + serverInfo : function(ServerInfoLoader) { + return ServerInfoLoader(); + } + }, + controller : 'RealmCibaPolicyCtrl' + }) .when('/realms/:realm/authentication/flows/:flow/config/:provider/:config', { templateUrl : resourceUrl + '/partials/authenticator-config.html', resolve : { 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 1e50f6d10d..16e779163a 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 @@ -1110,6 +1110,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled; $scope.disableCredentialsTab = client.publicClient; $scope.oauth2DeviceAuthorizationGrantEnabled = false; + $scope.oidcCibaGrantEnabled = false; // KEYCLOAK-6771 Certificate Bound Token // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 $scope.tlsClientCertificateBoundAccessTokens = false; @@ -1302,6 +1303,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } } + if ($scope.client.attributes["oidc.ciba.grant.enabled"]) { + if ($scope.client.attributes["oidc.ciba.grant.enabled"] == "true") { + $scope.oidcCibaGrantEnabled = true; + } else { + $scope.oidcCibaGrantEnabled = false; + } + } + if ($scope.client.attributes["use.refresh.tokens"]) { if ($scope.client.attributes["use.refresh.tokens"] == "true") { $scope.useRefreshTokens = true; @@ -1722,6 +1731,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "false"; } + if ($scope.oidcCibaGrantEnabled == true) { + $scope.clientEdit.attributes["oidc.ciba.grant.enabled"] = "true"; + } else { + $scope.clientEdit.attributes["oidc.ciba.grant.enabled"] = "false"; + } + if ($scope.useRefreshTokens == true) { $scope.clientEdit.attributes["use.refresh.tokens"] = "true"; } else { diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index 67b0e368a3..377733097f 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -452,6 +452,11 @@ module.controller('RealmWebAuthnPasswordlessPolicyCtrl', function ($scope, Curre genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/webauthn-policy-passwordless"); }); +module.controller('RealmCibaPolicyCtrl', function ($scope, Current, Realm, realm, serverInfo, $http, $route, $location, Dialog, Notifications) { + + genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/ciba-policy"); +}); + module.controller('RealmThemeCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) { genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings"); @@ -2492,7 +2497,6 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo } else if (realm.dockerAuthenticationFlow == $scope.flow.alias) { Notifications.error("Cannot remove flow, it is currently being used as the docker authentication flow."); - } else { AuthenticationFlows.remove({realm: realm.realm, flow: $scope.flow.id}, function () { $location.url("/realms/" + realm.realm + '/authentication/flows/' + flows[0].alias); diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/ciba-policy.html b/themes/src/main/resources/theme/base/admin/resources/partials/ciba-policy.html new file mode 100644 index 0000000000..9c7ccd1d7c --- /dev/null +++ b/themes/src/main/resources/theme/base/admin/resources/partials/ciba-policy.html @@ -0,0 +1,62 @@ +

+

{{:: 'authentication' | translate}}

+ + +
+ +
+ +
+
+ +
+
+ {{:: 'ciba-backchannel-tokendelivery-mode.tooltip' | translate}} +
+ +
+ +
+
+ +
+
+ {{:: 'ciba-expires-in.tooltip' | translate}} +
+ +
+ +
+
+ +
+
+ {{:: 'ciba-interval.tooltip' | translate}} +
+ +
+ +
+
+ +
+
+ {{:: 'ciba-auth-requested-user-hint.tooltip' | translate}} +
+ +
+
+ + +
+
+
+ +
+ + + \ No newline at end of file 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 9c95204cc5..c7d7ee376a 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 @@ -150,6 +150,17 @@ on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" /> +
+ + {{:: 'oidc-ciba-grant-enabled.tooltip' | translate}} +
+ +
+
{{:: 'authz-authorization-services-enabled.tooltip' | translate}} diff --git a/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-authentication.html b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-authentication.html index e1b29660b4..98ad23f11d 100755 --- a/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-authentication.html +++ b/themes/src/main/resources/theme/base/admin/resources/templates/kc-tabs-authentication.html @@ -12,4 +12,5 @@ {{:: 'webauthn-policy-passwordless' | translate}} {{:: 'webauthn-policy-passwordless.tooltip' | translate}} +
  • {{:: 'ciba-policy' | translate}}
  • \ No newline at end of file