KEYCLOAK-18353 Implement Pushed Authorization Request inside the Keycloak
Co-authored-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com> Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
parent
e5ae113453
commit
2803685cd7
44 changed files with 2555 additions and 277 deletions
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -163,6 +163,12 @@ public class OIDCConfigurationRepresentation {
|
|||
@JsonProperty("backchannel_authentication_request_signing_alg_values_supported")
|
||||
private List<String> backchannelAuthenticationRequestSigningAlgValuesSupported;
|
||||
|
||||
@JsonProperty("require_pushed_authorization_requests")
|
||||
private Boolean requirePushedAuthorizationRequests;
|
||||
|
||||
@JsonProperty("pushed_authorization_request_endpoint")
|
||||
private String pushedAuthorizationRequestEndpoint;
|
||||
|
||||
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
|
||||
|
||||
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<String, Object> getOtherClaims() {
|
||||
return otherClaims;
|
||||
|
|
|
@ -137,6 +137,9 @@ public class OIDCClientRepresentation {
|
|||
|
||||
private String authorization_encrypted_response_enc;
|
||||
|
||||
// PAR request
|
||||
private Boolean require_pushed_authorization_requests;
|
||||
|
||||
public List<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<RequiredCredentialModel> getRequiredCredentials() {
|
||||
if (isUpdated()) return updated.getRequiredCredentials();
|
||||
|
|
|
@ -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<RealmModel, OAuth2DeviceConfig> deviceConfig;
|
||||
protected LazyLoader<RealmModel, CibaConfig> cibaConfig;
|
||||
protected LazyLoader<RealmModel, ParConfig> 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<RealmModel> modelSupplier) {
|
||||
return parConfig.get(modelSupplier);
|
||||
}
|
||||
|
||||
public int getActionTokenGeneratedByAdminLifespan() {
|
||||
return actionTokenGeneratedByAdminLifespan;
|
||||
}
|
||||
|
|
|
@ -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<BasicCache<UUID, ActionTokenValueEntity>> parDataCache;
|
||||
|
||||
public InfinispanPushedAuthzRequestStoreProvider(KeycloakSession session, Supplier<BasicCache<UUID, ActionTokenValueEntity>> actionKeyCache) {
|
||||
this.parDataCache = actionKeyCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(UUID key, int lifespanSeconds, Map<String, String> codeData) {
|
||||
ActionTokenValueEntity tokenValue = new ActionTokenValueEntity(codeData);
|
||||
|
||||
try {
|
||||
BasicCache<UUID, ActionTokenValueEntity> 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<String, String> remove(UUID key) {
|
||||
try {
|
||||
BasicCache<UUID, ActionTokenValueEntity> 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() {
|
||||
|
||||
}
|
||||
}
|
|
@ -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<BasicCache<UUID, ActionTokenValueEntity>> 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);
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -562,6 +562,11 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
|
|||
return new CibaConfig(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ParConfig getParPolicy() {
|
||||
return new ParConfig(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Integer> getUserActionTokenLifespans() {
|
||||
|
||||
|
|
|
@ -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<K> extends AbstractRealmModel<MapRealmEnti
|
|||
public CibaConfig getCibaPolicy() {
|
||||
return new CibaConfig(this);
|
||||
}
|
||||
|
||||
public ParConfig getParPolicy() {
|
||||
return new ParConfig(this);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,7 +147,12 @@ public enum EventType {
|
|||
PERMISSION_TOKEN_ERROR(false),
|
||||
|
||||
DELETE_ACCOUNT(true),
|
||||
DELETE_ACCOUNT_ERROR(true);
|
||||
DELETE_ACCOUNT_ERROR(true),
|
||||
|
||||
// PAR request.
|
||||
PUSHED_AUTHORIZATION_REQUEST(false),
|
||||
PUSHED_AUTHORIZATION_REQUEST_ERROR(false);
|
||||
|
||||
|
||||
private boolean saveByDefault;
|
||||
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Provides single-use cache for Pushed Authorization Request. The data of this request may be used only once.
|
||||
*/
|
||||
public interface PushedAuthzRequestStoreProvider extends Provider {
|
||||
|
||||
/**
|
||||
* Stores the given data and guarantees that data should be available in the store for at least the time specified by {@param lifespanSeconds} parameter.
|
||||
*
|
||||
* @param key unique identifier
|
||||
* @param lifespanSeconds time to live
|
||||
* @param codeData the data to store
|
||||
*/
|
||||
void put(UUID key, int lifespanSeconds, Map<String, String> 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<String, String> remove(UUID key);
|
||||
}
|
|
@ -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<PushedAuthzRequestStoreProvider> {
|
||||
}
|
|
@ -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<? extends Provider> getProviderClass() {
|
||||
return PushedAuthzRequestStoreProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return PushedAuthzRequestStoreProviderFactory.class;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
|
|
|
@ -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<String, String> 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<String, String> newAttributes = rep.getAttributesOrEmpty();
|
||||
ParConfig parPolicy = realm.getParPolicy();
|
||||
|
||||
parPolicy.setRequestUriLifespan(newAttributes.get(ParConfig.PAR_REQUEST_URI_LIFESPAN));
|
||||
}
|
||||
|
||||
// Basic realm stuff
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<RealmModel> realm;
|
||||
|
||||
// Make sure setters are not called when calling this from constructor to avoid DB updates
|
||||
protected transient Supplier<RealmModel> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<RealmModel> realm;
|
||||
|
||||
// Make sure setters are not called when calling this from constructor to avoid DB updates
|
||||
private transient Supplier<RealmModel> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
70
server-spi/src/main/java/org/keycloak/models/ParConfig.java
Normal file
70
server-spi/src/main/java/org/keycloak/models/ParConfig.java
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
|
@ -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);
|
||||
|
|
|
@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
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<String, String> 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<String, String> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<String> 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<String> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<String, String> 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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, String> 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<String, String> 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<String> keySet() {
|
||||
return requestParams.keySet();
|
||||
}
|
||||
|
||||
}
|
|
@ -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<String, String> 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<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory
|
||||
org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint
|
||||
org.keycloak.protocol.oidc.par.endpoints.ParRootEndpoint
|
|
@ -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<CloseableHttpResponse> c) throws IOException {
|
||||
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
||||
HttpPost post = new HttpPost(getParEndpointUrl());
|
||||
|
||||
List<NameValuePair> 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<String, String> headers;
|
||||
|
||||
private String requestUri;
|
||||
private int expiresIn;
|
||||
|
||||
private String error;
|
||||
private String errorDescription;
|
||||
|
||||
public ParResponse(CloseableHttpResponse response, Consumer<CloseableHttpResponse> 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<String, String> 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;
|
||||
|
|
|
@ -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<String, String> 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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<RealmRepresentation> testRealms) {
|
||||
RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
|
||||
|
||||
List<UserRepresentation> users = realm.getUsers();
|
||||
|
||||
LinkedList<CredentialRepresentation> 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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String>(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<String, String> 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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -839,6 +839,15 @@
|
|||
</div>
|
||||
<kc-tooltip>{{:: 'pkce-code-challenge-method.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
|
||||
<label class="col-md-2 control-label" for="requirePushedAuthorizationRequests">{{:: 'require-pushed-authorization-requests' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
<input ng-model="requirePushedAuthorizationRequests" ng-click="switchChange()" name="requirePushedAuthorizationRequests" id="requirePushedAuthorizationRequests" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'require-pushed-authorization-requests.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
|
|
@ -320,6 +320,22 @@
|
|||
</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="requestUriLifespan" class="two-lines">{{:: 'request-uri-lifespan' | translate}}</label>
|
||||
|
||||
<div class="col-md-6 time-selector">
|
||||
<input class="form-control" type="number" required min="1" max="31536000" string-to-number data-ng-model="requestUriLifespan.time"
|
||||
id="requestUriLifespan" name="requestUriLifespan">
|
||||
<select class="form-control" name="requestUriLifespan" data-ng-model="requestUriLifespan.unit">
|
||||
<option value="Minutes">{{:: 'minutes' | translate}}</option>
|
||||
<option value="Hours">{{:: 'hours' | translate}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<kc-tooltip>
|
||||
{{:: 'request-uri-lifespan.tooltip' | translate}}
|
||||
</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="actionTokenAttributeSelect" class="two-lines">
|
||||
{{:: 'action-token-generated-by-user.operation' | translate }} </label>
|
||||
|
|
Loading…
Reference in a new issue