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:
Hryhorii Hevorkian 2021-04-08 15:50:45 +03:00 committed by Marek Posolda
parent e5ae113453
commit 2803685cd7
44 changed files with 2555 additions and 277 deletions

View file

@ -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;

View file

@ -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());

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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() {
}
}

View file

@ -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);
}
}

View file

@ -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

View file

@ -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() {

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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> {
}

View file

@ -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;
}
}

View file

@ -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());

View file

@ -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

View file

@ -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

View file

@ -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);
}
}
}

View file

@ -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);
}
}
}

View 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);
}
}

View file

@ -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.

View file

@ -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;

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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() {
}
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -1,2 +1,3 @@
org.keycloak.protocol.openshift.OpenShiftTokenReviewEndpointFactory
org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint
org.keycloak.protocol.oidc.par.endpoints.ParRootEndpoint

View file

@ -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;

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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);
}
}

View file

@ -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.

View file

@ -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) {

View file

@ -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();

View file

@ -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>

View file

@ -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>