KEYCLOAK-12137 OpenID Connect Client Initiated Backchannel Authentication (CIBA) (#7679)

* KEYCLOAK-12137 OpenID Connect Client Initiated Backchannel Authentication (CIBA)

Co-authored-by: Andrii Murashkin <amu@adorsys.com.ua>
Co-authored-by: Christophe Lannoy <c4r1570p4e@gmail.com>
Co-authored-by: Pedro Igor <pigor.craveiro@gmail.com>
Co-authored-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Takashi Norimatsu 2021-04-29 22:56:39 +09:00 committed by GitHub
parent 9a76ccce86
commit 65c48a4183
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 4140 additions and 69 deletions

View file

@ -54,7 +54,8 @@ public class Profile {
TOKEN_EXCHANGE(Type.PREVIEW),
UPLOAD_SCRIPTS(DEPRECATED),
WEB_AUTHN(Type.DEFAULT, Type.PREVIEW),
CLIENT_POLICIES(Type.PREVIEW);
CLIENT_POLICIES(Type.PREVIEW),
CIBA(Type.PREVIEW);
private Type typeProject;
private Type typeProduct;

View file

@ -21,8 +21,8 @@ public class ProfileTest {
@Test
public void checkDefaultsKeycloak() {
Assert.assertEquals("community", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CLIENT_POLICIES);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CLIENT_POLICIES);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);
Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType());
@ -37,8 +37,8 @@ public class ProfileTest {
Profile.init();
Assert.assertEquals("product", Profile.getName());
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES);
assertEquals(Profile.getDisabledFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.DOCKER, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.UPLOAD_SCRIPTS, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA);
assertEquals(Profile.getPreviewFeatures(), Profile.Feature.ADMIN_FINE_GRAINED_AUTHZ, Profile.Feature.SCRIPTS, Profile.Feature.TOKEN_EXCHANGE, Profile.Feature.OPENSHIFT_INTEGRATION, Profile.Feature.WEB_AUTHN, Profile.Feature.CLIENT_POLICIES, Profile.Feature.CIBA);
assertEquals(Profile.getDeprecatedFeatures(), Profile.Feature.UPLOAD_SCRIPTS);
Assert.assertTrue(Profile.Feature.WEB_AUTHN.hasDifferentProductType());

View file

@ -128,7 +128,11 @@ public interface OAuth2Constants {
String DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
String DEVICE_CODE = "device_code";
String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba";
String DISPLAY_CONSOLE = "console";
String INTERVAL = "interval";
String USER_CODE = "user_code";
}

View file

@ -145,6 +145,12 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("device_authorization_endpoint")
private String deviceAuthorizationEndpoint;
@JsonProperty("backchannel_token_delivery_modes_supported")
private List<String> backchannelTokenDeliveryModesSupported;
@JsonProperty("backchannel_authentication_endpoint")
private String backchannelAuthenticationEndpoint;
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() {
@ -439,6 +445,22 @@ public class OIDCConfigurationRepresentation {
this.backchannelLogoutSupported = backchannelLogoutSupported;
}
public List<String> getBackchannelTokenDeliveryModesSupported() {
return backchannelTokenDeliveryModesSupported;
}
public void setBackchannelTokenDeliveryModesSupported(List<String> backchannelTokenDeliveryModesSupported) {
this.backchannelTokenDeliveryModesSupported = backchannelTokenDeliveryModesSupported;
}
public String getBackchannelAuthenticationEndpoint() {
return backchannelAuthenticationEndpoint;
}
public void setBackchannelAuthenticationEndpoint(String backchannelAuthenticationEndpoint) {
this.backchannelAuthenticationEndpoint = backchannelAuthenticationEndpoint;
}
@JsonAnyGetter
public Map<String, Object> getOtherClaims() {
return otherClaims;

View file

@ -17,7 +17,11 @@
package org.keycloak.representations;
import static org.keycloak.OAuth2Constants.EXPIRES_IN;
import static org.keycloak.OAuth2Constants.INTERVAL;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.Version;
/**
@ -36,7 +40,7 @@ public class OAuth2DeviceAuthorizationResponse {
/**
* REQUIRED
*/
@JsonProperty("user_code")
@JsonProperty(OAuth2Constants.USER_CODE)
protected String userCode;
/**
@ -54,13 +58,13 @@ public class OAuth2DeviceAuthorizationResponse {
/**
* REQUIRED
*/
@JsonProperty("expires_in")
@JsonProperty(EXPIRES_IN)
protected long expiresIn;
/**
* OPTIONAL
*/
@JsonProperty("interval")
@JsonProperty(INTERVAL)
protected long interval;
public String getDeviceCode() {

View file

@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import org.keycloak.common.util.MultivaluedHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
@ -1326,4 +1327,9 @@ public class RealmRepresentation {
public Boolean isUserManagedAccessAllowed() {
return userManagedAccessAllowed;
}
@JsonIgnore
public Map<String, String> getAttributesOrEmpty() {
return (Map<String, String>) (attributes == null ? Collections.emptyMap() : attributes);
}
}

View file

@ -125,6 +125,9 @@ public class OIDCClientRepresentation {
private Boolean backchannel_logout_revoke_offline_tokens;
// OIDC CIBA
private String backchannel_token_delivery_mode;
public List<String> getRedirectUris() {
return redirect_uris;
}
@ -487,4 +490,11 @@ public class OIDCClientRepresentation {
this.tls_client_auth_subject_dn = tls_client_auth_subject_dn;
}
public String getBackchannelTokenDeliveryMode() {
return backchannel_token_delivery_mode;
}
public void setBackchannelTokenDeliveryMode(String backchannel_token_delivery_mode) {
this.backchannel_token_delivery_mode = backchannel_token_delivery_mode;
}
}

View file

@ -135,31 +135,9 @@ public class TokenUtil {
public static String jweDirectEncode(Key aesKey, Key hmacKey, JsonWebToken jwt) throws JWEException {
int keyLength = aesKey.getEncoded().length;
String encAlgorithm;
switch (keyLength) {
case 16: encAlgorithm = JWEConstants.A128CBC_HS256;
break;
case 24: encAlgorithm = JWEConstants.A192CBC_HS384;
break;
case 32: encAlgorithm = JWEConstants.A256CBC_HS512;
break;
default: throw new IllegalArgumentException("Bad size for Encryption key: " + aesKey + ". Valid sizes are 16, 24, 32.");
}
try {
byte[] contentBytes = JsonSerialization.writeValueAsBytes(jwt);
JWEHeader jweHeader = new JWEHeader(JWEConstants.DIR, encAlgorithm, null);
JWE jwe = new JWE()
.header(jweHeader)
.content(contentBytes);
jwe.getKeyStorage()
.setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION)
.setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE);
return jwe.encodeJwe();
return jweDirectEncode(aesKey, hmacKey, contentBytes);
} catch (IOException ioe) {
throw new JWEException(ioe);
}
@ -167,15 +145,9 @@ public class TokenUtil {
public static <T extends JsonWebToken> T jweDirectVerifyAndDecode(Key aesKey, Key hmacKey, String jweStr, Class<T> expectedClass) throws JWEException {
JWE jwe = new JWE();
jwe.getKeyStorage()
.setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION)
.setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE);
jwe.verifyAndDecodeJwe(jweStr);
byte[] contentBytes = jweDirectVerifyAndDecode(aesKey, hmacKey, jweStr);
try {
return JsonSerialization.readValue(jwe.getContent(), expectedClass);
return JsonSerialization.readValue(contentBytes, expectedClass);
} catch (IOException ioe) {
throw new JWEException(ioe);
}
@ -211,4 +183,42 @@ public class TokenUtil {
jwe.verifyAndDecodeJwe(encodedContent, algorithmProvider, encryptionProvider);
return jwe.getContent();
}
public static String jweDirectEncode(Key aesKey, Key hmacKey, byte[] contentBytes) throws JWEException {
int keyLength = aesKey.getEncoded().length;
String encAlgorithm;
switch (keyLength) {
case 16: encAlgorithm = JWEConstants.A128CBC_HS256;
break;
case 24: encAlgorithm = JWEConstants.A192CBC_HS384;
break;
case 32: encAlgorithm = JWEConstants.A256CBC_HS512;
break;
default: throw new IllegalArgumentException("Bad size for Encryption key: " + aesKey + ". Valid sizes are 16, 24, 32.");
}
JWEHeader jweHeader = new JWEHeader(JWEConstants.DIR, encAlgorithm, null);
JWE jwe = new JWE()
.header(jweHeader)
.content(contentBytes);
jwe.getKeyStorage()
.setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION)
.setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE);
return jwe.encodeJwe();
}
public static byte[] jweDirectVerifyAndDecode(Key aesKey, Key hmacKey, String jweStr) throws JWEException {
JWE jwe = new JWE();
jwe.getKeyStorage()
.setCEKKey(aesKey, JWEKeyStorage.KeyUse.ENCRYPTION)
.setCEKKey(hmacKey, JWEKeyStorage.KeyUse.SIGNATURE);
jwe.verifyAndDecodeJwe(jweStr);
return jwe.getContent();
}
}

View file

@ -653,6 +653,12 @@ public class RealmAdapter implements CachedRealmModel {
return cached.getOAuth2DeviceConfig(modelSupplier);
}
@Override
public CibaConfig getCibaPolicy() {
if (isUpdated()) return updated.getCibaPolicy();
return cached.getCibaConfig(modelSupplier);
}
@Override
public List<RequiredCredentialModel> getRequiredCredentials() {
if (isUpdated()) return updated.getRequiredCredentials();

View file

@ -23,6 +23,7 @@ import org.keycloak.component.ComponentModel;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.GroupModel;
@ -101,6 +102,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected int accessCodeLifespanUserAction;
protected int accessCodeLifespanLogin;
protected LazyLoader<RealmModel, OAuth2DeviceConfig> deviceConfig;
protected LazyLoader<RealmModel, CibaConfig> cibaConfig;
protected int actionTokenGeneratedByAdminLifespan;
protected int actionTokenGeneratedByUserLifespan;
protected int notBefore;
@ -217,6 +219,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
accessCodeLifespan = model.getAccessCodeLifespan();
deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null);
cibaConfig = new DefaultLazyLoader<>(CibaConfig::new, null);
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
@ -497,6 +500,10 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return deviceConfig.get(modelSupplier);
}
public CibaConfig getCibaConfig(Supplier<RealmModel> modelSupplier) {
return cibaConfig.get(modelSupplier);
}
public int getActionTokenGeneratedByAdminLifespan() {
return actionTokenGeneratedByAdminLifespan;
}

View file

@ -557,6 +557,11 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
return new OAuth2DeviceConfig(this);
}
@Override
public CibaConfig getCibaPolicy() {
return new CibaConfig(this);
}
@Override
public Map<String, Integer> getUserActionTokenLifespans() {

View file

@ -97,9 +97,10 @@ public abstract class AbstractRealmEntity<K> implements AbstractEntity<K> {
private String resetCredentialsFlow;
private String clientAuthenticationFlow;
private String dockerAuthenticationFlow;
private MapOTPPolicyEntity otpPolicy = MapOTPPolicyEntity.fromModel(OTPPolicy.DEFAULT_POLICY);;
private MapWebAuthnPolicyEntity webAuthnPolicy = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();;
private MapWebAuthnPolicyEntity webAuthnPolicyPasswordless = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();;
private MapOTPPolicyEntity otpPolicy = MapOTPPolicyEntity.fromModel(OTPPolicy.DEFAULT_POLICY);
private MapWebAuthnPolicyEntity webAuthnPolicy = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();
private MapWebAuthnPolicyEntity webAuthnPolicyPasswordless = MapWebAuthnPolicyEntity.defaultWebAuthnPolicy();
private Set<String> eventsListeners = new HashSet<>();
private Set<String> enabledEventTypes = new HashSet<>();

View file

@ -31,6 +31,7 @@ import org.keycloak.component.ComponentValidationException;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClientInitialAccessModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
@ -1552,4 +1553,8 @@ public class MapRealmAdapter extends AbstractRealmModel<MapRealmEntity> implemen
public String toString() {
return String.format("%s@%08x", getId(), hashCode());
}
public CibaConfig getCibaPolicy() {
return new CibaConfig(this);
}
}

View file

@ -138,6 +138,9 @@ public enum EventType {
OAUTH2_DEVICE_CODE_TO_TOKEN(true),
OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR(true),
AUTHREQID_TO_TOKEN(true),
AUTHREQID_TO_TOKEN_ERROR(true),
PERMISSION_TOKEN(true),
PERMISSION_TOKEN_ERROR(false),

View file

@ -44,6 +44,7 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -399,6 +400,14 @@ public class ModelToRepresentation {
rep.setWebAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(webAuthnPolicy.isAvoidSameAuthenticatorRegister());
rep.setWebAuthnPolicyPasswordlessAcceptableAaguids(webAuthnPolicy.getAcceptableAaguids());
CibaConfig cibaPolicy = realm.getCibaPolicy();
Map<String, String> attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>());
attrMap.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, cibaPolicy.getBackchannelTokenDeliveryMode());
attrMap.put(CibaConfig.CIBA_EXPIRES_IN, String.valueOf(cibaPolicy.getExpiresIn()));
attrMap.put(CibaConfig.CIBA_INTERVAL, String.valueOf(cibaPolicy.getPoolingInterval()));
attrMap.put(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT, cibaPolicy.getAuthRequestedUserHint());
rep.setAttributes(attrMap);
if (realm.getBrowserFlow() != null) rep.setBrowserFlow(realm.getBrowserFlow().getAlias());
if (realm.getRegistrationFlow() != null) rep.setRegistrationFlow(realm.getRegistrationFlow().getAlias());
if (realm.getDirectGrantFlow() != null) rep.setDirectGrantFlow(realm.getDirectGrantFlow().getAlias());

View file

@ -65,6 +65,7 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.AuthenticationFlowModel;
import org.keycloak.models.AuthenticatorConfigModel;
import org.keycloak.models.BrowserSecurityHeaders;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClaimMask;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
@ -293,6 +294,8 @@ public class RepresentationToModel {
webAuthnPolicy = getWebAuthnPolicyPasswordless(rep);
newRealm.setWebAuthnPolicyPasswordless(webAuthnPolicy);
updateCibaSettings(rep, newRealm);
Map<String, String> mappedFlows = importAuthenticationFlows(newRealm, rep);
if (rep.getRequiredActions() != null) {
for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) {
@ -1177,6 +1180,8 @@ public class RepresentationToModel {
webAuthnPolicy = getWebAuthnPolicyPasswordless(rep);
realm.setWebAuthnPolicyPasswordless(webAuthnPolicy);
updateCibaSettings(rep, realm);
if (rep.getSmtpServer() != null) {
Map<String, String> config = new HashMap(rep.getSmtpServer());
if (rep.getSmtpServer().containsKey("password") && ComponentRepresentation.SECRET_VALUE.equals(rep.getSmtpServer().get("password"))) {
@ -1217,6 +1222,17 @@ public class RepresentationToModel {
if (rep.getDockerAuthenticationFlow() != null) {
realm.setDockerAuthenticationFlow(realm.getFlowByAlias(rep.getDockerAuthenticationFlow()));
}
}
private static void updateCibaSettings(RealmRepresentation rep, RealmModel realm) {
Map<String, String> newAttributes = rep.getAttributesOrEmpty();
CibaConfig cibaPolicy = realm.getCibaPolicy();
cibaPolicy.setBackchannelTokenDeliveryMode(newAttributes.get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE));
cibaPolicy.setExpiresIn(newAttributes.get(CibaConfig.CIBA_EXPIRES_IN));
cibaPolicy.setPoolingInterval(newAttributes.get(CibaConfig.CIBA_INTERVAL));
cibaPolicy.setAuthRequestedUserHint(newAttributes.get(CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT));
}
// Basic realm stuff

View file

@ -0,0 +1,164 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.models;
import java.io.Serializable;
import java.util.function.Supplier;
import org.keycloak.utils.StringUtil;
public class CibaConfig implements Serializable {
// realm attribute names
public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE = "cibaBackchannelTokenDeliveryMode";
public static final String CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT = "ciba.backchannel.token.delivery.mode";
public static final String CIBA_EXPIRES_IN = "cibaExpiresIn";
public static final String CIBA_INTERVAL = "cibaInterval";
public static final String CIBA_AUTH_REQUESTED_USER_HINT = "cibaAuthRequestedUserHint";
// default value
public static final String DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE = "poll";
public static final int DEFAULT_CIBA_POLICY_EXPIRES_IN = 120;
public static final int DEFAULT_CIBA_POLICY_INTERVAL = 5;
public static final String DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT = "login_hint";
private String backchannelTokenDeliveryMode = DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE;
private int expiresIn = DEFAULT_CIBA_POLICY_EXPIRES_IN;
private int poolingInterval = DEFAULT_CIBA_POLICY_INTERVAL;
private String authRequestedUserHint = DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT;
// client attribute names
public static final String OIDC_CIBA_GRANT_ENABLED = "oidc.ciba.grant.enabled";
private transient Supplier<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;
setBackchannelTokenDeliveryMode(realm.getAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE));
String expiresIn = realm.getAttribute(CIBA_EXPIRES_IN);
if (StringUtil.isNotBlank(expiresIn)) {
setExpiresIn(Integer.parseInt(expiresIn));
}
String interval = realm.getAttribute(CIBA_INTERVAL);
if (StringUtil.isNotBlank(interval)) {
setPoolingInterval(Integer.parseInt(interval));
}
setAuthRequestedUserHint(realm.getAttribute(CIBA_AUTH_REQUESTED_USER_HINT));
this.realmForWrite = () -> realm;
}
public String getBackchannelTokenDeliveryMode(ClientModel client) {
String mode = client.getAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT);
if (StringUtil.isBlank(mode)) {
mode = getBackchannelTokenDeliveryMode();
}
return mode;
}
public String getBackchannelTokenDeliveryMode() {
return backchannelTokenDeliveryMode;
}
public void setBackchannelTokenDeliveryMode(String mode) {
if (StringUtil.isBlank(mode)) {
mode = DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE;
}
this.backchannelTokenDeliveryMode = mode;
persistRealmAttribute(CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE, mode);
}
public int getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(String expiresIn) {
if (expiresIn == null) {
setExpiresIn((Integer) null);
} else {
setExpiresIn(Integer.parseInt(expiresIn));
}
}
public void setExpiresIn(Integer expiresIn) {
if (expiresIn == null) {
expiresIn = DEFAULT_CIBA_POLICY_EXPIRES_IN;
}
this.expiresIn = expiresIn;
persistRealmAttribute(CIBA_EXPIRES_IN, expiresIn);
}
public int getPoolingInterval() {
return poolingInterval;
}
public void setPoolingInterval(String poolingInterval) {
if (poolingInterval == null) {
setPoolingInterval((Integer) null);
} else {
setPoolingInterval(Integer.parseInt(poolingInterval));
}
}
public void setPoolingInterval(Integer interval) {
if (interval == null) {
interval = DEFAULT_CIBA_POLICY_INTERVAL;
}
this.poolingInterval = interval;
persistRealmAttribute(CIBA_INTERVAL, interval);
}
public String getAuthRequestedUserHint() {
return authRequestedUserHint;
}
public void setAuthRequestedUserHint(String hint) {
if (hint == null) {
hint = DEFAULT_CIBA_POLICY_AUTH_REQUESTED_USER_HINT;
}
this.authRequestedUserHint = hint;
persistRealmAttribute(CIBA_AUTH_REQUESTED_USER_HINT, hint);
}
public boolean isOIDCCIBAGrantEnabled(ClientModel client) {
String enabled = client.getAttribute(OIDC_CIBA_GRANT_ENABLED);
return Boolean.parseBoolean(enabled);
}
private void persistRealmAttribute(String name, String value) {
RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get();
if (realm != null) {
realm.setAttribute(name, value);
}
}
private void persistRealmAttribute(String name, Integer value) {
RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get();
if (realm != null) {
realm.setAttribute(name, value);
}
}
}

View file

@ -43,6 +43,9 @@ public final class OAuth2DeviceConfig implements Serializable {
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;
private int lifespan = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
private int poolingInterval = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
@ -60,6 +63,8 @@ public final class OAuth2DeviceConfig implements Serializable {
if (pooling != null && !pooling.trim().isEmpty()) {
setOAuth2DevicePollingInterval(Integer.parseInt(pooling));
}
this.realmForWrite = () -> realm;
}
public int getLifespan() {
@ -71,7 +76,7 @@ public final class OAuth2DeviceConfig implements Serializable {
seconds = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
}
this.lifespan = seconds;
realm.get().setAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan);
persistRealmAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan);
}
public int getPoolingInterval() {
@ -86,7 +91,7 @@ public final class OAuth2DeviceConfig implements Serializable {
RealmModel model = getRealm();
model.setAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval);
persistRealmAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval);
}
public int getLifespan(ClientModel client) {
@ -123,4 +128,11 @@ public final class OAuth2DeviceConfig implements Serializable {
return model;
}
private void persistRealmAttribute(String name, Integer value) {
RealmModel realm = realmForWrite == null ? null : this.realmForWrite.get();
if (realm != null) {
realm.setAttribute(name, value);
}
}
}

View file

@ -29,9 +29,11 @@ import org.keycloak.storage.client.ClientStorageProvider;
import org.keycloak.storage.client.ClientStorageProviderModel;
import org.keycloak.storage.role.RoleStorageProvider;
import org.keycloak.storage.role.RoleStorageProviderModel;
import org.keycloak.utils.StringUtil;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -249,6 +251,8 @@ public interface RealmModel extends RoleContainerModel {
OAuth2DeviceConfig getOAuth2DeviceConfig();
CibaConfig getCibaPolicy();
/**
* This method will return a map with all the lifespans available
* or an empty map, but never null.

View file

@ -0,0 +1,29 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.utils;
public class StringUtil {
public static boolean isBlank(String str) {
return !(isNotBlank(str));
}
public static boolean isNotBlank(String str) {
return str != null && !"".equals(str.trim());
}
}

View file

@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc;
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.ClientRepresentation;

View file

@ -88,6 +88,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String UI_LOCALES_PARAM = OAuth2Constants.UI_LOCALES_PARAM;
public static final String CLAIMS_PARAM = "claims";
public static final String ACR_PARAM = "acr_values";
public static final String ID_TOKEN_HINT = "id_token_hint";
public static final String LOGOUT_REDIRECT_URI = "OIDC_LOGOUT_REDIRECT_URI";
public static final String ISSUER = "iss";

View file

@ -26,11 +26,13 @@ import org.keycloak.crypto.ClientSignatureVerifierProvider;
import org.keycloak.crypto.ContentEncryptionProvider;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -62,7 +64,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
public static final List<String> DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE,
OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS,
OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
OAuth2Constants.DEVICE_CODE_GRANT_TYPE,
OAuth2Constants.CIBA_GRANT_TYPE);
public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
@ -80,6 +83,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
// KEYCLOAK-7451 OAuth Authorization Server Metadata for Proof Key for Code Exchange
public static final List<String> DEFAULT_CODE_CHALLENGE_METHODS_SUPPORTED = list(OAuth2Constants.PKCE_METHOD_PLAIN, OAuth2Constants.PKCE_METHOD_S256);
public static final List<String> DEFAULT_BACKCHANNEL_TOKEN_DELIVERY_MODES_SUPPORTED= list(CibaConfig.DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE);
private KeycloakSession session;
public OIDCWellKnownProvider(KeycloakSession session) {
@ -167,6 +172,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setBackchannelLogoutSupported(true);
config.setBackchannelLogoutSessionSupported(true);
config.setBackchannelTokenDeliveryModesSupported(DEFAULT_BACKCHANNEL_TOKEN_DELIVERY_MODES_SUPPORTED);
config.setBackchannelAuthenticationEndpoint(CibaGrantType.authorizationUrl(backendUriInfo.getBaseUriBuilder()).build(realm.getName()).toString());
return config;
}

View file

@ -62,6 +62,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -149,7 +150,7 @@ public class TokenEndpoint {
private Map<String, String> clientAuthAttributes;
private enum Action {
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE, CIBA
}
// https://tools.ietf.org/html/rfc7636#section-4.2
@ -231,6 +232,8 @@ public class TokenEndpoint {
return permissionGrant();
case OAUTH2_DEVICE_CODE:
return oauth2DeviceCodeToToken();
case CIBA:
return cibaGrant();
}
throw new RuntimeException("Unknown action " + action);
@ -305,6 +308,9 @@ public class TokenEndpoint {
} else if (grantType.equals(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)) {
event.event(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN);
action = Action.OAUTH2_DEVICE_CODE;
} else if (grantType.equals(OAuth2Constants.CIBA_GRANT_TYPE)) {
event.event(EventType.AUTHREQID_TO_TOKEN);
action = Action.CIBA;
} else {
throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_GRANT_TYPE,
"Unsupported " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST);
@ -449,10 +455,10 @@ public class TokenEndpoint {
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce());
return codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, true);
return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true);
}
public Response codeOrDeviceCodeToToken(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
public Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
String scopeParam, boolean code) {
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
@ -1392,6 +1398,11 @@ public class TokenEndpoint {
return deviceGrantType.oauth2DeviceFlow();
}
public Response cibaGrant() {
CibaGrantType grantType = new CibaGrantType(formParams, client, session, this, realm, event, cors);
return grantType.cibaGrant();
}
// https://tools.ietf.org/html/rfc7636#section-4.1
private boolean isValidPkceCodeVerifier(String codeVerifier) {
if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) {

View file

@ -0,0 +1,271 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants.ciba;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import org.jboss.logging.Logger;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest;
import org.keycloak.protocol.oidc.grants.ciba.endpoints.CibaRootEndpoint;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.utils.ProfileHelper;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class CibaGrantType {
private static final Logger logger = Logger.getLogger(CibaGrantType.class);
public static final String IS_CONSENT_REQUIRED = "is_consent_required";
public static final String LOGIN_HINT = "login_hint";
public static final String LOGIN_HINT_TOKEN = "login_hint_token";
public static final String BINDING_MESSAGE = "binding_message";
public static final String AUTH_REQ_ID = "auth_req_id";
public static final String CLIENT_NOTIFICATION_TOKEN = "client_notification_token";
public static final String REQUESTED_EXPIRY = "requested_expiry";
public static UriBuilder authorizationUrl(UriBuilder baseUriBuilder) {
UriBuilder uriBuilder = OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder);
return uriBuilder.path(OIDCLoginProtocolService.class, "resolveExtension").resolveTemplate("extension", CibaRootEndpoint.PROVIDER_ID, false).path(CibaRootEndpoint.class, "authorize");
}
public static UriBuilder authenticationUrl(UriBuilder baseUriBuilder) {
UriBuilder uriBuilder = OIDCLoginProtocolService.tokenServiceBaseUrl(baseUriBuilder);
return uriBuilder.path(OIDCLoginProtocolService.class, "resolveExtension").resolveTemplate("extension", CibaRootEndpoint.PROVIDER_ID, false).path(CibaRootEndpoint.class, "authenticate");
}
private final MultivaluedMap<String, String> formParams;
private final ClientModel client;
private final KeycloakSession session;
private final TokenEndpoint tokenEndpoint;
private final RealmModel realm;
private final EventBuilder event;
private final Cors cors;
public CibaGrantType(MultivaluedMap<String, String> formParams, ClientModel client, KeycloakSession session,
TokenEndpoint tokenEndpoint, RealmModel realm, EventBuilder event, Cors cors) {
this.formParams = formParams;
this.client = client;
this.session = session;
this.tokenEndpoint = tokenEndpoint;
this.realm = realm;
this.event = event;
this.cors = cors;
}
public Response cibaGrant() {
ProfileHelper.requireFeature(Profile.Feature.CIBA);
if (!realm.getCibaPolicy().isOIDCCIBAGrantEnabled(client)) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT,
"Client not allowed OIDC CIBA Grant", Response.Status.BAD_REQUEST);
}
String jwe = formParams.getFirst(AUTH_REQ_ID);
if (jwe == null) {
event.error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing parameter: " + AUTH_REQ_ID, Response.Status.BAD_REQUEST);
}
logger.tracev("CIBA Grant :: authReqId = {0}", jwe);
CIBAAuthenticationRequest request;
try {
request = CIBAAuthenticationRequest.deserialize(session, jwe);
} catch (Exception e) {
logger.warnf("illegal format of auth_req_id : e.getMessage() = %s", e.getMessage());
// Auth Req ID has not put onto cache, no need to remove Auth Req ID.
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid Auth Req ID", Response.Status.BAD_REQUEST);
}
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel deviceCode = store.getByDeviceCode(realm, request.getId());
if (deviceCode == null) {
// Auth Req ID has not put onto cache, no need to remove Auth Req ID.
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid " + AUTH_REQ_ID, Response.Status.BAD_REQUEST);
}
if (!request.getIssuedFor().equals(client.getClientId())) {
logDebug("invalid client.", request);
// the client sending this Auth Req ID does not match the client to which keycloak had issued Auth Req ID.
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "unauthorized client", Response.Status.BAD_REQUEST);
}
if (deviceCode.isExpired()) {
logDebug("expired.", request);
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "authentication timed out", Response.Status.BAD_REQUEST);
}
if (!store.isPollingAllowed(deviceCode)) {
logDebug("pooling.", request);
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "too early to access", Response.Status.BAD_REQUEST);
}
if (deviceCode.isDenied()) {
logDebug("denied.", request);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "not authorized", Response.Status.FORBIDDEN);
}
// get corresponding Authentication Channel Result entry
if (deviceCode.isPending()) {
logDebug("not yet authenticated by Authentication Device or auth_req_id has already been used to get tokens.", request);
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is still pending as the end-user hasn't yet been authenticated.", Response.Status.BAD_REQUEST);
}
UserSessionModel userSession = createUserSession(request);
UserModel user = userSession.getUser();
store.removeDeviceCode(realm, request.getId());
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = request.getScope();
if (!TokenManager
.verifyConsentStillAvailable(session,
user, client, TokenManager.getRequestedClientScopes(scopeParam, client))) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST);
}
ClientSessionContext clientSessionCtx = DefaultClientSessionContext
.fromClientSessionAndClientScopes(userSession.getAuthenticatedClientSessionByClient(client.getId()), TokenManager.getRequestedClientScopes(scopeParam, client), session);
return tokenEndpoint.createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true);
}
private UserSessionModel createUserSession(CIBAAuthenticationRequest request) {
RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm);
// here Client Model of CD(Consumption Device) needs to be used to bind its Client Session with User Session.
AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client);
authSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
authSession.setAction(AuthenticatedClientSessionModel.Action.AUTHENTICATE.name());
authSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
authSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, request.getScope());
UserModel user = session.users().getUserById(realm, request.getSubject());
if (user == null) {
event.error(Errors.USERNAME_MISSING);
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Could not identify user", Response.Status.BAD_REQUEST);
}
if (!user.isEnabled()) {
event.error(Errors.USER_DISABLED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled", Response.Status.BAD_REQUEST);
}
logger.debugf("CIBA Grant :: user model found. user.getId() = %s, user.getEmail() = %s, user.getUsername() = %s.", user.getId(), user.getEmail(), user.getUsername());
authSession.setAuthenticatedUser(user);
if (user.getRequiredActionsStream().count() > 0) {
event.error(Errors.RESOLVE_REQUIRED_ACTIONS);
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "Account is not fully set up", Response.Status.BAD_REQUEST);
}
AuthenticationManager.setClientScopesInSession(authSession);
ClientSessionContext context = AuthenticationProcessor
.attachSession(authSession, null, session, realm, session.getContext().getConnection(), event);
UserSessionModel userSession = context.getClientSession().getUserSession();
if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "User session is not found", Response.Status.BAD_REQUEST);
}
// authorization (consent)
UserConsentModel grantedConsent = session.users().getConsentByClient(realm, user.getId(), client.getId());
if (grantedConsent == null) {
grantedConsent = new UserConsentModel(client);
session.users().addConsent(realm, user.getId(), grantedConsent);
if (logger.isTraceEnabled()) {
grantedConsent.getGrantedClientScopes().forEach(i->logger.tracef("CIBA Grant :: Consent granted. %s", i.getName()));
}
}
boolean updateConsentRequired = false;
for (String clientScopeId : authSession.getClientScopes()) {
ClientScopeModel clientScope = KeycloakModelUtils.findClientScopeById(realm, client, clientScopeId);
if (clientScope != null && !grantedConsent.isClientScopeGranted(clientScope) && clientScope.isDisplayOnConsentScreen()) {
grantedConsent.addGrantedClientScope(clientScope);
updateConsentRequired = true;
}
}
if (updateConsentRequired) {
session.users().updateConsent(realm, user.getId(), grantedConsent);
if (logger.isTraceEnabled()) {
grantedConsent.getGrantedClientScopes().forEach(i->logger.tracef("CIBA Grant :: Consent updated. %s", i.getName()));
}
}
event.detail(Details.CONSENT, Details.CONSENT_VALUE_CONSENT_GRANTED);
event.detail(Details.CODE_ID, userSession.getId());
event.session(userSession.getId());
event.user(user);
logger.debugf("Successfully verified Authe Req Id '%s'. User session: '%s', client: '%s'", request, userSession.getId(), client.getId());
return userSession;
}
private static void logDebug(String message, CIBAAuthenticationRequest request) {
logger.debugf("CIBA Grant :: authentication channel %s clientId = %s, authResultId = %s", message, request.getIssuedFor(), request.getAuthResultId());
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import org.keycloak.provider.Provider;
/**
* Provides the interface for requesting the authentication(AuthN) and authorization(AuthZ) by an authentication device (AD) to the external entity via Authentication Channel.
* This interface is for Client Initiated Backchannel Authentication(CIBA).
*
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface AuthenticationChannelProvider extends Provider {
/**
* Request the authentication(AuthN) and authorization(AuthZ) by an authentication device (AD) to the external entity via Authentication Channel.
* @param request the representation of Authentication Request received on Backchannel Authentication Endpoint
* @param infoUsedByAuthenticator some value to help the AD to identify the user
* @return
*/
boolean requestAuthentication(CIBAAuthenticationRequest request, String infoUsedByAuthenticator);
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import org.keycloak.common.Profile;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface AuthenticationChannelProviderFactory extends ProviderFactory<AuthenticationChannelProvider>,
EnvironmentDependentProviderFactory {
@Override
default boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.CIBA);
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.OAuth2Constants;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class AuthenticationChannelRequest {
@JsonProperty(CibaGrantType.BINDING_MESSAGE)
private String bindingMessage;
@JsonProperty(CibaGrantType.LOGIN_HINT)
private String loginHint;
@JsonProperty(CibaGrantType.IS_CONSENT_REQUIRED)
private Boolean consentRequired;
@JsonProperty(OAuth2Constants.ACR_VALUES)
private String acrValues;
private String scope;
public void setBindingMessage(String bindingMessage) {
this.bindingMessage = bindingMessage;
}
public String getBindingMessage() {
return bindingMessage;
}
public void setLoginHint(String loginHint) {
this.loginHint = loginHint;
}
public String getLoginHint() {
return loginHint;
}
public void setConsentRequired(Boolean consentRequired) {
this.consentRequired = consentRequired;
}
public Boolean getConsentRequired() {
return consentRequired;
}
public String getAcrValues() {
return acrValues;
}
public void setAcrValues(String acrValues) {
this.acrValues = acrValues;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getScope() {
return scope;
}
}

View file

@ -0,0 +1,52 @@
/*
*
* * Copyright 2021 Red Hat, Inc. and/or its affiliates
* * and other contributors as indicated by the @author tags.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class AuthenticationChannelResponse {
public enum Status {
SUCCEED,
UNAUTHORIZED,
CANCELLED;
}
private Status status;
public AuthenticationChannelResponse() {
// for reflection
}
public AuthenticationChannelResponse(Status status) {
this.status = status;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class AuthenticationChannelSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "ciba-auth-channel";
}
@Override
public Class<? extends Provider> getProviderClass() {
return AuthenticationChannelProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return AuthenticationChannelProviderFactory.class;
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import javax.crypto.SecretKey;
import java.io.UnsupportedEncodingException;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.keycloak.OAuth2Constants;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.SignatureProvider;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.services.Urls;
import org.keycloak.util.TokenUtil;
/**
* <p>Represents an authentication request sent by a consumption device (CD).
*
* <p>A authentication request can be serialized to a JWE so that it can be exchanged with authentication devices (AD)
* to communicate and authorize the authentication request made by consumption devices (CDs).
*
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class CIBAAuthenticationRequest extends JsonWebToken {
/**
* Deserialize the given {@code jwe} to a {@link CIBAAuthenticationRequest} instance.
*
* @param session the session
* @param jwe the authentication request in JWE format.
* @return the authentication request instance
* @throws Exception
*/
public static CIBAAuthenticationRequest deserialize(KeycloakSession session, String jwe) {
SecretKey aesKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.ENC, Algorithm.AES).getSecretKey();
SecretKey hmacKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.SIG, Algorithm.HS256).getSecretKey();
try {
byte[] contentBytes = TokenUtil.jweDirectVerifyAndDecode(aesKey, hmacKey, jwe);
jwe = new String(contentBytes, "UTF-8");
} catch (JWEException | UnsupportedEncodingException e) {
throw new RuntimeException("Error decoding auth_req_id.", e);
}
return session.tokens().decode(jwe, CIBAAuthenticationRequest.class);
}
public static final String SESSION_STATE = IDToken.SESSION_STATE;
public static final String AUTH_RESULT_ID = "auth_result_id";
@JsonProperty(OAuth2Constants.SCOPE)
protected String scope;
@JsonProperty(AUTH_RESULT_ID)
protected String authResultId;
@JsonProperty(CibaGrantType.BINDING_MESSAGE)
protected String bindingMessage;
@JsonProperty(OAuth2Constants.ACR_VALUES)
protected String acrValues;
@JsonIgnore
protected ClientModel client;
@JsonIgnore
protected UserModel user;
public CIBAAuthenticationRequest() {
// for reflection
}
public CIBAAuthenticationRequest(KeycloakSession session, UserModel user, ClientModel client) {
id(KeycloakModelUtils.generateId());
issuedNow();
RealmModel realm = session.getContext().getRealm();
issuer(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
audience(getIssuer());
subject(user.getId());
issuedFor(client.getClientId());
setAuthResultId(KeycloakModelUtils.generateId());
setClient(client);
setUser(user);
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getAuthResultId() {
return authResultId;
}
public void setAuthResultId(String authResultId) {
this.authResultId = authResultId;
}
public String getBindingMessage() {
return bindingMessage;
}
public void setBindingMessage(String binding_message) {
this.bindingMessage = binding_message;
}
public String getAcrValues() {
return acrValues;
}
public void setAcrValues(String acrValues) {
this.acrValues = acrValues;
}
/**
* Serializes this instance to a JWE.
*
* @param session the session
* @return the JWE
*/
public String serialize(KeycloakSession session) {
try {
SignatureProvider signatureProvider = session.getProvider(SignatureProvider.class, Algorithm.HS256);
SignatureSignerContext signer = signatureProvider.signer();
String encodedJwt = new JWSBuilder().type("JWT").jsonContent(this).sign(signer);
SecretKey aesKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.ENC, Algorithm.AES).getSecretKey();
SecretKey hmacKey = session.keys().getActiveKey(session.getContext().getRealm(), KeyUse.SIG, Algorithm.HS256).getSecretKey();
return TokenUtil.jweDirectEncode(aesKey, hmacKey, encodedJwt.getBytes("UTF-8"));
} catch (JWEException | UnsupportedEncodingException e) {
throw new RuntimeException("Error encoding auth_req_id.", e);
}
}
public void setClient(ClientModel client) {
this.client = client;
}
public ClientModel getClient() {
return client;
}
public void setUser(UserModel user) {
this.user = user;
}
public UserModel getUser() {
return user;
}
}

View file

@ -0,0 +1,131 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import java.io.IOException;
import java.util.Map;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response.Status;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.resources.Cors;
import org.keycloak.util.TokenUtil;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class HttpAuthenticationChannelProvider implements AuthenticationChannelProvider{
public static final String AUTHENTICATION_CHANNEL_ID = "authentication_channel_id";
protected KeycloakSession session;
protected MultivaluedMap<String, String> formParams;
protected RealmModel realm;
protected Map<String, String> clientAuthAttributes;
protected Cors cors;
protected final String httpAuthenticationChannelUri;
public HttpAuthenticationChannelProvider(KeycloakSession session, String httpAuthenticationRequestUri) {
this.session = session;
this.realm = session.getContext().getRealm();
this.httpAuthenticationChannelUri = httpAuthenticationRequestUri;
}
@Override
public boolean requestAuthentication(CIBAAuthenticationRequest request, String infoUsedByAuthenticator) {
// Creates JWT formatted/JWS signed/JWE encrypted Authentication Channel ID by the same manner in creating auth_req_id.
// Authentication Channel ID binds Backchannel Authentication Request with Authentication by Authentication Device (AD).
// JWE serialized Authentication Channel ID works as a bearer token. It includes client_id
// that can be used on Authentication Channel Callback Endpoint to recognize the Consumption Device (CD)
// that sent Backchannel Authentication Request.
// The following scopes should be displayed on AD:
// 1. scopes specified explicitly as query parameter in the authorization request
// 2. scopes specified implicitly as default client scope in keycloak
checkAuthenticationChannel();
ClientModel client = request.getClient();
try {
AuthenticationChannelRequest channelRequest = new AuthenticationChannelRequest();
channelRequest.setScope(request.getScope());
channelRequest.setBindingMessage(request.getBindingMessage());
channelRequest.setLoginHint(infoUsedByAuthenticator);
channelRequest.setConsentRequired(client.isConsentRequired());
channelRequest.setAcrValues(request.getAcrValues());
SimpleHttp simpleHttp = SimpleHttp.doPost(httpAuthenticationChannelUri, session)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
.json(channelRequest)
.auth(createBearerToken(request, client));
int status = completeDecoupledAuthnRequest(simpleHttp, channelRequest).asStatus();
if (status == Status.CREATED.getStatusCode()) {
return true;
}
} catch (IOException ioe) {
throw new RuntimeException("Authentication Channel Access failed.", ioe);
}
return false;
}
private String createBearerToken(CIBAAuthenticationRequest request, ClientModel client) {
AccessToken bearerToken = new AccessToken();
bearerToken.type(TokenUtil.TOKEN_TYPE_BEARER);
bearerToken.issuer(request.getIssuer());
bearerToken.id(request.getAuthResultId());
bearerToken.issuedFor(client.getClientId());
bearerToken.audience(request.getIssuer());
bearerToken.exp(request.getExp());
bearerToken.subject(request.getSubject());
return session.tokens().encode(bearerToken);
}
protected void checkAuthenticationChannel() {
if (httpAuthenticationChannelUri == null) {
throw new RuntimeException("Authentication Channel Request URI not set properly.");
}
if (!httpAuthenticationChannelUri.startsWith("http://") && !httpAuthenticationChannelUri.startsWith("https://")) {
throw new RuntimeException("Authentication Channel Request URI not set properly.");
}
}
/**
* Extension point to allow subclass to override this method in order to add data to post to decoupled server.
*/
protected SimpleHttp completeDecoupledAuthnRequest(SimpleHttp simpleHttp, AuthenticationChannelRequest channelRequest) {
return simpleHttp;
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants.ciba.channel;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class HttpAuthenticationChannelProviderFactory implements AuthenticationChannelProviderFactory {
public static final String PROVIDER_ID = "ciba-http-auth-channel";
protected String httpAuthenticationChannelUri;
@Override
public AuthenticationChannelProvider create(KeycloakSession session) {
return new HttpAuthenticationChannelProvider(session, httpAuthenticationChannelUri);
}
@Override
public void init(Scope config) {
httpAuthenticationChannelUri = config.get("httpAuthenticationChannelUri");
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

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.protocol.oidc.grants.ciba.endpoints;
import javax.ws.rs.core.Response;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.ClientConnection;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.services.ErrorResponseException;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public abstract class AbstractCibaEndpoint {
protected final KeycloakSession session;
protected final EventBuilder event;
protected final RealmModel realm;
public AbstractCibaEndpoint(KeycloakSession session, EventBuilder event) {
this.session = session;
this.event = event;
realm = session.getContext().getRealm();
}
protected ClientModel authenticateClient() {
checkSsl();
checkRealm();
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, null);
ClientModel client = clientAuth.getClient();
if (client.isBearerOnly()) {
throw new ErrorResponseException(OAuthErrorException.INVALID_CLIENT, "Bearer-only not allowed", Response.Status.BAD_REQUEST);
}
if (!realm.getCibaPolicy().isOIDCCIBAGrantEnabled(client)) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT,
"Client not allowed OIDC CIBA Grant", Response.Status.BAD_REQUEST);
}
event.client(client);
return client;
}
protected void checkSsl() {
ClientConnection clientConnection = session.getContext().getContextObject(ClientConnection.class);
RealmModel realm = session.getContext().getRealm();
if (!session.getContext().getUri().getBaseUri().getScheme().equals("https") && realm.getSslRequired().isRequired(clientConnection)) {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "HTTPS required", Response.Status.FORBIDDEN);
}
}
protected void checkRealm() {
RealmModel realm = session.getContext().getRealm();
if (!realm.isEnabled()) {
throw new ErrorResponseException("access_denied", "Realm not enabled", Response.Status.FORBIDDEN);
}
}
}

View file

@ -0,0 +1,160 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.endpoints;
import static org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status.CANCELLED;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuthErrorException;
import org.keycloak.TokenVerifier;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse.Status;
import org.keycloak.representations.AccessToken;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
public class BackchannelAuthenticationCallbackEndpoint extends AbstractCibaEndpoint {
@Context
private HttpRequest httpRequest;
public BackchannelAuthenticationCallbackEndpoint(KeycloakSession session, EventBuilder event) {
super(session, event);
}
@Path("/")
@POST
@NoCache
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public Response processAuthenticationChannelResult(AuthenticationChannelResponse response) {
event.event(EventType.LOGIN);
AccessToken bearerToken = verifyAuthenticationRequest(httpRequest.getHttpHeaders());
Status status = response.getStatus();
if (status == null) {
event.error(Errors.INVALID_REQUEST);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid authentication status",
Response.Status.BAD_REQUEST);
}
switch (status) {
case SUCCEED:
approveRequest(bearerToken);
break;
case CANCELLED:
case UNAUTHORIZED:
denyRequest(bearerToken, status);
break;
}
return Response.ok(MediaType.APPLICATION_JSON_TYPE).build();
}
private AccessToken verifyAuthenticationRequest(HttpHeaders headers) {
String rawBearerToken = AppAuthManager.extractAuthorizationHeaderTokenOrReturnNull(headers);
if (rawBearerToken == null) {
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.UNAUTHORIZED);
}
AccessToken bearerToken;
try {
bearerToken = TokenVerifier.createWithoutSignature(session.tokens().decode(rawBearerToken, AccessToken.class))
.withDefaultChecks()
.realmUrl(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()))
.checkActive(true)
.audience(Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()))
.verify().getToken();
} catch (Exception e) {
event.error(Errors.INVALID_TOKEN);
// authentication channel id format is invalid or it has already been used
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN);
}
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel deviceCode = store.getByUserCode(realm, bearerToken.getId());
if (deviceCode == null) {
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN);
}
if (!deviceCode.isPending()) {
cancelRequest(bearerToken.getId());
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Invalid token", Response.Status.FORBIDDEN);
}
ClientModel issuedFor = realm.getClientByClientId(bearerToken.getIssuedFor());
if (issuedFor == null || !issuedFor.isEnabled()) {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Invalid token recipient",
Response.Status.BAD_REQUEST);
}
if (!deviceCode.getClientId().equals(issuedFor.getClientId())) {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Token recipient mismatch",
Response.Status.BAD_REQUEST);
}
event.client(issuedFor);
return bearerToken;
}
private void cancelRequest(String authResultId) {
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel userCode = store.getByUserCode(realm, authResultId);
store.removeDeviceCode(realm, userCode.getDeviceCode());
store.removeUserCode(realm, authResultId);
}
private void approveRequest(AccessToken authReqId) {
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
store.approve(realm, authReqId.getId(), "fake");
}
private void denyRequest(AccessToken authReqId, Status status) {
if (CANCELLED.equals(status)) {
event.error(Errors.NOT_ALLOWED);
} else {
event.error(Errors.CONSENT_DENIED);
}
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
store.deny(realm, authReqId.getId());
}
}

View file

@ -0,0 +1,237 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.endpoints;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ID_TOKEN_HINT;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.util.Collections;
import java.util.Optional;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.OAuth2DeviceUserCodeModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProvider;
import org.keycloak.protocol.oidc.grants.ciba.channel.CIBAAuthenticationRequest;
import org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolver;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.ProfileHelper;
public class BackchannelAuthenticationEndpoint extends AbstractCibaEndpoint {
private final RealmModel realm;
public BackchannelAuthenticationEndpoint(KeycloakSession session, EventBuilder event) {
super(session, event);
this.realm = session.getContext().getRealm();
event.event(EventType.LOGIN);
}
@POST
@NoCache
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
public Response processGrantRequest(@Context HttpRequest httpRequest) {
CIBAAuthenticationRequest request = authorizeClient(httpRequest.getDecodedFormParameters());
try {
String authReqId = request.serialize(session);
AuthenticationChannelProvider provider = session.getProvider(AuthenticationChannelProvider.class);
if (provider == null) {
throw new RuntimeException("Authentication Channel Provider not found.");
}
CIBALoginUserResolver resolver = session.getProvider(CIBALoginUserResolver.class);
if (resolver == null) {
throw new RuntimeException("CIBA Login User Resolver not setup properly.");
}
UserModel user = request.getUser();
String infoUsedByAuthentication = resolver.getInfoUsedByAuthentication(user);
if (provider.requestAuthentication(request, infoUsedByAuthentication)) {
CibaConfig cibaPolicy = realm.getCibaPolicy();
int poolingInterval = cibaPolicy.getPoolingInterval();
storeAuthenticationRequest(request, cibaPolicy);
ObjectNode response = JsonSerialization.createObjectNode();
response.put(CibaGrantType.AUTH_REQ_ID, authReqId)
.put(OAuth2Constants.EXPIRES_IN, cibaPolicy.getExpiresIn());
if (poolingInterval > 0) {
response.put(OAuth2Constants.INTERVAL, poolingInterval);
}
return Response.ok(JsonSerialization.writeValueAsBytes(response))
.build();
}
} catch (Exception e) {
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR, "Failed to send authentication request", Response.Status.SERVICE_UNAVAILABLE);
}
throw new ErrorResponseException(OAuthErrorException.SERVER_ERROR, "Unexpected response from authentication device", Response.Status.SERVICE_UNAVAILABLE);
}
/**
* TODO: Leverage the device code storage for tracking authentication requests. Not sure if we need a specific storage,
* but probably make the {@link OAuth2DeviceTokenStoreProvider} more generic for ciba, device, or any other use case
* that relies on cross-references for unsolicited user authentication requests from devices.
*/
private void storeAuthenticationRequest(CIBAAuthenticationRequest request, CibaConfig cibaConfig) {
ClientModel client = request.getClient();
int expiresIn = cibaConfig.getExpiresIn();
int poolingInterval = cibaConfig.getPoolingInterval();
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
request.getId(), request.getScope(), null, expiresIn, poolingInterval,
Collections.emptyMap());
String authResultId = request.getAuthResultId();
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(),
authResultId);
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
int lifespanSeconds = expiresIn + poolingInterval + 10;
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
store.put(deviceCode, userCode, lifespanSeconds);
}
private CIBAAuthenticationRequest authorizeClient(MultivaluedMap<String, String> params) {
ClientModel client = authenticateClient();
UserModel user = resolveUser(params, realm.getCibaPolicy().getAuthRequestedUserHint());
CIBAAuthenticationRequest request = new CIBAAuthenticationRequest(session, user, client);
request.setClient(client);
String scope = params.getFirst(OAuth2Constants.SCOPE);
if (scope == null)
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : scope",
Response.Status.BAD_REQUEST);
request.setScope(scope);
// optional parameters
if (params.getFirst(CibaGrantType.BINDING_MESSAGE) != null) request.setBindingMessage(params.getFirst(CibaGrantType.BINDING_MESSAGE));
if (params.getFirst(OAuth2Constants.ACR_VALUES) != null) request.setAcrValues(params.getFirst(OAuth2Constants.ACR_VALUES));
CibaConfig policy = realm.getCibaPolicy();
// create JWE encoded auth_req_id from Auth Req ID.
Integer expiresIn = policy.getExpiresIn();
String requestedExpiry = params.getFirst(CibaGrantType.REQUESTED_EXPIRY);
if (requestedExpiry != null) {
expiresIn = Integer.valueOf(requestedExpiry);
}
request.exp(Time.currentTime() + expiresIn.longValue());
StringBuilder scopes = new StringBuilder(Optional.ofNullable(request.getScope()).orElse(""));
client.getClientScopes(true)
.forEach((key, value) -> {
if (value.isDisplayOnConsentScreen())
scopes.append(" ").append(value.getName());
});
request.setScope(scopes.toString());
String clientNotificationToken = params.getFirst(CibaGrantType.CLIENT_NOTIFICATION_TOKEN);
if (clientNotificationToken != null) {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST,
"Ping and push modes not supported. Use poll mode instead.", Response.Status.BAD_REQUEST);
}
String userCode = params.getFirst(OAuth2Constants.USER_CODE);
if (userCode != null) {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User code not supported",
Response.Status.BAD_REQUEST);
}
return request;
}
private UserModel resolveUser(MultivaluedMap<String, String> params, String authRequestedUserHint) {
CIBALoginUserResolver resolver = session.getProvider(CIBALoginUserResolver.class);
if (resolver == null) {
throw new RuntimeException("CIBA Login User Resolver not setup properly.");
}
String userHint;
UserModel user;
if (authRequestedUserHint.equals(LOGIN_HINT_PARAM)) {
userHint = params.getFirst(LOGIN_HINT_PARAM);
if (userHint == null)
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : login_hint",
Response.Status.BAD_REQUEST);
user = resolver.getUserFromLoginHint(userHint);
} else if (authRequestedUserHint.equals(ID_TOKEN_HINT)) {
userHint = params.getFirst(ID_TOKEN_HINT);
if (userHint == null)
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : id_token_hint",
Response.Status.BAD_REQUEST);
user = resolver.getUserFromIdTokenHint(userHint);
} else if (authRequestedUserHint.equals(CibaGrantType.LOGIN_HINT_TOKEN)) {
userHint = params.getFirst(CibaGrantType.LOGIN_HINT_TOKEN);
if (userHint == null)
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "missing parameter : login_hint_token",
Response.Status.BAD_REQUEST);
user = resolver.getUserFromLoginHintToken(userHint);
} else {
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST,
"invalid user hint", Response.Status.BAD_REQUEST);
}
if (user == null || !user.isEnabled())
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "invalid user", Response.Status.BAD_REQUEST);
return user;
}
}

View file

@ -0,0 +1,102 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants.ciba.endpoints;
import javax.ws.rs.Path;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
import org.keycloak.protocol.oidc.ext.OIDCExtProviderFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class CibaRootEndpoint implements OIDCExtProvider, OIDCExtProviderFactory, EnvironmentDependentProviderFactory {
public static final String PROVIDER_ID = "ciba";
private final KeycloakSession session;
private EventBuilder event;
public CibaRootEndpoint() {
// for reflection
this(null);
}
public CibaRootEndpoint(KeycloakSession session) {
this.session = session;
}
/**
* The backchannel authentication endpoint used by consumption devices to obtain authorization from end-users.
*
* @return
*/
@Path("/auth")
public BackchannelAuthenticationEndpoint authorize() {
BackchannelAuthenticationEndpoint endpoint = new BackchannelAuthenticationEndpoint(session, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint;
}
/**
* The callback endpoint used by authentication devices to notify Keycloak about the end-user authentication status.
*
* @return
*/
@Path("/auth/callback")
public BackchannelAuthenticationCallbackEndpoint authenticate() {
BackchannelAuthenticationCallbackEndpoint endpoint = new BackchannelAuthenticationCallbackEndpoint(session, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint;
}
@Override
public OIDCExtProvider create(KeycloakSession session) {
return new CibaRootEndpoint(session);
}
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public void setEvent(EventBuilder event) {
this.event = event;
}
@Override
public void close() {
}
@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.CIBA);
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.resolvers;
import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider;
/**
* Provides the resolver that converts several types of receives login hint to its corresponding UserModel.
* Also converts between UserModel and the user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD.
*
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface CIBALoginUserResolver extends Provider {
/**
* This method receives the login_hint parameter and returns its corresponding UserModel.
*
* @param loginHint
* @return UserModel
*/
default UserModel getUserFromLoginHint(String loginHint) {
return null;
}
/**
* This method receives the login_hint_token parameter and returns its corresponding UserModel.
*
* @param loginHintToken
* @return UserModel
*/
default UserModel getUserFromLoginHintToken(String loginHintToken) {
return null;
}
/**
* This method receives the id_token_hint parameter and returns its corresponding UserModel.
*
* @param idToken
* @return UserModel
*/
default UserModel getUserFromIdTokenHint(String idToken) {
return null;
}
/**
* This method converts the UserModel to its corresponding user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD.
*
* @param user
* @return its corresponding user identifier
*/
default String getInfoUsedByAuthentication(UserModel user) {
return user.getUsername();
}
/**
* This method converts the user identifier that can be recognized by the external entity executing AuthN and AuthZ by AD to the corresponding UserModel.
*
* @param info
* @return UserModel
*/
UserModel getUserFromInfoUsedByAuthentication(String info);
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.resolvers;
import org.keycloak.common.Profile;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderFactory;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public interface CIBALoginUserResolverFactory extends ProviderFactory<CIBALoginUserResolver>,
EnvironmentDependentProviderFactory {
@Override
default boolean isSupported() {
return Profile.isFeatureEnabled(Profile.Feature.CIBA);
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.resolvers;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class CIBALoginUserResolverSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "ciba-login-user-resolver";
}
@Override
public Class<? extends Provider> getProviderClass() {
return CIBALoginUserResolver.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return CIBALoginUserResolverFactory.class;
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.resolvers;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class DefaultCIBALoginUserResolver implements CIBALoginUserResolver {
private KeycloakSession session;
public DefaultCIBALoginUserResolver(KeycloakSession session) {
this.session = session;
}
@Override
public UserModel getUserFromLoginHint(String loginHint) {
return KeycloakModelUtils.findUserByNameOrEmail(session, session.getContext().getRealm(), loginHint);
}
@Override
public String getInfoUsedByAuthentication(UserModel user) {
return user.getUsername();
}
@Override
public UserModel getUserFromInfoUsedByAuthentication(String info) {
return KeycloakModelUtils.findUserByNameOrEmail(session, session.getContext().getRealm(), info);
}
@Override
public void close() {
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.ciba.resolvers;
import org.keycloak.Config.Scope;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class DefaultCIBALoginUserResolverFactory implements CIBALoginUserResolverFactory {
public static final String PROVIDER_ID = "default-ciba-login-user-resolver";
@Override
public CIBALoginUserResolver create(KeycloakSession session) {
return new DefaultCIBALoginUserResolver(session);
}
@Override
public void init(Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -19,7 +19,6 @@ package org.keycloak.protocol.oidc.grants.device;
import static org.keycloak.protocol.oidc.OIDCLoginProtocolService.tokenServiceBaseUrl;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.events.Details;
@ -38,28 +37,20 @@ import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.services.util.MtlsHoKTokenUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
@ -285,6 +276,6 @@ public class DeviceGrantType {
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
return tokenEndpoint.codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, false);
return tokenEndpoint.createTokenResponse(user, userSession, clientSessionCtx, scopeParam, false);
}
}

View file

@ -259,7 +259,7 @@ public class DeviceEndpoint extends AuthorizationEndpointBase implements RealmRe
}
private Response processVerification(OAuth2DeviceCodeModel deviceCode, String userCode) {
int expiresIn = deviceCode.getExpiration() - Time.currentTime();
long expiresIn = deviceCode.getExpiration() - Time.currentTime();
if (expiresIn < 0) {
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);

View file

@ -164,6 +164,8 @@ public class DefaultKeycloakSessionFactory implements KeycloakSessionFactory, Pr
}
factoriesMap = copy;
// need to update the default provider map
checkProvider();
boolean cfChanged = false;
for (ProviderFactory factory : undeployed) {
invalidate(ObjectType.PROVIDER_FACTORY, factory.getClass());
@ -216,6 +218,9 @@ public class DefaultKeycloakSessionFactory implements KeycloakSessionFactory, Pr
}
protected void checkProvider() {
// make sure to recreated the default providers map
provider.clear();
for (Spi spi : spis) {
String defaultProvider = Config.getProvider(spi.getName());
if (defaultProvider != null) {

View file

@ -26,6 +26,7 @@ import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
@ -42,17 +43,22 @@ import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.services.clientregistration.ClientRegistrationException;
import org.keycloak.services.util.CertificateInfoHelper;
import org.keycloak.util.JWKSUtils;
import org.keycloak.utils.StringUtil;
import java.net.URI;
import java.security.PublicKey;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import static org.keycloak.models.OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED;
import static org.keycloak.models.CibaConfig.OIDC_CIBA_GRANT_ENABLED;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -87,6 +93,7 @@ public class DescriptionConverter {
if (oidcGrantTypes != null) {
client.setDirectAccessGrantsEnabled(oidcGrantTypes.contains(OAuth2Constants.PASSWORD));
client.setServiceAccountsEnabled(oidcGrantTypes.contains(OAuth2Constants.CLIENT_CREDENTIALS));
setOidcCibaGrantEnabled(client, oidcGrantTypes.contains(OAuth2Constants.CIBA_GRANT_TYPE));
}
} catch (IllegalArgumentException iae) {
throw new ClientRegistrationException(iae.getMessage(), iae);
@ -165,9 +172,31 @@ public class DescriptionConverter {
configWrapper.setBackchannelLogoutRevokeOfflineTokens(clientOIDC.getBackchannelLogoutRevokeOfflineTokens());
}
String backchannelTokenDeliveryMode = clientOIDC.getBackchannelTokenDeliveryMode();
if (backchannelTokenDeliveryMode != null) {
if(isSupportedBackchannelTokenDeliveryMode(backchannelTokenDeliveryMode)) {
Map<String, String> attr = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>());
attr.put(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT, backchannelTokenDeliveryMode);
client.setAttributes(attr);
} else {
throw new ClientRegistrationException("Unsupported requested backchannel_token_delivery_mode");
}
}
return client;
}
private static void setOidcCibaGrantEnabled(ClientRepresentation client, Boolean isEnabled) {
if (isEnabled == null) return;
Map<String, String> attributes = Optional.ofNullable(client.getAttributes()).orElse(new HashMap<>());
attributes.put(CibaConfig.OIDC_CIBA_GRANT_ENABLED, isEnabled.toString());
client.setAttributes(attributes);
}
private static boolean isSupportedBackchannelTokenDeliveryMode(String mode) {
if (mode.equals(CibaConfig.DEFAULT_CIBA_POLICY_TOKEN_DELIVERY_MODE)) return true;
return false;
}
private static boolean setPublicKey(OIDCClientRepresentation clientOIDC, ClientRepresentation clientRep) {
if (clientOIDC.getJwksUri() == null && clientOIDC.getJwks() == null) {
@ -270,6 +299,13 @@ public class DescriptionConverter {
response.setBackchannelLogoutSessionRequired(config.isBackchannelLogoutSessionRequired());
response.setBackchannelLogoutSessionRequired(config.getBackchannelLogoutRevokeOfflineTokens());
if (client.getAttributes() != null) {
String mode = client.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT);
if (StringUtil.isNotBlank(mode)) {
response.setBackchannelTokenDeliveryMode(mode);
}
}
List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE;
response.setSubjectType(subjectType.toString().toLowerCase());
@ -318,6 +354,10 @@ public class DescriptionConverter {
if (oauth2DeviceEnabled) {
grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
}
boolean oidcCibaEnabled = client.getAttributes() != null && Boolean.parseBoolean(client.getAttributes().get(OIDC_CIBA_GRANT_ENABLED));
if (oidcCibaEnabled) {
grantTypes.add(OAuth2Constants.CIBA_GRANT_TYPE);
}
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {
grantTypes.add(OAuth2Constants.UMA_GRANT_TYPE);
}

View file

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

View file

@ -0,0 +1 @@
org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProviderFactory

View file

@ -0,0 +1 @@
org.keycloak.protocol.oidc.grants.ciba.resolvers.DefaultCIBALoginUserResolverFactory

View file

@ -24,3 +24,5 @@ org.keycloak.services.x509.X509ClientCertificateLookupSpi
org.keycloak.protocol.oidc.ext.OIDCExtSPI
org.keycloak.protocol.saml.preprocessor.SamlAuthenticationPreprocessorSpi
org.keycloak.encoding.ResourceEncodingSpi
org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelSpi
org.keycloak.protocol.oidc.grants.ciba.resolvers.CIBALoginUserResolverSpi

View file

@ -0,0 +1,56 @@
/*
*
* * Copyright 2021 Red Hat, Inc. and/or its affiliates
* * and other contributors as indicated by the @author tags.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.keycloak.testsuite.authentication;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelProvider;
import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProvider;
import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProviderFactory;
import org.keycloak.testsuite.util.ServerURLs;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class TestHttpAuthenticationChannelProviderFactory extends HttpAuthenticationChannelProviderFactory {
private static final String TEST_HTTP_AUTH_CHANNEL =
String.format("%s://%s:%s/auth/realms/master/app/oidc-client-endpoints/request-authentication-channel",
ServerURLs.AUTH_SERVER_SCHEME, ServerURLs.AUTH_SERVER_HOST, ServerURLs.AUTH_SERVER_PORT);
@Override
public AuthenticationChannelProvider create(KeycloakSession session) {
return new HttpAuthenticationChannelProvider(session, TEST_HTTP_AUTH_CHANNEL);
}
@Override
public int order() {
return 100;
}
@Override
public String getId() {
return "test-http-auth-channel";
}
@Override
public boolean isSupported() {
return true;
}
}

View file

@ -30,6 +30,7 @@ import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest;
import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource;
import org.keycloak.utils.MediaType;
@ -45,6 +46,7 @@ import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
/**
@ -61,6 +63,8 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
private final BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction;
private final TestApplicationResourceProviderFactory.OIDCClientData oidcClientData;
private final ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests;
@Context
HttpRequest request;
@ -68,13 +72,15 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
BlockingQueue<LogoutToken> backChannelLogoutTokens,
BlockingQueue<PushNotBeforeAction> adminPushNotBeforeActions,
BlockingQueue<TestAvailabilityAction> adminTestAvailabilityAction,
TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) {
TestApplicationResourceProviderFactory.OIDCClientData oidcClientData,
ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests) {
this.session = session;
this.adminLogoutActions = adminLogoutActions;
this.backChannelLogoutTokens = backChannelLogoutTokens;
this.adminPushNotBeforeActions = adminPushNotBeforeActions;
this.adminTestAvailabilityAction = adminTestAvailabilityAction;
this.oidcClientData = oidcClientData;
this.authenticationChannelRequests = authenticationChannelRequests;
}
@POST
@ -227,7 +233,7 @@ public class TestApplicationResourceProvider implements RealmResourceProvider {
@Path("/oidc-client-endpoints")
public TestingOIDCEndpointsApplicationResource getTestingOIDCClientEndpoints() {
return new TestingOIDCEndpointsApplicationResource(oidcClientData);
return new TestingOIDCEndpointsApplicationResource(oidcClientData, authenticationChannelRequests);
}
@Override

View file

@ -30,10 +30,13 @@ import org.keycloak.representations.adapters.action.PushNotBeforeAction;
import org.keycloak.representations.adapters.action.TestAvailabilityAction;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest;
import java.security.KeyPair;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.LinkedBlockingDeque;
/**
@ -47,11 +50,12 @@ public class TestApplicationResourceProviderFactory implements RealmResourceProv
private BlockingQueue<TestAvailabilityAction> testAvailabilityActions = new LinkedBlockingDeque<>();
private final OIDCClientData oidcClientData = new OIDCClientData();
private ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests = new ConcurrentHashMap<String, TestAuthenticationChannelRequest>();
@Override
public RealmResourceProvider create(KeycloakSession session) {
TestApplicationResourceProvider provider = new TestApplicationResourceProvider(session, adminLogoutActions,
backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData);
backChannelLogoutTokens, pushNotBeforeActions, testAvailabilityActions, oidcClientData, authenticationChannelRequests);
ResteasyProviderFactory.getInstance().injectProperties(provider);

View file

@ -0,0 +1,54 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.rest.representation;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class TestAuthenticationChannelRequest {
private String bearerToken;
private AuthenticationChannelRequest request;
public TestAuthenticationChannelRequest() {
// for reflection
}
public TestAuthenticationChannelRequest(AuthenticationChannelRequest request, String bearerToken) {
setBearerToken(bearerToken);
setRequest(request);
}
public void setBearerToken(String bearerToken) {
this.bearerToken = bearerToken;
}
public String getBearerToken() {
return bearerToken;
}
public void setRequest(AuthenticationChannelRequest request) {
this.request = request;
}
public AuthenticationChannelRequest getRequest() {
return request;
}
}

View file

@ -19,6 +19,8 @@ package org.keycloak.testsuite.rest.resource;
import org.jboss.resteasy.annotations.cache.NoCache;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.Base64Url;
@ -37,19 +39,32 @@ import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.Constants;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelRequest;
import org.keycloak.protocol.oidc.grants.ciba.channel.HttpAuthenticationChannelProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.testsuite.rest.TestApplicationResourceProviderFactory;
import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest;
import org.keycloak.util.JsonSerialization;
import com.fasterxml.jackson.annotation.JsonProperty;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import java.io.IOException;
import java.security.InvalidAlgorithmParameterException;
@ -63,6 +78,10 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -73,9 +92,13 @@ public class TestingOIDCEndpointsApplicationResource {
public static final String PUBLIC_KEY = "publicKey";
private final TestApplicationResourceProviderFactory.OIDCClientData clientData;
private final ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests;
public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData) {
public TestingOIDCEndpointsApplicationResource(TestApplicationResourceProviderFactory.OIDCClientData oidcClientData,
ConcurrentMap<String, TestAuthenticationChannelRequest> authenticationChannelRequests) {
this.clientData = oidcClientData;
this.authenticationChannelRequests = authenticationChannelRequests;
}
@GET
@ -490,6 +513,50 @@ public class TestingOIDCEndpointsApplicationResource {
public void setAction(String action) {
this.action = action;
}
}
@POST
@Path("/request-authentication-channel")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response requestAuthenticationChannel(@Context HttpHeaders headers, AuthenticationChannelRequest request) {
String rawBearerToken = AppAuthManager.extractAuthorizationHeaderToken(headers);
AccessToken bearerToken;
try {
bearerToken = new JWSInput(rawBearerToken).readJsonContent(AccessToken.class);
} catch (JWSInputException e) {
throw new RuntimeException("Failed to parse bearer token", e);
}
// required
String authenticationChannelId = bearerToken.getId();
if (authenticationChannelId == null) throw new BadRequestException("missing parameter : " + HttpAuthenticationChannelProvider.AUTHENTICATION_CHANNEL_ID);
String loginHint = request.getLoginHint();
if (loginHint == null) throw new BadRequestException("missing parameter : " + CibaGrantType.LOGIN_HINT);
if (request.getConsentRequired() == null)
throw new BadRequestException("missing parameter : " + CibaGrantType.IS_CONSENT_REQUIRED);
String scope = request.getScope();
if (scope == null) throw new BadRequestException("missing parameter : " + OAuth2Constants.SCOPE);
// optional
// for testing purpose
if (request.getBindingMessage() != null && request.getBindingMessage().equals("GODOWN")) throw new BadRequestException("intentional error : GODOWN");
authenticationChannelRequests.put(request.getBindingMessage(), new TestAuthenticationChannelRequest(request, rawBearerToken));
return Response.status(Status.CREATED).build();
}
@GET
@Path("/get-authentication-channel")
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public TestAuthenticationChannelRequest getAuthenticationChannel(@QueryParam("bindingMessage") String bindingMessage) {
return authenticationChannelRequests.get(bindingMessage);
}
}

View file

@ -0,0 +1,20 @@
#
# /*
# * Copyright 2021 Red Hat, Inc. and/or its affiliates
# * and other contributors as indicated by the @author tags.
# *
# * Licensed under the Apache License, Version 2.0 (the "License");
# * you may not use this file except in compliance with the License.
# * You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software
# * distributed under the License is distributed on an "AS IS" BASIS,
# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# * See the License for the specific language governing permissions and
# * limitations under the License.
# */
#
org.keycloak.testsuite.authentication.TestHttpAuthenticationChannelProviderFactory

View file

@ -182,6 +182,13 @@ public class AuthServerTestEnricher {
return removeDefaultPorts(String.format("%s://%s:%s", "http", host, httpPort));
}
public static String getHttpsAuthServerContextRoot() {
String host = System.getProperty("auth.server.host", "localhost");
int httpPort = Integer.parseInt(System.getProperty("auth.server.https.port")); // property must be set
return removeDefaultPorts(String.format("%s://%s:%s", "https", host, httpPort));
}
public static String getAuthServerBrowserContextRoot() throws MalformedURLException {
return getAuthServerBrowserContextRoot(new URL(getAuthServerContextRoot()));
}

View file

@ -17,13 +17,20 @@
package org.keycloak.testsuite.client.resources;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.testsuite.rest.representation.TestAuthenticationChannelRequest;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.Map;
@ -80,4 +87,16 @@ public interface TestOIDCEndpointsApplicationResource {
@Produces(MediaType.APPLICATION_JSON)
List<String> getSectorIdentifierRedirectUris();
@POST
@Path("/request-authentication-channel")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
@NoCache
Response requestAuthenticationChannel(final MultivaluedMap<String, String> request);
@GET
@Path("/get-authentication-channel")
@Produces(MediaType.APPLICATION_JSON)
TestAuthenticationChannelRequest getAuthenticationChannel(@QueryParam("bindingMessage") String bindingMessage);
}

View file

@ -17,6 +17,9 @@
package org.keycloak.testsuite.util;
import static org.keycloak.protocol.oidc.OIDCLoginProtocol.LOGIN_HINT_PARAM;
import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.AUTH_REQ_ID;
import static org.keycloak.protocol.oidc.grants.ciba.CibaGrantType.BINDING_MESSAGE;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.oauth2DeviceAuthUrl;
import static org.keycloak.testsuite.admin.Users.getPasswordOf;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
@ -34,6 +37,8 @@ import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
@ -59,9 +64,10 @@ import org.keycloak.jose.jwk.JWKParser;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.grants.ciba.CibaGrantType;
import org.keycloak.protocol.oidc.grants.ciba.channel.AuthenticationChannelResponse;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken;
@ -705,6 +711,75 @@ public class OAuthClient {
}
}
public AuthenticationRequestAcknowledgement doBackchannelAuthenticationRequest(String clientId, String clientSecret, String userid, String bindingMessage, String acrValues) throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getBackchannelAuthenticationUrl());
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
List<NameValuePair> parameters = new LinkedList<>();
if (userid != null) parameters.add(new BasicNameValuePair(LOGIN_HINT_PARAM, userid));
if (bindingMessage != null) parameters.add(new BasicNameValuePair(BINDING_MESSAGE, bindingMessage));
if (acrValues != null) parameters.add(new BasicNameValuePair(OAuth2Constants.ACR_VALUES, acrValues));
if (scope != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID + " " + scope));
} else {
parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, OAuth2Constants.SCOPE_OPENID));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return new AuthenticationRequestAcknowledgement(client.execute(post));
}
}
public int doAuthenticationChannelCallback(String requestToken, AuthenticationChannelResponse.Status authStatus) throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getAuthenticationChannelCallbackUrl());
String authorization = TokenUtil.TOKEN_TYPE_BEARER + " " + requestToken;
post.setHeader("Authorization", authorization);
post.setEntity(new StringEntity(JsonSerialization.writeValueAsString(new AuthenticationChannelResponse(authStatus)), ContentType.APPLICATION_JSON));
return client.execute(post).getStatusLine().getStatusCode();
}
}
public AccessTokenResponse doBackchannelAuthenticationTokenRequest(String clientSecret, String authReqId) throws Exception {
return doBackchannelAuthenticationTokenRequest(this.clientId, clientSecret, authReqId);
}
public AccessTokenResponse doBackchannelAuthenticationTokenRequest(String clientId, String clientSecret, String authReqId) throws Exception {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
HttpPost post = new HttpPost(getBackchannelAuthenticationTokenRequestUrl());
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
List<NameValuePair> parameters = new LinkedList<>();
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CIBA_GRANT_TYPE));
parameters.add(new BasicNameValuePair(AUTH_REQ_ID, authReqId));
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return new AccessTokenResponse(client.execute(post));
}
}
// KEYCLOAK-6771 Certificate Bound Token
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) {
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
@ -1215,6 +1290,21 @@ public class OAuthClient {
return b.build(realm).toString();
}
public String getBackchannelAuthenticationUrl() {
UriBuilder b = CibaGrantType.authorizationUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString();
}
public String getAuthenticationChannelCallbackUrl() {
UriBuilder b = CibaGrantType.authenticationUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString();
}
public String getBackchannelAuthenticationTokenRequestUrl() {
UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString();
}
public OAuthClient baseUrl(String baseUrl) {
this.baseUrl = baseUrl;
return this;
@ -1437,6 +1527,78 @@ public class OAuthClient {
}
}
public static class AuthenticationRequestAcknowledgement {
private int statusCode;
private Map<String, String> headers;
private String authReqId;
private int expiresIn;
private int interval = -1;
private String error;
private String errorDescription;
public AuthenticationRequestAcknowledgement(CloseableHttpResponse response) throws Exception {
try {
statusCode = response.getStatusLine().getStatusCode();
headers = new HashMap<>();
for (Header h : response.getAllHeaders()) {
headers.put(h.getName(), h.getValue());
}
Header[] contentTypeHeaders = response.getHeaders("Content-Type");
String contentType = (contentTypeHeaders != null && contentTypeHeaders.length > 0) ? contentTypeHeaders[0].getValue() : null;
if (!"application/json".equals(contentType)) {
Assert.fail("Invalid content type. Status: " + statusCode + ", contentType: " + contentType);
}
String s = IOUtils.toString(response.getEntity().getContent(), "UTF-8");
Map responseJson = JsonSerialization.readValue(s, Map.class);
if (statusCode == 200) {
authReqId = (String) responseJson.get("auth_req_id");
expiresIn = (Integer) responseJson.get("expires_in");
if (responseJson.containsKey("interval")) interval = (Integer) responseJson.get("interval");
} else {
error = (String) responseJson.get(OAuth2Constants.ERROR);
errorDescription = responseJson.containsKey(OAuth2Constants.ERROR_DESCRIPTION) ? (String) responseJson.get(OAuth2Constants.ERROR_DESCRIPTION) : null;
}
} finally {
response.close();
}
}
public int getStatusCode() {
return statusCode;
}
public Map<String, String> getHeaders() {
return headers;
}
public String getAuthReqId() {
return authReqId;
}
public int getExpiresIn() {
return expiresIn;
}
public int getInterval() {
return interval;
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
}
public static class AccessTokenResponse {
private int statusCode;

View file

@ -199,6 +199,16 @@ public class AssertEvents implements TestRule {
return expect(event).client("account");
}
public ExpectedEvent expectAuthReqIdToToken(String codeId, String sessionId) {
return expect(EventType.AUTHREQID_TO_TOKEN)
.detail(Details.CODE_ID, codeId)
.detail(Details.TOKEN_ID, isUUID())
.detail(Details.REFRESH_TOKEN_ID, isUUID())
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH)
.detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID)
.session(isUUID());
}
public ExpectedEvent expect(EventType event) {
return new ExpectedEvent()
.realm(defaultRealmId())

View file

@ -1958,7 +1958,7 @@ public class PermissionsTest extends AbstractKeycloakTest {
}
private void assertGettersEmpty(RealmRepresentation rep) {
assertGettersEmpty(rep, "getRealm");
assertGettersEmpty(rep, "getRealm", "getAttributesOrEmpty");
}
private void assertGettersEmpty(ClientRepresentation rep) {

View file

@ -30,6 +30,7 @@ import org.keycloak.events.EventType;
import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType;
import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -179,6 +180,12 @@ public class RealmTest extends AbstractAdminTest {
try {
RealmRepresentation rep2 = adminClient.realm("attributes").toRepresentation();
if (rep2.getAttributes() != null) {
Arrays.asList(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE,
CibaConfig.CIBA_EXPIRES_IN,
CibaConfig.CIBA_INTERVAL,
CibaConfig.CIBA_AUTH_REQUESTED_USER_HINT).stream().forEach(i -> rep2.getAttributes().remove(i));
}
Map<String, String> attributes = rep2.getAttributes();
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),

View file

@ -31,6 +31,7 @@ import org.keycloak.common.util.CollectionUtil;
import org.keycloak.events.Errors;
import org.keycloak.jose.jwe.JWEConstants;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.CibaConfig;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -49,7 +50,6 @@ import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.keycloak.testsuite.auth.page.AuthRealm.TEST;
@ -61,6 +61,7 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
private static final String PRIVATE_KEY = "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=";
private static final String PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB";
private static final String ERR_MSG_CLIENT_REG_FAIL = "Failed to send request";
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
@ -383,6 +384,30 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
}
}
@Test
public void testCIBASettings() throws Exception {
OIDCClientRepresentation clientRep = null;
OIDCClientRepresentation response = null;
clientRep = createRep();
clientRep.setBackchannelTokenDeliveryMode("poll");
response = reg.oidc().create(clientRep);
Assert.assertEquals("poll", response.getBackchannelTokenDeliveryMode());
// Test Keycloak representation
ClientRepresentation kcClient = getClient(response.getClientId());
Assert.assertEquals("poll", kcClient.getAttributes().get(CibaConfig.CIBA_BACKCHANNEL_TOKEN_DELIVERY_MODE_PER_CLIENT));
// update
clientRep.setBackchannelTokenDeliveryMode("ping");
try {
reg.oidc().create(clientRep);
fail();
} catch (ClientRegistrationException e) {
assertEquals(ERR_MSG_CLIENT_REG_FAIL, e.getMessage());
}
}
@Test
public void testOIDCEndpointCreateWithSamlClient() throws Exception {
ClientsResource clientsResource = adminClient.realm(TEST).clients();

View file

@ -110,6 +110,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
assertEquals(oidcConfig.getUserinfoEndpoint(), OIDCLoginProtocolService.userInfoUrl(UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT)).build("test").toString());
assertEquals(oidcConfig.getJwksUri(), oauth.getCertsUrl("test"));
String registrationUri = UriBuilder
.fromUri(OAuthClient.AUTH_SERVER_ROOT)
.path(RealmsResource.class)
@ -168,6 +169,11 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-6.2
Assert.assertTrue(oidcConfig.getTlsClientCertificateBoundAccessTokens());
// CIBA
assertEquals(oidcConfig.getBackchannelAuthenticationEndpoint(), oauth.getBackchannelAuthenticationUrl());
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.CIBA_GRANT_TYPE);
Assert.assertNames(oidcConfig.getBackchannelTokenDeliveryModesSupported(), "poll");
Assert.assertTrue(oidcConfig.getBackchannelLogoutSupported());
Assert.assertTrue(oidcConfig.getBackchannelLogoutSessionSupported());

View file

@ -80,6 +80,7 @@ log4j.logger.org.keycloak.services.clientregistration.policy=debug
# log4j.logger.org.keycloak.services.clientpolicy=trace
# log4j.logger.org.keycloak.testsuite.clientpolicy=trace
# log4j.logger.org.keycloak.protocol.ciba=trace
#log4j.logger.org.keycloak.authentication=debug

View file

@ -156,4 +156,5 @@
"certificateChainLength": 1
}
}
}

View file

@ -331,6 +331,8 @@ service-accounts-enabled=Service Accounts Enabled
service-accounts-enabled.tooltip=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.
oauth2-device-authorization-grant-enabled=OAuth 2.0 Device Authorization Grant Enabled
oauth2-device-authorization-grant-enabled.tooltip=This enables support for OAuth 2.0 Device Authorization Grant, which means that client is an application on device that has limited input capabilities or lack a suitable browser.
oidc-ciba-grant-enabled=OIDC CIBA Grant Enabled
oidc-ciba-grant-enabled.tooltip=This enables support for OIDC CIBA Grant, which means that the user is authenticated via some external authentication device instead of the user's browser.
include-authnstatement=Include AuthnStatement
include-authnstatement.tooltip=Should a statement specifying the method and timestamp be included in login responses?
include-onetimeuse-condition=Include OneTimeUse Condition
@ -1277,6 +1279,15 @@ manage-webauthn-authenticator=Manage WebAuthn Authenticator
public-key-credential-id=Public Key Credential ID
public-key-credential-aaguid=Public Key Credential AAGUID
public-key-credential-label=Public Key Credential Label
ciba-policy=CIBA Policy
ciba-backchannel-tokendelivery-mode=Backchannel Token Delivery Mode
ciba-backchannel-tokendelivery-mode.tooltip=Specifies how the CD(Consumption Device) gets the authentication result and related tokens.
ciba-expires-in=Expires In
ciba-expires-in.tooltip=The expiration time of the "auth_req_id" in seconds since the authentication request was received.
ciba-interval=Interval
ciba-interval.tooltip=The minimum amount of time in seconds that the CD(Consumption Device) must wait between polling requests to the token endpoint.
ciba-auth-requested-user-hint=Authentication Requested User Hint
ciba-auth-requested-user-hint.tooltip=The way of identifying the end-user for whom authentication is being requested.
admin-events=Admin Events
admin-events.tooltip=Displays saved admin events for the realm. Events are related to admin account, for example a realm creation. To enable persisted events go to config.
login-events=Login Events

View file

@ -2079,6 +2079,18 @@ module.config([ '$routeProvider', function($routeProvider) {
},
controller : 'RealmWebAuthnPasswordlessPolicyCtrl'
})
.when('/realms/:realm/authentication/ciba-policy', {
templateUrl : resourceUrl + '/partials/ciba-policy.html',
resolve : {
realm : function(RealmLoader) {
return RealmLoader();
},
serverInfo : function(ServerInfoLoader) {
return ServerInfoLoader();
}
},
controller : 'RealmCibaPolicyCtrl'
})
.when('/realms/:realm/authentication/flows/:flow/config/:provider/:config', {
templateUrl : resourceUrl + '/partials/authenticator-config.html',
resolve : {

View file

@ -1110,6 +1110,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient;
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
$scope.oidcCibaGrantEnabled = false;
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
$scope.tlsClientCertificateBoundAccessTokens = false;
@ -1302,6 +1303,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
}
}
if ($scope.client.attributes["oidc.ciba.grant.enabled"]) {
if ($scope.client.attributes["oidc.ciba.grant.enabled"] == "true") {
$scope.oidcCibaGrantEnabled = true;
} else {
$scope.oidcCibaGrantEnabled = false;
}
}
if ($scope.client.attributes["use.refresh.tokens"]) {
if ($scope.client.attributes["use.refresh.tokens"] == "true") {
$scope.useRefreshTokens = true;
@ -1722,6 +1731,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "false";
}
if ($scope.oidcCibaGrantEnabled == true) {
$scope.clientEdit.attributes["oidc.ciba.grant.enabled"] = "true";
} else {
$scope.clientEdit.attributes["oidc.ciba.grant.enabled"] = "false";
}
if ($scope.useRefreshTokens == true) {
$scope.clientEdit.attributes["use.refresh.tokens"] = "true";
} else {

View file

@ -452,6 +452,11 @@ module.controller('RealmWebAuthnPasswordlessPolicyCtrl', function ($scope, Curre
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/webauthn-policy-passwordless");
});
module.controller('RealmCibaPolicyCtrl', function ($scope, Current, Realm, realm, serverInfo, $http, $route, $location, Dialog, Notifications) {
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/authentication/ciba-policy");
});
module.controller('RealmThemeCtrl', function($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications) {
genericRealmUpdate($scope, Current, Realm, realm, serverInfo, $http, $route, Dialog, Notifications, "/realms/" + realm.realm + "/theme-settings");
@ -2492,7 +2497,6 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo
} else if (realm.dockerAuthenticationFlow == $scope.flow.alias) {
Notifications.error("Cannot remove flow, it is currently being used as the docker authentication flow.");
} else {
AuthenticationFlows.remove({realm: realm.realm, flow: $scope.flow.id}, function () {
$location.url("/realms/" + realm.realm + '/authentication/flows/' + flows[0].alias);

View file

@ -0,0 +1,62 @@
<div class="col-sm-9 col-md-10 col-sm-push-3 col-md-push-2">
<h1>{{:: 'authentication' | translate}}</h1>
<kc-tabs-authentication></kc-tabs-authentication>
<form class="form-horizontal" name="realmForm" novalidate kc-read-only="!access.manageRealm">
<div class="form-group">
<label for="tokendelivery" class="col-md-2 control-label"><span class="required">*</span>{{:: 'ciba-backchannel-tokendelivery-mode' | translate}}</label>
<div class="col-md-2">
<div>
<select id="tokendelivery" ng-model="realm.attributes.cibaBackchannelTokenDeliveryMode" class="form-control">
<option value="poll">Poll</option>
</select>
</div>
</div>
<kc-tooltip>{{:: 'ciba-backchannel-tokendelivery-mode.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label for="expiresin" class="col-md-2 control-label"><span class="required">*</span>{{:: 'ciba-expires-in' | translate}}</label>
<div class="col-md-2">
<div>
<input id="expiresin" type="number" min="10" max="600" string-to-number ng-model="realm.attributes.cibaExpiresIn" class="form-control"/>
</div>
</div>
<kc-tooltip>{{:: 'ciba-expires-in.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label for="interval" class="col-md-2 control-label">{{:: 'ciba-interval' | translate}}</label>
<div class="col-md-2">
<div>
<input id="interval"" type="number" min="0" max="600" string-to-number ng-model="realm.attributes.cibaInterval" class="form-control"/>
</div>
</div>
<kc-tooltip>{{:: 'ciba-interval.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label for="authRequestedUserHint" class="col-md-2 control-label"><span class="required">*</span>{{:: 'ciba-auth-requested-user-hint' | translate}}</label>
<div class="col-md-2">
<div>
<select id="authRequestedUserHint" ng-model="realm.attributes.cibaAuthRequestedUserHint" class="form-control">
<option value="login_hint">login_hint</option>
</select>
</div>
</div>
<kc-tooltip>{{:: 'ciba-auth-requested-user-hint.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group" data-ng-show="access.manageRealm">
<div class="col-md-10 col-md-offset-2">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
<button kc-reset data-ng-disabled="!changed">{{:: 'cancel' | translate}}</button>
</div>
</div>
</form>
</div>
<kc-menu></kc-menu>

View file

@ -150,6 +150,17 @@
on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
</div>
<div class="form-group"
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly && serverInfo.featureEnabled('CIBA')">
<label class="col-md-2 control-label" for="oidcCibaGrantEnabled">{{::
'oidc-ciba-grant-enabled' | translate}}</label>
<kc-tooltip>{{:: 'oidc-ciba-grant-enabled.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input ng-model="oidcCibaGrantEnabled" ng-click="switchChange()"
name="oidcCibaGrantEnabled" id="oidcCibaGrantEnabled" onoffswitch
on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div>
</div>
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
<label class="col-md-2 control-label" for="authorizationServicesEnabled">{{:: 'authz-authorization-services-enabled' | translate}}</label>
<kc-tooltip>{{:: 'authz-authorization-services-enabled.tooltip' | translate}}</kc-tooltip>

View file

@ -12,4 +12,5 @@
<a href="#/realms/{{realm.realm}}/authentication/webauthn-policy-passwordless">{{:: 'webauthn-policy-passwordless' | translate}}</a>
<kc-tooltip>{{:: 'webauthn-policy-passwordless.tooltip' | translate}}</kc-tooltip>
</li>
<li ng-class="{active: path[3] == 'ciba-policy'}" data-ng-show="access.viewRealm && serverInfo.featureEnabled('CIBA')"><a href="#/realms/{{realm.realm}}/authentication/ciba-policy">{{:: 'ciba-policy' | translate}}</a></li>
</ul>