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")
private Boolean backchannelLogoutSessionSupported;
@JsonProperty("device_authorization_endpoint")
private String deviceAuthorizationEndpoint;
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
public String getIssuer() {
@ -445,4 +448,12 @@ public class OIDCConfigurationRepresentation {
public void setOtherClaims(String name, Object 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;
}
public Boolean isOAuth2DeviceAuthorizationGrantEnabled() {
return oauth2DeviceAuthorizationGrantEnabled;
}
public void setOAuth2DeviceAuthorizationGrantEnabled(Boolean oauth2DeviceAuthorizationGrantEnabled) {
this.oauth2DeviceAuthorizationGrantEnabled = oauth2DeviceAuthorizationGrantEnabled;
}
public Boolean getAuthorizationServicesEnabled() {
if (authorizationSettings != null) {
return true;

View file

@ -29,6 +29,7 @@ import org.keycloak.storage.client.ClientStorageProvider;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Supplier;
import java.util.stream.Stream;
/**
@ -40,18 +41,20 @@ public class RealmAdapter implements CachedRealmModel {
protected RealmCacheSession cacheSession;
protected volatile RealmModel updated;
protected KeycloakSession session;
private final Supplier<RealmModel> modelSupplier;
public RealmAdapter(KeycloakSession session, CachedRealm cached, RealmCacheSession cacheSession) {
this.cached = cached;
this.cacheSession = cacheSession;
this.session = session;
this.modelSupplier = this::getRealm;
}
@Override
public RealmModel getDelegateForUpdate() {
if (updated == null) {
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");
}
return updated;
@ -643,27 +646,11 @@ public class RealmAdapter implements CachedRealmModel {
return cached.getRequiredCredentials().stream();
}
public int getOAuth2DeviceCodeLifespan() {
if (isUpdated()) return updated.getOAuth2DeviceCodeLifespan();
return cached.getOAuth2DeviceCodeLifespan();
}
@Override
public void setOAuth2DeviceCodeLifespan(int oauth2DeviceCodeLifespan) {
getDelegateForUpdate();
updated.setOAuth2DeviceCodeLifespan(oauth2DeviceCodeLifespan);
}
@Override
public int getOAuth2DevicePollingInterval() {
if (isUpdated()) return updated.getOAuth2DevicePollingInterval();
return cached.getOAuth2DevicePollingInterval();
}
@Override
public void setOAuth2DevicePollingInterval(int oauth2DevicePollingInterval) {
getDelegateForUpdate();
updated.setOAuth2DevicePollingInterval(oauth2DevicePollingInterval);
public OAuth2DeviceConfig getOAuth2DeviceConfig() {
if (isUpdated())
return updated.getOAuth2DeviceConfig();
return cached.getOAuth2DeviceConfig(modelSupplier);
}
@Override
@ -1721,6 +1708,10 @@ public class RealmAdapter implements CachedRealmModel {
return Collections.unmodifiableMap(localizationTexts);
}
private RealmModel getRealm() {
return cacheSession.getRealmDelegate().getRealm(cached.getId());
}
@Override
public String toString() {
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.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.OTPPolicy;
import org.keycloak.models.PasswordPolicy;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RequiredActionProviderModel;
import org.keycloak.models.RequiredCredentialModel;
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.HashMap;
@ -43,6 +46,7 @@ import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
@ -96,8 +100,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
protected int accessCodeLifespan;
protected int accessCodeLifespanUserAction;
protected int accessCodeLifespanLogin;
protected int oauth2DeviceCodeLifespan;
protected int oauth2DevicePollingInterval;
protected LazyLoader<RealmModel, OAuth2DeviceConfig> deviceConfig;
protected int actionTokenGeneratedByAdminLifespan;
protected int actionTokenGeneratedByUserLifespan;
protected int notBefore;
@ -213,8 +216,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
accessTokenLifespan = model.getAccessTokenLifespan();
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
accessCodeLifespan = model.getAccessCodeLifespan();
oauth2DeviceCodeLifespan = model.getOAuth2DeviceCodeLifespan();
oauth2DevicePollingInterval = model.getOAuth2DevicePollingInterval();
deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null);
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
@ -491,12 +493,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
return accessCodeLifespanLogin;
}
public int getOAuth2DeviceCodeLifespan() {
return oauth2DeviceCodeLifespan;
}
public int getOAuth2DevicePollingInterval() {
return oauth2DevicePollingInterval;
public OAuth2DeviceConfig getOAuth2DeviceConfig(Supplier<RealmModel> modelSupplier) {
return deviceConfig.get(modelSupplier);
}
public int getActionTokenGeneratedByAdminLifespan() {

View file

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

View file

@ -17,18 +17,14 @@
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.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.models.*;
import org.keycloak.models.KeycloakSession;
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.util.InfinispanUtil;
import java.util.UUID;
import java.util.function.Supplier;
/**
@ -36,8 +32,6 @@ import java.util.function.Supplier;
*/
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class);
// Reuse "actionTokens" infinispan cache for now
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
@ -50,25 +44,7 @@ public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2De
private void lazyInit(KeycloakSession session) {
if (codeCache == null) {
synchronized (this) {
if (codeCache == null) {
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;
};
}
}
codeCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session);
}
}
}

View file

@ -588,23 +588,8 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
}
@Override
public int getOAuth2DeviceCodeLifespan() {
return getAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
}
@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);
public OAuth2DeviceConfig getOAuth2DeviceConfig() {
return new OAuth2DeviceConfig(this);
}
@Override

View file

@ -35,15 +35,10 @@ public interface RealmAttributes {
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_MAX_LIFESPAN = "clientSessionMaxLifespan";
String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout";
String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan";
String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName";
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";
// 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;
import org.keycloak.common.util.Time;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.keycloak.common.util.Time;
/**
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class OAuth2DeviceCodeModel {
private static final String REALM_ID = "rid";
private static final String CLIENT_ID = "cid";
private static final String EXPIRATION_NOTE = "exp";
private static final String POLLING_INTERVAL_NOTE = "int";
@ -46,15 +46,14 @@ public class OAuth2DeviceCodeModel {
private final String scope;
private final String nonce;
private final String userSessionId;
private final boolean denied;
private final Boolean denied;
private final Map<String, String> additionalParams;
public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
String deviceCode, String scope, String nonce, Map<String, String> additionalParams) {
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
String deviceCode, String scope, String nonce, int expiresIn, int pollingInterval, Map<String, String> additionalParams) {
int expiration = Time.currentTime() + expiresIn;
int pollingInterval = realm.getOAuth2DevicePollingInterval();
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, false, additionalParams);
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, null, additionalParams);
}
public OAuth2DeviceCodeModel approve(String userSessionId) {
@ -67,7 +66,7 @@ public class OAuth2DeviceCodeModel {
private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
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.clientId = clientId;
this.deviceCode = deviceCode;
@ -81,7 +80,13 @@ public class OAuth2DeviceCodeModel {
}
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) {
@ -128,58 +133,51 @@ public class OAuth2DeviceCodeModel {
return userSessionId == null;
}
public boolean isApproved() {
return userSessionId != null && !denied;
}
public boolean isDenied() {
return userSessionId != null && denied;
return denied;
}
public String getUserSessionId() {
return userSessionId;
}
public static String createKey(RealmModel realm, String deviceCode) {
return String.format("%s.dc.%s", realm.getId(), deviceCode);
public static String createKey(String deviceCode) {
return String.format("dc.%s", deviceCode);
}
public String serializeKey() {
return createKey(realm, deviceCode);
return createKey(deviceCode);
}
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<>();
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() {
Map<String, String> result = new HashMap<>();
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);
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
return result;
}
result.put(REALM_ID, realm.getId());
if (denied == null) {
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);
} 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));
return result;
}
@ -192,4 +190,8 @@ public class OAuth2DeviceCodeModel {
this.additionalParams.forEach(params::putSingle);
return params;
}
public boolean isExpired() {
return getExpiration() - Time.currentTime() < 0;
}
}

View file

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

View file

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

View file

@ -36,7 +36,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
String PRIVATE_KEY = "privateKey";
String PUBLIC_KEY = "publicKey";
String X509CERTIFICATE = "X509Certificate";
String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
public static class SearchableFields {
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);
@ -200,15 +199,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
boolean isServiceAccountsEnabled();
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();
/**

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);
int getOAuth2DeviceCodeLifespan();
void setOAuth2DeviceCodeLifespan(int seconds);
int getOAuth2DevicePollingInterval();
void setOAuth2DevicePollingInterval(int seconds);
OAuth2DeviceConfig getOAuth2DeviceConfig();
/**
* This method will return a map with all the lifespans available

View file

@ -16,6 +16,8 @@
*/
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.services.Urls;
import org.keycloak.theme.Theme;
@ -110,7 +112,7 @@ public class UrlBean {
return this.actionuri.getPath();
}
return Urls.realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
return realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
}
public String getResourcesPath() {

View file

@ -16,6 +16,10 @@
*/
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.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
@ -33,10 +37,10 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
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.OIDCResponseMode;
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_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);
protected KeycloakSession session;
@ -191,8 +191,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
return approveOAuth2DeviceAuthorization(authSession, clientSession);
if (isOAuth2DeviceVerificationFlow(authSession)) {
return approveOAuth2DeviceAuthorization(authSession, clientSession, session);
}
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
@ -278,8 +278,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
@Override
public Response sendError(AuthenticationSessionModel authSession, Error error) {
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
return denyOAuth2DeviceAuthorization(authSession, error);
if (isOAuth2DeviceVerificationFlow(authSession)) {
return denyOAuth2DeviceAuthorization(authSession, error, session);
}
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
@ -304,47 +304,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
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) {
switch (error) {
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.LogoutEndpoint;
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.TokenRevocationEndpoint;
import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.saml.common.util.StringUtil;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
@ -52,7 +50,6 @@ import org.keycloak.services.util.CacheControlUtil;
import java.util.Objects;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.OPTIONS;
@ -118,16 +115,6 @@ public class OIDCLoginProtocolService {
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) {
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete");
@ -177,58 +164,6 @@ public class OIDCLoginProtocolService {
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
*/

View file

@ -29,7 +29,9 @@ import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.provider.Provider;
@ -58,7 +60,9 @@ import java.util.stream.Stream;
*/
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");
@ -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.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.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(),
OIDCLoginProtocol.LOGIN_PROTOCOL);

View file

@ -18,6 +18,7 @@
package org.keycloak.protocol.oidc.endpoints;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationProcessor;
@ -37,6 +38,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -57,6 +59,7 @@ import org.keycloak.util.TokenUtil;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
@ -116,6 +119,16 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
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) {
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.util.Base64Url;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.Time;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
@ -57,14 +56,13 @@ import org.keycloak.models.IdentityProviderMapperModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
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.models.utils.AuthenticationFlowResolver;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
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.SamlProtocol;
import org.keycloak.protocol.saml.SamlService;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
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.OAuth2CodeParser;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.IdentityBrokerService;
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
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);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
.accessToken(token)
.generateRefreshToken();
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
@ -468,7 +469,8 @@ public class TokenEndpoint {
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);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
@ -477,16 +479,20 @@ public class TokenEndpoint {
}
AccessTokenResponse res = null;
try {
res = responseBuilder.build();
} catch (RuntimeException re) {
if ("can not get encryption KEK".equals(re.getMessage())) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "can not get encryption KEK", Response.Status.BAD_REQUEST);
} else {
throw re;
if (code) {
try {
res = responseBuilder.build();
} catch (RuntimeException re) {
if ("can not get encryption KEK".equals(re.getMessage())) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"can not get encryption KEK", Response.Status.BAD_REQUEST);
} else {
throw re;
}
}
} else {
res = responseBuilder.build();
}
event.success();
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
@ -1375,134 +1381,8 @@ public class TokenEndpoint {
}
public Response oauth2DeviceCodeToToken() {
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
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 (!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();
DeviceGrantType deviceGrantType = new DeviceGrantType(formParams, client, session, this, realm, event, cors);
return deviceGrantType.oauth2DeviceFlow();
}
// 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);
}
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) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
.build(realmName);
@ -262,7 +258,7 @@ public class Urls {
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");
}

View file

@ -52,6 +52,8 @@ import java.util.List;
import java.util.Set;
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>
*/
@ -312,7 +314,8 @@ public class DescriptionConverter {
if (client.isServiceAccountsEnabled()) {
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);
}
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {

View file

@ -81,8 +81,6 @@ public class ApplianceBootstrap {
realm.setAccessCodeLifespan(60);
realm.setAccessCodeLifespanUserAction(300);
realm.setAccessCodeLifespanLogin(1800);
realm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
realm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
realm.setSslRequired(SslRequired.EXTERNAL);
realm.setRegistrationAllowed(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.SessionTimeoutHelper;
import org.keycloak.models.utils.SystemClientUtil;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error;
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.UriInfo;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@ -102,6 +100,7 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
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;
/**
@ -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,
AuthenticationSessionModel authSession) {
// 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.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier;
import org.keycloak.authentication.AuthenticationFlowException;
@ -68,7 +67,6 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocol.Error;
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.OIDCResponseType;
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.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.util.AuthenticationFlowURLHelper;
import org.keycloak.services.util.BrowserHistoryHelper;
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 FIRST_BROKER_LOGIN_PATH = "first-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";
@ -131,8 +129,6 @@ public class LoginActionsService {
public static final String CANCEL_AIA = "cancel-aia";
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
private RealmModel realm;
@Context
@ -850,36 +846,6 @@ public class LoginActionsService {
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!
*

View file

@ -31,7 +31,6 @@ import org.keycloak.models.RealmModel;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
import org.keycloak.services.clientregistration.ClientRegistrationService;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.resource.RealmResourceProvider;
@ -101,15 +100,6 @@ public class RealmsResource {
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) {
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
}
@ -279,18 +269,6 @@ public class RealmsResource {
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>.
*
@ -299,9 +277,9 @@ public class RealmsResource {
*/
@Path("{realm}/{extension}")
public Object resolveRealmExtension(@PathParam("realm") String realmName, @PathParam("extension") String extension) {
init(realmName);
RealmResourceProvider provider = session.getProvider(RealmResourceProvider.class, extension);
if (provider != null) {
init(realmName);
Object resource = provider.getResource();
if (resource != null) {
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
public boolean isCurrent() {
if (driver.getTitle().startsWith("Log in to ")) {
if (driver.getTitle().startsWith("Sign in to ")) {
try {
driver.findElement(By.id("device-user-code"));
return true;
@ -59,38 +59,23 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
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() {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isApprovedPage());
}
public void assertDeniedPage() {
String name = getClass().getSimpleName();
Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isDeniedPage());
}
private boolean isLoginPage() {
if (driver.getTitle().startsWith("Log in to ")) {
try {
driver.findElement(By.id("username"));
driver.findElement(By.id("password"));
return true;
} catch (Throwable t) {
}
}
return false;
public void assertInvalidUserCodePage() {
Assert.assertTrue("Expected invalid user code page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isInvalidUserCodePage());
}
private boolean isApprovedPage() {
if (driver.getTitle().startsWith("Log in to ")) {
if (driver.getTitle().startsWith("Sign in to ")) {
try {
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful");
return true;
@ -101,7 +86,7 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
}
private boolean isDeniedPage() {
if (driver.getTitle().startsWith("Log in to ")) {
if (driver.getTitle().startsWith("Sign in to ")) {
try {
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.");
@ -112,6 +97,19 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
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
public void open() {
}

View file

@ -17,6 +17,7 @@
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.util.ServerURLs.getAuthServerContextRoot;
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.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.representations.AccessToken;
@ -68,8 +70,6 @@ import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.UserInfo;
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.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
@ -1058,11 +1058,6 @@ public class OAuthClient {
driver.navigate().to(getLoginFormUrl());
}
public void openOAuth2DeviceVerificationForm() {
UriBuilder b = RealmsResource.oauth2DeviceVerificationUrl(UriBuilder.fromUri(baseUrl));
openOAuth2DeviceVerificationForm(b.build(realm).toString());
}
public void openOAuth2DeviceVerificationForm(String verificationUri) {
driver.navigate().to(verificationUri);
}
@ -1206,7 +1201,7 @@ public class OAuthClient {
}
public String getDeviceAuthorizationUrl() {
UriBuilder b = OIDCLoginProtocolService.oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
UriBuilder b = oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
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.log.JBossLoggingEventListenerProviderFactory;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.adapters.action.GlobalRequestResult;
@ -181,7 +182,8 @@ public class RealmTest extends AbstractAdminTest {
Map<String, String> attributes = rep2.getAttributes();
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),
attributes.size() == 2 && attributes.containsKey("oauth2DeviceCodeLifespan") && attributes.containsKey("oauth2DevicePollingInterval"));
attributes.size() == 2 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN)
&& attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL));
} finally {
adminClient.realm("attributes").remove();
}

View file

@ -16,17 +16,27 @@
*/
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.junit.*;
import org.keycloak.events.Details;
import org.junit.Before;
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.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.EventRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
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.RealmBuilder;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import java.util.List;
@ -72,19 +81,11 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
ClientRepresentation app = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("test-device")
.oauth2DeviceAuthorizationGrant()
.secret("secret")
.attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
.build();
realm.client(app);
ClientRepresentation app2 = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("test-device-public")
.oauth2DeviceAuthorizationGrant()
.publicClient()
.build();
realm.client(app2);
userId = KeycloakModelUtils.generateId();
UserRepresentation user = UserBuilder.create()
.id(userId)
@ -98,26 +99,26 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
}
@Before
public void resetConifg() {
public void resetConfig() {
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
realm.setOAuth2DeviceCodeLifespan(600);
realm.setOAuth2DeviceCodeLifespan(60);
realm.setOAuth2DevicePollingInterval(5);
getAdminClient().realm(REALM_NAME).update(realm);
}
@Test
public void publicClientTest() throws Exception {
public void testConfidentialClient() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP_PUBLIC);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC, null);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
Assert.assertNotNull(response.getDeviceCode());
Assert.assertNotNull(response.getUserCode());
Assert.assertNotNull(response.getVerificationUri());
Assert.assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(600, response.getExpiresIn());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(60, response.getExpiresIn());
Assert.assertEquals(5, response.getInterval());
// Verify user code from verification page using browser
@ -125,10 +126,7 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertCurrent();
verificationPage.submit(response.getUserCode());
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP_PUBLIC).assertEvent();
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
verificationPage.assertLoginPage();
loginPage.assertCurrent();
// Do Login
oauth.fillLoginForm("device-login", "password");
@ -140,47 +138,148 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertApprovedPage();
events.expectDeviceLogin(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
// 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());
String tokenString = tokenResponse.getAccessToken();
Assert.assertNotNull(tokenString);
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_PUBLIC, codeId, userId).assertEvent();
assertNotNull(token);
}
@Test
public void confidentialClientTest() throws Exception {
public void testConsentCancel() 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());
Assert.assertNotNull(response.getDeviceCode());
Assert.assertNotNull(response.getUserCode());
Assert.assertNotNull(response.getVerificationUri());
Assert.assertNotNull(response.getVerificationUriComplete());
Assert.assertEquals(600, response.getExpiresIn());
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.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());
// Verify user code from verification page using browser
openVerificationPage(response.getVerificationUri());
verificationPage.assertCurrent();
verificationPage.submit(response.getUserCode());
verificationPage.submit("x");
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP).assertEvent();
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
verificationPage.assertInvalidUserCodePage();
}
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
oauth.fillLoginForm("device-login", "password");
@ -192,95 +291,251 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
verificationPage.assertApprovedPage();
events.expectDeviceLogin(DEVICE_APP, codeId, userId).assertEvent();
// Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
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
public void pollingTest() throws Exception {
public void testExpiredDeviceCode() 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(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());
// Polling token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
try {
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("authorization_pending", tokenResponse.getError());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("expired_token", tokenResponse.getError());
} finally {
resetTimeOffset();
}
}
// Polling again without waiting
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
@Test
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(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
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());
// Wait the interval
WaitUtils.pause(5000);
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT, "120");
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");
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());
// Polling token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
try {
// 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());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
// Wait
WaitUtils.pause(5000);
// Token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Polling again without waiting
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
// Slow down
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
setTimeOffset(7);
// Wait
WaitUtils.pause(5000);
// Token request from device
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
// Polling again
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("slow_down", tokenResponse.getError());
// Not approved yet
Assert.assertEquals(400, tokenResponse.getStatusCode());
Assert.assertEquals("authorization_pending", tokenResponse.getError());
setTimeOffset(10);
// 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) {

View file

@ -121,7 +121,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
// 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.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT);
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT,
OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public");
@ -177,6 +178,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
Assert.assertNames(oidcConfig.getRevocationEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256,
Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256,
Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
assertEquals(oidcConfig.getDeviceAuthorizationEndpoint(), oauth.getDeviceAuthorizationUrl());
} finally {
client.close();
}

View file

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

View file

@ -1108,6 +1108,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
$scope.disableAuthorizationTab = !client.authorizationServicesEnabled;
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
$scope.disableCredentialsTab = client.publicClient;
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
$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.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']);
$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 ($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
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
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() {
if ($scope.clientEdit.authorizationServicesEnabled) {
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
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
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}}"/>
</div>
</div>
<div class="form-group" 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>
<div class="form-group"
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>
<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 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>
</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'">
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
<div class="col-sm-6">