From 2803685cd73c703e970f61d9a5e20a1c0d6d4b85 Mon Sep 17 00:00:00 2001 From: Hryhorii Hevorkian Date: Thu, 8 Apr 2021 15:50:45 +0300 Subject: [PATCH] KEYCLOAK-18353 Implement Pushed Authorization Request inside the Keycloak Co-authored-by: Takashi Norimatsu Co-authored-by: mposolda --- .../java/org/keycloak/common/Profile.java | 3 +- .../java/org/keycloak/common/ProfileTest.java | 10 +- .../OIDCConfigurationRepresentation.java | 22 + .../oidc/OIDCClientRepresentation.java | 11 + .../models/cache/infinispan/RealmAdapter.java | 6 + .../infinispan/entities/CachedRealm.java | 7 + ...nispanPushedAuthzRequestStoreProvider.java | 85 ++ ...ushedAuthzRequestStoreProviderFactory.java | 78 ++ ...els.PushedAuthzRequestStoreProviderFactory | 18 + .../org/keycloak/models/jpa/RealmAdapter.java | 5 + .../models/map/realm/MapRealmAdapter.java | 5 + .../java/org/keycloak/events/EventType.java | 7 +- .../PushedAuthzRequestStoreProvider.java | 49 + ...ushedAuthzRequestStoreProviderFactory.java | 23 + .../models/PushedAuthzRequestStoreSpi.java | 47 + .../models/utils/ModelToRepresentation.java | 4 + .../models/utils/RepresentationToModel.java | 11 + .../services/org.keycloak.provider.Spi | 1 + .../org/keycloak/models/AbstractConfig.java | 42 + .../java/org/keycloak/models/CibaConfig.java | 24 +- .../java/org/keycloak/models/ParConfig.java | 70 ++ .../java/org/keycloak/models/RealmModel.java | 2 + .../oidc/OIDCAdvancedConfigWrapper.java | 1 - .../protocol/oidc/OIDCWellKnownProvider.java | 4 + .../oidc/endpoints/AuthorizationEndpoint.java | 265 +----- .../AuthorizationEndpointChecker.java | 372 ++++++++ ...izationEndpointRequestParserProcessor.java | 36 +- .../endpoints/request/RequestUriType.java | 25 + .../protocol/oidc/par/ParResponse.java | 50 + .../par/endpoints/AbstractParEndpoint.java | 97 ++ .../oidc/par/endpoints/ParEndpoint.java | 165 ++++ .../oidc/par/endpoints/ParRootEndpoint.java | 79 ++ .../request/AuthzEndpointParParser.java | 90 ++ .../oidc/DescriptionConverter.java | 10 + ...k.protocol.oidc.ext.OIDCExtProviderFactory | 3 +- .../keycloak/testsuite/util/OAuthClient.java | 152 ++- .../testsuite/admin/realm/RealmTest.java | 6 +- .../oidc/OIDCWellKnownProviderTest.java | 5 + .../org/keycloak/testsuite/par/ParTest.java | 885 ++++++++++++++++++ .../messages/admin-messages_en.properties | 7 + .../admin/resources/js/controllers/clients.js | 19 + .../admin/resources/js/controllers/realm.js | 6 + .../resources/partials/client-detail.html | 9 + .../resources/partials/realm-tokens.html | 16 + 44 files changed, 2555 insertions(+), 277 deletions(-) create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProvider.java create mode 100644 model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProviderFactory.java create mode 100644 model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.PushedAuthzRequestStoreProviderFactory create mode 100644 server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreProvider.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreProviderFactory.java create mode 100644 server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreSpi.java create mode 100644 server-spi/src/main/java/org/keycloak/models/AbstractConfig.java create mode 100644 server-spi/src/main/java/org/keycloak/models/ParConfig.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/RequestUriType.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/par/ParResponse.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/AbstractParEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParRootEndpoint.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/par/ParTest.java diff --git a/common/src/main/java/org/keycloak/common/Profile.java b/common/src/main/java/org/keycloak/common/Profile.java index 3684fea4c2..77b7144b19 100755 --- a/common/src/main/java/org/keycloak/common/Profile.java +++ b/common/src/main/java/org/keycloak/common/Profile.java @@ -61,7 +61,8 @@ public class Profile { WEB_AUTHN(Type.DEFAULT, Type.PREVIEW), CLIENT_POLICIES(Type.DEFAULT), CIBA(Type.PREVIEW), - MAP_STORAGE(Type.EXPERIMENTAL); + MAP_STORAGE(Type.EXPERIMENTAL), + PAR(Type.PREVIEW); private final Type typeProject; private final 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 0261f15cbc..6b1033c62a 100644 --- a/common/src/test/java/org/keycloak/common/ProfileTest.java +++ b/common/src/test/java/org/keycloak/common/ProfileTest.java @@ -21,8 +21,9 @@ 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.CIBA, Profile.Feature.MAP_STORAGE); - assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA); + + 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.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.PAR); + assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CIBA, Profile.Feature.PAR); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); @@ -37,8 +38,9 @@ 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.CIBA, Profile.Feature.MAP_STORAGE); - 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.CIBA); + + 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.CIBA, Profile.Feature.MAP_STORAGE, Profile.Feature.PAR); + 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.CIBA, Profile.Feature.PAR); assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS); Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType()); 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 4824bfc98a..db88dc42d2 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 @@ -163,6 +163,12 @@ public class OIDCConfigurationRepresentation { @JsonProperty("backchannel_authentication_request_signing_alg_values_supported") private List backchannelAuthenticationRequestSigningAlgValuesSupported; + @JsonProperty("require_pushed_authorization_requests") + private Boolean requirePushedAuthorizationRequests; + + @JsonProperty("pushed_authorization_request_endpoint") + private String pushedAuthorizationRequestEndpoint; + protected Map otherClaims = new HashMap(); public String getIssuer() { @@ -481,6 +487,22 @@ public class OIDCConfigurationRepresentation { this.backchannelAuthenticationRequestSigningAlgValuesSupported = backchannelAuthenticationRequestSigningAlgValuesSupported; } + public String getPushedAuthorizationRequestEndpoint() { + return pushedAuthorizationRequestEndpoint; + } + + public void setPushedAuthorizationRequestEndpoint(String pushedAuthorizationRequestEndpoint) { + this.pushedAuthorizationRequestEndpoint = pushedAuthorizationRequestEndpoint; + } + + public Boolean getRequirePushedAuthorizationRequests() { + return requirePushedAuthorizationRequests; + } + + public void setRequirePushedAuthorizationRequests(Boolean requirePushedAuthorizationRequests) { + this.requirePushedAuthorizationRequests = requirePushedAuthorizationRequests; + } + @JsonAnyGetter public Map getOtherClaims() { return otherClaims; 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 803ee3c7b5..933b7ebd25 100644 --- a/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/oidc/OIDCClientRepresentation.java @@ -137,6 +137,9 @@ public class OIDCClientRepresentation { private String authorization_encrypted_response_enc; + // PAR request + private Boolean require_pushed_authorization_requests; + public List getRedirectUris() { return redirect_uris; } @@ -538,4 +541,12 @@ public class OIDCClientRepresentation { public void setAuthorizationEncryptedResponseEnc(String authorization_encrypted_response_enc) { this.authorization_encrypted_response_enc = authorization_encrypted_response_enc; } + + public Boolean getRequirePushedAuthorizationRequests() { + return require_pushed_authorization_requests; + } + + public void setRequirePushedAuthorizationRequests(Boolean require_pushed_authorization_requests) { + this.require_pushed_authorization_requests = require_pushed_authorization_requests; + } } 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 07632a881d..af08724645 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 @@ -659,6 +659,12 @@ public class RealmAdapter implements CachedRealmModel { return cached.getCibaConfig(modelSupplier); } + @Override + public ParConfig getParPolicy() { + if (isUpdated()) return updated.getParPolicy(); + return cached.getParConfig(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 5dc5f0fb7b..009f644ee0 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 @@ -31,6 +31,7 @@ import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.OAuth2DeviceConfig; import org.keycloak.models.OTPPolicy; +import org.keycloak.models.ParConfig; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; @@ -103,6 +104,7 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int accessCodeLifespanLogin; protected LazyLoader deviceConfig; protected LazyLoader cibaConfig; + protected LazyLoader parConfig; protected int actionTokenGeneratedByAdminLifespan; protected int actionTokenGeneratedByUserLifespan; protected int notBefore; @@ -220,6 +222,7 @@ public class CachedRealm extends AbstractExtendableRevisioned { accessCodeLifespan = model.getAccessCodeLifespan(); deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null); cibaConfig = new DefaultLazyLoader<>(CibaConfig::new, null); + parConfig = new DefaultLazyLoader<>(ParConfig::new, null); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan(); @@ -504,6 +507,10 @@ public class CachedRealm extends AbstractExtendableRevisioned { return cibaConfig.get(modelSupplier); } + public ParConfig getParConfig(Supplier modelSupplier) { + return parConfig.get(modelSupplier); + } + public int getActionTokenGeneratedByAdminLifespan() { return actionTokenGeneratedByAdminLifespan; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProvider.java new file mode 100644 index 0000000000..d4364af52c --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProvider.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.models.sessions.infinispan; + +import org.infinispan.client.hotrod.exceptions.HotRodClientException; +import org.infinispan.commons.api.BasicCache; +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PushedAuthzRequestStoreProvider; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + + +public class InfinispanPushedAuthzRequestStoreProvider implements PushedAuthzRequestStoreProvider { + + public static final Logger logger = Logger.getLogger(InfinispanPushedAuthzRequestStoreProvider.class); + + private final Supplier> parDataCache; + + public InfinispanPushedAuthzRequestStoreProvider(KeycloakSession session, Supplier> actionKeyCache) { + this.parDataCache = actionKeyCache; + } + + @Override + public void put(UUID key, int lifespanSeconds, Map codeData) { + ActionTokenValueEntity tokenValue = new ActionTokenValueEntity(codeData); + + try { + BasicCache cache = parDataCache.get(); + long lifespanMs = InfinispanUtil.toHotrodTimeMs(cache, Time.toMillis(lifespanSeconds)); + cache.put(key, tokenValue, lifespanMs, TimeUnit.MILLISECONDS); + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when adding PAR data for redirect URI: %s", key); + } + + throw re; + } + } + + @Override + public Map remove(UUID key) { + try { + BasicCache cache = parDataCache.get(); + ActionTokenValueEntity existing = cache.remove(key); + return existing == null ? null : existing.getNotes(); + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when removing PAR data for redirect URI %s", key); + } + + return null; + } + } + + + @Override + public void close() { + + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProviderFactory.java new file mode 100644 index 0000000000..ce81960761 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanPushedAuthzRequestStoreProviderFactory.java @@ -0,0 +1,78 @@ +/* + * 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.models.sessions.infinispan; + +import org.infinispan.commons.api.BasicCache; +import org.keycloak.Config; +import org.keycloak.common.Profile; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.PushedAuthzRequestStoreProvider; +import org.keycloak.models.PushedAuthzRequestStoreProviderFactory; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.provider.EnvironmentDependentProviderFactory; + +import java.util.UUID; +import java.util.function.Supplier; + +public class InfinispanPushedAuthzRequestStoreProviderFactory implements PushedAuthzRequestStoreProviderFactory, EnvironmentDependentProviderFactory { + + // Reuse "actionTokens" infinispan cache for now + private volatile Supplier> codeCache; + + @Override + public PushedAuthzRequestStoreProvider create(KeycloakSession session) { + lazyInit(session); + return new InfinispanPushedAuthzRequestStoreProvider(session, codeCache); + } + + private void lazyInit(KeycloakSession session) { + if (codeCache == null) { + synchronized (this) { + if (codeCache == null) { + this.codeCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session); + } + } + } + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "infinispan"; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.PAR); + } +} diff --git a/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.PushedAuthzRequestStoreProviderFactory b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.PushedAuthzRequestStoreProviderFactory new file mode 100644 index 0000000000..131bf6dd8b --- /dev/null +++ b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.PushedAuthzRequestStoreProviderFactory @@ -0,0 +1,18 @@ +# +# 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.models.sessions.infinispan.InfinispanPushedAuthzRequestStoreProviderFactory \ No newline at end of file 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 aa1538d08a..1786e024a9 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 @@ -562,6 +562,11 @@ public class RealmAdapter implements RealmModel, JpaModel { return new CibaConfig(this); } + @Override + public ParConfig getParPolicy() { + return new ParConfig(this); + } + @Override public Map getUserActionTokenLifespans() { 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 b8aa457c5a..9e0a3f630d 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 @@ -43,6 +43,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.OAuth2DeviceConfig; import org.keycloak.models.OTPPolicy; import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.ParConfig; import org.keycloak.models.RealmModel; import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.RequiredCredentialModel; @@ -1565,4 +1566,8 @@ public abstract class MapRealmAdapter extends AbstractRealmModel codeData); + + + /** + * This method returns data just if removal was successful. Implementation should guarantee that "remove" is single-use. So if + * 2 threads (even on different cluster nodes or on different cross-dc nodes) calls "remove(123)" concurrently, then just one of them + * is allowed to succeed and return data back. It can't happen that both will succeed. + * + * @param key unique identifier + * @return context data related Pushed Authorization Request. It returns null if there is no context data available. + */ + Map remove(UUID key); +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreProviderFactory.java new file mode 100644 index 0000000000..f6c38794c7 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreProviderFactory.java @@ -0,0 +1,23 @@ +/* + * 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.models; + +import org.keycloak.provider.ProviderFactory; + +public interface PushedAuthzRequestStoreProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreSpi.java b/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreSpi.java new file mode 100644 index 0000000000..5471a0fad5 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/PushedAuthzRequestStoreSpi.java @@ -0,0 +1,47 @@ +/* + * 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.models; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class PushedAuthzRequestStoreSpi implements Spi { + + public static final String NAME = "pushedAuthzRequestStore"; + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Class getProviderClass() { + return PushedAuthzRequestStoreProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return PushedAuthzRequestStoreProviderFactory.class; + } +} 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 2b751474f9..45b6f20511 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 @@ -411,6 +411,10 @@ public class ModelToRepresentation { 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()); + + ParConfig parPolicy = realm.getParPolicy(); + attrMap.put(ParConfig.PAR_REQUEST_URI_LIFESPAN, String.valueOf(parPolicy.getRequestUriLifespan())); + rep.setAttributes(attrMap); if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().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 9f7f03c183..478c42f53c 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 @@ -80,6 +80,7 @@ import org.keycloak.models.LDAPConstants; import org.keycloak.models.ModelException; import org.keycloak.models.OAuth2DeviceConfig; import org.keycloak.models.OTPPolicy; +import org.keycloak.models.ParConfig; import org.keycloak.models.PasswordPolicy; import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; @@ -300,6 +301,8 @@ public class RepresentationToModel { updateCibaSettings(rep, newRealm); + updateParSettings(rep, newRealm); + Map mappedFlows = importAuthenticationFlows(newRealm, rep); if (rep.getRequiredActions() != null) { for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) { @@ -1190,6 +1193,7 @@ public class RepresentationToModel { realm.setWebAuthnPolicyPasswordless(webAuthnPolicy); updateCibaSettings(rep, realm); + updateParSettings(rep, realm); session.clientPolicy().updateRealmModelFromRepresentation(realm, rep); if (rep.getSmtpServer() != null) { @@ -1245,6 +1249,13 @@ public class RepresentationToModel { cibaPolicy.setAuthRequestedUserHint(newAttributes.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT)); } + private static void updateParSettings(RealmRepresentation rep, RealmModel realm) { + Map newAttributes = rep.getAttributesOrEmpty(); + ParConfig parPolicy = realm.getParPolicy(); + + parPolicy.setRequestUriLifespan(newAttributes.get(ParConfig.PAR_REQUEST_URI_LIFESPAN)); + } + // Basic realm stuff diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 92771b4b3d..a4d2f052c7 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -30,6 +30,7 @@ org.keycloak.models.CodeToTokenStoreSpi org.keycloak.models.OAuth2DeviceTokenStoreSpi org.keycloak.models.OAuth2DeviceUserCodeSpi org.keycloak.models.SamlArtifactSessionMappingStoreSpi +org.keycloak.models.PushedAuthzRequestStoreSpi org.keycloak.models.SingleUseTokenStoreSpi org.keycloak.models.TokenRevocationStoreSpi org.keycloak.models.UserSessionSpi diff --git a/server-spi/src/main/java/org/keycloak/models/AbstractConfig.java b/server-spi/src/main/java/org/keycloak/models/AbstractConfig.java new file mode 100644 index 0000000000..b0c03e473b --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/AbstractConfig.java @@ -0,0 +1,42 @@ +/* + * 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.models; + +import java.io.Serializable; +import java.util.function.Supplier; + +public abstract class AbstractConfig implements Serializable { + + protected transient Supplier realm; + + // Make sure setters are not called when calling this from constructor to avoid DB updates + protected transient Supplier realmForWrite; + + protected void persistRealmAttribute(String name, String value) { + RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get(); + if (realm != null) { + realm.setAttribute(name, value); + } + } + + protected 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/CibaConfig.java b/server-spi/src/main/java/org/keycloak/models/CibaConfig.java index e3b00f82ec..77fd16943b 100644 --- a/server-spi/src/main/java/org/keycloak/models/CibaConfig.java +++ b/server-spi/src/main/java/org/keycloak/models/CibaConfig.java @@ -16,13 +16,10 @@ */ package org.keycloak.models; -import java.io.Serializable; -import java.util.function.Supplier; - import org.keycloak.jose.jws.Algorithm; import org.keycloak.utils.StringUtil; -public class CibaConfig implements Serializable { +public class CibaConfig extends AbstractConfig { // realm attribute names public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE = "cibaBackchannelTokenDeliveryMode"; @@ -46,11 +43,6 @@ public class CibaConfig implements Serializable { public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT = "ciba.backchannel.token.delivery.mode"; public static final String CIBA_BACKCHANNEL_AUTH_REQUEST_SIGNING_ALG = "ciba.backchannel.auth.request.signing.alg"; - 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; @@ -154,18 +146,4 @@ public class CibaConfig implements Serializable { String alg = client.getAttribute(CIBA_BACKCHANNEL_AUTH_REQUEST_SIGNING_ALG); return alg==null ? null : Enum.valueOf(Algorithm.class, alg); } - - 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/ParConfig.java b/server-spi/src/main/java/org/keycloak/models/ParConfig.java new file mode 100644 index 0000000000..0144b9eb1c --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/ParConfig.java @@ -0,0 +1,70 @@ +/* + * 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.models; + +import org.keycloak.utils.StringUtil; + +public class ParConfig extends AbstractConfig { + + // realm attribute names + public static final String PAR_REQUEST_URI_LIFESPAN = "parRequestUriLifespan"; + + // default value + public static final int DEFAULT_PAR_REQUEST_URI_LIFESPAN = 60; // sec + + private int requestUriLifespan = DEFAULT_PAR_REQUEST_URI_LIFESPAN; + + // client attribute names + public static final String REQUIRE_PUSHED_AUTHORIZATION_REQUESTS = "require.pushed.authorization.requests"; + + public ParConfig(RealmModel realm) { + this.realm = () -> realm; + + String requestUriLifespan = realm.getAttribute(PAR_REQUEST_URI_LIFESPAN); + + if (StringUtil.isNotBlank(requestUriLifespan)) { + setRequestUriLifespan(Integer.parseInt(requestUriLifespan)); + } + + this.realmForWrite = () -> realm; + } + + public int getRequestUriLifespan() { + return requestUriLifespan; + } + + public void setRequestUriLifespan(String requestUriLifespan) { + if (requestUriLifespan == null) { + setRequestUriLifespan((Integer) null); + } else { + setRequestUriLifespan(Integer.parseInt(requestUriLifespan)); + } + } + + public void setRequestUriLifespan(Integer requestUriLifespan) { + if (requestUriLifespan == null) { + requestUriLifespan = DEFAULT_PAR_REQUEST_URI_LIFESPAN; + } + this.requestUriLifespan = requestUriLifespan; + persistRealmAttribute(PAR_REQUEST_URI_LIFESPAN, requestUriLifespan); + } + + public boolean isRequirePushedAuthorizationRequests(ClientModel client) { + String enabled = client.getAttribute(REQUIRE_PUSHED_AUTHORIZATION_REQUESTS); + return Boolean.parseBoolean(enabled); + } +} 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 c2c0e0d024..4145a78e71 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -253,6 +253,8 @@ public interface RealmModel extends RoleContainerModel { CibaConfig getCibaPolicy(); + ParConfig getParPolicy(); + /** * This method will return a map with all the lifespans available * or an empty map, but never null. 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 3ed00d2266..41fffcdfdb 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java @@ -19,7 +19,6 @@ 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/OIDCWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java index 78a65a4c46..9173c0c800 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCWellKnownProvider.java @@ -34,6 +34,7 @@ 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.par.endpoints.ParEndpoint; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.provider.Provider; @@ -182,6 +183,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider { config.setBackchannelAuthenticationEndpoint(CibaGrantType.authorizationUrl(backendUriInfo.getBaseUriBuilder()).build(realm.getName()).toString()); config.setBackchannelAuthenticationRequestSigningAlgValuesSupported(getSupportedBackchannelAuthenticationRequestSigningAlgorithms()); + config.setPushedAuthorizationRequestEndpoint(ParEndpoint.parUrl(backendUriInfo.getBaseUriBuilder()).build(realm.getName()).toString()); + config.setRequirePushedAuthorizationRequests(Boolean.FALSE); + return config; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java index 54cf97efac..7b46fadb2c 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpoint.java @@ -20,7 +20,6 @@ package org.keycloak.protocol.oidc.endpoints; import org.jboss.logging.Logger; import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.keycloak.OAuth2Constants; -import org.keycloak.OAuthErrorException; import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.constants.AdapterConstants; import org.keycloak.events.Details; @@ -33,19 +32,14 @@ import org.keycloak.models.ClientModel; import org.keycloak.models.Constants; import org.keycloak.models.RealmModel; import org.keycloak.protocol.AuthorizationEndpointBase; -import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; -import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; -import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseType; -import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.ErrorPageException; -import org.keycloak.services.ServicesLogger; import org.keycloak.services.Urls; import org.keycloak.services.clientpolicy.ClientPolicyException; import org.keycloak.services.clientpolicy.context.AuthorizationRequestContext; @@ -54,7 +48,6 @@ import org.keycloak.services.resources.LoginActionsService; import org.keycloak.services.util.CacheControlUtil; import org.keycloak.sessions.AuthenticationSessionModel; import org.keycloak.util.TokenUtil; -import org.keycloak.utils.StringUtil; import javax.ws.rs.Consumes; import javax.ws.rs.GET; @@ -63,8 +56,6 @@ import javax.ws.rs.Path; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * @author Stian Thorgersen @@ -83,9 +74,6 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { */ public static final String LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX = "client_request_param_"; - // https://tools.ietf.org/html/rfc7636#section-4.2 - private static final Pattern VALID_CODE_CHALLENGE_PATTERN = Pattern.compile("^[0-9a-zA-Z\\-\\.~_]+$"); - private enum Action { REGISTER, CODE, FORGOT_CREDENTIALS } @@ -137,36 +125,42 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, params); - checkRedirectUri(); - Response errorResponse = checkResponseType(); - if (errorResponse != null) { - return errorResponse; + AuthorizationEndpointChecker checker = new AuthorizationEndpointChecker() + .event(event) + .client(client) + .realm(realm) + .request(request) + .session(session) + .params(params); + + try { + checker.checkRedirectUri(); + this.redirectUri = checker.getRedirectUri(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + ex.throwAsErrorPageException(authenticationSession); } - if (request.getInvalidRequestMessage() != null) { - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, Errors.INVALID_REQUEST, request.getInvalidRequestMessage()); + try { + checker.checkResponseType(); + this.parsedResponseType = checker.getParsedResponseType(); + this.parsedResponseMode = checker.getParsedResponseMode(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + OIDCResponseMode responseMode = checker.getParsedResponseMode() != null ? checker.getParsedResponseMode() : OIDCResponseMode.QUERY; + return redirectErrorToClient(responseMode, ex.getError(), ex.getErrorDescription()); + } + if (action == null) { + action = AuthorizationEndpoint.Action.CODE; } - if (!TokenUtil.isOIDCRequest(request.getScope())) { - ServicesLogger.LOGGER.oidcScopeMissing(); - } - - if (!TokenManager.isValidScope(request.getScope(), client)) { - ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_SCOPE, "Invalid scopes: " + request.getScope()); - } - - errorResponse = checkOIDCParams(); - if (errorResponse != null) { - return errorResponse; - } - - // https://tools.ietf.org/html/rfc7636#section-4 - errorResponse = checkPKCEParams(); - if (errorResponse != null) { - return errorResponse; + try { + checker.checkParRequired(); + checker.checkInvalidRequestMessage(); + checker.checkOIDCRequest(); + checker.checkValidScope(); + checker.checkOIDCParams(); + checker.checkPKCEParams(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + return redirectErrorToClient(parsedResponseMode, ex.getError(), ex.getErrorDescription()); } try { @@ -251,185 +245,6 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { session.getContext().setClient(client); } - private Response checkResponseType() { - String responseType = request.getResponseType(); - - if (responseType == null) { - ServicesLogger.LOGGER.missingParameter(OAuth2Constants.RESPONSE_TYPE); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(OIDCResponseMode.QUERY, OAuthErrorException.INVALID_REQUEST, "Missing parameter: response_type"); - } - - event.detail(Details.RESPONSE_TYPE, responseType); - - try { - parsedResponseType = OIDCResponseType.parse(responseType); - if (action == null) { - action = Action.CODE; - } - } catch (IllegalArgumentException iae) { - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(OIDCResponseMode.QUERY, OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, null); - } - - OIDCResponseMode parsedResponseMode = null; - try { - parsedResponseMode = OIDCResponseMode.parse(request.getResponseMode(), parsedResponseType); - } catch (IllegalArgumentException iae) { - ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(OIDCResponseMode.QUERY, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: response_mode"); - } - - event.detail(Details.RESPONSE_MODE, parsedResponseMode.toString().toLowerCase()); - - // Disallowed by OIDC specs - if (parsedResponseType.isImplicitOrHybridFlow() && parsedResponseMode == OIDCResponseMode.QUERY) { - ServicesLogger.LOGGER.responseModeQueryNotAllowed(); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(OIDCResponseMode.QUERY, OAuthErrorException.INVALID_REQUEST, "Response_mode 'query' not allowed for implicit or hybrid flow"); - } - - if(parsedResponseType.isImplicitOrHybridFlow() && parsedResponseMode == OIDCResponseMode.QUERY_JWT && - (!StringUtil.isNotBlank(client.getAttribute(OIDCConfigAttributes.AUTHORIZATION_ENCRYPTED_RESPONSE_ALG)) || - !StringUtil.isNotBlank(client.getAttribute(OIDCConfigAttributes.AUTHORIZATION_ENCRYPTED_RESPONSE_ENC)))) { - ServicesLogger.LOGGER.responseModeQueryJwtNotAllowed(); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(OIDCResponseMode.QUERY_JWT, OAuthErrorException.INVALID_REQUEST, "Response_mode 'query.jwt' is allowed only when the authorization response token is encrypted"); - } - - if ((parsedResponseType.hasResponseType(OIDCResponseType.CODE) || parsedResponseType.hasResponseType(OIDCResponseType.NONE)) && !client.isStandardFlowEnabled()) { - ServicesLogger.LOGGER.flowNotAllowed("Standard"); - event.error(Errors.NOT_ALLOWED); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.UNAUTHORIZED_CLIENT, "Client is not allowed to initiate browser login with given response_type. Standard flow is disabled for the client."); - } - - if (parsedResponseType.isImplicitOrHybridFlow() && !client.isImplicitFlowEnabled()) { - ServicesLogger.LOGGER.flowNotAllowed("Implicit"); - event.error(Errors.NOT_ALLOWED); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.UNAUTHORIZED_CLIENT, "Client is not allowed to initiate browser login with given response_type. Implicit flow is disabled for the client."); - } - - this.parsedResponseMode = parsedResponseMode; - - return null; - } - - private Response checkOIDCParams() { - // If request is not OIDC request, but pure OAuth2 request and response_type is just 'token', then 'nonce' is not mandatory - boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); - if (!isOIDCRequest && parsedResponseType.toString().equals(OIDCResponseType.TOKEN)) { - return null; - } - - if (parsedResponseType.isImplicitOrHybridFlow() && request.getNonce() == null) { - ServicesLogger.LOGGER.missingParameter(OIDCLoginProtocol.NONCE_PARAM); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Missing parameter: nonce"); - } - - return null; - } - - // https://tools.ietf.org/html/rfc7636#section-4 - private Response checkPKCEParams() { - String codeChallenge = request.getCodeChallenge(); - String codeChallengeMethod = request.getCodeChallengeMethod(); - - // PKCE not adopted to OAuth2 Implicit Grant and OIDC Implicit Flow, - // adopted to OAuth2 Authorization Code Grant and OIDC Authorization Code Flow, Hybrid Flow - // Namely, flows using authorization code. - if (parsedResponseType.isImplicitFlow()) return null; - - String pkceCodeChallengeMethod = OIDCAdvancedConfigWrapper.fromClientModel(client).getPkceCodeChallengeMethod(); - Response response = null; - if (pkceCodeChallengeMethod != null && !pkceCodeChallengeMethod.isEmpty()) { - response = checkParamsForPkceEnforcedClient(codeChallengeMethod, pkceCodeChallengeMethod, codeChallenge); - } else { - // if PKCE Activation is OFF, execute the codes implemented in KEYCLOAK-2604 - response = checkParamsForPkceNotEnforcedClient(codeChallengeMethod, pkceCodeChallengeMethod, codeChallenge); - } - return response; - } - - // https://tools.ietf.org/html/rfc7636#section-4 - private boolean isValidPkceCodeChallenge(String codeChallenge) { - if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) { - logger.debugf("PKCE codeChallenge length under lower limit , codeChallenge = %s", codeChallenge); - return false; - } - if (codeChallenge.length() > OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MAX_LENGTH) { - logger.debugf("PKCE codeChallenge length over upper limit , codeChallenge = %s", codeChallenge); - return false; - } - Matcher m = VALID_CODE_CHALLENGE_PATTERN.matcher(codeChallenge); - return m.matches(); - } - - private Response checkParamsForPkceEnforcedClient(String codeChallengeMethod, String pkceCodeChallengeMethod, String codeChallenge) { - // check whether code challenge method is specified - if (codeChallengeMethod == null) { - logger.info("PKCE enforced Client without code challenge method."); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge_method"); - } - // check whether specified code challenge method is configured one in advance - if (!codeChallengeMethod.equals(pkceCodeChallengeMethod)) { - logger.info("PKCE enforced Client code challenge method is not configured one."); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code challenge method is not configured one"); - } - // check whether code challenge is specified - if (codeChallenge == null) { - logger.info("PKCE supporting Client without code challenge"); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge"); - } - // check whether code challenge is formatted along with the PKCE specification - if (!isValidPkceCodeChallenge(codeChallenge)) { - logger.infof("PKCE supporting Client with invalid code challenge specified in PKCE, codeChallenge = %s", codeChallenge); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge"); - } - return null; - } - - private Response checkParamsForPkceNotEnforcedClient(String codeChallengeMethod, String pkceCodeChallengeMethod, String codeChallenge) { - if (codeChallenge == null && codeChallengeMethod != null) { - logger.info("PKCE supporting Client without code challenge"); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge"); - } - - // based on code_challenge value decide whether this client(RP) supports PKCE - if (codeChallenge == null) { - logger.debug("PKCE non-supporting Client"); - return null; - } - - if (codeChallengeMethod != null) { - // https://tools.ietf.org/html/rfc7636#section-4.2 - // plain or S256 - if (!codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_S256) && !codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_PLAIN)) { - logger.infof("PKCE supporting Client with invalid code challenge method not specified in PKCE, codeChallengeMethod = %s", codeChallengeMethod); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge_method"); - } - } else { - // https://tools.ietf.org/html/rfc7636#section-4.3 - // default code_challenge_method is plane - codeChallengeMethod = OIDCLoginProtocol.PKCE_METHOD_PLAIN; - } - - if (!isValidPkceCodeChallenge(codeChallenge)) { - logger.infof("PKCE supporting Client with invalid code challenge specified in PKCE, codeChallenge = %s", codeChallenge); - event.error(Errors.INVALID_REQUEST); - return redirectErrorToClient(parsedResponseMode, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge"); - } - - return null; - } - private Response redirectErrorToClient(OIDCResponseMode responseMode, String error, String errorDescription) { OIDCRedirectUriBuilder errorResponseBuilder = OIDCRedirectUriBuilder.fromUri(redirectUri, responseMode, session, null) .addParam(OAuth2Constants.ERROR, error); @@ -445,21 +260,6 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { return errorResponseBuilder.build(); } - private void checkRedirectUri() { - String redirectUriParam = request.getRedirectUriParam(); - boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); - - event.detail(Details.REDIRECT_URI, redirectUriParam); - - // redirect_uri parameter is required per OpenID Connect, but optional per OAuth2 - redirectUri = RedirectUtils.verifyRedirectUri(session, redirectUriParam, client, isOIDCRequest); - if (redirectUri == null) { - event.error(Errors.INVALID_REDIRECT_URI); - throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.INVALID_PARAMETER, OIDCLoginProtocol.REDIRECT_URI_PARAM); - } - } - - private void updateAuthenticationSession() { authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL); authenticationSession.setRedirectUri(redirectUri); @@ -493,7 +293,6 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase { } } - private Response buildAuthorizationCodeAuthorizationResponse() { this.event.event(EventType.LOGIN); authenticationSession.setAuthNote(Details.AUTH_TYPE, CODE_AUTH_TYPE); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java new file mode 100644 index 0000000000..4843b1bf4f --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/AuthorizationEndpointChecker.java @@ -0,0 +1,372 @@ +/* + * 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.endpoints; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; + +import org.jboss.logging.Logger; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.events.Details; +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.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.TokenManager; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; +import org.keycloak.protocol.oidc.endpoints.request.RequestUriType; +import org.keycloak.protocol.oidc.utils.OIDCResponseMode; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.protocol.oidc.utils.RedirectUtils; +import org.keycloak.services.CorsErrorResponseException; +import org.keycloak.services.ErrorPageException; +import org.keycloak.services.ServicesLogger; +import org.keycloak.services.messages.Messages; +import org.keycloak.services.resources.Cors; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.util.TokenUtil; +import org.keycloak.utils.StringUtil; + +/** + * Implements some checks typical for OIDC Authorization Endpoint. Useful to consolidate various checks on single place to avoid duplicated + * code logic in different contexts (OIDC Authorization Endpoint triggered from browser, PAR) + * + * @author Marek Posolda + */ +public class AuthorizationEndpointChecker { + + private EventBuilder event; + private AuthorizationEndpointRequest request; + private KeycloakSession session; + private ClientModel client; + private RealmModel realm; + + private String redirectUri; + private OIDCResponseType parsedResponseType; + private OIDCResponseMode parsedResponseMode; + private MultivaluedMap params; + + private static final Logger logger = Logger.getLogger(AuthorizationEndpointChecker.class); + + // https://tools.ietf.org/html/rfc7636#section-4.2 + private static final Pattern VALID_CODE_CHALLENGE_PATTERN = Pattern.compile("^[0-9a-zA-Z\\-\\.~_]+$"); + + public AuthorizationEndpointChecker event(EventBuilder event) { + this.event = event; + return this; + } + + public AuthorizationEndpointChecker request(AuthorizationEndpointRequest request) { + this.request = request; + return this; + } + + public AuthorizationEndpointChecker session(KeycloakSession session) { + this.session = session; + return this; + } + + public AuthorizationEndpointChecker client(ClientModel client) { + this.client = client; + return this; + } + + public AuthorizationEndpointChecker realm(RealmModel realm) { + this.realm = realm; + return this; + } + + public AuthorizationEndpointChecker params(MultivaluedMap params) { + this.params = params; + return this; + } + + public String getRedirectUri() { + return redirectUri; + } + + public OIDCResponseType getParsedResponseType() { + return parsedResponseType; + } + + public OIDCResponseMode getParsedResponseMode() { + return parsedResponseMode; + } + + public void checkRedirectUri() throws AuthorizationCheckException { + String redirectUriParam = request.getRedirectUriParam(); + boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); + + event.detail(Details.REDIRECT_URI, redirectUriParam); + + // redirect_uri parameter is required per OpenID Connect, but optional per OAuth2 + this.redirectUri = RedirectUtils.verifyRedirectUri(session, redirectUriParam, client, isOIDCRequest); + if (redirectUri == null) { + event.error(Errors.INVALID_REDIRECT_URI); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, Messages.INVALID_PARAMETER, OIDCLoginProtocol.REDIRECT_URI_PARAM); + } + } + + + public void checkResponseType() throws AuthorizationCheckException { + String responseType = request.getResponseType(); + + if (responseType == null) { + ServicesLogger.LOGGER.missingParameter(OAuth2Constants.RESPONSE_TYPE); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Missing parameter: response_type"); + } + + event.detail(Details.RESPONSE_TYPE, responseType); + + try { + this.parsedResponseType = OIDCResponseType.parse(responseType); + } catch (IllegalArgumentException iae) { + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE, null); + } + + OIDCResponseMode parsedResponseMode = null; + try { + parsedResponseMode = OIDCResponseMode.parse(request.getResponseMode(), parsedResponseType); + } catch (IllegalArgumentException iae) { + ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.RESPONSE_MODE_PARAM); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: response_mode"); + } + + event.detail(Details.RESPONSE_MODE, parsedResponseMode.toString().toLowerCase()); + + // Disallowed by OIDC specs + if (parsedResponseType.isImplicitOrHybridFlow() && parsedResponseMode == OIDCResponseMode.QUERY) { + ServicesLogger.LOGGER.responseModeQueryNotAllowed(); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Response_mode 'query' not allowed for implicit or hybrid flow"); + } + + this.parsedResponseMode = parsedResponseMode; + + if (parsedResponseType.isImplicitOrHybridFlow() && parsedResponseMode == OIDCResponseMode.QUERY_JWT && + (!StringUtil.isNotBlank(client.getAttribute(OIDCConfigAttributes.AUTHORIZATION_ENCRYPTED_RESPONSE_ALG)) || + !StringUtil.isNotBlank(client.getAttribute(OIDCConfigAttributes.AUTHORIZATION_ENCRYPTED_RESPONSE_ENC)))) { + ServicesLogger.LOGGER.responseModeQueryJwtNotAllowed(); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Response_mode 'query.jwt' is allowed only when the authorization response token is encrypted"); + } + + if ((parsedResponseType.hasResponseType(OIDCResponseType.CODE) || parsedResponseType.hasResponseType(OIDCResponseType.NONE)) && !client.isStandardFlowEnabled()) { + ServicesLogger.LOGGER.flowNotAllowed("Standard"); + event.error(Errors.NOT_ALLOWED); + throw new AuthorizationCheckException(Response.Status.UNAUTHORIZED, OAuthErrorException.UNAUTHORIZED_CLIENT, "Client is not allowed to initiate browser login with given response_type. Standard flow is disabled for the client."); + } + + if (parsedResponseType.isImplicitOrHybridFlow() && !client.isImplicitFlowEnabled()) { + ServicesLogger.LOGGER.flowNotAllowed("Implicit"); + event.error(Errors.NOT_ALLOWED); + throw new AuthorizationCheckException(Response.Status.UNAUTHORIZED, OAuthErrorException.UNAUTHORIZED_CLIENT, "Client is not allowed to initiate browser login with given response_type. Implicit flow is disabled for the client."); + } + } + + public void checkInvalidRequestMessage() throws AuthorizationCheckException { + if (request.getInvalidRequestMessage() != null) { + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, Errors.INVALID_REQUEST, request.getInvalidRequestMessage()); + } + } + + public void checkOIDCRequest() { + if (!TokenUtil.isOIDCRequest(request.getScope())) { + ServicesLogger.LOGGER.oidcScopeMissing(); + } + } + + public void checkValidScope() throws AuthorizationCheckException { + if (!TokenManager.isValidScope(request.getScope(), client)) { + ServicesLogger.LOGGER.invalidParameter(OIDCLoginProtocol.SCOPE_PARAM); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_SCOPE, "Invalid scopes: " + request.getScope()); + } + } + + public void checkOIDCParams() throws AuthorizationCheckException { + // If request is not OIDC request, but pure OAuth2 request and response_type is just 'token', then 'nonce' is not mandatory + boolean isOIDCRequest = TokenUtil.isOIDCRequest(request.getScope()); + if (!isOIDCRequest && parsedResponseType.toString().equals(OIDCResponseType.TOKEN)) { + return; + } + + if (parsedResponseType.isImplicitOrHybridFlow() && request.getNonce() == null) { + ServicesLogger.LOGGER.missingParameter(OIDCLoginProtocol.NONCE_PARAM); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Missing parameter: nonce"); + } + + return; + } + + // https://tools.ietf.org/html/rfc7636#section-4 + public void checkPKCEParams() throws AuthorizationCheckException { + String codeChallenge = request.getCodeChallenge(); + String codeChallengeMethod = request.getCodeChallengeMethod(); + + // PKCE not adopted to OAuth2 Implicit Grant and OIDC Implicit Flow, + // adopted to OAuth2 Authorization Code Grant and OIDC Authorization Code Flow, Hybrid Flow + // Namely, flows using authorization code. + if (parsedResponseType.isImplicitFlow()) return; + + String pkceCodeChallengeMethod = OIDCAdvancedConfigWrapper.fromClientModel(client).getPkceCodeChallengeMethod(); + + if (pkceCodeChallengeMethod != null && !pkceCodeChallengeMethod.isEmpty()) { + checkParamsForPkceEnforcedClient(codeChallengeMethod, pkceCodeChallengeMethod, codeChallenge); + } else { + // if PKCE Activation is OFF, execute the codes implemented in KEYCLOAK-2604 + checkParamsForPkceNotEnforcedClient(codeChallengeMethod, pkceCodeChallengeMethod, codeChallenge); + } + } + + public void checkParRequired() throws AuthorizationCheckException { + boolean isParRequired = realm.getParPolicy().isRequirePushedAuthorizationRequests(client); + if (!isParRequired) { + return; + } + String requestUriParam = params.getFirst(OIDCLoginProtocol.REQUEST_URI_PARAM); + if (requestUriParam != null && AuthorizationEndpointRequestParserProcessor.getRequestUriType(requestUriParam) == RequestUriType.PAR) { + return; + } + ServicesLogger.LOGGER.missingParameter(OIDCLoginProtocol.REQUEST_URI_PARAM); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Pushed Authorization Request is only allowed."); + } + + // https://tools.ietf.org/html/rfc7636#section-4 + private boolean isValidPkceCodeChallenge(String codeChallenge) { + if (codeChallenge.length() < OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MIN_LENGTH) { + logger.debugf("PKCE codeChallenge length under lower limit , codeChallenge = %s", codeChallenge); + return false; + } + if (codeChallenge.length() > OIDCLoginProtocol.PKCE_CODE_CHALLENGE_MAX_LENGTH) { + logger.debugf("PKCE codeChallenge length over upper limit , codeChallenge = %s", codeChallenge); + return false; + } + Matcher m = VALID_CODE_CHALLENGE_PATTERN.matcher(codeChallenge); + return m.matches(); + } + + private void checkParamsForPkceEnforcedClient(String codeChallengeMethod, String pkceCodeChallengeMethod, String codeChallenge) throws AuthorizationCheckException { + // check whether code challenge method is specified + if (codeChallengeMethod == null) { + logger.info("PKCE enforced Client without code challenge method."); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge_method"); + } + // check whether specified code challenge method is configured one in advance + if (!codeChallengeMethod.equals(pkceCodeChallengeMethod)) { + logger.info("PKCE enforced Client code challenge method is not configured one."); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code challenge method is not configured one"); + } + // check whether code challenge is specified + if (codeChallenge == null) { + logger.info("PKCE supporting Client without code challenge"); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge"); + } + // check whether code challenge is formatted along with the PKCE specification + if (!isValidPkceCodeChallenge(codeChallenge)) { + logger.infof("PKCE supporting Client with invalid code challenge specified in PKCE, codeChallenge = %s", codeChallenge); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge"); + } + } + + private void checkParamsForPkceNotEnforcedClient(String codeChallengeMethod, String pkceCodeChallengeMethod, String codeChallenge) throws AuthorizationCheckException { + if (codeChallenge == null && codeChallengeMethod != null) { + logger.info("PKCE supporting Client without code challenge"); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Missing parameter: code_challenge"); + } + + // based on code_challenge value decide whether this client(RP) supports PKCE + if (codeChallenge == null) { + logger.debug("PKCE non-supporting Client"); + return; + } + + if (codeChallengeMethod != null) { + // https://tools.ietf.org/html/rfc7636#section-4.2 + // plain or S256 + if (!codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_S256) && !codeChallengeMethod.equals(OIDCLoginProtocol.PKCE_METHOD_PLAIN)) { + logger.infof("PKCE supporting Client with invalid code challenge method not specified in PKCE, codeChallengeMethod = %s", codeChallengeMethod); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge_method"); + } + } else { + // https://tools.ietf.org/html/rfc7636#section-4.3 + // default code_challenge_method is plane + codeChallengeMethod = OIDCLoginProtocol.PKCE_METHOD_PLAIN; + } + + if (!isValidPkceCodeChallenge(codeChallenge)) { + logger.infof("PKCE supporting Client with invalid code challenge specified in PKCE, codeChallenge = %s", codeChallenge); + event.error(Errors.INVALID_REQUEST); + throw new AuthorizationCheckException(Response.Status.BAD_REQUEST, OAuthErrorException.INVALID_REQUEST, "Invalid parameter: code_challenge"); + } + } + + + // Exception propagated to the caller, which will allow caller to send proper error response based on the context (Browser OIDC Authorization Endpoint, PAR etc) + public class AuthorizationCheckException extends Exception { + + private final Response.Status status; + private final String error; + private final String errorDescription; + + public AuthorizationCheckException(Response.Status status, String error, String errorDescription) { + this.status = status; + this.error = error; + this.errorDescription = errorDescription; + } + + public void throwAsErrorPageException(AuthenticationSessionModel authenticationSession) { + throw new ErrorPageException(session, authenticationSession, status, error, errorDescription); + } + + public void throwAsCorsErrorResponseException(Cors cors) { + AuthorizationEndpointChecker.this.event.detail("detail", errorDescription).error(error); + throw new CorsErrorResponseException(cors, error, errorDescription, status); + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java index de8253117c..d695ee003d 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/AuthorizationEndpointRequestParserProcessor.java @@ -26,6 +26,7 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.protocol.oidc.par.endpoints.request.AuthzEndpointParParser; import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.services.ErrorPageException; import org.keycloak.services.ServicesLogger; @@ -77,16 +78,21 @@ public class AuthorizationEndpointRequestParserProcessor { if (requestParam != null) { new AuthzEndpointRequestObjectParser(session, requestParam, client).parseRequest(request); } else if (requestUriParam != null) { - // Validate "requestUriParam" with allowed requestUris - List requestUris = OIDCAdvancedConfigWrapper.fromClientModel(client).getRequestUris(); - String requestUri = RedirectUtils.verifyRedirectUri(session, client.getRootUrl(), requestUriParam, new HashSet<>(requestUris), false); - if (requestUri == null) { - throw new RuntimeException("Specified 'request_uri' not allowed for this client."); - } - - try (InputStream is = session.getProvider(HttpClientProvider.class).get(requestUri)) { - String retrievedRequest = StreamUtil.readString(is); - new AuthzEndpointRequestObjectParser(session, retrievedRequest, client).parseRequest(request); + // Define, if the request is `PAR` or usual `Request Object`. + RequestUriType requestUriType = getRequestUriType(requestUriParam); + if (requestUriType == RequestUriType.PAR) { + new AuthzEndpointParParser(session, requestUriParam).parseRequest(request); + } else { + // Validate "requestUriParam" with allowed requestUris + List requestUris = OIDCAdvancedConfigWrapper.fromClientModel(client).getRequestUris(); + String requestUri = RedirectUtils.verifyRedirectUri(session, client.getRootUrl(), requestUriParam, new HashSet<>(requestUris), false); + if (requestUri == null) { + throw new RuntimeException("Specified 'request_uri' not allowed for this client."); + } + try (InputStream is = session.getProvider(HttpClientProvider.class).get(requestUri)) { + String retrievedRequest = StreamUtil.readString(is); + new AuthzEndpointRequestObjectParser(session, retrievedRequest, client).parseRequest(request); + } } } @@ -109,4 +115,14 @@ public class AuthorizationEndpointRequestParserProcessor { } } + public static RequestUriType getRequestUriType(String requestUri) { + if (requestUri == null) { + throw new RuntimeException("'request_uri' parameter is null"); + } + + return requestUri.toLowerCase().startsWith("urn:ietf:params:oauth:request_uri:") + ? RequestUriType.PAR + : RequestUriType.REQUEST_OBJECT; + } + } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/RequestUriType.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/RequestUriType.java new file mode 100644 index 0000000000..a779e2e02b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/request/RequestUriType.java @@ -0,0 +1,25 @@ +/* + * 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.endpoints.request; + +public enum RequestUriType { + + REQUEST_OBJECT, + PAR + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/ParResponse.java b/services/src/main/java/org/keycloak/protocol/oidc/par/ParResponse.java new file mode 100644 index 0000000000..51dc9fb92e --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/ParResponse.java @@ -0,0 +1,50 @@ +/* + * 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.par; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ParResponse { + + @JsonProperty("request_uri") + private String requestUri; + + @JsonProperty("expires_in") + private int expiresIn; + + public ParResponse(String requestUri, int expiresIn) { + this.requestUri = requestUri; + this.expiresIn = expiresIn; + } + + public String getRequestUri() { + return requestUri; + } + + public void setRequestUri(String requestUri) { + this.requestUri = requestUri; + } + + public int getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(int expiresIn) { + this.expiresIn = expiresIn; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/AbstractParEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/AbstractParEndpoint.java new file mode 100644 index 0000000000..b04d0071f8 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/AbstractParEndpoint.java @@ -0,0 +1,97 @@ +/* + * 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.par.endpoints; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.ws.rs.core.Response; + +import org.keycloak.OAuthErrorException; +import org.keycloak.common.ClientConnection; +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.CorsErrorResponseException; +import org.keycloak.services.resources.Cors; + +public abstract class AbstractParEndpoint { + + protected final KeycloakSession session; + protected final EventBuilder event; + protected final RealmModel realm; + protected Cors cors; + protected ClientModel client; + + public AbstractParEndpoint(KeycloakSession session, EventBuilder event) { + this.session = session; + this.event = event; + realm = session.getContext().getRealm(); + } + + protected void checkSsl() { + ClientConnection clientConnection = session.getContext().getContextObject(ClientConnection.class); + + if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) { + throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN); + } + } + + protected void checkRealm() { + if (!realm.isEnabled()) { + throw new CorsErrorResponseException(cors.allowAllOrigins(), OAuthErrorException.ACCESS_DENIED, "Realm not enabled", Response.Status.FORBIDDEN); + } + } + + protected void authorizeClient() { + try { + AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, cors); + client = clientAuth.getClient(); + + this.event.client(client); + + cors.allowedOrigins(session, client); + + if (client == null || client.isPublicClient()) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not allowed.", Response.Status.FORBIDDEN); + } + } catch (Exception e) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Authentication failed.", Response.Status.UNAUTHORIZED); + } + } + + protected byte[] getHash(String inputData) { + byte[] hash; + + try { + hash = MessageDigest.getInstance("SHA-256").digest(inputData.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("Error calculating hash"); + } + + return hash; + } + + protected CorsErrorResponseException throwErrorResponseException(String error, String detail, Response.Status status) { + this.event.detail("detail", detail).error(error); + return new CorsErrorResponseException(cors, error, detail, status); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java new file mode 100644 index 0000000000..315b1d063c --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParEndpoint.java @@ -0,0 +1,165 @@ +/* + * 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.par.endpoints; + +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.Profile; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.headers.SecurityHeadersProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PushedAuthzRequestStoreProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocolService; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; +import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; +import org.keycloak.protocol.oidc.par.ParResponse; +import org.keycloak.services.resources.Cors; +import org.keycloak.utils.ProfileHelper; + +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.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriBuilder; + +import static org.keycloak.protocol.oidc.OIDCLoginProtocol.REQUEST_URI_PARAM; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Pushed Authorization Request endpoint + */ +public class ParEndpoint extends AbstractParEndpoint { + + public static final String PAR_CREATED_TIME = "par.created.time"; + private static final String REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"; + public static final int REQUEST_URI_PREFIX_LENGTH = REQUEST_URI_PREFIX.length(); + + @Context + private HttpRequest httpRequest; + + private AuthorizationEndpointRequest authorizationRequest; + + public static UriBuilder parUrl(UriBuilder baseUriBuilder) { + UriBuilder uriBuilder = OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder); + return uriBuilder.path(OIDCLoginProtocolService.class, "resolveExtension").resolveTemplate("extension", ParRootEndpoint.PROVIDER_ID, false).path(ParRootEndpoint.class, "request"); + } + + public ParEndpoint(KeycloakSession session, EventBuilder event) { + super(session, event); + } + + @Path("/") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public Response request() { + + ProfileHelper.requireFeature(Profile.Feature.PAR); + + cors = Cors.add(httpRequest).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS); + + event.event(EventType.PUSHED_AUTHORIZATION_REQUEST); + + checkSsl(); + checkRealm(); + authorizeClient(); + + if (httpRequest.getDecodedFormParameters().containsKey(REQUEST_URI_PARAM)) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "It is not allowed to include request_uri to PAR.", Response.Status.BAD_REQUEST); + } + + try { + authorizationRequest = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, httpRequest.getDecodedFormParameters()); + } catch (Exception e) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, e.getMessage(), Response.Status.BAD_REQUEST); + } + + AuthorizationEndpointChecker checker = new AuthorizationEndpointChecker() + .event(event) + .client(client) + .realm(realm) + .request(authorizationRequest) + .session(session); + + try { + checker.checkRedirectUri(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid parameter: redirect_uri", Response.Status.BAD_REQUEST); + } + + try { + checker.checkResponseType(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + if (ex.getError().equals(OAuthErrorException.UNSUPPORTED_RESPONSE_TYPE)) { + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Unsupported response type", Response.Status.BAD_REQUEST); + } else { + ex.throwAsCorsErrorResponseException(cors); + } + } + + try { + checker.checkValidScope(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + // PAR throws this as "invalid_request" error + throw throwErrorResponseException(OAuthErrorException.INVALID_REQUEST, ex.getErrorDescription(), Response.Status.BAD_REQUEST); + } + + try { + checker.checkInvalidRequestMessage(); + checker.checkOIDCRequest(); + checker.checkOIDCParams(); + checker.checkPKCEParams(); + } catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) { + ex.throwAsCorsErrorResponseException(cors); + } + + Map params = new HashMap<>(); + + UUID key = UUID.randomUUID(); + String requestUri = REQUEST_URI_PREFIX + key.toString(); + + int expiresIn = realm.getParPolicy().getRequestUriLifespan(); + + httpRequest.getDecodedFormParameters().forEach((k, v) -> { + // PAR store only accepts Map so that MultivaluedMap needs to be converted to Map. + String singleValue = String.valueOf(v).replace("[", "").replace("]", ""); + params.put(k, singleValue); + }); + params.put(PAR_CREATED_TIME, String.valueOf(System.currentTimeMillis())); + + PushedAuthzRequestStoreProvider parStore = session.getProvider(PushedAuthzRequestStoreProvider.class); + parStore.put(key, expiresIn, params); + + ParResponse parResponse = new ParResponse(requestUri, expiresIn); + + session.getProvider(SecurityHeadersProvider.class).options().allowEmptyContentType(); + return cors.builder(Response.status(Response.Status.CREATED) + .entity(parResponse) + .type(MediaType.APPLICATION_JSON_TYPE)) + .build(); + } + +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParRootEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParRootEndpoint.java new file mode 100644 index 0000000000..6d64fecc4b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/ParRootEndpoint.java @@ -0,0 +1,79 @@ +/* + * 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.par.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; + +public class ParRootEndpoint implements OIDCExtProvider, OIDCExtProviderFactory, EnvironmentDependentProviderFactory { + + public static final String PROVIDER_ID = "par"; + + private final KeycloakSession session; + private EventBuilder event; + + public ParRootEndpoint() { + // for reflection + this(null); + } + + public ParRootEndpoint(KeycloakSession session) { + this.session = session; + } + + @Path("/request") + public ParEndpoint request() { + ParEndpoint endpoint = new ParEndpoint(session, event); + + ResteasyProviderFactory.getInstance().injectProperties(endpoint); + + return endpoint; + } + + @Override + public OIDCExtProvider create(KeycloakSession session) { + return new ParRootEndpoint(session); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public boolean isSupported() { + return Profile.isFeatureEnabled(Profile.Feature.PAR); + } + + @Override + public void setEvent(EventBuilder event) { + this.event = event; + } + + @Override + public void close() { + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java new file mode 100644 index 0000000000..ce275db549 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/par/endpoints/request/AuthzEndpointParParser.java @@ -0,0 +1,90 @@ +/* + * 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.par.endpoints.request; + +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PushedAuthzRequestStoreProvider; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.endpoints.request.AuthzEndpointRequestParser; +import org.keycloak.protocol.oidc.par.endpoints.ParEndpoint; + +import static org.keycloak.protocol.oidc.par.endpoints.ParEndpoint.PAR_CREATED_TIME; + +/** + * Parse the parameters from PAR + * + */ +public class AuthzEndpointParParser extends AuthzEndpointRequestParser { + + private static final Logger logger = Logger.getLogger(AuthzEndpointParParser.class); + + private Map requestParams; + + private String invalidRequestMessage = null; + + public AuthzEndpointParParser(KeycloakSession session, String requestUri) { + PushedAuthzRequestStoreProvider parStore = session.getProvider(PushedAuthzRequestStoreProvider.class); + UUID key; + try { + key = UUID.fromString(requestUri.substring(ParEndpoint.REQUEST_URI_PREFIX_LENGTH)); + } catch (RuntimeException re) { + logger.warnf(re,"Unable to parse request_uri: %s", requestUri); + throw new RuntimeException("Unable to parse request_uri"); + } + Map retrievedRequest = parStore.remove(key); + if (retrievedRequest == null) { + throw new RuntimeException("PAR not found. not issued or used multiple times."); + } + + RealmModel realm = session.getContext().getRealm(); + int expiresIn = realm.getParPolicy().getRequestUriLifespan(); + long created = Long.parseLong(retrievedRequest.get(PAR_CREATED_TIME)); + if (System.currentTimeMillis() - created < (expiresIn * 1000)) { + requestParams = retrievedRequest; + } else { + throw new RuntimeException("PAR expired."); + } + } + + @Override + protected String getParameter(String paramName) { + return requestParams.get(paramName); + } + + @Override + protected Integer getIntParameter(String paramName) { + String paramVal = requestParams.get(paramName); + return paramVal == null ? null : Integer.parseInt(paramVal); + } + + public String getInvalidRequestMessage() { + return invalidRequestMessage; + } + + @Override + protected Set keySet() { + return requestParams.keySet(); + } + +} 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 a3cfc2f903..98771370af 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 @@ -29,6 +29,7 @@ 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.ParConfig; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCLoginProtocol; @@ -207,6 +208,13 @@ public class DescriptionConverter { } } + Boolean requirePushedAuthorizationRequests = clientOIDC.getRequirePushedAuthorizationRequests(); + if (requirePushedAuthorizationRequests != null) { + Map attr = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>()); + attr.put(ParConfig.REQUIRE_PUSHED_AUTHORIZATION_REQUESTS, requirePushedAuthorizationRequests.toString()); + client.setAttributes(attr); + } + return client; } @@ -362,6 +370,8 @@ public class DescriptionConverter { if (StringUtil.isNotBlank(alg)) { response.setBackchannelAuthenticationRequestSigningAlg(alg); } + Boolean requirePushedAuthorizationRequests = Boolean.valueOf(client.getAttributes().get(ParConfig.REQUIRE_PUSHED_AUTHORIZATION_REQUESTS)); + response.setRequirePushedAuthorizationRequests(requirePushedAuthorizationRequests.booleanValue()); } List foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client); 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 3e94ed24f0..29f6126cb2 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,2 +1,3 @@ org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory -org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint \ No newline at end of file +org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint +org.keycloak.protocol.oidc.par.endpoints.ParRootEndpoint \ No newline at end of file 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 0a2a87bc0c..685acf51ec 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 @@ -66,6 +66,7 @@ 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.par.endpoints.ParEndpoint; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; @@ -101,6 +102,7 @@ import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.function.Consumer; import java.util.function.Supplier; import javax.ws.rs.client.Entity; @@ -823,7 +825,7 @@ public class OAuthClient { post.addHeader("Origin", origin); } - UrlEncodedFormEntity formEntity; + UrlEncodedFormEntity formEntity; try { formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); } catch (UnsupportedEncodingException e) { @@ -1038,6 +1040,149 @@ public class OAuthClient { } } + public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret) throws IOException { + return doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{}); + } + + public ParResponse doPushedAuthorizationRequest(String clientId, String clientSecret, Consumer c) throws IOException { + try (CloseableHttpClient client = HttpClientBuilder.create().build()) { + HttpPost post = new HttpPost(getParEndpointUrl()); + + List parameters = new LinkedList<>(); + + if (origin != null) { + post.addHeader("Origin", origin); + } + if (responseType != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.RESPONSE_TYPE, responseType)); + } + if (responseMode != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.RESPONSE_MODE_PARAM, responseMode)); + } + if (clientId != null && clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId)); + } + if (redirectUri != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, redirectUri)); + } + if (kcAction != null) { + parameters.add(new BasicNameValuePair(Constants.KC_ACTION, kcAction)); + } + // on authz request, state is putting automatically so that. + // if state is put here, they are not matched. + //String state = this.state.getState(); + //if (state != null) { + // parameters.add(new BasicNameValuePair(OAuth2Constants.STATE, state)); + //} + if (uiLocales != null){ + parameters.add(new BasicNameValuePair(OAuth2Constants.UI_LOCALES_PARAM, uiLocales)); + } + if (nonce != null){ + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, nonce)); + } + String scopeParam = openid ? TokenUtil.attachOIDCScope(scope) : scope; + if (scopeParam != null && !scopeParam.isEmpty()) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scopeParam)); + } + if (maxAge != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.MAX_AGE_PARAM, maxAge)); + } + if (request != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request)); + } + if (requestUri != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri)); + } + if (codeChallenge != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE, codeChallenge)); + } + if (codeChallengeMethod != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE_METHOD, codeChallengeMethod)); + } + if (customParameters != null) { + customParameters.keySet().stream().forEach(i -> parameters.add(new BasicNameValuePair(i, customParameters.get(i)))); + } + + UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, Charsets.UTF_8); + post.setEntity(formEntity); + try { + return new ParResponse(client.execute(post), c); + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Failed to do PAR request", e); + } + } + } + + public static class ParResponse { + private int statusCode; + private Map headers; + + private String requestUri; + private int expiresIn; + + private String error; + private String errorDescription; + + public ParResponse(CloseableHttpResponse response, Consumer c) 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 == 201) { + requestUri = (String) responseJson.get("request_uri"); + expiresIn = ((Integer) responseJson.get("expires_in")).intValue(); + } else { + error = (String) responseJson.get(OAuth2Constants.ERROR); + errorDescription = responseJson.containsKey(OAuth2Constants.ERROR_DESCRIPTION) ? (String) responseJson.get(OAuth2Constants.ERROR_DESCRIPTION) : null; + } + + c.accept(response); + } finally { + response.close(); + } + } + + public int getStatusCode() { + return statusCode; + } + + public Map getHeaders() { + return headers; + } + + public String getRequestUri() { + return requestUri; + } + + public int getExpiresIn() { + return expiresIn; + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + } + public void closeClient(CloseableHttpClient client) { try { client.close(); @@ -1325,6 +1470,11 @@ public class OAuthClient { return b.build(realm).toString(); } + public String getParEndpointUrl() { + UriBuilder b = ParEndpoint.parUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + public OAuthClient baseUrl(String baseUrl) { this.baseUrl = baseUrl; return this; 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 9f9532cba6..7a61c4f583 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 @@ -34,6 +34,7 @@ import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory; import org.keycloak.models.CibaConfig; import org.keycloak.models.Constants; import org.keycloak.models.OAuth2DeviceConfig; +import org.keycloak.models.ParConfig; import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.representations.adapters.action.GlobalRequestResult; @@ -191,8 +192,9 @@ public class RealmTest extends AbstractAdminTest { Map attributes = rep2.getAttributes(); assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()), - attributes.size() == 2 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN) - && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL)); + attributes.size() == 3 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN) + && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL) + && attributes.containsKey(ParConfig.PAR_REQUEST_URI_LIFESPAN)); } finally { adminClient.realm("attributes").remove(); } 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 5b7b595995..cdd0fad8df 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 @@ -190,6 +190,11 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest { Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512); assertEquals(oidcConfig.getDeviceAuthorizationEndpoint(), oauth.getDeviceAuthorizationUrl()); + + // Pushed Authorization Request (PAR) + assertEquals(oauth.getParEndpointUrl(), oidcConfig.getPushedAuthorizationRequestEndpoint()); + assertEquals(Boolean.FALSE, oidcConfig.getRequirePushedAuthorizationRequests()); + } finally { client.close(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/par/ParTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/par/ParTest.java new file mode 100644 index 0000000000..ced7cf826c --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/par/ParTest.java @@ -0,0 +1,885 @@ +/* + * 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.par; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.ws.rs.core.UriBuilder; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.OAuth2Constants; +import org.keycloak.OAuthErrorException; +import org.keycloak.common.Profile; +import org.keycloak.models.AdminRoles; +import org.keycloak.models.Constants; +import org.keycloak.models.ParConfig; +import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude; +import org.keycloak.testsuite.arquillian.annotation.EnableFeature; +import org.keycloak.testsuite.client.AbstractClientPoliciesTest; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.OAuthClient.ParResponse; + +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.QUARKUS; +import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE; + +@EnableFeature(value = Profile.Feature.PAR, skipRestart = true) +@AuthServerContainerExclude({REMOTE, QUARKUS}) +public class ParTest extends AbstractClientPoliciesTest { + + // defined in testrealm.json + private static final String TEST_USER_NAME = "test-user@localhost"; + private static final String TEST_USER_PASSWORD = "password"; + private static final String TEST_USER2_NAME = "john-doh@localhost"; + private static final String TEST_USER2_PASSWORD = "password"; + + private static final String CLIENT_NAME = "Zahlungs-App"; + private static final String CLIENT_REDIRECT_URI = "https://localhost:8543/auth/realms/test/app/auth/cb"; + private static final String IMAGINARY_REQUEST_URI = "urn:ietf:params:oauth:request_uri:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + private static final int DEFAULT_REQUEST_URI_LIFESPAN = 60; + + private static final String VALID_CORS_URL = "http://localtest.me:8180"; + private static final String INVALID_CORS_URL = "http://invalid.localtest.me:8180"; + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + + List users = realm.getUsers(); + + LinkedList credentials = new LinkedList<>(); + CredentialRepresentation password = new CredentialRepresentation(); + password.setType(CredentialRepresentation.PASSWORD); + password.setValue("password"); + credentials.add(password); + + UserRepresentation user = new UserRepresentation(); + user.setEnabled(true); + user.setUsername("manage-clients"); + user.setCredentials(credentials); + user.setClientRoles(Collections.singletonMap(Constants.REALM_MANAGEMENT_CLIENT_ID, Collections.singletonList(AdminRoles.MANAGE_CLIENTS))); + + users.add(user); + + user = new UserRepresentation(); + user.setEnabled(true); + user.setUsername("create-clients"); + user.setCredentials(credentials); + user.setClientRoles(Collections.singletonMap(Constants.REALM_MANAGEMENT_CLIENT_ID, Collections.singletonList(AdminRoles.CREATE_CLIENT))); + user.setGroups(Arrays.asList("topGroup")); // defined in testrealm.json + + users.add(user); + + realm.setUsers(users); + + realm.getClients().add(ClientBuilder.create().redirectUris(VALID_CORS_URL + "/realms/master/app") + .addWebOrigin(VALID_CORS_URL).clientId("test-app2").publicClient().directAccessGrants().build()); + + testRealms.add(realm); + } + + // success with one client conducting one authz request + @Test + public void testSuccessfulSinglePar() throws Exception { + try { + // setup PAR realm settings + int requestUriLifespan = 45; + setParRealmSettings(requestUriLifespan); + + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + assertEquals(requestUriLifespan, pResp.getExpiresIn()); + + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUri); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + assertEquals(state, loginResponse.getState()); + String code = loginResponse.getCode(); + String sessionId =loginResponse.getSessionState(); + + // Token Request + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + AccessToken token = oauth.verifyToken(res.getAccessToken()); + String userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER_NAME, token.getSubject()); + assertEquals(clientId, token.getIssuedFor()); + + // Token Refresh + String refreshTokenString = res.getRefreshToken(); + RefreshToken refreshToken = oauth.parseRefreshToken(refreshTokenString); + assertEquals(sessionId, refreshToken.getSessionState()); + assertEquals(clientId, refreshToken.getIssuedFor()); + + OAuthClient.AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(refreshTokenString, clientSecret); + assertEquals(200, refreshResponse.getStatusCode()); + + AccessToken refreshedToken = oauth.verifyToken(refreshResponse.getAccessToken()); + RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshResponse.getRefreshToken()); + assertEquals(sessionId, refreshedToken.getSessionState()); + assertEquals(sessionId, refreshedRefreshToken.getSessionState()); + assertEquals(findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(), refreshedToken.getSubject()); + + // Logout + oauth.doLogout(refreshResponse.getRefreshToken(), clientSecret); + refreshResponse = oauth.doRefreshTokenRequest(refreshResponse.getRefreshToken(), clientSecret); + assertEquals(400, refreshResponse.getStatusCode()); + + } finally { + restoreParRealmSettings(); + } + } + + // success with the same client conducting multiple authz requests + PAR simultaneously + @Test + public void testSuccessfulMultipleParBySameClient() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request #1 + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUriOne = pResp.getRequestUri(); + + // Pushed Authorization Request #2 + oauth.clientId(clientId); + oauth.scope("microprofile-jwt" + " " + "profile"); + oauth.redirectUri(CLIENT_REDIRECT_URI); + pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUriTwo = pResp.getRequestUri(); + + // Authorization Request with request_uri of PAR #2 + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUriTwo); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER2_NAME, TEST_USER2_PASSWORD); + assertEquals(state, loginResponse.getState()); + String code = loginResponse.getCode(); + String sessionId =loginResponse.getSessionState(); + + // Token Request #2 + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + AccessToken token = oauth.verifyToken(res.getAccessToken()); + String userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER2_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER2_NAME, token.getSubject()); + assertEquals(clientId, token.getIssuedFor()); + assertTrue(token.getScope().contains("openid")); + assertTrue(token.getScope().contains("microprofile-jwt")); + assertTrue(token.getScope().contains("profile")); + + // Logout + oauth.doLogout(res.getRefreshToken(), clientSecret); // same oauth instance is used so that this logout is needed to send authz request consecutively. + + // Authorization Request with request_uri of PAR #1 + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUriOne); + state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + assertEquals(state, loginResponse.getState()); + code = loginResponse.getCode(); + sessionId =loginResponse.getSessionState(); + + // Token Request #1 + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + token = oauth.verifyToken(res.getAccessToken()); + userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER_NAME, token.getSubject()); + assertEquals(clientId, token.getIssuedFor()); + assertFalse(token.getScope().contains("microprofile-jwt")); + assertTrue(token.getScope().contains("openid")); + } + + // success with several clients conducting multiple authz requests + PAR simultaneously + @Test + public void testSuccessfulMultipleParByMultipleClients() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + authManageClients(); // call it when several clients are created consecutively. + + String client2Id = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcC2Rep = getClientDynamically(client2Id); + String client2Secret = oidcC2Rep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcC2Rep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcC2Rep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcC2Rep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request #1 + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUriOne = pResp.getRequestUri(); + + // Pushed Authorization Request #2 + oauth.clientId(client2Id); + oauth.scope("microprofile-jwt" + " " + "profile"); + oauth.redirectUri(CLIENT_REDIRECT_URI); + pResp = oauth.doPushedAuthorizationRequest(client2Id, client2Secret); + assertEquals(201, pResp.getStatusCode()); + String requestUriTwo = pResp.getRequestUri(); + + // Authorization Request with request_uri of PAR #2 + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUriTwo); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER2_NAME, TEST_USER2_PASSWORD); + assertEquals(state, loginResponse.getState()); + String code = loginResponse.getCode(); + String sessionId =loginResponse.getSessionState(); + + // Token Request #2 + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, client2Secret); + assertEquals(200, res.getStatusCode()); + + AccessToken token = oauth.verifyToken(res.getAccessToken()); + String userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER2_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER2_NAME, token.getSubject()); + assertEquals(client2Id, token.getIssuedFor()); + assertTrue(token.getScope().contains("openid")); + assertTrue(token.getScope().contains("microprofile-jwt")); + assertTrue(token.getScope().contains("profile")); + + // Logout + oauth.doLogout(res.getRefreshToken(), client2Secret); // same oauth instance is used so that this logout is needed to send authz request consecutively. + + // Authorization Request with request_uri of PAR #1 + // remove parameters as query strings of uri + oauth.clientId(clientId); + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUriOne); + state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + assertEquals(state, loginResponse.getState()); + code = loginResponse.getCode(); + sessionId =loginResponse.getSessionState(); + + // Token Request #1 + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + token = oauth.verifyToken(res.getAccessToken()); + userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER_NAME, token.getSubject()); + assertEquals(clientId, token.getIssuedFor()); + assertFalse(token.getScope().contains("microprofile-jwt")); + assertTrue(token.getScope().contains("openid")); + } + + // not issued PAR request_uri used + @Test + public void testFailureNotIssuedParUsed() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + // but not use issued request_uri + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + // use not issued request_uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(IMAGINARY_REQUEST_URI); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + driver.navigate().to(b.build().toURL()); + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + Assert.assertFalse(errorResponse.isRedirected()); + } + + // PAR request_uri used twice + @Test + public void testFailureParUsedTwice() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUri); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + assertEquals(state, loginResponse.getState()); + String code = loginResponse.getCode(); + + // Token Request + oauth.redirectUri(CLIENT_REDIRECT_URI); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + // Authorization Request with request_uri of PAR + // use same redirect_uri + state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + driver.navigate().to(b.build().toURL()); + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + Assert.assertFalse(errorResponse.isRedirected()); + } + + // PAR request_uri used by other client + @Test + public void testFailureParUsedByOtherClient() throws Exception { + // create client dynamically + String victimClientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation victimOidcCRep = getClientDynamically(victimClientId); + String victimClientSecret = victimOidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, victimOidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(victimOidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, victimOidcCRep.getTokenEndpointAuthMethod()); + + authManageClients(); + + String attackerClientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation attackerOidcCRep = getClientDynamically(attackerClientId); + assertEquals(Boolean.TRUE, attackerOidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(attackerOidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, attackerOidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + oauth.clientId(victimClientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(victimClientId, victimClientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + // used by other client + oauth.clientId(attackerClientId); + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUri); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + driver.navigate().to(b.build().toURL()); + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + Assert.assertFalse(errorResponse.isRedirected()); + } + + // not PAR by PAR required client + @Test + public void testFailureNotParByParRequiredCilent() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + + oauth.clientId(clientId); + oauth.openLoginForm(); + assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentQuery().get(OAuth2Constants.ERROR)); + assertEquals("Pushed Authorization Request is only allowed.", oauth.getCurrentQuery().get(OAuth2Constants.ERROR_DESCRIPTION)); + + updateClientDynamically(clientId, (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + }); + + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + String code = loginResponse.getCode(); + + // Token Request + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + } + + // expired PAR used + @Test + public void testFailureParExpired() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + int expiresIn = pResp.getExpiresIn(); + + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + // PAR expired + setTimeOffset(expiresIn + 5); + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUri); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl()); + driver.navigate().to(b.build().toURL()); + OAuthClient.AuthorizationEndpointResponse errorResponse = new OAuthClient.AuthorizationEndpointResponse(oauth); + Assert.assertFalse(errorResponse.isRedirected()); + } + + // client authentication failed + @Test + public void testFailureClientAuthnFailed() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret + "abc"); + assertEquals(401, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("Authentication failed.", pResp.getErrorDescription()); + } + + // PAR including request_uri + @Test + public void testFailureParIncludesRequestUri() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + oauth.requestUri(IMAGINARY_REQUEST_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("It is not allowed to include request_uri to PAR.", pResp.getErrorDescription()); + } + + // invalid PAR + @Test + public void testFailureInvalidPar() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + updateClientByAdmin(clientId, (ClientRepresentation cRep)->{ + OIDCAdvancedConfigWrapper.fromClientRepresentation(cRep).setRequestObjectRequired(OIDCConfigAttributes.REQUEST_OBJECT_REQUIRED_REQUEST); + }); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + } + + // PAR including invalid redirect_uri + @Test + public void testFailureParIncludesInvalidRedirectUri() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(INVALID_CORS_URL); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("Invalid parameter: redirect_uri", pResp.getErrorDescription()); + } + + // PAR including invalid response_type + @Test + public void testFailureParIncludesInvalidResponseType() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + oauth.responseType(null); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("Missing parameter: response_type", pResp.getErrorDescription()); + } + + // PAR including invalid scope + @Test + public void testFailureParIncludesInvalidScope() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + oauth.scope("not_registered_scope"); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("Invalid scopes: openid not_registered_scope", pResp.getErrorDescription()); + } + + // PAR invalid PKCE setting + @Test + public void testFailureParInvalidPkceSetting() throws Exception { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + updateClientByAdmin(clientId, (ClientRepresentation cRep)->{ + OIDCAdvancedConfigWrapper.fromClientRepresentation(cRep).setPkceCodeChallengeMethod("S256"); + }); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(CLIENT_REDIRECT_URI); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret); + assertEquals(400, pResp.getStatusCode()); + assertEquals(OAuthErrorException.INVALID_REQUEST, pResp.getError()); + assertEquals("Missing parameter: code_challenge_method", pResp.getErrorDescription()); + } + + // CORS test + @Test + public void testParCorsRequestWithValidUrl() throws Exception { + try { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRequirePushedAuthorizationRequests(Boolean.FALSE); + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI, VALID_CORS_URL + "/realms/master/app"))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + updateClientByAdmin(clientId, (ClientRepresentation cRep)->{ + cRep.setOrigin(VALID_CORS_URL); + }); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(VALID_CORS_URL + "/realms/master/app"); + oauth.origin(VALID_CORS_URL); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{ + assertCors(c); + }); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + + doNormalAuthzProcess(requestUri, VALID_CORS_URL + "/realms/master/app", clientId, clientSecret); + } finally { + oauth.origin(null); + } + } + + // CORS test + @Test + public void testParCorsRequestWithInvalidUrlShouldFail() throws Exception { + try { + // create client dynamically + String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> { + clientRep.setRedirectUris(new ArrayList(Arrays.asList(CLIENT_REDIRECT_URI, VALID_CORS_URL + "/realms/master/app"))); + }); + OIDCClientRepresentation oidcCRep = getClientDynamically(clientId); + String clientSecret = oidcCRep.getClientSecret(); + assertEquals(Boolean.FALSE, oidcCRep.getRequirePushedAuthorizationRequests()); + assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI)); + assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod()); + + updateClientByAdmin(clientId, (ClientRepresentation cRep)->{ + cRep.setOrigin(VALID_CORS_URL); + }); + + // Pushed Authorization Request + oauth.clientId(clientId); + oauth.redirectUri(VALID_CORS_URL + "/realms/master/app"); + oauth.origin(INVALID_CORS_URL); + ParResponse pResp = oauth.doPushedAuthorizationRequest(clientId, clientSecret, (CloseableHttpResponse c)->{ + assertNotCors(c); + }); + assertEquals(201, pResp.getStatusCode()); + String requestUri = pResp.getRequestUri(); + + doNormalAuthzProcess(requestUri, VALID_CORS_URL + "/realms/master/app", clientId, clientSecret); + + } finally { + oauth.origin(null); + } + } + + private void doNormalAuthzProcess(String requestUri, String redirectUrl, String clientId, String clientSecret) { + // Authorization Request with request_uri of PAR + // remove parameters as query strings of uri + oauth.redirectUri(null); + oauth.scope(null); + oauth.responseType(null); + oauth.requestUri(requestUri); + String state = oauth.stateParamRandom().getState(); + oauth.stateParamHardcoded(state); + OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD); + assertEquals(state, loginResponse.getState()); + String code = loginResponse.getCode(); + String sessionId =loginResponse.getSessionState(); + + // Token Request + oauth.redirectUri(redirectUrl); // get tokens, it needed. https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + OAuthClient.AccessTokenResponse res = oauth.doAccessTokenRequest(code, clientSecret); + assertEquals(200, res.getStatusCode()); + + AccessToken token = oauth.verifyToken(res.getAccessToken()); + String userId = findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(); + assertEquals(userId, token.getSubject()); + assertEquals(sessionId, token.getSessionState()); + Assert.assertNotEquals(TEST_USER_NAME, token.getSubject()); + assertEquals(clientId, token.getIssuedFor()); + + // Token Refresh + String refreshTokenString = res.getRefreshToken(); + RefreshToken refreshToken = oauth.parseRefreshToken(refreshTokenString); + assertEquals(sessionId, refreshToken.getSessionState()); + assertEquals(clientId, refreshToken.getIssuedFor()); + + OAuthClient.AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(refreshTokenString, clientSecret); + assertEquals(200, refreshResponse.getStatusCode()); + + AccessToken refreshedToken = oauth.verifyToken(refreshResponse.getAccessToken()); + RefreshToken refreshedRefreshToken = oauth.parseRefreshToken(refreshResponse.getRefreshToken()); + assertEquals(sessionId, refreshedToken.getSessionState()); + assertEquals(sessionId, refreshedRefreshToken.getSessionState()); + assertEquals(findUserByUsername(adminClient.realm(REALM_NAME), TEST_USER_NAME).getId(), refreshedToken.getSubject()); + + // Logout + oauth.doLogout(refreshResponse.getRefreshToken(), clientSecret); + refreshResponse = oauth.doRefreshTokenRequest(refreshResponse.getRefreshToken(), clientSecret); + assertEquals(400, refreshResponse.getStatusCode()); + } + + private void setParRealmSettings(int requestUriLifespan) { + RealmRepresentation rep = adminClient.realm(REALM_NAME).toRepresentation(); + Map attributes = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>()); + attributes.put(ParConfig.PAR_REQUEST_URI_LIFESPAN, String.valueOf(requestUriLifespan)); + rep.setAttributes(attributes); + adminClient.realm(REALM_NAME).update(rep); + } + + private void restoreParRealmSettings() { + setParRealmSettings(DEFAULT_REQUEST_URI_LIFESPAN); + } + + private static void assertCors(CloseableHttpResponse response) { + assertEquals("true", response.getHeaders("Access-Control-Allow-Credentials")[0].getValue()); + assertEquals(VALID_CORS_URL, response.getHeaders("Access-Control-Allow-Origin")[0].getValue()); + assertEquals("Access-Control-Allow-Methods", response.getHeaders("Access-Control-Expose-Headers")[0].getValue()); + } + + private static void assertNotCors(CloseableHttpResponse response) { + assertEquals(0, response.getHeaders("Access-Control-Allow-Credentials").length); + assertEquals(0, response.getHeaders("Access-Control-Allow-Origin").length); + assertEquals(0, response.getHeaders("Access-Control-Expose-Headers").length); + } + +} \ No newline at end of file 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 81a90ea30a..b8353c5e9a 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 @@ -1876,6 +1876,13 @@ advanced-client-settings=Advanced Settings advanced-client-settings.tooltip=Expand this section to configure advanced settings of this client tls-client-certificate-bound-access-tokens=OAuth 2.0 Mutual TLS Certificate Bound Access Tokens Enabled tls-client-certificate-bound-access-tokens.tooltip=This enables support for OAuth 2.0 Mutual TLS Certificate Bound Access Tokens, which means that keycloak bind an access token and a refresh token with a X.509 certificate of a token requesting client exchanged in mutual TLS between keycloak's Token Endpoint and this client. These tokens can be treated as Holder-of-Key tokens instead of bearer tokens. + +# PAR request parameters. +require-pushed-authorization-requests=Pushed Authorization Request Enabled +require-pushed-authorization-requests.tooltip=Boolean parameter indicating whether the authorization server accepts authorization request data only via the pushed authorization request method. +request-uri-lifespan=Lifetime of the Request URI for Pushed Authorization Request +request-uri-lifespan.tooltip=Number that represents the lifetime of the request URI in minutes or hours, the default value is 1 minute. + subjectdn=Subject DN subjectdn-tooltip=A regular expression for validating Subject DN in the Client Certificate. Use "(.*?)(?:$)" to match all kind of expressions. 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 ba6176806c..dd04779c4c 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 @@ -1143,6 +1143,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(client.attributes['oauth2.device.code.lifespan']); $scope.oauth2DevicePollingInterval = parseInt(client.attributes['oauth2.device.polling.interval']); + // PAR request. + $scope.requirePushedAuthorizationRequests = false; + if(client.origin) { if ($scope.access.viewRealm) { Components.get({realm: realm.realm, componentId: client.origin}, function (link) { @@ -1361,6 +1364,15 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } } + // PAR request. + if ($scope.client.attributes["require.pushed.authorization.requests"]) { + if ($scope.client.attributes["require.pushed.authorization.requests"] == "true") { + $scope.requirePushedAuthorizationRequests = true; + } else { + $scope.requirePushedAuthorizationRequests = false; + } + } + var useRefreshToken = $scope.client.attributes["client_credentials.use_refresh_token"]; if (useRefreshToken === "true") { $scope.useRefreshTokenForClientCredentialsGrant = true; @@ -1814,6 +1826,13 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes["tls.client.certificate.bound.access.tokens"] = "false"; } + // PAR request. + if ($scope.requirePushedAuthorizationRequests == true) { + $scope.clientEdit.attributes["require.pushed.authorization.requests"] = "true"; + } else { + $scope.clientEdit.attributes["require.pushed.authorization.requests"] = "false"; + } + // KEYCLOAK-9551 Client Credentials Grant generates refresh token // https://tools.ietf.org/html/rfc6749#section-4.4.3 if ($scope.useRefreshTokenForClientCredentialsGrant === true) { 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 e5445e5b04..aeae780f8e 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 @@ -1325,6 +1325,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan); $scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan); $scope.realm.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(realm.oauth2DeviceCodeLifespan); + $scope.requestUriLifespan = TimeUnit2.asUnit(realm.attributes.parRequestUriLifespan); $scope.realm.attributes = realm.attributes var oldCopy = angular.copy($scope.realm); @@ -1336,6 +1337,10 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, } }, true); + $scope.$watch('requestUriLifespan', function () { + $scope.changed = true; + }, true); + $scope.$watch('actionLifespanId', function () { // changedActionLifespanId signals other watchers that we were merely // changing the dropdown and we should not enable 'save' button @@ -1385,6 +1390,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.actionTokenGeneratedByAdminLifespan = $scope.realm.actionTokenGeneratedByAdminLifespan.toSeconds(); $scope.realm.actionTokenGeneratedByUserLifespan = $scope.realm.actionTokenGeneratedByUserLifespan.toSeconds(); $scope.realm.oauth2DeviceCodeLifespan = $scope.realm.oauth2DeviceCodeLifespan.toSeconds(); + $scope.realm.attributes.parRequestUriLifespan = $scope.requestUriLifespan.toSeconds().toString(); Realm.update($scope.realm, function () { $route.reload(); 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 0d14a47c4a..fcfc7fef14 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 @@ -839,6 +839,15 @@ {{:: 'pkce-code-challenge-method.tooltip' | translate}} + +
+ +
+ +
+ {{:: 'require-pushed-authorization-requests.tooltip' | translate}} +
+
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html index c85c68fce6..9c0b0fe4da 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html @@ -320,6 +320,22 @@ +
+ + +
+ + +
+ + {{:: 'request-uri-lifespan.tooltip' | translate}} + +
+