KEYCLOAK-7675 Support for Device Authorization Grant

This commit is contained in:
Michito Okai 2021-01-20 15:36:23 +09:00 committed by Pedro Igor
parent f58bf0deeb
commit 298ab0bc3e
41 changed files with 1498 additions and 1007 deletions

View file

@ -142,6 +142,9 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("backchannel_logout_session_supported") @JsonProperty("backchannel_logout_session_supported")
private Boolean backchannelLogoutSessionSupported; private Boolean backchannelLogoutSessionSupported;
@JsonProperty("device_authorization_endpoint")
private String deviceAuthorizationEndpoint;
protected Map<String, Object> otherClaims = new HashMap<String, Object>(); protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() { public String getIssuer() {
@ -445,4 +448,12 @@ public class OIDCConfigurationRepresentation {
public void setOtherClaims(String name, Object value) { public void setOtherClaims(String name, Object value) {
otherClaims.put(name, value); otherClaims.put(name, value);
} }
public void setDeviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) {
this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
}
public String getDeviceAuthorizationEndpoint() {
return deviceAuthorizationEndpoint;
}
} }

View file

@ -268,14 +268,6 @@ public class ClientRepresentation {
this.serviceAccountsEnabled = serviceAccountsEnabled; this.serviceAccountsEnabled = serviceAccountsEnabled;
} }
public Boolean isOAuth2DeviceAuthorizationGrantEnabled() {
return oauth2DeviceAuthorizationGrantEnabled;
}
public void setOAuth2DeviceAuthorizationGrantEnabled(Boolean oauth2DeviceAuthorizationGrantEnabled) {
this.oauth2DeviceAuthorizationGrantEnabled = oauth2DeviceAuthorizationGrantEnabled;
}
public Boolean getAuthorizationServicesEnabled() { public Boolean getAuthorizationServicesEnabled() {
if (authorizationSettings != null) { if (authorizationSettings != null) {
return true; return true;

View file

@ -29,6 +29,7 @@ import org.keycloak.storage.client.ClientStorageProvider;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import java.util.stream.Stream; import java.util.stream.Stream;
/** /**
@ -40,18 +41,20 @@ public class RealmAdapter implements CachedRealmModel {
protected RealmCacheSession cacheSession; protected RealmCacheSession cacheSession;
protected volatile RealmModel updated; protected volatile RealmModel updated;
protected KeycloakSession session; protected KeycloakSession session;
private final Supplier<RealmModel> modelSupplier;
public RealmAdapter(KeycloakSession session, CachedRealm cached, RealmCacheSession cacheSession) { public RealmAdapter(KeycloakSession session, CachedRealm cached, RealmCacheSession cacheSession) {
this.cached = cached; this.cached = cached;
this.cacheSession = cacheSession; this.cacheSession = cacheSession;
this.session = session; this.session = session;
this.modelSupplier = this::getRealm;
} }
@Override @Override
public RealmModel getDelegateForUpdate() { public RealmModel getDelegateForUpdate() {
if (updated == null) { if (updated == null) {
cacheSession.registerRealmInvalidation(cached.getId(), cached.getName()); cacheSession.registerRealmInvalidation(cached.getId(), cached.getName());
updated = cacheSession.getRealmDelegate().getRealm(cached.getId()); updated = modelSupplier.get();
if (updated == null) throw new IllegalStateException("Not found in database"); if (updated == null) throw new IllegalStateException("Not found in database");
} }
return updated; return updated;
@ -643,27 +646,11 @@ public class RealmAdapter implements CachedRealmModel {
return cached.getRequiredCredentials().stream(); return cached.getRequiredCredentials().stream();
} }
public int getOAuth2DeviceCodeLifespan() {
if (isUpdated()) return updated.getOAuth2DeviceCodeLifespan();
return cached.getOAuth2DeviceCodeLifespan();
}
@Override @Override
public void setOAuth2DeviceCodeLifespan(int oauth2DeviceCodeLifespan) { public OAuth2DeviceConfig getOAuth2DeviceConfig() {
getDelegateForUpdate(); if (isUpdated())
updated.setOAuth2DeviceCodeLifespan(oauth2DeviceCodeLifespan); return updated.getOAuth2DeviceConfig();
} return cached.getOAuth2DeviceConfig(modelSupplier);
@Override
public int getOAuth2DevicePollingInterval() {
if (isUpdated()) return updated.getOAuth2DevicePollingInterval();
return cached.getOAuth2DevicePollingInterval();
}
@Override
public void setOAuth2DevicePollingInterval(int oauth2DevicePollingInterval) {
getDelegateForUpdate();
updated.setOAuth2DevicePollingInterval(oauth2DevicePollingInterval);
} }
@Override @Override
@ -1721,6 +1708,10 @@ public class RealmAdapter implements CachedRealmModel {
return Collections.unmodifiableMap(localizationTexts); return Collections.unmodifiableMap(localizationTexts);
} }
private RealmModel getRealm() {
return cacheSession.getRealmDelegate().getRealm(cached.getId());
}
@Override @Override
public String toString() { public String toString() {
return String.format("%s@%08x", getId(), hashCode()); return String.format("%s@%08x", getId(), hashCode());

View file

@ -28,12 +28,15 @@ import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.GroupModel; import org.keycloak.models.GroupModel;
import org.keycloak.models.IdentityProviderMapperModel; import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.OTPPolicy; import org.keycloak.models.OTPPolicy;
import org.keycloak.models.PasswordPolicy; import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel; import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.RequiredCredentialModel; import org.keycloak.models.RequiredCredentialModel;
import org.keycloak.models.WebAuthnPolicy; import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.cache.infinispan.DefaultLazyLoader;
import org.keycloak.models.cache.infinispan.LazyLoader;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
@ -43,6 +46,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -96,8 +100,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected int accessCodeLifespan; protected int accessCodeLifespan;
protected int accessCodeLifespanUserAction; protected int accessCodeLifespanUserAction;
protected int accessCodeLifespanLogin; protected int accessCodeLifespanLogin;
protected int oauth2DeviceCodeLifespan; protected LazyLoader<RealmModel, OAuth2DeviceConfig> deviceConfig;
protected int oauth2DevicePollingInterval;
protected int actionTokenGeneratedByAdminLifespan; protected int actionTokenGeneratedByAdminLifespan;
protected int actionTokenGeneratedByUserLifespan; protected int actionTokenGeneratedByUserLifespan;
protected int notBefore; protected int notBefore;
@ -213,8 +216,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
accessTokenLifespan = model.getAccessTokenLifespan(); accessTokenLifespan = model.getAccessTokenLifespan();
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow(); accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
accessCodeLifespan = model.getAccessCodeLifespan(); accessCodeLifespan = model.getAccessCodeLifespan();
oauth2DeviceCodeLifespan = model.getOAuth2DeviceCodeLifespan(); deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null);
oauth2DevicePollingInterval = model.getOAuth2DevicePollingInterval();
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan(); actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
@ -491,12 +493,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return accessCodeLifespanLogin; return accessCodeLifespanLogin;
} }
public int getOAuth2DeviceCodeLifespan() { public OAuth2DeviceConfig getOAuth2DeviceConfig(Supplier<RealmModel> modelSupplier) {
return oauth2DeviceCodeLifespan; return deviceConfig.get(modelSupplier);
}
public int getOAuth2DevicePollingInterval() {
return oauth2DevicePollingInterval;
} }
public int getActionTokenGeneratedByAdminLifespan() { public int getActionTokenGeneratedByAdminLifespan() {

View file

@ -49,7 +49,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) { public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) {
try { try {
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get(); BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(realm, deviceCode)); ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(deviceCode));
if (existing == null) { if (existing == null) {
return null; return null;
@ -74,7 +74,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
@Override @Override
public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) { public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) {
ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.serializeValue()); ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.toMap());
ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue()); ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue());
try { try {
@ -142,7 +142,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes()); OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes());
String deviceCode = data.getDeviceCode(); String deviceCode = data.getDeviceCode();
String deviceCodeKey = OAuth2DeviceCodeModel.createKey(realm, deviceCode); String deviceCodeKey = OAuth2DeviceCodeModel.createKey(deviceCode);
ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey); ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey);
if (existingDeviceCode == null) { if (existingDeviceCode == null) {
@ -164,7 +164,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
// Update the device code with approved status // Update the device code with approved status
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get(); BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.serializeApprovedValue())); cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.toMap()));
return true; return true;
} catch (HotRodClientException re) { } catch (HotRodClientException re) {
@ -189,7 +189,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
OAuth2DeviceCodeModel denied = deviceCode.deny(); OAuth2DeviceCodeModel denied = deviceCode.deny();
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get(); BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.serializeDeniedValue())); cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.toMap()));
return true; return true;
} catch (HotRodClientException re) { } catch (HotRodClientException re) {
@ -207,7 +207,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
public boolean removeDeviceCode(RealmModel realm, String deviceCode) { public boolean removeDeviceCode(RealmModel realm, String deviceCode) {
try { try {
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get(); BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
String key = OAuth2DeviceCodeModel.createKey(realm, deviceCode); String key = OAuth2DeviceCodeModel.createKey(deviceCode);
ActionTokenValueEntity existing = cache.remove(key); ActionTokenValueEntity existing = cache.remove(key);
return existing == null ? false : true; return existing == null ? false : true;
} catch (HotRodClientException re) { } catch (HotRodClientException re) {

View file

@ -17,18 +17,14 @@
package org.keycloak.models.sessions.infinispan; package org.keycloak.models.sessions.infinispan;
import org.infinispan.Cache;
import org.infinispan.client.hotrod.Flag;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.commons.api.BasicCache; import org.infinispan.commons.api.BasicCache;
import org.jboss.logging.Logger;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.*; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.OAuth2DeviceTokenStoreProviderFactory;
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
import org.keycloak.models.sessions.infinispan.util.InfinispanUtil;
import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
/** /**
@ -36,8 +32,6 @@ import java.util.function.Supplier;
*/ */
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory { public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class);
// Reuse "actionTokens" infinispan cache for now // Reuse "actionTokens" infinispan cache for now
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache; private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
@ -50,25 +44,7 @@ public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2De
private void lazyInit(KeycloakSession session) { private void lazyInit(KeycloakSession session) {
if (codeCache == null) { if (codeCache == null) {
synchronized (this) { synchronized (this) {
if (codeCache == null) { codeCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session);
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
if (remoteCache != null) {
LOG.debugf("Having remote stores. Using remote cache '%s' for token of OAuth 2.0 Device Authorization Grant", remoteCache.getName());
this.codeCache = () -> {
// Doing this way as flag is per invocation
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
};
} else {
LOG.debugf("Not having remote stores. Using normal cache '%s' for token of OAuth 2.0 Device Authorization Grant", cache.getName());
this.codeCache = () -> {
return cache;
};
}
}
} }
} }
} }

View file

@ -588,23 +588,8 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
} }
@Override @Override
public int getOAuth2DeviceCodeLifespan() { public OAuth2DeviceConfig getOAuth2DeviceConfig() {
return getAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN); return new OAuth2DeviceConfig(this);
}
@Override
public void setOAuth2DeviceCodeLifespan(int seconds) {
setAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, seconds);
}
@Override
public int getOAuth2DevicePollingInterval() {
return getAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
}
@Override
public void setOAuth2DevicePollingInterval(int seconds) {
setAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, seconds);
} }
@Override @Override

View file

@ -35,15 +35,10 @@ public interface RealmAttributes {
String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan"; String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan";
// OAuth 2.0 Device Authorization Grant
String OAUTH2_DEVICE_CODE_LIFESPAN = "oauth2DeviceCodeLifespan";
String OAUTH2_DEVICE_POLLING_INTERVAL = "oauth2DevicePollingInterval";
String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout"; String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout";
String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan"; String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan";
String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout"; String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout";
String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan"; String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan";
String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName"; String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName";
String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms"; String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms";

View file

@ -119,8 +119,4 @@ public final class Constants {
*/ */
public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size"; public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size";
// 10 minutes
public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600;
// 5 seconds
public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5;
} }

View file

@ -16,19 +16,19 @@
*/ */
package org.keycloak.models; package org.keycloak.models;
import org.keycloak.common.util.Time;
import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import org.keycloak.common.util.Time;
/** /**
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a> * @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/ */
public class OAuth2DeviceCodeModel { public class OAuth2DeviceCodeModel {
private static final String REALM_ID = "rid";
private static final String CLIENT_ID = "cid"; private static final String CLIENT_ID = "cid";
private static final String EXPIRATION_NOTE = "exp"; private static final String EXPIRATION_NOTE = "exp";
private static final String POLLING_INTERVAL_NOTE = "int"; private static final String POLLING_INTERVAL_NOTE = "int";
@ -46,15 +46,14 @@ public class OAuth2DeviceCodeModel {
private final String scope; private final String scope;
private final String nonce; private final String nonce;
private final String userSessionId; private final String userSessionId;
private final boolean denied; private final Boolean denied;
private final Map<String, String> additionalParams; private final Map<String, String> additionalParams;
public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client, public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
String deviceCode, String scope, String nonce, Map<String, String> additionalParams) { String deviceCode, String scope, String nonce, int expiresIn, int pollingInterval, Map<String, String> additionalParams) {
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
int expiration = Time.currentTime() + expiresIn; int expiration = Time.currentTime() + expiresIn;
int pollingInterval = realm.getOAuth2DevicePollingInterval(); return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, null, additionalParams);
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, false, additionalParams);
} }
public OAuth2DeviceCodeModel approve(String userSessionId) { public OAuth2DeviceCodeModel approve(String userSessionId) {
@ -67,7 +66,7 @@ public class OAuth2DeviceCodeModel {
private OAuth2DeviceCodeModel(RealmModel realm, String clientId, private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
String deviceCode, String scope, String nonce, int expiration, int pollingInterval, String deviceCode, String scope, String nonce, int expiration, int pollingInterval,
String userSessionId, boolean denied, Map<String, String> additionalParams) { String userSessionId, Boolean denied, Map<String, String> additionalParams) {
this.realm = realm; this.realm = realm;
this.clientId = clientId; this.clientId = clientId;
this.deviceCode = deviceCode; this.deviceCode = deviceCode;
@ -81,7 +80,13 @@ public class OAuth2DeviceCodeModel {
} }
public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) { public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) {
return new OAuth2DeviceCodeModel(realm, deviceCode, data); OAuth2DeviceCodeModel model = new OAuth2DeviceCodeModel(realm, deviceCode, data);
if (!realm.getId().equals(data.get(REALM_ID))) {
return null;
}
return model;
} }
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) { private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) {
@ -128,58 +133,51 @@ public class OAuth2DeviceCodeModel {
return userSessionId == null; return userSessionId == null;
} }
public boolean isApproved() {
return userSessionId != null && !denied;
}
public boolean isDenied() { public boolean isDenied() {
return userSessionId != null && denied; return denied;
} }
public String getUserSessionId() { public String getUserSessionId() {
return userSessionId; return userSessionId;
} }
public static String createKey(RealmModel realm, String deviceCode) { public static String createKey(String deviceCode) {
return String.format("%s.dc.%s", realm.getId(), deviceCode); return String.format("dc.%s", deviceCode);
} }
public String serializeKey() { public String serializeKey() {
return createKey(realm, deviceCode); return createKey(deviceCode);
} }
public String serializePollingKey() { public String serializePollingKey() {
return createKey(realm, deviceCode) + ".polling"; return createKey(deviceCode) + ".polling";
} }
public Map<String, String> serializeValue() { public Map<String, String> toMap() {
Map<String, String> result = new HashMap<>(); Map<String, String> result = new HashMap<>();
result.put(CLIENT_ID, clientId);
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(SCOPE_NOTE, scope);
result.put(NONCE_NOTE, nonce);
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
return result;
}
public Map<String, String> serializeApprovedValue() { result.put(REALM_ID, realm.getId());
Map<String, String> result = new HashMap<>();
result.put(EXPIRATION_NOTE, String.valueOf(expiration)); if (denied == null) {
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval)); result.put(CLIENT_ID, clientId);
result.put(SCOPE_NOTE, scope); result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(NONCE_NOTE, nonce); result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(USER_SESSION_ID_NOTE, userSessionId); result.put(SCOPE_NOTE, scope);
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value)); result.put(NONCE_NOTE, nonce);
return result; } else if (denied) {
} result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(DENIED_NOTE, String.valueOf(denied));
} else {
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(SCOPE_NOTE, scope);
result.put(NONCE_NOTE, nonce);
result.put(USER_SESSION_ID_NOTE, userSessionId);
}
public Map<String, String> serializeDeniedValue() {
Map<String, String> result = new HashMap<>();
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(DENIED_NOTE, String.valueOf(denied));
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value)); additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
return result; return result;
} }
@ -192,4 +190,8 @@ public class OAuth2DeviceCodeModel {
this.additionalParams.forEach(params::putSingle); this.additionalParams.forEach(params::putSingle);
return params; return params;
} }
public boolean isExpired() {
return getExpiration() - Time.currentTime() < 0;
}
} }

View file

@ -352,8 +352,8 @@ public class ModelToRepresentation {
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin()); rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());
rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan()); rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan());
rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan()); rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan());
rep.setOAuth2DeviceCodeLifespan(realm.getOAuth2DeviceCodeLifespan()); rep.setOAuth2DeviceCodeLifespan(realm.getOAuth2DeviceConfig().getLifespan());
rep.setOAuth2DevicePollingInterval(realm.getOAuth2DevicePollingInterval()); rep.setOAuth2DevicePollingInterval(realm.getOAuth2DeviceConfig().getPoolingInterval());
rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig())); rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig()));
rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders()); rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders());
rep.setAccountTheme(realm.getAccountTheme()); rep.setAccountTheme(realm.getAccountTheme());
@ -585,7 +585,6 @@ public class ModelToRepresentation {
rep.setImplicitFlowEnabled(clientModel.isImplicitFlowEnabled()); rep.setImplicitFlowEnabled(clientModel.isImplicitFlowEnabled());
rep.setDirectAccessGrantsEnabled(clientModel.isDirectAccessGrantsEnabled()); rep.setDirectAccessGrantsEnabled(clientModel.isDirectAccessGrantsEnabled());
rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled()); rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled());
rep.setOAuth2DeviceAuthorizationGrantEnabled(clientModel.isOAuth2DeviceAuthorizationGrantEnabled());
rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired()); rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired());
rep.setRootUrl(clientModel.getRootUrl()); rep.setRootUrl(clientModel.getRootUrl());
rep.setBaseUrl(clientModel.getBaseUrl()); rep.setBaseUrl(clientModel.getBaseUrl());

View file

@ -76,6 +76,7 @@ import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.LDAPConstants; import org.keycloak.models.LDAPConstants;
import org.keycloak.models.ModelException; import org.keycloak.models.ModelException;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.OTPPolicy; import org.keycloak.models.OTPPolicy;
import org.keycloak.models.PasswordPolicy; import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.ProtocolMapperModel;
@ -250,12 +251,10 @@ public class RepresentationToModel {
else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction()); else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction());
// OAuth 2.0 Device Authorization Grant // OAuth 2.0 Device Authorization Grant
if (rep.getOAuth2DeviceCodeLifespan() != null) OAuth2DeviceConfig deviceConfig = newRealm.getOAuth2DeviceConfig();
newRealm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
else newRealm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN); deviceConfig.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
if (rep.getOAuth2DevicePollingInterval() != null) deviceConfig.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
newRealm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
else newRealm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
if (rep.getSslRequired() != null) if (rep.getSslRequired() != null)
newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase())); newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase()));
@ -1118,10 +1117,12 @@ public class RepresentationToModel {
realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan()); realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan());
if (rep.getActionTokenGeneratedByUserLifespan() != null) if (rep.getActionTokenGeneratedByUserLifespan() != null)
realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan()); realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
if (rep.getOAuth2DeviceCodeLifespan() != null)
realm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan()); OAuth2DeviceConfig deviceConfig = realm.getOAuth2DeviceConfig();
if (rep.getOAuth2DevicePollingInterval() != null)
realm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval()); deviceConfig.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
deviceConfig.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore()); if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm()); if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm());
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
@ -1487,10 +1488,6 @@ public class RepresentationToModel {
client.setFullScopeAllowed(!client.isConsentRequired()); client.setFullScopeAllowed(!client.isConsentRequired());
} }
if (resourceRep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
client.setOAuth2DeviceAuthorizationGrantEnabled(resourceRep.isOAuth2DeviceAuthorizationGrantEnabled());
}
client.updateClient(); client.updateClient();
resourceRep.setId(client.getId()); resourceRep.setId(client.getId());
@ -1556,6 +1553,7 @@ public class RepresentationToModel {
} }
} }
} }
if (rep.getNotBefore() != null) { if (rep.getNotBefore() != null) {
resource.setNotBefore(rep.getNotBefore()); resource.setNotBefore(rep.getNotBefore());
} }
@ -1589,10 +1587,6 @@ public class RepresentationToModel {
} }
} }
if (rep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
resource.setOAuth2DeviceAuthorizationGrantEnabled(rep.isOAuth2DeviceAuthorizationGrantEnabled());
}
resource.updateClient(); resource.updateClient();
} }

View file

@ -36,7 +36,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
String PRIVATE_KEY = "privateKey"; String PRIVATE_KEY = "privateKey";
String PUBLIC_KEY = "publicKey"; String PUBLIC_KEY = "publicKey";
String X509CERTIFICATE = "X509Certificate"; String X509CERTIFICATE = "X509Certificate";
String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
public static class SearchableFields { public static class SearchableFields {
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class); public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);
@ -200,15 +199,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
boolean isServiceAccountsEnabled(); boolean isServiceAccountsEnabled();
void setServiceAccountsEnabled(boolean serviceAccountsEnabled); void setServiceAccountsEnabled(boolean serviceAccountsEnabled);
default boolean isOAuth2DeviceAuthorizationGrantEnabled() {
String enabled = getAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED);
return Boolean.parseBoolean(enabled);
}
default void setOAuth2DeviceAuthorizationGrantEnabled(boolean oauth2DeviceAuthorizationGrantEnabled) {
setAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, Boolean.toString(oauth2DeviceAuthorizationGrantEnabled));
}
RealmModel getRealm(); RealmModel getRealm();
/** /**

View file

@ -0,0 +1,126 @@
/*
*
* * Copyright 2021 Red Hat, Inc. and/or its affiliates
* * and other contributors as indicated by the @author tags.
* *
* * Licensed under the Apache License, Version 2.0 (the "License");
* * you may not use this file except in compliance with the License.
* * You may obtain a copy of the License at
* *
* * http://www.apache.org/licenses/LICENSE-2.0
* *
* * Unless required by applicable law or agreed to in writing, software
* * distributed under the License is distributed on an "AS IS" BASIS,
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* * See the License for the specific language governing permissions and
* * limitations under the License.
*
*/
package org.keycloak.models;
import java.io.Serializable;
import java.util.function.Supplier;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public final class OAuth2DeviceConfig implements Serializable {
// 10 minutes
public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600;
// 5 seconds
public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5;
// realm attribute names
public static String OAUTH2_DEVICE_CODE_LIFESPAN = "oauth2DeviceCodeLifespan";
public static String OAUTH2_DEVICE_POLLING_INTERVAL = "oauth2DevicePollingInterval";
// client attribute names
public static String OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT = "oauth2.device.code.lifespan";
public static String OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT = "oauth2.device.polling.interval";
public static final String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
private transient Supplier<RealmModel> realm;
private int lifespan = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
private int poolingInterval = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
public OAuth2DeviceConfig(RealmModel realm) {
this.realm = () -> realm;
String lifespan = realm.getAttribute(OAUTH2_DEVICE_CODE_LIFESPAN);
if (lifespan != null && !lifespan.trim().isEmpty()) {
setOAuth2DeviceCodeLifespan(Integer.parseInt(lifespan));
}
String pooling = realm.getAttribute(OAUTH2_DEVICE_POLLING_INTERVAL);
if (pooling != null && !pooling.trim().isEmpty()) {
setOAuth2DevicePollingInterval(Integer.parseInt(pooling));
}
}
public int getLifespan() {
return lifespan;
}
public void setOAuth2DeviceCodeLifespan(Integer seconds) {
if (seconds == null) {
seconds = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
}
this.lifespan = seconds;
realm.get().setAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan);
}
public int getPoolingInterval() {
return poolingInterval;
}
public void setOAuth2DevicePollingInterval(Integer seconds) {
if (seconds == null) {
seconds = DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL;
}
this.poolingInterval = seconds;
RealmModel model = getRealm();
model.setAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval);
}
public int getLifespan(ClientModel client) {
String lifespan = client.getAttribute(OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT);
if (lifespan != null && !lifespan.trim().isEmpty()) {
return Integer.parseInt(lifespan);
}
return getLifespan();
}
public int getPoolingInterval(ClientModel client) {
String interval = client.getAttribute(OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT);
if (interval != null && !interval.trim().isEmpty()) {
return Integer.parseInt(interval);
}
return getPoolingInterval();
}
public boolean isOAuth2DeviceAuthorizationGrantEnabled(ClientModel client) {
String enabled = client.getAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED);
return Boolean.parseBoolean(enabled);
}
private RealmModel getRealm() {
RealmModel model = realm.get();
if (model == null) {
throw new RuntimeException("Can only update after invalidating the realm");
}
return model;
}
}

View file

@ -213,11 +213,7 @@ public interface RealmModel extends RoleContainerModel {
void setAccessCodeLifespanUserAction(int seconds); void setAccessCodeLifespanUserAction(int seconds);
int getOAuth2DeviceCodeLifespan(); OAuth2DeviceConfig getOAuth2DeviceConfig();
void setOAuth2DeviceCodeLifespan(int seconds);
int getOAuth2DevicePollingInterval();
void setOAuth2DevicePollingInterval(int seconds);
/** /**
* This method will return a map with all the lifespans available * This method will return a map with all the lifespans available

View file

@ -16,6 +16,8 @@
*/ */
package org.keycloak.forms.login.freemarker.model; package org.keycloak.forms.login.freemarker.model;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.realmOAuth2DeviceVerificationAction;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.theme.Theme; import org.keycloak.theme.Theme;
@ -110,7 +112,7 @@ public class UrlBean {
return this.actionuri.getPath(); return this.actionuri.getPath();
} }
return Urls.realmOAuth2DeviceVerificationAction(baseURI, realm).toString(); return realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
} }
public String getResourcesPath() { public String getResourcesPath() {

View file

@ -16,6 +16,10 @@
*/ */
package org.keycloak.protocol.oidc; package org.keycloak.protocol.oidc;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.approveOAuth2DeviceAuthorization;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.denyOAuth2DeviceAuthorization;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.isOAuth2DeviceVerificationFlow;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
@ -33,10 +37,10 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext; import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -118,10 +122,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String PKCE_METHOD_PLAIN = "plain"; public static final String PKCE_METHOD_PLAIN = "plain";
public static final String PKCE_METHOD_S256 = "S256"; public static final String PKCE_METHOD_S256 = "S256";
// OAuth 2.0 Device Authorization Grant
public static final String OAUTH2_DEVICE_VERIFIED_USER_CODE = "OAUTH2_DEVICE_VERIFIED_USER_CODE";
public static final String OAUTH2_DEVICE_USER_CODE_EXPIRATION = "OAUTH2_DEVICE_USER_CODE_EXPIRATION";
private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class); private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
protected KeycloakSession session; protected KeycloakSession session;
@ -191,8 +191,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession(); AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) { if (isOAuth2DeviceVerificationFlow(authSession)) {
return approveOAuth2DeviceAuthorization(authSession, clientSession); return approveOAuth2DeviceAuthorization(authSession, clientSession, session);
} }
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
@ -278,8 +278,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
@Override @Override
public Response sendError(AuthenticationSessionModel authSession, Error error) { public Response sendError(AuthenticationSessionModel authSession, Error error) {
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) { if (isOAuth2DeviceVerificationFlow(authSession)) {
return denyOAuth2DeviceAuthorization(authSession, error); return denyOAuth2DeviceAuthorization(authSession, error, session);
} }
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
@ -289,7 +289,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
String redirect = authSession.getRedirectUri(); String redirect = authSession.getRedirectUri();
String state = authSession.getClientNote(OIDCLoginProtocol.STATE_PARAM); String state = authSession.getClientNote(OIDCLoginProtocol.STATE_PARAM);
OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode); OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode);
if (error != Error.CANCELLED_AIA_SILENT) { if (error != Error.CANCELLED_AIA_SILENT) {
redirectUri.addParam(OAuth2Constants.ERROR, translateError(error)); redirectUri.addParam(OAuth2Constants.ERROR, translateError(error));
} }
@ -304,47 +304,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
return redirectUri.build(); return redirectUri.build();
} }
private Response approveOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, AuthenticatedClientSessionModel clientSession) {
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
String userSessionId = clientSession.getUserSession().getId();
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
if (!store.approve(realm, verifiedUserCode, userSessionId)) {
// Already expired and removed in the store
return Response.status(302).location(
uriBuilder.queryParam(OAuth2Constants.ERROR, OAuthErrorException.EXPIRED_TOKEN)
.build(realm.getName())
).build();
}
// Now, remove the verified user code
store.removeUserCode(realm, verifiedUserCode);
return Response.status(302).location(
uriBuilder.build(realm.getName())
).build();
}
private Response denyOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, Error error) {
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
String errorType = OAuthErrorException.SERVER_ERROR;
if (error == Error.CONSENT_DENIED) {
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
if (!store.deny(realm, verifiedUserCode)) {
// Already expired and removed in the store
errorType = OAuthErrorException.EXPIRED_TOKEN;
} else {
errorType = OAuthErrorException.ACCESS_DENIED;
}
}
return Response.status(302).location(
uriBuilder.queryParam(OAuth2Constants.ERROR, errorType)
.build(realm.getName())
).build();
}
private String translateError(Error error) { private String translateError(Error error) {
switch (error) { switch (error) {
case CANCELLED_BY_USER: case CANCELLED_BY_USER:

View file

@ -37,13 +37,11 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint; import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint; import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint; import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint;
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint;
import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint; import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
import org.keycloak.protocol.oidc.ext.OIDCExtProvider; import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
@ -52,7 +50,6 @@ import org.keycloak.services.util.CacheControlUtil;
import java.util.Objects; import java.util.Objects;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException; import javax.ws.rs.NotFoundException;
import javax.ws.rs.OPTIONS; import javax.ws.rs.OPTIONS;
@ -118,16 +115,6 @@ public class OIDCLoginProtocolService {
return uriBuilder.path(OIDCLoginProtocolService.class, "auth"); return uriBuilder.path(OIDCLoginProtocolService.class, "auth");
} }
public static UriBuilder oauth2DeviceAuthUrl(UriBuilder baseUriBuilder) {
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceAuth");
}
public static UriBuilder oauth2DeviceVerificationCompletedUrl(UriInfo uriInfo) {
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceVerificationCompleted");
}
public static UriBuilder delegatedUrl(UriInfo uriInfo) { public static UriBuilder delegatedUrl(UriInfo uriInfo) {
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo); UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete"); return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete");
@ -177,58 +164,6 @@ public class OIDCLoginProtocolService {
return endpoint; return endpoint;
} }
/**
* OAuth 2.0 Device Authorization endpoint
*/
@Path("device/auth")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
public Object oauth2DeviceAuth() {
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint;
}
/**
* Showing the result of verification process for OAuth 2.0 Device Authorization Grant.
* This outputs login success or failure messages.
*
* @param error
* @return
*/
@GET
@Path("device/verification")
public Response oauth2DeviceVerificationCompleted(@QueryParam("error") String error) {
if (!StringUtil.isNullOrEmpty(error)) {
String message;
switch (error) {
case OAuthErrorException.ACCESS_DENIED:
// cased by CANCELLED_BY_USER or CONSENT_DENIED:
message = Messages.OAUTH2_DEVICE_CONSENT_DENIED;
break;
case OAuthErrorException.EXPIRED_TOKEN:
message = Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE;
break;
default:
message = Messages.OAUTH2_DEVICE_VERIFICATION_FAILED;
}
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
String restartUri = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
return forms
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER))
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, restartUri)
.setError(message)
.createInfoPage();
} else {
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
return forms
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER))
.setAttribute(Constants.SKIP_LINK, true)
.setSuccess(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE)
.createInfoPage();
}
}
/** /**
* Registration endpoint * Registration endpoint
*/ */

View file

@ -29,7 +29,9 @@ import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientScopeModel; import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation; import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.provider.Provider; import org.keycloak.provider.Provider;
@ -58,7 +60,9 @@ import java.util.stream.Stream;
*/ */
public class OIDCWellKnownProvider implements WellKnownProvider { 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); 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);
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"); 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");
@ -99,6 +103,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setIntrospectionEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "token").path(TokenEndpoint.class, "introspect").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString()); config.setIntrospectionEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "token").path(TokenEndpoint.class, "introspect").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
config.setUserinfoEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "issueUserInfo").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString()); config.setUserinfoEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "issueUserInfo").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
config.setLogoutEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "logout").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString()); config.setLogoutEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "logout").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
config.setDeviceAuthorizationEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "auth")
.path(AuthorizationEndpoint.class, "authorizeDevice").path(DeviceEndpoint.class, "handleDeviceRequest")
.build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
URI jwksUri = backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(), URI jwksUri = backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(),
OIDCLoginProtocol.LOGIN_PROTOCOL); OIDCLoginProtocol.LOGIN_PROTOCOL);

View file

@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc.endpoints; package org.keycloak.protocol.oidc.endpoints;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor; import org.keycloak.authentication.AuthenticationProcessor;
@ -37,6 +38,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor; import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder; import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -57,6 +59,7 @@ import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
@ -116,6 +119,16 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
return process(session.getContext().getUri().getQueryParameters()); return process(session.getContext().getUri().getQueryParameters());
} }
/**
* OAuth 2.0 Device Authorization endpoint
*/
@Path("device")
public Object authorizeDevice() {
DeviceEndpoint endpoint = new DeviceEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint;
}
private Response process(MultivaluedMap<String, String> params) { private Response process(MultivaluedMap<String, String> params) {
String clientId = AuthorizationEndpointRequestParserProcessor.getClientId(event, session, params); String clientId = AuthorizationEndpointRequestParserProcessor.getClientId(event, session, params);

View file

@ -1,346 +0,0 @@
/*
* Copyright 2019 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.endpoints;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.OAuth2DeviceUserCodeModel;
import org.keycloak.models.OAuth2DeviceUserCodeProvider;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.services.util.CacheControlUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
/**
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class OAuth2DeviceAuthorizationEndpoint extends AuthorizationEndpointBase {
private static final Logger logger = Logger.getLogger(OAuth2DeviceAuthorizationEndpoint.class);
private enum Action {
OAUTH2_DEVICE_AUTH, OAUTH2_DEVICE_VERIFY_USER_CODE
}
private ClientModel client;
private AuthenticationSessionModel authenticationSession;
private Action action;
private AuthorizationEndpointRequest request;
private Cors cors;
public OAuth2DeviceAuthorizationEndpoint(RealmModel realm, EventBuilder event) {
super(realm, event);
}
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response buildPost() {
logger.trace("Processing @POST request");
cors = Cors.add(httpRequest).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
action = Action.OAUTH2_DEVICE_AUTH;
event.event(EventType.OAUTH2_DEVICE_AUTH);
return process(httpRequest.getDecodedFormParameters());
}
private Response process(MultivaluedMap<String, String> params) {
checkSsl();
checkRealm();
checkClient(null);
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, params);
if (!TokenUtil.isOIDCRequest(request.getScope())) {
ServicesLogger.LOGGER.oidcScopeMissing();
}
authenticationSession = createAuthenticationSession(client, request.getState());
updateAuthenticationSession();
// So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader();
switch (action) {
case OAUTH2_DEVICE_AUTH:
return buildDeviceAuthorizationResponse();
}
throw new RuntimeException("Unknown action " + action);
}
public Response buildDeviceAuthorizationResponse() {
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
int interval = realm.getOAuth2DevicePollingInterval();
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce(), request.getAdditionalReqParams());
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
String secret = userCodeProvider.generate();
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm,
deviceCode.getDeviceCode(),
secret
);
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
int lifespanSeconds = expiresIn + interval + 10;
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
store.put(deviceCode, userCode, lifespanSeconds);
try {
String deviceUrl = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
OAuth2DeviceAuthorizationResponse response = new OAuth2DeviceAuthorizationResponse();
response.setDeviceCode(deviceCode.getDeviceCode());
response.setUserCode(userCodeProvider.display(secret));
response.setExpiresIn(expiresIn);
response.setInterval(interval);
response.setVerificationUri(deviceUrl);
response.setVerificationUriComplete(deviceUrl + "?user_code=" + response.getUserCode());
return Response.ok(JsonSerialization.writeValueAsBytes(response)).type(MediaType.APPLICATION_JSON_TYPE).build();
} catch (Exception e) {
throw new RuntimeException("Error creating OAuth 2.0 Device Authorization Response.", e);
}
}
private void checkClient(String clientId) {
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.1
// The spec says "The client authentication requirements of Section 3.2.1 of [RFC6749]
// apply to requests on this endpoint".
if (action == Action.OAUTH2_DEVICE_AUTH) {
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event);
client = clientAuth.getClient();
clientId = client.getClientId();
}
if (clientId == null) {
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
}
event.client(clientId);
client = realm.getClientByClientId(clientId);
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND);
}
if (!client.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_DISABLED);
}
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED);
}
if (client.isBearerOnly()) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorPageException(session, authenticationSession, Response.Status.FORBIDDEN, Messages.BEARER_ONLY);
}
String protocol = client.getProtocol();
if (protocol == null) {
logger.warnf("Client '%s' doesn't have protocol set. Fallback to openid-connect. Please fix client configuration", clientId);
protocol = OIDCLoginProtocol.LOGIN_PROTOCOL;
}
if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
event.error(Errors.INVALID_CLIENT);
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, "Wrong client protocol.");
}
session.getContext().setClient(client);
}
private void updateAuthenticationSession() {
authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
if (request.getNonce() != null) authenticationSession.setClientNote(OIDCLoginProtocol.NONCE_PARAM, request.getNonce());
if (request.getMaxAge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.MAX_AGE_PARAM, String.valueOf(request.getMaxAge()));
if (request.getScope() != null) authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, request.getScope());
if (request.getLoginHint() != null) authenticationSession.setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, request.getLoginHint());
if (request.getPrompt() != null) authenticationSession.setClientNote(OIDCLoginProtocol.PROMPT_PARAM, request.getPrompt());
if (request.getIdpHint() != null) authenticationSession.setClientNote(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
if (request.getClaims()!= null) authenticationSession.setClientNote(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
if (request.getAcr() != null) authenticationSession.setClientNote(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
if (request.getDisplay() != null) authenticationSession.setAuthNote(OAuth2Constants.DISPLAY, request.getDisplay());
if (request.getAdditionalReqParams() != null) {
for (String paramName : request.getAdditionalReqParams().keySet()) {
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
}
}
}
public Response buildVerificationResponse(String userCode) {
this.event.event(EventType.LOGIN);
action = Action.OAUTH2_DEVICE_VERIFY_USER_CODE;
checkSsl();
checkRealm();
// So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader();
client = SystemClientUtil.getSystemClient(realm);
authenticationSession = createAuthenticationSession(client, null);
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
if (StringUtil.isNullOrEmpty(userCode)) {
return createVerificationPage(null);
} else {
return processVerification(userCode);
}
}
public Response processVerification(AuthenticationSessionModel authSessionWithSystemClient, String userCode) {
authenticationSession = authSessionWithSystemClient;
return processVerification(userCode);
}
public Response processVerification(String userCode) {
if (userCode == null) {
event.error(Errors.INVALID_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
}
// Format inputted user code
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
String formattedUserCode = userCodeProvider.format(userCode);
// Find the token from store
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel deviceCodeModel = store.getByUserCode(realm, formattedUserCode);
if (deviceCodeModel == null) {
event.error(Errors.INVALID_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
}
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
if (expiresIn < 0) {
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
}
// Update authentication session with requested clientId and scope from the device
updateAuthenticationSession(deviceCodeModel);
// Verification OK
authenticationSession.setClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE, formattedUserCode);
// Event logging for the verification
event.client(deviceCodeModel.getClientId())
.detail(Details.SCOPE, deviceCodeModel.getScope())
.success();
return redirectToBrowserAuthentication();
}
private AuthenticationSessionModel updateAuthenticationSession(OAuth2DeviceCodeModel deviceCode) {
checkClient(deviceCode.getClientId());
// Create request object using parameters which is used in device authorization request from the device
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, deviceCode.getParams());
// Re-create authentication session because current session doesn't have relation with the target device client
authenticationSession = createAuthenticationSession(client, null);
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
updateAuthenticationSession();
AuthenticationManager.setClientScopesInSession(authenticationSession);
return authenticationSession;
}
public Response createVerificationPage(String errorMessage) {
String execution = AuthenticatedClientSessionModel.Action.USER_CODE_VERIFICATION.name();
ClientSessionCode<AuthenticationSessionModel> accessCode = new ClientSessionCode<>(session, realm, authenticationSession);
authenticationSession.getParentSession().setTimestamp(Time.currentTime());
accessCode.setAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name());
authenticationSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution);
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authenticationSession)
.setExecution(execution)
.setClientSessionCode(accessCode.getOrGenerateCode());
if (errorMessage != null) {
provider = provider.setError(errorMessage);
}
return provider.createOAuth2DeviceVerifyUserCodePage();
}
public Response redirectToBrowserAuthentication() {
return handleBrowserAuthenticationRequest(authenticationSession, new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event), false, true);
}
}

View file

@ -39,7 +39,6 @@ import org.keycloak.common.Profile;
import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.common.constants.ServiceAccountConstants;
import org.keycloak.common.util.Base64Url; import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.Time;
import org.keycloak.constants.AdapterConstants; import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
@ -57,14 +56,13 @@ import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.AuthenticationFlowResolver; import org.keycloak.models.utils.AuthenticationFlowResolver;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper; import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
@ -74,7 +72,6 @@ import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlClient; import org.keycloak.protocol.saml.SamlClient;
import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.SamlService; import org.keycloak.protocol.saml.SamlService;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.JsonWebToken; import org.keycloak.representations.JsonWebToken;
@ -99,7 +96,6 @@ import org.keycloak.services.managers.ClientManager;
import org.keycloak.protocol.oidc.utils.OAuth2Code; import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser; import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.IdentityBrokerService; import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.services.resources.admin.AdminAuth; import org.keycloak.services.resources.admin.AdminAuth;
@ -453,11 +449,16 @@ public class TokenEndpoint {
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation // Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce()); clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce());
return codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, true);
}
public Response codeOrDeviceCodeToToken(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
String scopeParam, boolean code) {
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx); AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx) TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
.accessToken(token) .responseBuilder(realm, client, event, session, userSession, clientSessionCtx).accessToken(token)
.generateRefreshToken(); .generateRefreshToken();
// KEYCLOAK-6771 Certificate Bound Token // KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
@ -468,25 +469,30 @@ public class TokenEndpoint {
responseBuilder.getRefreshToken().setCertConf(certConf); responseBuilder.getRefreshToken().setCertConf(certConf);
} else { } else {
event.error(Errors.INVALID_REQUEST); event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST); throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
} }
} }
if (TokenUtil.isOIDCRequest(scopeParam)) { if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken().generateAccessTokenHash(); responseBuilder.generateIDToken().generateAccessTokenHash();
} }
AccessTokenResponse res = null; AccessTokenResponse res = null;
try { if (code) {
res = responseBuilder.build(); try {
} catch (RuntimeException re) { res = responseBuilder.build();
if ("can not get encryption KEK".equals(re.getMessage())) { } catch (RuntimeException re) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "can not get encryption KEK", Response.Status.BAD_REQUEST); if ("can not get encryption KEK".equals(re.getMessage())) {
} else { throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
throw re; "can not get encryption KEK", Response.Status.BAD_REQUEST);
} else {
throw re;
}
} }
} else {
res = responseBuilder.build();
} }
event.success(); event.success();
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build(); return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
@ -1375,134 +1381,8 @@ public class TokenEndpoint {
} }
public Response oauth2DeviceCodeToToken() { public Response oauth2DeviceCodeToToken() {
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) { DeviceGrantType deviceGrantType = new DeviceGrantType(formParams, client, session, this, realm, event, cors);
event.error(Errors.NOT_ALLOWED); return deviceGrantType.oauth2DeviceFlow();
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
String deviceCode = formParams.getFirst(OAuth2Constants.DEVICE_CODE);
if (deviceCode == null) {
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
}
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel deviceCodeModel = store.getByDeviceCode(realm, deviceCode);
if (deviceCodeModel == null) {
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Device code not valid", Response.Status.BAD_REQUEST);
}
if (!store.isPollingAllowed(deviceCodeModel)) {
event.error(Errors.SLOW_DOWN);
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "Slow down", Response.Status.BAD_REQUEST);
}
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
if (expiresIn < 0) {
event.error(Errors.EXPIRED_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "Device code is expired", Response.Status.BAD_REQUEST);
}
if (deviceCodeModel.isPending()) {
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is still pending", Response.Status.BAD_REQUEST);
}
if (deviceCodeModel.isDenied()) {
event.error(Errors.ACCESS_DENIED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "The end user denied the authorization request", Response.Status.BAD_REQUEST);
}
// Approved
String userSessionId = deviceCodeModel.getUserSessionId();
event.detail(Details.CODE_ID, userSessionId);
event.session(userSessionId);
// Retrieve UserSession
UserSessionModel userSession = new UserSessionCrossDCManager(session)
.getUserSessionWithClient(realm, userSessionId, client.getId());
if (userSession == null) {
userSession = session.sessions().getUserSession(realm, userSessionId);
if (userSession == null) {
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is verified but can not lookup the user session yet", Response.Status.BAD_REQUEST);
}
}
// Now, remove the device code
store.removeDeviceCode(realm, deviceCode);
UserModel user = userSession.getUser();
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found", Response.Status.BAD_REQUEST);
}
event.user(userSession.getUser());
if (!user.isEnabled()) {
event.error(Errors.USER_DISABLED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled", Response.Status.BAD_REQUEST);
}
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
event.error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Auth error", Response.Status.BAD_REQUEST);
}
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Session not active", Response.Status.BAD_REQUEST);
}
updateClientSession(clientSession);
updateUserSessionFromClientAuth(userSession);
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = deviceCodeModel.getScope();
Stream<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
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(clientSession, clientScopes, session);
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
.accessToken(token)
.generateRefreshToken();
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
responseBuilder.getRefreshToken().setCertConf(certConf);
} else {
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken();
}
AccessTokenResponse res = responseBuilder.build();
event.success();
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
} }
// https://tools.ietf.org/html/rfc7636#section-4.1 // https://tools.ietf.org/html/rfc7636#section-4.1

View file

@ -0,0 +1,290 @@
/*
* Copyright 2019 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.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;
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.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakUriInfo;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
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;
import javax.ws.rs.core.UriInfo;
import java.net.URI;
import java.util.stream.Stream;
/**
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
* @author <a href="mailto:michito.okai.zn@hitachi.com">Michito Okai</a>
*/
public class DeviceGrantType {
// OAuth 2.0 Device Authorization Grant
public static final String OAUTH2_DEVICE_VERIFIED_USER_CODE = "OAUTH2_DEVICE_VERIFIED_USER_CODE";
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
public static final String OAUTH2_USER_CODE_VERIFY = "device/verify";
public static UriBuilder oauth2DeviceVerificationUrl(UriInfo uriInfo) {
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
return baseUriBuilder.path(RealmsResource.class).path("{realm}").path("device");
}
public static URI realmOAuth2DeviceVerificationAction(URI baseUri, String realmName) {
return UriBuilder.fromUri(baseUri).path(RealmsResource.class).path("{realm}").path("device")
.build(realmName);
}
public static UriBuilder oauth2DeviceAuthUrl(UriBuilder baseUriBuilder) {
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
return uriBuilder.path(OIDCLoginProtocolService.class, "auth").path(AuthorizationEndpoint.class, "authorizeDevice")
.path(DeviceEndpoint.class, "handleDeviceRequest");
}
public static UriBuilder oauth2DeviceVerificationCompletedUrl(UriInfo baseUri) {
return baseUri.getBaseUriBuilder().path(RealmsResource.class).path("{realm}").path("device").path("status");
}
public static Response denyOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, LoginProtocol.Error error, KeycloakSession session) {
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
KeycloakUriInfo uri = context.getUri();
UriBuilder uriBuilder = DeviceGrantType.oauth2DeviceVerificationCompletedUrl(uri);
String errorType = OAuthErrorException.SERVER_ERROR;
if (error == LoginProtocol.Error.CONSENT_DENIED) {
String verifiedUserCode = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
if (!store.deny(realm, verifiedUserCode)) {
// Already expired and removed in the store
errorType = OAuthErrorException.EXPIRED_TOKEN;
} else {
errorType = OAuthErrorException.ACCESS_DENIED;
}
}
return Response.status(302).location(
uriBuilder.queryParam(OAuth2Constants.ERROR, errorType)
.build(realm.getName())
).build();
}
public static Response approveOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, AuthenticatedClientSessionModel clientSession, KeycloakSession session) {
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
KeycloakUriInfo uriInfo = context.getUri();
UriBuilder uriBuilder = DeviceGrantType.oauth2DeviceVerificationCompletedUrl(uriInfo);
String verifiedUserCode = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
String userSessionId = clientSession.getUserSession().getId();
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
if (!store.approve(realm, verifiedUserCode, userSessionId)) {
// Already expired and removed in the store
return Response.status(302).location(
uriBuilder.queryParam(OAuth2Constants.ERROR, OAuthErrorException.EXPIRED_TOKEN)
.build(realm.getName())
).build();
}
// Now, remove the verified user code
store.removeUserCode(realm, verifiedUserCode);
return Response.status(302).location(
uriBuilder.build(realm.getName())
).build();
}
public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSessionModel authSession) {
String flow = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
return flow != null;
}
private MultivaluedMap<String, String> formParams;
private ClientModel client;
private KeycloakSession session;
private TokenEndpoint tokenEndpoint;
private final RealmModel realm;
private final EventBuilder event;
private Cors cors;
public DeviceGrantType(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 oauth2DeviceFlow() {
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT,
"Client not allowed OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
String deviceCode = formParams.getFirst(OAuth2Constants.DEVICE_CODE);
if (deviceCode == null) {
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
}
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceCodeModel deviceCodeModel = store.getByDeviceCode(realm, deviceCode);
if (deviceCodeModel == null) {
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Device code not valid",
Response.Status.BAD_REQUEST);
}
if (deviceCodeModel.isExpired()) {
event.error(Errors.EXPIRED_OAUTH2_DEVICE_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "Device code is expired",
Response.Status.BAD_REQUEST);
}
if (!store.isPollingAllowed(deviceCodeModel)) {
event.error(Errors.SLOW_DOWN);
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "Slow down", Response.Status.BAD_REQUEST);
}
if (deviceCodeModel.isDenied()) {
event.error(Errors.ACCESS_DENIED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED,
"The end user denied the authorization request", Response.Status.BAD_REQUEST);
}
if (deviceCodeModel.isPending()) {
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING,
"The authorization request is still pending", Response.Status.BAD_REQUEST);
}
// Approved
String userSessionId = deviceCodeModel.getUserSessionId();
event.detail(Details.CODE_ID, userSessionId);
event.session(userSessionId);
// Retrieve UserSession
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, userSessionId,
client.getId());
if (userSession == null) {
userSession = session.sessions().getUserSession(realm, userSessionId);
if (userSession == null) {
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING,
"The authorization request is verified but can not lookup the user session yet",
Response.Status.BAD_REQUEST);
}
}
// Now, remove the device code
store.removeDeviceCode(realm, deviceCode);
UserModel user = userSession.getUser();
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found",
Response.Status.BAD_REQUEST);
}
event.user(userSession.getUser());
if (!user.isEnabled()) {
event.error(Errors.USER_DISABLED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled",
Response.Status.BAD_REQUEST);
}
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
event.error(Errors.INVALID_CODE);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Auth error",
Response.Status.BAD_REQUEST);
}
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Session not active",
Response.Status.BAD_REQUEST);
}
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = deviceCodeModel.getScope();
Stream<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
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(clientSession,
clientScopes, session);
// 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);
}
}

View file

@ -0,0 +1,367 @@
/*
* Copyright 2019 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.device.endpoints;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.Time;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.OAuth2DeviceUserCodeModel;
import org.keycloak.models.OAuth2DeviceUserCodeProvider;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resources.SessionCodeChecks;
import org.keycloak.services.util.CacheControlUtil;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.HttpMethod;
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 static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.OAUTH2_DEVICE_USER_CODE;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.OAUTH2_USER_CODE_VERIFY;
import static org.keycloak.services.resources.LoginActionsService.SESSION_CODE;
/**
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class DeviceEndpoint extends AuthorizationEndpointBase implements RealmResourceProvider {
protected static final Logger logger = Logger.getLogger(DeviceEndpoint.class);
public DeviceEndpoint(RealmModel realm, EventBuilder event) {
super(realm, event);
}
/**
* Handles device authorization requests.
*
* @return the device authorization response.
*/
@Path("")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
public Response handleDeviceRequest() {
logger.trace("Processing @POST request");
event.event(EventType.OAUTH2_DEVICE_AUTH);
checkSsl();
checkRealm();
ClientModel client = authenticateClient();
AuthorizationEndpointRequest request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client,
httpRequest.getDecodedFormParameters());
if (!TokenUtil.isOIDCRequest(request.getScope())) {
ServicesLogger.LOGGER.oidcScopeMissing();
}
// So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader();
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT,
"Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
int expiresIn = realm.getOAuth2DeviceConfig().getLifespan(client);
int interval = realm.getOAuth2DeviceConfig().getPoolingInterval(client);
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce(), expiresIn, interval,
request.getAdditionalReqParams());
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
String secret = userCodeProvider.generate();
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(), secret);
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
int lifespanSeconds = expiresIn + interval + 10;
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
store.put(deviceCode, userCode, lifespanSeconds);
try {
String deviceUrl = DeviceGrantType.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName())
.toString();
OAuth2DeviceAuthorizationResponse response = new OAuth2DeviceAuthorizationResponse();
response.setDeviceCode(deviceCode.getDeviceCode());
response.setUserCode(userCodeProvider.display(secret));
response.setExpiresIn(expiresIn);
response.setInterval(interval);
response.setVerificationUri(deviceUrl);
response.setVerificationUriComplete(deviceUrl + "?user_code=" + response.getUserCode());
return Response.ok(JsonSerialization.writeValueAsBytes(response)).type(MediaType.APPLICATION_JSON_TYPE).build();
} catch (Exception e) {
throw new RuntimeException("Error creating OAuth 2.0 Device Authorization Response.", e);
}
}
/**
* This endpoint is used by end-users to start the flow to authorize a device.
*
* @param userCode the user code to authorize
* @return
*/
@GET
public Response verifyUserCode(@QueryParam("user_code") String userCode) {
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
checkSsl();
checkRealm();
// So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader();
// code is not known, we can infer the client neither. ask the user to provide the code.
if (StringUtil.isNullOrEmpty(userCode)) {
return createVerificationPage(null);
} else {
// code exists, probably due to using a verification_uri_complete. Start the authentication considering the client
// that started the flow.
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
String formattedUserCode = userCodeProvider.format(userCode);
OAuth2DeviceCodeModel deviceCode = store.getByUserCode(realm, formattedUserCode);
if (deviceCode == null) {
event.error(Errors.INVALID_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
}
if (deviceCode.isExpired()) {
event.error(Errors.INVALID_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE);
}
return processVerification(deviceCode, formattedUserCode);
}
}
/**
* Verifies the code provided by the end-user and start the authentication.
*
* @return
*/
@Path("/")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response verifyUserCode() {
MultivaluedMap<String, String> formData = httpRequest.getDecodedFormParameters();
return verifyUserCode(formData.getFirst(OAUTH2_DEVICE_USER_CODE));
}
/**
* Showing the result of verification process for OAuth 2.0 Device Authorization Grant. This outputs login success or
* failure messages.
*
* @param error
* @return
*/
@Path("status")
@GET
public Response status(@QueryParam("error") String error) {
if (!StringUtil.isNullOrEmpty(error)) {
String message;
switch (error) {
case OAuthErrorException.ACCESS_DENIED:
// cased by CANCELLED_BY_USER or CONSENT_DENIED:
message = Messages.OAUTH2_DEVICE_CONSENT_DENIED;
break;
case OAuthErrorException.EXPIRED_TOKEN:
message = Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE;
break;
default:
message = Messages.OAUTH2_DEVICE_VERIFICATION_FAILED;
}
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
String restartUri = DeviceGrantType.oauth2DeviceVerificationUrl(session.getContext().getUri())
.build(realm.getName()).toString();
return forms.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER))
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, restartUri).setError(message).createInfoPage();
} else {
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
return forms.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER))
.setAttribute(Constants.SKIP_LINK, true).setSuccess(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE)
.createInfoPage();
}
}
private Response createVerificationPage(String errorMessage) {
String execution = AuthenticatedClientSessionModel.Action.USER_CODE_VERIFICATION.name();
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
.setExecution(execution);
if (errorMessage != null) {
provider = provider.setError(errorMessage);
}
return provider.createOAuth2DeviceVerifyUserCodePage();
}
private Response processVerification(OAuth2DeviceCodeModel deviceCode, String userCode) {
int expiresIn = deviceCode.getExpiration() - Time.currentTime();
if (expiresIn < 0) {
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
}
ClientModel client = realm.getClientByClientId(deviceCode.getClientId());
AuthenticationSessionModel authenticationSession = createAuthenticationSession(client);
// Verification OK
authenticationSession.setClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE, userCode);
// Event logging for the verification
event.client(deviceCode.getClientId()).detail(Details.SCOPE, deviceCode.getScope()).success();
OIDCLoginProtocol protocol = new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event);
return handleBrowserAuthenticationRequest(authenticationSession, protocol, false, true);
}
@Override
public Object getResource() {
return this;
}
@Override
public void close() {
}
private ClientModel authenticateClient() {
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.1
// The spec says "The client authentication requirements of Section 3.2.1 of [RFC6749]
// apply to requests on this endpoint".
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, null);
ClientModel client = clientAuth.getClient();
if (client == null) {
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, Messages.MISSING_PARAMETER,
OIDCLoginProtocol.CLIENT_ID_PARAM);
}
checkClient(client.getClientId());
return client;
}
private ClientModel checkClient(String clientId) {
if (clientId == null) {
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
}
event.client(clientId);
ClientModel client = realm.getClientByClientId(clientId);
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
Messages.CLIENT_NOT_FOUND);
}
if (!client.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, Messages.CLIENT_DISABLED);
}
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
Messages.OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED);
}
if (client.isBearerOnly()) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorPageException(session, null, Response.Status.FORBIDDEN, Messages.BEARER_ONLY);
}
String protocol = client.getProtocol();
if (protocol == null) {
logger.warnf("Client '%s' doesn't have protocol set. Fallback to openid-connect. Please fix client configuration",
clientId);
protocol = OIDCLoginProtocol.LOGIN_PROTOCOL;
}
if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
event.error(Errors.INVALID_CLIENT);
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
}
session.getContext().setClient(client);
return client;
}
protected AuthenticationSessionModel createAuthenticationSession(ClientModel client) {
AuthenticationSessionModel authenticationSession = super.createAuthenticationSession(client, null);
authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER,
Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
return authenticationSession;
}
}

View file

@ -0,0 +1,66 @@
/*
*
* * 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.device.endpoints;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.Config;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
/**
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
public class DeviceEndpointFactory implements RealmResourceProviderFactory {
@Override
public RealmResourceProvider create(KeycloakSession session) {
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
EventBuilder event = new EventBuilder(realm, session, context.getConnection());
DeviceEndpoint provider = new DeviceEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(provider);
return provider;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "device";
}
}

View file

@ -245,10 +245,6 @@ public class Urls {
return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmName); return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmName);
} }
public static URI realmOAuth2DeviceVerificationAction(URI baseUri, String realmName) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "processOAuth2DeviceVerification").build(realmName);
}
public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) { public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet") return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
.build(realmName); .build(realmName);
@ -262,7 +258,7 @@ public class Urls {
return themeBase(baseUri).path(Version.RESOURCES_VERSION).build(); return themeBase(baseUri).path(Version.RESOURCES_VERSION).build();
} }
private static UriBuilder loginActionsBase(URI baseUri) { public static UriBuilder loginActionsBase(URI baseUri) {
return realmBase(baseUri).path(RealmsResource.class, "getLoginActionsService"); return realmBase(baseUri).path(RealmsResource.class, "getLoginActionsService");
} }

View file

@ -52,6 +52,8 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.keycloak.models.OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
@ -312,7 +314,8 @@ public class DescriptionConverter {
if (client.isServiceAccountsEnabled()) { if (client.isServiceAccountsEnabled()) {
grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS); grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS);
} }
if (client.isOAuth2DeviceAuthorizationGrantEnabled()) { boolean oauth2DeviceEnabled = client.getAttributes() != null && Boolean.parseBoolean(client.getAttributes().get(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED));
if (oauth2DeviceEnabled) {
grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE); grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
} }
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) { if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {

View file

@ -81,8 +81,6 @@ public class ApplianceBootstrap {
realm.setAccessCodeLifespan(60); realm.setAccessCodeLifespan(60);
realm.setAccessCodeLifespanUserAction(300); realm.setAccessCodeLifespanUserAction(300);
realm.setAccessCodeLifespanLogin(1800); realm.setAccessCodeLifespanLogin(1800);
realm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
realm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
realm.setSslRequired(SslRequired.EXTERNAL); realm.setSslRequired(SslRequired.EXTERNAL);
realm.setRegistrationAllowed(false); realm.setRegistrationAllowed(false);
realm.setRegistrationEmailAsUsername(false); realm.setRegistrationEmailAsUsername(false);

View file

@ -62,7 +62,6 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.SessionTimeoutHelper; import org.keycloak.models.utils.SessionTimeoutHelper;
import org.keycloak.models.utils.SystemClientUtil; import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error; import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.oidc.BackchannelLogoutResponse; import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
@ -90,7 +89,6 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.net.URI; import java.net.URI;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
@ -102,6 +100,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import static org.keycloak.common.util.ServerCookie.SameSiteAttributeValue; import static org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.isOAuth2DeviceVerificationFlow;
import static org.keycloak.services.util.CookieHelper.getCookie; import static org.keycloak.services.util.CookieHelper.getCookie;
/** /**
@ -1080,11 +1079,6 @@ public class AuthenticationManager {
} }
public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSessionModel authSession) {
String flow = authSession.getClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW);
return flow != null && flow.equals(LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
}
private static List<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent, private static List<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent,
AuthenticationSessionModel authSession) { AuthenticationSessionModel authSession) {
// Client Scopes to be displayed on consent screen // Client Scopes to be displayed on consent screen

View file

@ -18,7 +18,6 @@ package org.keycloak.services.resources;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowException; import org.keycloak.authentication.AuthenticationFlowException;
@ -68,7 +67,6 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error; import org.keycloak.protocol.LoginProtocol.Error;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode; import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.RedirectUtils; import org.keycloak.protocol.oidc.utils.RedirectUtils;
@ -80,6 +78,7 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager; import org.keycloak.services.managers.AuthenticationSessionManager;
import org.keycloak.services.managers.ClientSessionCode; import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.util.AuthenticationFlowURLHelper; import org.keycloak.services.util.AuthenticationFlowURLHelper;
import org.keycloak.services.util.BrowserHistoryHelper; import org.keycloak.services.util.BrowserHistoryHelper;
import org.keycloak.services.util.CacheControlUtil; import org.keycloak.services.util.CacheControlUtil;
@ -120,7 +119,6 @@ public class LoginActionsService {
public static final String REQUIRED_ACTION = "required-action"; public static final String REQUIRED_ACTION = "required-action";
public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login"; public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login";
public static final String POST_BROKER_LOGIN_PATH = "post-broker-login"; public static final String POST_BROKER_LOGIN_PATH = "post-broker-login";
public static final String OAUTH2_DEVICE_VERIFICATION_PATH = "verification";
public static final String RESTART_PATH = "restart"; public static final String RESTART_PATH = "restart";
@ -131,8 +129,6 @@ public class LoginActionsService {
public static final String CANCEL_AIA = "cancel-aia"; public static final String CANCEL_AIA = "cancel-aia";
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
private RealmModel realm; private RealmModel realm;
@Context @Context
@ -850,36 +846,6 @@ public class LoginActionsService {
return Response.status(302).location(redirect).build(); return Response.status(302).location(redirect).build();
} }
/**
* Verifying user code page. You should not invoked this directly!
*
* @param formData
* @return
*/
@Path("verification")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response processOAuth2DeviceVerification(final MultivaluedMap<String, String> formData) {
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
String code = formData.getFirst(SESSION_CODE);
String tabId = session.getContext().getUri().getQueryParameters().getFirst(Constants.TAB_ID);
String systemClientId = SystemClientUtil.getSystemClient(realm).getClientId();
SessionCodeChecks checks = checksForCode(null, code, null, systemClientId, tabId, OAUTH2_DEVICE_VERIFICATION_PATH);
if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name(), ClientSessionCode.ActionType.LOGIN)) {
return checks.getResponse();
}
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
AuthenticationSessionModel authSessionWithSystemClient = checks.getAuthenticationSession();
String userCode = formData.getFirst(OAUTH2_DEVICE_USER_CODE);
return endpoint.processVerification(authSessionWithSystemClient, userCode);
}
/** /**
* OAuth grant page. You should not invoked this directly! * OAuth grant page. You should not invoked this directly!
* *

View file

@ -31,7 +31,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.protocol.LoginProtocol; import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory; import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
import org.keycloak.services.clientregistration.ClientRegistrationService; import org.keycloak.services.clientregistration.ClientRegistrationService;
import org.keycloak.services.managers.RealmManager; import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resource.RealmResourceProvider; import org.keycloak.services.resource.RealmResourceProvider;
@ -101,15 +100,6 @@ public class RealmsResource {
return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService"); return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService");
} }
public static UriBuilder oauth2DeviceVerificationUrl(UriInfo uriInfo) {
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
return oauth2DeviceVerificationUrl(baseUriBuilder);
}
public static UriBuilder oauth2DeviceVerificationUrl(UriBuilder baseUriBuilder) {
return baseUriBuilder.path(RealmsResource.class).path(RealmsResource.class, "getOAuth2DeviceVerificationService");
}
public static UriBuilder wellKnownProviderUrl(UriBuilder builder) { public static UriBuilder wellKnownProviderUrl(UriBuilder builder) {
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown"); return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
} }
@ -279,18 +269,6 @@ public class RealmsResource {
return service; return service;
} }
@GET
@Path("{realm}/device")
public Object getOAuth2DeviceVerificationService(@PathParam("realm") String realmName, @QueryParam("user_code") String userCode) {
RealmModel realm = init(realmName);
EventBuilder event = new EventBuilder(realm, session, clientConnection);
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
return endpoint.buildVerificationResponse(userCode);
}
/** /**
* A JAX-RS sub-resource locator that uses the {@link org.keycloak.services.resource.RealmResourceSPI} to resolve sub-resources instances given an <code>unknownPath</code>. * A JAX-RS sub-resource locator that uses the {@link org.keycloak.services.resource.RealmResourceSPI} to resolve sub-resources instances given an <code>unknownPath</code>.
* *
@ -299,9 +277,9 @@ public class RealmsResource {
*/ */
@Path("{realm}/{extension}") @Path("{realm}/{extension}")
public Object resolveRealmExtension(@PathParam("realm") String realmName, @PathParam("extension") String extension) { public Object resolveRealmExtension(@PathParam("realm") String realmName, @PathParam("extension") String extension) {
init(realmName);
RealmResourceProvider provider = session.getProvider(RealmResourceProvider.class, extension); RealmResourceProvider provider = session.getProvider(RealmResourceProvider.class, extension);
if (provider != null) { if (provider != null) {
init(realmName);
Object resource = provider.getResource(); Object resource = provider.getResource();
if (resource != null) { if (resource != null) {
return resource; return resource;

View file

@ -0,0 +1,17 @@
#
# Copyright 2016 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.protocol.oidc.grants.device.endpoints.DeviceEndpointFactory

View file

@ -49,7 +49,7 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
@Override @Override
public boolean isCurrent() { public boolean isCurrent() {
if (driver.getTitle().startsWith("Log in to ")) { if (driver.getTitle().startsWith("Sign in to ")) {
try { try {
driver.findElement(By.id("device-user-code")); driver.findElement(By.id("device-user-code"));
return true; return true;
@ -59,38 +59,23 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
return false; return false;
} }
public void assertLoginPage() {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected device login page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isLoginPage());
}
public void assertApprovedPage() { public void assertApprovedPage() {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")", Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isApprovedPage()); isApprovedPage());
} }
public void assertDeniedPage() { public void assertDeniedPage() {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")", Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isDeniedPage()); isDeniedPage());
} }
private boolean isLoginPage() { public void assertInvalidUserCodePage() {
if (driver.getTitle().startsWith("Log in to ")) { Assert.assertTrue("Expected invalid user code page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
try { isInvalidUserCodePage());
driver.findElement(By.id("username"));
driver.findElement(By.id("password"));
return true;
} catch (Throwable t) {
}
}
return false;
} }
private boolean isApprovedPage() { private boolean isApprovedPage() {
if (driver.getTitle().startsWith("Log in to ")) { if (driver.getTitle().startsWith("Sign in to ")) {
try { try {
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful"); driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful");
return true; return true;
@ -101,7 +86,7 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
} }
private boolean isDeniedPage() { private boolean isDeniedPage() {
if (driver.getTitle().startsWith("Log in to ")) { if (driver.getTitle().startsWith("Sign in to ")) {
try { try {
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Failed"); driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Failed");
driver.findElement(By.className("instruction")).getText().equals("Consent denied for connecting the device."); driver.findElement(By.className("instruction")).getText().equals("Consent denied for connecting the device.");
@ -112,6 +97,19 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
return false; return false;
} }
private boolean isInvalidUserCodePage() {
if (driver.getTitle().startsWith("Sign in to ")) {
try {
driver.findElement(By.id("device-user-code"));
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login");
driver.findElement(By.className("kc-feedback-text")).getText().equals("Invalid code, please try again.");
return true;
} catch (Throwable t) {
}
}
return false;
}
@Override @Override
public void open() { public void open() {
} }

View file

@ -17,6 +17,7 @@
package org.keycloak.testsuite.util; package org.keycloak.testsuite.util;
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.oauth2DeviceAuthUrl;
import static org.keycloak.testsuite.admin.Users.getPasswordOf; import static org.keycloak.testsuite.admin.Users.getPasswordOf;
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot; import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts; import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
@ -60,6 +61,7 @@ import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; 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.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
@ -68,8 +70,6 @@ import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken; import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo; import org.keycloak.representations.UserInfo;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
import org.keycloak.testsuite.runonserver.RunOnServerException; import org.keycloak.testsuite.runonserver.RunOnServerException;
import org.keycloak.util.BasicAuthHelper; import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization; import org.keycloak.util.JsonSerialization;
@ -1058,11 +1058,6 @@ public class OAuthClient {
driver.navigate().to(getLoginFormUrl()); driver.navigate().to(getLoginFormUrl());
} }
public void openOAuth2DeviceVerificationForm() {
UriBuilder b = RealmsResource.oauth2DeviceVerificationUrl(UriBuilder.fromUri(baseUrl));
openOAuth2DeviceVerificationForm(b.build(realm).toString());
}
public void openOAuth2DeviceVerificationForm(String verificationUri) { public void openOAuth2DeviceVerificationForm(String verificationUri) {
driver.navigate().to(verificationUri); driver.navigate().to(verificationUri);
} }
@ -1206,7 +1201,7 @@ public class OAuthClient {
} }
public String getDeviceAuthorizationUrl() { public String getDeviceAuthorizationUrl() {
UriBuilder b = OIDCLoginProtocolService.oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl)); UriBuilder b = oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
return b.build(realm).toString(); return b.build(realm).toString();
} }

View file

@ -31,6 +31,7 @@ import org.keycloak.events.admin.OperationType;
import org.keycloak.events.admin.ResourceType; import org.keycloak.events.admin.ResourceType;
import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory; import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory;
import org.keycloak.models.Constants; import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlProtocol; import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.adapters.action.GlobalRequestResult; import org.keycloak.representations.adapters.action.GlobalRequestResult;
@ -181,7 +182,8 @@ public class RealmTest extends AbstractAdminTest {
Map<String, String> attributes = rep2.getAttributes(); Map<String, String> attributes = rep2.getAttributes();
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()), assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),
attributes.size() == 2 && attributes.containsKey("oauth2DeviceCodeLifespan") && attributes.containsKey("oauth2DevicePollingInterval")); attributes.size() == 2 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN)
&& attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL));
} finally { } finally {
adminClient.realm("attributes").remove(); adminClient.realm("attributes").remove();
} }

View file

@ -16,17 +16,27 @@
*/ */
package org.keycloak.testsuite.oauth; package org.keycloak.testsuite.oauth;
import static org.junit.Assert.assertNotNull;
import static org.keycloak.models.OAuth2DeviceConfig.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
import static org.keycloak.models.OAuth2DeviceConfig.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL;
import org.jboss.arquillian.graphene.page.Page; import org.jboss.arquillian.graphene.page.Page;
import org.junit.*; import org.junit.Before;
import org.keycloak.events.Details; import org.junit.Rule;
import org.junit.Test;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage; import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
import org.keycloak.testsuite.pages.OAuthGrantPage; import org.keycloak.testsuite.pages.OAuthGrantPage;
@ -34,7 +44,6 @@ import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import java.util.List; import java.util.List;
@ -72,19 +81,11 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
ClientRepresentation app = ClientBuilder.create() ClientRepresentation app = ClientBuilder.create()
.id(KeycloakModelUtils.generateId()) .id(KeycloakModelUtils.generateId())
.clientId("test-device") .clientId("test-device")
.oauth2DeviceAuthorizationGrant()
.secret("secret") .secret("secret")
.attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
.build(); .build();
realm.client(app); realm.client(app);
ClientRepresentation app2 = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("test-device-public")
.oauth2DeviceAuthorizationGrant()
.publicClient()
.build();
realm.client(app2);
userId = KeycloakModelUtils.generateId(); userId = KeycloakModelUtils.generateId();
UserRepresentation user = UserBuilder.create() UserRepresentation user = UserBuilder.create()
.id(userId) .id(userId)
@ -98,26 +99,26 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
} }
@Before @Before
public void resetConifg() { public void resetConfig() {
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation(); RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
realm.setOAuth2DeviceCodeLifespan(600); realm.setOAuth2DeviceCodeLifespan(60);
realm.setOAuth2DevicePollingInterval(5); realm.setOAuth2DevicePollingInterval(5);
getAdminClient().realm(REALM_NAME).update(realm); getAdminClient().realm(REALM_NAME).update(realm);
} }
@Test @Test
public void publicClientTest() throws Exception { public void testConfidentialClient() throws Exception {
// Device Authorization Request from device // Device Authorization Request from device
oauth.realm(REALM_NAME); oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP_PUBLIC); oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC, null); OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode()); Assert.assertEquals(200, response.getStatusCode());
Assert.assertNotNull(response.getDeviceCode()); assertNotNull(response.getDeviceCode());
Assert.assertNotNull(response.getUserCode()); assertNotNull(response.getUserCode());
Assert.assertNotNull(response.getVerificationUri()); assertNotNull(response.getVerificationUri());
Assert.assertNotNull(response.getVerificationUriComplete()); assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(600, response.getExpiresIn()); Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval()); Assert.assertEquals(5, response.getInterval());
// Verify user code from verification page using browser // Verify user code from verification page using browser
@ -125,10 +126,7 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertCurrent(); verificationPage.assertCurrent();
verificationPage.submit(response.getUserCode()); verificationPage.submit(response.getUserCode());
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP_PUBLIC).assertEvent(); loginPage.assertCurrent();
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
verificationPage.assertLoginPage();
// Do Login // Do Login
oauth.fillLoginForm("device-login", "password"); oauth.fillLoginForm("device-login", "password");
@ -140,47 +138,148 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertApprovedPage(); verificationPage.assertApprovedPage();
events.expectDeviceLogin(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
// Token request from device // Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP_PUBLIC, null, response.getDeviceCode()); OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(200, tokenResponse.getStatusCode()); Assert.assertEquals(200, tokenResponse.getStatusCode());
String tokenString = tokenResponse.getAccessToken(); String tokenString = tokenResponse.getAccessToken();
Assert.assertNotNull(tokenString); assertNotNull(tokenString);
AccessToken token = oauth.verifyToken(tokenString); AccessToken token = oauth.verifyToken(tokenString);
// Check receiving access token which is bound to the user session of the verification process assertNotNull(token);
Assert.assertTrue(codeId.equals(token.getSessionState()));
events.expectDeviceCodeToToken(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
} }
@Test @Test
public void confidentialClientTest() throws Exception { public void testConsentCancel() throws Exception {
// Device Authorization Request from device // Device Authorization Request from device
oauth.realm(REALM_NAME); oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP); oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret"); OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode()); Assert.assertEquals(200, response.getStatusCode());
Assert.assertNotNull(response.getDeviceCode()); assertNotNull(response.getDeviceCode());
Assert.assertNotNull(response.getUserCode()); assertNotNull(response.getUserCode());
Assert.assertNotNull(response.getVerificationUri()); assertNotNull(response.getVerificationUri());
Assert.assertNotNull(response.getVerificationUriComplete()); assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(600, response.getExpiresIn()); Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
openVerificationPage(response.getVerificationUriComplete());
loginPage.assertCurrent();
// Do Login
oauth.fillLoginForm("device-login", "password");
// Consent
grantPage.assertCurrent();
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
grantPage.cancel();
verificationPage.assertDeniedPage();
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("access_denied", tokenResponse.getError());
}
@Test
public void testInvalidUserCode() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval()); Assert.assertEquals(5, response.getInterval());
// Verify user code from verification page using browser
openVerificationPage(response.getVerificationUri()); openVerificationPage(response.getVerificationUri());
verificationPage.assertCurrent(); verificationPage.submit("x");
verificationPage.submit(response.getUserCode());
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP).assertEvent(); verificationPage.assertInvalidUserCodePage();
String codeId = verifyEvent.getDetails().get(Details.CODE_ID); }
verificationPage.assertLoginPage(); @Test
public void testExpiredUserCodeTest() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
try {
setTimeOffset(610);
openVerificationPage(response.getVerificationUriComplete());
} finally {
resetTimeOffset();
}
verificationPage.assertInvalidUserCodePage();
}
@Test
public void testInvalidDeviceCode() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
openVerificationPage(response.getVerificationUriComplete());
loginPage.assertCurrent();
// Do Login
oauth.fillLoginForm("device-login", "password");
// Consent
grantPage.assertCurrent();
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
grantPage.accept();
// Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", "x");
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("invalid_grant", tokenResponse.getError());
}
@Test
public void testSuccessVerificationUriComplete() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
openVerificationPage(response.getVerificationUriComplete());
loginPage.assertCurrent();
// Do Login // Do Login
oauth.fillLoginForm("device-login", "password"); oauth.fillLoginForm("device-login", "password");
@ -192,95 +291,251 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertApprovedPage(); verificationPage.assertApprovedPage();
events.expectDeviceLogin(DEVICE_APP, codeId, userId).assertEvent();
// Token request from device // Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(200, tokenResponse.getStatusCode()); Assert.assertEquals(200, tokenResponse.getStatusCode());
String tokenString = tokenResponse.getAccessToken();
Assert.assertNotNull(tokenString);
AccessToken token = oauth.verifyToken(tokenString);
// Check receiving access token which is bound to the user session of the verification process
Assert.assertTrue(codeId.equals(token.getSessionState()));
events.expectDeviceCodeToToken(DEVICE_APP, codeId, userId).assertEvent();
} }
@Test @Test
public void pollingTest() throws Exception { public void testExpiredDeviceCode() throws Exception {
// Device Authorization Request from device // Device Authorization Request from device
oauth.realm(REALM_NAME); oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP); oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret"); OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(600, response.getExpiresIn()); Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval()); Assert.assertEquals(5, response.getInterval());
// Polling token request from device try {
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); setTimeOffset(610);
// Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret",
response.getDeviceCode());
// Not approved yet Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals(400, tokenResponse.getStatusCode()); Assert.assertEquals("expired_token", tokenResponse.getError());
Assert.assertEquals("authorization_pending", tokenResponse.getError()); } finally {
resetTimeOffset();
}
}
// Polling again without waiting @Test
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); public void testDeviceCodeLifespanPerClient() throws Exception {
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), DEVICE_APP);
ClientRepresentation clientRepresentation = client.toRepresentation();
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
// Slow down Assert.assertEquals(200, response.getStatusCode());
Assert.assertEquals(400, tokenResponse.getStatusCode()); assertNotNull(response.getDeviceCode());
Assert.assertEquals("slow_down", tokenResponse.getError()); assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
// Wait the interval clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT, "120");
WaitUtils.pause(5000); clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "600000");
client.update(clientRepresentation);
// Polling again
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Change the interval setting of the realm from 5 seconds to 10 seconds.
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
realm.setOAuth2DevicePollingInterval(10);
getAdminClient().realm(REALM_NAME).update(realm);
// Checking the new interval is applied
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret"); response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(120, response.getExpiresIn());
OAuthClient.AccessTokenResponse tokenResponse;
Assert.assertEquals(600, response.getExpiresIn()); try {
setTimeOffset(100);
// Token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
setTimeOffset(125);
// Token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("expired_token", tokenResponse.getError());
} finally {
resetTimeOffset();
}
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT, "");
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "");
client.update(clientRepresentation);
}
@Test
public void testDevicePollingIntervalPerClient() throws Exception {
getTestingClient().testing().setTestingInfinispanTimeService();
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), DEVICE_APP);
ClientRepresentation clientRepresentation = client.toRepresentation();
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "10");
client.update(clientRepresentation);
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(10, response.getInterval()); Assert.assertEquals(10, response.getInterval());
// Polling token request from device try {
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); // Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret",
response.getDeviceCode());
// Not approved yet Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals(400, tokenResponse.getStatusCode()); Assert.assertEquals("authorization_pending", tokenResponse.getError());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Wait // Token request from device
WaitUtils.pause(5000); tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Polling again without waiting Assert.assertEquals(400, tokenResponse.getStatusCode());
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); Assert.assertEquals("slow_down", tokenResponse.getError());
// Slow down setTimeOffset(7);
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
// Wait // Token request from device
WaitUtils.pause(5000); tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Polling again Assert.assertEquals(400, tokenResponse.getStatusCode());
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); Assert.assertEquals("slow_down", tokenResponse.getError());
// Not approved yet setTimeOffset(10);
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError()); // Token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
} finally {
getTestingClient().testing().revertTestingInfinispanTimeService();
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "");
client.update(clientRepresentation);
}
}
@Test
public void testPooling() throws Exception {
getTestingClient().testing().setTestingInfinispanTimeService();
try {
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
realm.setOAuth2DeviceCodeLifespan(600);
getAdminClient().realm(REALM_NAME).update(realm);
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(600, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
// Polling token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Polling again without waiting
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Slow down
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
// Wait the interval
setTimeOffset(5);
// Polling again
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Change the interval setting of the realm from 5 seconds to 10 seconds.
realm.setOAuth2DevicePollingInterval(10);
getAdminClient().realm(REALM_NAME).update(realm);
// Checking the new interval is applied
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(600, response.getExpiresIn());
Assert.assertEquals(10, response.getInterval());
// Polling token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Wait
setTimeOffset(10);
// Polling again without waiting
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Slow down
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
// Wait
setTimeOffset(15);
// Polling again
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
} finally {
getTestingClient().testing().revertTestingInfinispanTimeService();
}
}
@Test
public void testUpdateConfig() {
RealmResource realm = getAdminClient().realm(REALM_NAME);
RealmRepresentation rep = realm.toRepresentation();
rep.setOAuth2DevicePollingInterval(DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
rep.setOAuth2DeviceCodeLifespan(DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
realm.update(rep);
rep = realm.toRepresentation();
Assert.assertEquals(DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL, rep.getOAuth2DevicePollingInterval().intValue());
Assert.assertEquals(DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN, rep.getOAuth2DeviceCodeLifespan().intValue());
rep.setOAuth2DevicePollingInterval(10);
rep.setOAuth2DeviceCodeLifespan(15);
realm.update(rep);
rep = realm.toRepresentation();
Assert.assertEquals(10, rep.getOAuth2DevicePollingInterval().intValue());
Assert.assertEquals(15, rep.getOAuth2DeviceCodeLifespan().intValue());
} }
private void openVerificationPage(String verificationUri) { private void openVerificationPage(String verificationUri) {

View file

@ -121,7 +121,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// Support standard + implicit + hybrid flow // Support standard + implicit + hybrid flow
assertContains(oidcConfig.getResponseTypesSupported(), OAuth2Constants.CODE, OIDCResponseType.ID_TOKEN, "id_token token", "code id_token", "code token", "code id_token token"); assertContains(oidcConfig.getResponseTypesSupported(), OAuth2Constants.CODE, OIDCResponseType.ID_TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT); assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT,
OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment"); assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public"); Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public");
@ -177,6 +178,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
Assert.assertNames(oidcConfig.getRevocationEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256, Assert.assertNames(oidcConfig.getRevocationEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256,
Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256, Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256,
Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512); Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
assertEquals(oidcConfig.getDeviceAuthorizationEndpoint(), oauth.getDeviceAuthorizationUrl());
} finally { } finally {
client.close(); client.close();
} }

View file

@ -109,11 +109,6 @@ public class ClientBuilder {
return this; return this;
} }
public ClientBuilder oauth2DeviceAuthorizationGrant() {
rep.setOAuth2DeviceAuthorizationGrantEnabled(true);
return this;
}
public ClientRepresentation build() { public ClientRepresentation build() {
return rep; return rep;
} }

View file

@ -1108,6 +1108,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.disableAuthorizationTab = !client.authorizationServicesEnabled; $scope.disableAuthorizationTab = !client.authorizationServicesEnabled;
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled; $scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient; $scope.disableCredentialsTab = client.publicClient;
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
// KEYCLOAK-6771 Certificate Bound Token // KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
$scope.tlsClientCertificateBoundAccessTokens = false; $scope.tlsClientCertificateBoundAccessTokens = false;
@ -1118,6 +1119,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.clientSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.session.max.lifespan']); $scope.clientSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.session.max.lifespan']);
$scope.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']); $scope.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']);
$scope.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.offline.session.max.lifespan']); $scope.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.offline.session.max.lifespan']);
$scope.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(client.attributes['oauth2.device.code.lifespan']);
$scope.oauth2DevicePollingInterval = parseInt(client.attributes['oauth2.device.polling.interval']);
if(client.origin) { if(client.origin) {
if ($scope.access.viewRealm) { if ($scope.access.viewRealm) {
@ -1279,6 +1282,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
} }
} }
if ($scope.client.attributes["oauth2.device.authorization.grant.enabled"]) {
if ($scope.client.attributes["oauth2.device.authorization.grant.enabled"] == "true") {
$scope.oauth2DeviceAuthorizationGrantEnabled = true;
} else {
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
}
}
// KEYCLOAK-6771 Certificate Bound Token // KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) { if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) {
@ -1521,6 +1532,22 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
} }
} }
$scope.updateOauth2DeviceCodeLifespan = function() {
if ($scope.oauth2DeviceCodeLifespan.time) {
$scope.clientEdit.attributes['oauth2.device.code.lifespan'] = $scope.oauth2DeviceCodeLifespan.toSeconds();
} else {
$scope.clientEdit.attributes['oauth2.device.code.lifespan'] = null;
}
}
$scope.updateOauth2DevicePollingInterval = function() {
if ($scope.oauth2DevicePollingInterval) {
$scope.clientEdit.attributes['oauth2.device.polling.interval'] = $scope.oauth2DevicePollingInterval;
} else {
$scope.clientEdit.attributes['oauth2.device.polling.interval'] = null;
}
}
function configureAuthorizationServices() { function configureAuthorizationServices() {
if ($scope.clientEdit.authorizationServicesEnabled) { if ($scope.clientEdit.authorizationServicesEnabled) {
if ($scope.accessType == 'public') { if ($scope.accessType == 'public') {
@ -1664,6 +1691,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
} }
if ($scope.oauth2DeviceAuthorizationGrantEnabled == true) {
$scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "true";
} else {
$scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "false";
}
// KEYCLOAK-6771 Certificate Bound Token // KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if ($scope.tlsClientCertificateBoundAccessTokens == true) { if ($scope.tlsClientCertificateBoundAccessTokens == true) {

View file

@ -139,11 +139,15 @@
<input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/> <input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div> </div>
</div> </div>
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly"> <div class="form-group"
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{:: 'oauth2-device-authorization-grant-enabled' | translate}}</label> data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{::
'oauth2-device-authorization-grant-enabled' | translate}}</label>
<kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6"> <div class="col-md-6">
<input ng-model="clientEdit.oauth2DeviceAuthorizationGrantEnabled" name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/> <input ng-model="oauth2DeviceAuthorizationGrantEnabled" ng-click="switchChange()"
name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch
on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
</div> </div>
</div> </div>
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly"> <div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
@ -669,6 +673,36 @@
<kc-tooltip>{{:: 'client-offline-session-max.tooltip' | translate}}</kc-tooltip> <kc-tooltip>{{:: 'client-offline-session-max.tooltip' | translate}}</kc-tooltip>
</div> </div>
<div class="form-group"
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly && oauth2DeviceAuthorizationGrantEnabled == true">
<label class="col-md-2 control-label" for="oauth2DeviceCodeLifespan">{{:: 'oauth2-device-code-lifespan'
| translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" min="1" max="31536000"
data-ng-model="oauth2DeviceCodeLifespan.time" id="oauth2DeviceCodeLifespan"
name="oauth2DeviceCodeLifespan" data-ng-change="updateOauth2DeviceCodeLifespan()" /> <select
class="form-control" name="oauth2DeviceCodeLifespanUnit" data-ng-model="oauth2DeviceCodeLifespan.unit"
data-ng-change="updateOauth2DeviceCodeLifespan()">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'oauth2-device-code-lifespan.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group"
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly && oauth2DeviceAuthorizationGrantEnabled == true">
<label class="col-md-2 control-label" for="oauth2DevicePollingInterval">{{::
'oauth2-device-polling-interval' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" min="1" max="31536000" data-ng-model="oauth2DevicePollingInterval"
id="oauth2DevicePollingInterval" name="oauth2DevicePollingInterval"
data-ng-change="updateOauth2DevicePollingInterval()" />
</div>
<kc-tooltip>{{:: 'oauth2-device-polling-interval.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'"> <div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label> <label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
<div class="col-sm-6"> <div class="col-sm-6">