diff --git a/core/src/main/java/org/keycloak/OAuth2Constants.java b/core/src/main/java/org/keycloak/OAuth2Constants.java old mode 100644 new mode 100755 index 580291a0e9..e5ea1a1cd9 --- a/core/src/main/java/org/keycloak/OAuth2Constants.java +++ b/core/src/main/java/org/keycloak/OAuth2Constants.java @@ -120,6 +120,9 @@ public interface OAuth2Constants { String UMA_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket"; + // https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.4 + String DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; + String DEVICE_CODE = "device_code"; String DISPLAY_CONSOLE = "console"; } diff --git a/core/src/main/java/org/keycloak/OAuthErrorException.java b/core/src/main/java/org/keycloak/OAuthErrorException.java index ec8f690c6e..4800033e72 100755 --- a/core/src/main/java/org/keycloak/OAuthErrorException.java +++ b/core/src/main/java/org/keycloak/OAuthErrorException.java @@ -45,6 +45,12 @@ public class OAuthErrorException extends Exception { public static final String INVALID_REDIRECT_URI = "invalid_redirect_uri"; public static final String INVALID_CLIENT_METADATA = "invalid_client_metadata"; + // OAuth2 Device Authorization Grant + // https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.5 + public static final String AUTHORIZATION_PENDING = "authorization_pending"; + public static final String SLOW_DOWN = "slow_down"; + public static final String EXPIRED_TOKEN = "expired_token"; + // Others public static final String INVALID_CLIENT = "invalid_client"; public static final String INVALID_GRANT = "invalid_grant"; diff --git a/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java b/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java new file mode 100755 index 0000000000..d6cc9ac060 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/OAuth2DeviceAuthorizationResponse.java @@ -0,0 +1,113 @@ +/* + * 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.representations; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.common.Version; + +/** + * Representation for Device Authorization Response. + * + * @author Hiroyuki Wada + */ +public class OAuth2DeviceAuthorizationResponse { + + /** + * REQUIRED + */ + @JsonProperty("device_code") + protected String deviceCode; + + /** + * REQUIRED + */ + @JsonProperty("user_code") + protected String userCode; + + /** + * REQUIRED + */ + @JsonProperty("verification_uri") + protected String verificationUri; + + /** + * OPTIONAL + */ + @JsonProperty("verification_uri_complete") + protected String verificationUriComplete; + + /** + * REQUIRED + */ + @JsonProperty("expires_in") + protected long expiresIn; + + /** + * OPTIONAL + */ + @JsonProperty("interval") + protected long interval; + + public String getDeviceCode() { + return deviceCode; + } + + public void setDeviceCode(String deviceCode) { + this.deviceCode = deviceCode; + } + + public String getUserCode() { + return userCode; + } + + public void setUserCode(String userCode) { + this.userCode = userCode; + } + + public String getVerificationUri() { + return verificationUri; + } + + public void setVerificationUri(String verificationUri) { + this.verificationUri = verificationUri; + } + + public String getVerificationUriComplete() { + return verificationUriComplete; + } + + public void setVerificationUriComplete(String verificationUriComplete) { + this.verificationUriComplete = verificationUriComplete; + } + + public long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(long expiresIn) { + this.expiresIn = expiresIn; + } + + public long getInterval() { + return interval; + } + + public void setInterval(long interval) { + this.interval = interval; + } +} diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java index fdb8b3e241..af837cb68d 100755 --- a/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/ClientRepresentation.java @@ -51,6 +51,7 @@ public class ClientRepresentation { protected Boolean implicitFlowEnabled; protected Boolean directAccessGrantsEnabled; protected Boolean serviceAccountsEnabled; + protected Boolean oauth2DeviceAuthorizationGrantEnabled; protected Boolean authorizationServicesEnabled; @Deprecated protected Boolean directGrantsOnly; @@ -267,6 +268,14 @@ 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; diff --git a/core/src/main/java/org/keycloak/representations/idm/ClientTemplateRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/ClientTemplateRepresentation.java old mode 100644 new mode 100755 diff --git a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java index 53b695f1ad..396c601dd7 100755 --- a/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/RealmRepresentation.java @@ -59,6 +59,8 @@ public class RealmRepresentation { protected Integer accessCodeLifespanLogin; protected Integer actionTokenGeneratedByAdminLifespan; protected Integer actionTokenGeneratedByUserLifespan; + protected Integer oauth2DeviceCodeLifespan; + protected Integer oauth2DevicePollingInterval; protected Boolean enabled; protected String sslRequired; @Deprecated @@ -476,6 +478,22 @@ public class RealmRepresentation { this.actionTokenGeneratedByAdminLifespan = actionTokenGeneratedByAdminLifespan; } + public void setOAuth2DeviceCodeLifespan(Integer oauth2DeviceCodeLifespan) { + this.oauth2DeviceCodeLifespan = oauth2DeviceCodeLifespan; + } + + public Integer getOAuth2DeviceCodeLifespan() { + return oauth2DeviceCodeLifespan; + } + + public void setOAuth2DevicePollingInterval(Integer oauth2DevicePollingInterval) { + this.oauth2DevicePollingInterval = oauth2DevicePollingInterval; + } + + public Integer getOAuth2DevicePollingInterval() { + return oauth2DevicePollingInterval; + } + public Integer getActionTokenGeneratedByUserLifespan() { return actionTokenGeneratedByUserLifespan; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java index 9fe63af4cb..f8b5e2d21a 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/RealmAdapter.java @@ -643,6 +643,35 @@ 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); + } + + @Override + public List getRequiredCredentials() { + if (isUpdated()) return updated.getRequiredCredentials(); + return cached.getRequiredCredentials(); + } + @Override public void addRequiredCredential(String cred) { getDelegateForUpdate(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java index e64a20e344..b7767b2464 100755 --- a/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java +++ b/model/infinispan/src/main/java/org/keycloak/models/cache/infinispan/entities/CachedRealm.java @@ -96,6 +96,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { protected int accessCodeLifespan; protected int accessCodeLifespanUserAction; protected int accessCodeLifespanLogin; + protected int oauth2DeviceCodeLifespan; + protected int oauth2DevicePollingInterval; protected int actionTokenGeneratedByAdminLifespan; protected int actionTokenGeneratedByUserLifespan; protected int notBefore; @@ -211,6 +213,8 @@ public class CachedRealm extends AbstractExtendableRevisioned { accessTokenLifespan = model.getAccessTokenLifespan(); accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow(); accessCodeLifespan = model.getAccessCodeLifespan(); + oauth2DeviceCodeLifespan = model.getOAuth2DeviceCodeLifespan(); + oauth2DevicePollingInterval = model.getOAuth2DevicePollingInterval(); accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction(); accessCodeLifespanLogin = model.getAccessCodeLifespanLogin(); actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan(); @@ -487,6 +491,14 @@ public class CachedRealm extends AbstractExtendableRevisioned { return accessCodeLifespanLogin; } + public int getOAuth2DeviceCodeLifespan() { + return oauth2DeviceCodeLifespan; + } + + public int getOAuth2DevicePollingInterval() { + return oauth2DevicePollingInterval; + } + public int getActionTokenGeneratedByAdminLifespan() { return actionTokenGeneratedByAdminLifespan; } diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProvider.java new file mode 100644 index 0000000000..d98bdb22d3 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProvider.java @@ -0,0 +1,241 @@ +/* + * 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.models.sessions.infinispan; + +import org.infinispan.client.hotrod.exceptions.HotRodClientException; +import org.infinispan.commons.api.BasicCache; +import org.jboss.logging.Logger; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.OAuth2DeviceCodeModel; +import org.keycloak.models.OAuth2DeviceTokenStoreProvider; +import org.keycloak.models.OAuth2DeviceUserCodeModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * @author Hiroyuki Wada + */ +public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTokenStoreProvider { + + public static final Logger logger = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProvider.class); + + private final Supplier> codeCache; + private final KeycloakSession session; + + public InfinispanOAuth2DeviceTokenStoreProvider(KeycloakSession session, Supplier> actionKeyCache) { + this.session = session; + this.codeCache = actionKeyCache; + } + + @Override + public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) { + try { + BasicCache cache = codeCache.get(); + ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(realm, deviceCode)); + + if (existing == null) { + return null; + } + + return OAuth2DeviceCodeModel.fromCache(realm, deviceCode, existing.getNotes()); + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when getting device code %s", deviceCode); + } + + return null; + } + } + + @Override + public void close() { + + } + + @Override + public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) { + ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.serializeValue()); + ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue()); + + try { + BasicCache cache = codeCache.get(); + cache.put(deviceCode.serializeKey(), deviceCodeValue, lifespanSeconds, TimeUnit.SECONDS); + cache.put(userCode.serializeKey(), userCodeValue, lifespanSeconds, TimeUnit.SECONDS); + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when adding device code %s and user code %s", + deviceCode.getDeviceCode(), userCode.getUserCode()); + } + throw re; + } + } + + @Override + public boolean isPollingAllowed(OAuth2DeviceCodeModel deviceCode) { + try { + BasicCache cache = codeCache.get(); + String key = deviceCode.serializePollingKey(); + ActionTokenValueEntity value = new ActionTokenValueEntity(null); + ActionTokenValueEntity existing = cache.putIfAbsent(key, value, deviceCode.getPollingInterval(), TimeUnit.SECONDS); + return existing == null; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when putting polling key for device code %s", deviceCode.getDeviceCode()); + } + + return false; + } + } + + @Override + public OAuth2DeviceCodeModel getByUserCode(RealmModel realm, String userCode) { + try { + OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode); + if (deviceCode == null) { + return null; + } + + return deviceCode; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when getting device code by user code %s", userCode); + } + + return null; + } + } + + private OAuth2DeviceCodeModel findDeviceCodeByUserCode(RealmModel realm, String userCode) throws HotRodClientException { + BasicCache cache = codeCache.get(); + String userCodeKey = OAuth2DeviceUserCodeModel.createKey(realm, userCode); + ActionTokenValueEntity existing = cache.get(userCodeKey); + + if (existing == null) { + return null; + } + + OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes()); + String deviceCode = data.getDeviceCode(); + + String deviceCodeKey = OAuth2DeviceCodeModel.createKey(realm, deviceCode); + ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey); + + if (existingDeviceCode == null) { + return null; + } + + return OAuth2DeviceCodeModel.fromCache(realm, deviceCode, existingDeviceCode.getNotes()); + } + + @Override + public boolean approve(RealmModel realm, String userCode, String userSessionId) { + try { + OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode); + if (deviceCode == null) { + return false; + } + + OAuth2DeviceCodeModel approved = deviceCode.approve(userSessionId); + + // Update the device code with approved status + BasicCache cache = codeCache.get(); + cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.serializeApprovedValue())); + + return true; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when verifying device user code %s", userCode); + } + + return false; + } + } + + @Override + public boolean deny(RealmModel realm, String userCode) { + try { + OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode); + if (deviceCode == null) { + return false; + } + + OAuth2DeviceCodeModel denied = deviceCode.deny(); + + BasicCache cache = codeCache.get(); + cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.serializeDeniedValue())); + + return true; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when denying device user code %s", userCode); + } + + return false; + } + } + + @Override + public boolean removeDeviceCode(RealmModel realm, String deviceCode) { + try { + BasicCache cache = codeCache.get(); + String key = OAuth2DeviceCodeModel.createKey(realm, deviceCode); + ActionTokenValueEntity existing = cache.remove(key); + return existing == null ? false : true; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when removing device code %s", deviceCode); + } + + return false; + } + } + + @Override + public boolean removeUserCode(RealmModel realm, String userCode) { + try { + BasicCache cache = codeCache.get(); + String key = OAuth2DeviceUserCodeModel.createKey(realm, userCode); + ActionTokenValueEntity existing = cache.remove(key); + return existing == null ? false : true; + } catch (HotRodClientException re) { + // No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened. + // In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place. + if (logger.isDebugEnabled()) { + logger.debugf(re, "Failed when removing user code %s", userCode); + } + + return false; + } + } +} diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProviderFactory.java new file mode 100644 index 0000000000..47f079efb0 --- /dev/null +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanOAuth2DeviceTokenStoreProviderFactory.java @@ -0,0 +1,95 @@ +/* + * 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.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.sessions.infinispan.entities.ActionTokenValueEntity; +import org.keycloak.models.sessions.infinispan.util.InfinispanUtil; + +import java.util.UUID; +import java.util.function.Supplier; + +/** + * @author Hiroyuki Wada + */ +public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory { + + private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class); + + // Reuse "actionTokens" infinispan cache for now + private volatile Supplier> codeCache; + + @Override + public OAuth2DeviceTokenStoreProvider create(KeycloakSession session) { + lazyInit(session); + return new InfinispanOAuth2DeviceTokenStoreProvider(session, codeCache); + } + + 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; + }; + } + } + } + } + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return "infinispan"; + } +} diff --git a/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.OAuth2DeviceTokenStoreProviderFactory b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.OAuth2DeviceTokenStoreProviderFactory new file mode 100644 index 0000000000..4c77370f6b --- /dev/null +++ b/model/infinispan/src/main/resources/META-INF/services/org.keycloak.models.OAuth2DeviceTokenStoreProviderFactory @@ -0,0 +1,18 @@ +# +# 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. +# + +org.keycloak.models.sessions.infinispan.InfinispanOAuth2DeviceTokenStoreProviderFactory \ No newline at end of file diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java index 4a3fac60fb..316b8882b4 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/RealmAdapter.java @@ -587,6 +587,26 @@ public class RealmAdapter implements RealmModel, JpaModel { em.flush(); } + @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); + } + @Override public Map getUserActionTokenLifespans() { diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java index daadf51946..9a9c5458d0 100644 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmAttributes.java @@ -34,10 +34,16 @@ public interface RealmAttributes { String OFFLINE_SESSION_MAX_LIFESPAN_ENABLED = "offlineSessionMaxLifespanEnabled"; 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"; diff --git a/server-spi-private/src/main/java/org/keycloak/events/Errors.java b/server-spi-private/src/main/java/org/keycloak/events/Errors.java index a02d33e3b4..a06b70635f 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/Errors.java +++ b/server-spi-private/src/main/java/org/keycloak/events/Errors.java @@ -103,4 +103,9 @@ public interface Errors { String INVALID_PERMISSION_TICKET = "invalid_permission_ticket"; String ACCESS_DENIED = "access_denied"; + String INVALID_OAUTH2_DEVICE_CODE = "invalid_oauth2_device_code"; + String EXPIRED_OAUTH2_DEVICE_CODE = "expired_oauth2_device_code"; + String INVALID_OAUTH2_USER_CODE = "invalid_oauth2_user_code"; + String EXPIRED_OAUTH2_USER_CODE = "expired_oauth2_user_code"; + String SLOW_DOWN = "slow_down"; } diff --git a/server-spi-private/src/main/java/org/keycloak/events/EventType.java b/server-spi-private/src/main/java/org/keycloak/events/EventType.java index 02e13eac28..302dcc1667 100755 --- a/server-spi-private/src/main/java/org/keycloak/events/EventType.java +++ b/server-spi-private/src/main/java/org/keycloak/events/EventType.java @@ -131,6 +131,13 @@ public enum EventType { TOKEN_EXCHANGE(true), TOKEN_EXCHANGE_ERROR(true), + OAUTH2_DEVICE_AUTH(true), + OAUTH2_DEVICE_AUTH_ERROR(true), + OAUTH2_DEVICE_VERIFY_USER_CODE(true), + OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR(true), + OAUTH2_DEVICE_CODE_TO_TOKEN(true), + OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR(true), + PERMISSION_TOKEN(true), PERMISSION_TOKEN_ERROR(false), diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java index d0edc8e4d6..c2f9333f97 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsPages.java @@ -25,6 +25,7 @@ public enum LoginFormsPages { LOGIN, LOGIN_USERNAME, LOGIN_PASSWORD, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_WEBAUTHN, LOGIN_VERIFY_EMAIL, LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL, OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE, - LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM; + LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM, + LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE; } diff --git a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java index ae322acfd5..4562f86f63 100755 --- a/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java +++ b/server-spi-private/src/main/java/org/keycloak/forms/login/LoginFormsProvider.java @@ -90,6 +90,8 @@ public interface LoginFormsProvider extends Provider { Response createSelectAuthenticator(); + Response createOAuth2DeviceVerifyUserCodePage(); + Response createCode(); Response createX509ConfirmPage(); diff --git a/server-spi-private/src/main/java/org/keycloak/models/Constants.java b/server-spi-private/src/main/java/org/keycloak/models/Constants.java index 943089db75..758d7b7b48 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/Constants.java +++ b/server-spi-private/src/main/java/org/keycloak/models/Constants.java @@ -118,4 +118,9 @@ public final class Constants { * If {@code #STORAGE_BATCH_ENABLED} is set, indicates the batch size. */ public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size"; + + // 10 minutes + public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600; + // 5 seconds + public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5; } diff --git a/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProvider.java b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProvider.java new file mode 100755 index 0000000000..852812b351 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProvider.java @@ -0,0 +1,101 @@ +/* + * 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.models; + +import org.keycloak.provider.Provider; + + +/** + * Provides cache for OAuth2 Device Authorization Grant tokens. + * + * @author Hiroyuki Wada + */ +public interface OAuth2DeviceTokenStoreProvider extends Provider { + + /** + * Stores the given device code and user code + * + * @param deviceCode + * @param userCode + * @param lifespanSeconds + */ + void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds); + + /** + * Get the model object by the given device code + * + * @param realm + * @param deviceCode + * @return + */ + OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode); + + /** + * Check the device code is allowed to poll + * + * @param deviceCode + * @return Return true if the given device code is allowed to poll + */ + boolean isPollingAllowed(OAuth2DeviceCodeModel deviceCode); + + /** + * Get the model object by the given user code + * + * @param realm + * @param userCode + * @return + */ + OAuth2DeviceCodeModel getByUserCode(RealmModel realm, String userCode); + + /** + * Approve the given user code + * + * @param realm + * @param userCode + * @param userSessionId + * @return Return true if approving successful. If the code is already expired and cleared, it returns false. + */ + boolean approve(RealmModel realm, String userCode, String userSessionId); + + /** + * Deny the given user code + * + * @param realm + * @param userCode + * @return Return true if denying successful. If the code is already expired and cleared, it returns false. + */ + boolean deny(RealmModel realm, String userCode); + + /** + * Remove the given device code + * + * @param realm + * @param deviceCode + * @return Return true if removing successful. If the code is already expired and cleared, it returns false. + */ + boolean removeDeviceCode(RealmModel realm, String deviceCode); + + /** + * Remove the given user code + * + * @param realm + * @param userCode + * @return Return true if removing successful. If the code is already expired and cleared, it returns false. + */ + boolean removeUserCode(RealmModel realm, String userCode); +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProviderFactory.java b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProviderFactory.java new file mode 100644 index 0000000000..076dc9e614 --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreProviderFactory.java @@ -0,0 +1,26 @@ +/* + * 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.models; + +import org.keycloak.provider.ProviderFactory; + +/** + * @author Hiroyuki Wada + */ +public interface OAuth2DeviceTokenStoreProviderFactory extends ProviderFactory { +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreSpi.java b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreSpi.java new file mode 100644 index 0000000000..699076760c --- /dev/null +++ b/server-spi-private/src/main/java/org/keycloak/models/OAuth2DeviceTokenStoreSpi.java @@ -0,0 +1,50 @@ +/* + * 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.models; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +/** + * @author Hiroyuki Wada + */ +public class OAuth2DeviceTokenStoreSpi implements Spi { + + public static final String NAME = "oauth2DeviceTokenStore"; + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Class getProviderClass() { + return OAuth2DeviceTokenStoreProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return OAuth2DeviceTokenStoreProviderFactory.class; + } +} diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java index 7ab8051e68..8a0f73a9ff 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/ModelToRepresentation.java @@ -352,6 +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.setSmtpServer(new HashMap<>(realm.getSmtpConfig())); rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders()); rep.setAccountTheme(realm.getAccountTheme()); @@ -583,6 +585,7 @@ 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()); diff --git a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java index 0048a28384..bad467ef4e 100755 --- a/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/utils/RepresentationToModel.java @@ -249,6 +249,14 @@ public class RepresentationToModel { newRealm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan()); 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); + if (rep.getSslRequired() != null) newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase())); if (rep.isRegistrationAllowed() != null) newRealm.setRegistrationAllowed(rep.isRegistrationAllowed()); @@ -1110,6 +1118,10 @@ 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()); if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore()); if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm()); if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken()); @@ -1475,6 +1487,10 @@ public class RepresentationToModel { client.setFullScopeAllowed(!client.isConsentRequired()); } + if (resourceRep.isOAuth2DeviceAuthorizationGrantEnabled() != null) { + client.setOAuth2DeviceAuthorizationGrantEnabled(resourceRep.isOAuth2DeviceAuthorizationGrantEnabled()); + } + client.updateClient(); resourceRep.setId(client.getId()); @@ -1540,7 +1556,6 @@ public class RepresentationToModel { } } } - if (rep.getNotBefore() != null) { resource.setNotBefore(rep.getNotBefore()); } @@ -1574,6 +1589,10 @@ public class RepresentationToModel { } } + if (rep.isOAuth2DeviceAuthorizationGrantEnabled() != null) { + resource.setOAuth2DeviceAuthorizationGrantEnabled(rep.isOAuth2DeviceAuthorizationGrantEnabled()); + } + resource.updateClient(); } diff --git a/server-spi-private/src/main/java/org/keycloak/storage/client/AbstractReadOnlyClientStorageAdapter.java b/server-spi-private/src/main/java/org/keycloak/storage/client/AbstractReadOnlyClientStorageAdapter.java old mode 100644 new mode 100755 diff --git a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 1f72a1485c..b14edc47dd 100755 --- a/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/server-spi-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -25,6 +25,7 @@ org.keycloak.models.RealmSpi org.keycloak.models.RoleSpi org.keycloak.models.ActionTokenStoreSpi org.keycloak.models.CodeToTokenStoreSpi +org.keycloak.models.OAuth2DeviceTokenStoreSpi org.keycloak.models.SingleUseTokenStoreSpi org.keycloak.models.TokenRevocationStoreSpi org.keycloak.models.UserSessionSpi diff --git a/server-spi/src/main/java/org/keycloak/models/ClientModel.java b/server-spi/src/main/java/org/keycloak/models/ClientModel.java index cdd3e1f051..aca3adf121 100755 --- a/server-spi/src/main/java/org/keycloak/models/ClientModel.java +++ b/server-spi/src/main/java/org/keycloak/models/ClientModel.java @@ -36,6 +36,7 @@ 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 ID = new SearchableModelField<>("id", String.class); @@ -199,6 +200,15 @@ 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(); /** diff --git a/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceCodeModel.java b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceCodeModel.java new file mode 100755 index 0000000000..0d532a1f74 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceCodeModel.java @@ -0,0 +1,181 @@ +/* + * 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.models; + +import org.keycloak.common.util.Time; + +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Hiroyuki Wada + */ +public class OAuth2DeviceCodeModel { + + private static final String CLIENT_ID = "cid"; + private static final String EXPIRATION_NOTE = "exp"; + private static final String POLLING_INTERVAL_NOTE = "int"; + private static final String NONCE_NOTE = "nonce"; + private static final String SCOPE_NOTE = "scope"; + private static final String USER_SESSION_ID_NOTE = "uid"; + private static final String DENIED_NOTE = "denied"; + + private final RealmModel realm; + private final String clientId; + private final String deviceCode; + private final int expiration; + private final int pollingInterval; + private final String scope; + private final String nonce; + private final String userSessionId; + private final boolean denied; + + public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client, + String deviceCode, String scope, String nonce) { + int expiresIn = realm.getOAuth2DeviceCodeLifespan(); + int expiration = Time.currentTime() + expiresIn; + int pollingInterval = realm.getOAuth2DevicePollingInterval(); + return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, false); + } + + public OAuth2DeviceCodeModel approve(String userSessionId) { + return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, userSessionId, false); + } + + public OAuth2DeviceCodeModel deny() { + return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, null, true); + } + + private OAuth2DeviceCodeModel(RealmModel realm, String clientId, + String deviceCode, String scope, String nonce, int expiration, int pollingInterval, + String userSessionId, boolean denied) { + this.realm = realm; + this.clientId = clientId; + this.deviceCode = deviceCode; + this.scope = scope; + this.nonce = nonce; + this.expiration = expiration; + this.pollingInterval = pollingInterval; + this.userSessionId = userSessionId; + this.denied = denied; + } + + public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map data) { + return new OAuth2DeviceCodeModel(realm, deviceCode, data); + } + + private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map data) { + this.realm = realm; + this.clientId = data.get(CLIENT_ID); + this.deviceCode = deviceCode; + this.scope = data.get(SCOPE_NOTE); + this.nonce = data.get(NONCE_NOTE); + this.expiration = Integer.parseInt(data.get(EXPIRATION_NOTE)); + this.pollingInterval = Integer.parseInt(data.get(POLLING_INTERVAL_NOTE)); + this.userSessionId = data.get(USER_SESSION_ID_NOTE); + this.denied = Boolean.parseBoolean(data.get(DENIED_NOTE)); + } + + public String getDeviceCode() { + return deviceCode; + } + + public String getScope() { + return scope; + } + + public String getNonce() { + return nonce; + } + + public int getExpiration() { + return expiration; + } + + public int getPollingInterval() { + return pollingInterval; + } + + public String getClientId() { + return clientId; + } + + public boolean isPending() { + return userSessionId == null; + } + + public boolean isApproved() { + return userSessionId != null && !denied; + } + + public boolean isDenied() { + return userSessionId != null && denied; + } + + public String getUserSessionId() { + return userSessionId; + } + + public static String createKey(RealmModel realm, String deviceCode) { + return String.format("%s.dc.%s", realm.getId(), deviceCode); + } + + public String serializeKey() { + return createKey(realm, deviceCode); + } + + public String serializePollingKey() { + return createKey(realm, deviceCode) + ".polling"; + } + + public Map serializeValue() { + Map 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); + return result; + } + + public Map serializeApprovedValue() { + Map 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); + return result; + } + + public Map serializeDeniedValue() { + Map 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)); + return result; + } + + public MultivaluedMap getParams() { + MultivaluedHashMap params = new MultivaluedHashMap<>(); + params.putSingle(SCOPE_NOTE, scope); + params.putSingle(NONCE_NOTE, nonce); + return params; + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceUserCodeModel.java b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceUserCodeModel.java new file mode 100755 index 0000000000..4264b91e94 --- /dev/null +++ b/server-spi/src/main/java/org/keycloak/models/OAuth2DeviceUserCodeModel.java @@ -0,0 +1,70 @@ +/* + * 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.models; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Hiroyuki Wada + */ +public class OAuth2DeviceUserCodeModel { + + private static final String DEVICE_CODE_NOTE = "dc"; + + private final RealmModel realm; + private final String deviceCode; + private final String userCode; + + public OAuth2DeviceUserCodeModel(RealmModel realm, String deviceCode, String userCode) { + this.realm = realm; + this.deviceCode = deviceCode; + this.userCode = userCode; + } + + public static OAuth2DeviceUserCodeModel fromCache(RealmModel realm, String userCode, Map data) { + return new OAuth2DeviceUserCodeModel(realm, userCode, data); + } + + private OAuth2DeviceUserCodeModel(RealmModel realm, String userCode, Map data) { + this.realm = realm; + this.userCode = userCode; + this.deviceCode = data.get(DEVICE_CODE_NOTE); + } + + public String getDeviceCode() { + return deviceCode; + } + + public String getUserCode() { + return userCode; + } + + public static String createKey(RealmModel realm, String userCode) { + return String.format("%s.uc.%s", realm.getId(), userCode); + } + + public String serializeKey() { + return createKey(realm, userCode); + } + + public Map serializeValue() { + Map result = new HashMap<>(); + result.put(DEVICE_CODE_NOTE, deviceCode); + return result; + } +} diff --git a/server-spi/src/main/java/org/keycloak/models/RealmModel.java b/server-spi/src/main/java/org/keycloak/models/RealmModel.java index b727b6e0ca..011e4587d1 100755 --- a/server-spi/src/main/java/org/keycloak/models/RealmModel.java +++ b/server-spi/src/main/java/org/keycloak/models/RealmModel.java @@ -213,6 +213,12 @@ public interface RealmModel extends RoleContainerModel { void setAccessCodeLifespanUserAction(int seconds); + int getOAuth2DeviceCodeLifespan(); + void setOAuth2DeviceCodeLifespan(int seconds); + + int getOAuth2DevicePollingInterval(); + void setOAuth2DevicePollingInterval(int seconds); + /** * This method will return a map with all the lifespans available * or an empty map, but never null. diff --git a/server-spi/src/main/java/org/keycloak/sessions/CommonClientSessionModel.java b/server-spi/src/main/java/org/keycloak/sessions/CommonClientSessionModel.java index 3b2ab2ec17..3d2d130a77 100644 --- a/server-spi/src/main/java/org/keycloak/sessions/CommonClientSessionModel.java +++ b/server-spi/src/main/java/org/keycloak/sessions/CommonClientSessionModel.java @@ -44,7 +44,8 @@ public interface CommonClientSessionModel { AUTHENTICATE, LOGGED_OUT, LOGGING_OUT, - REQUIRED_ACTIONS + REQUIRED_ACTIONS, + USER_CODE_VERIFICATION } enum ExecutionStatus { diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java index 5b31df372e..680cf6b76e 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/FreeMarkerLoginFormsProvider.java @@ -573,11 +573,15 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider { return createResponse(LoginFormsPages.OAUTH_GRANT); } - @Override public Response createSelectAuthenticator() { return createResponse(LoginFormsPages.LOGIN_SELECT_AUTHENTICATOR); } + @Override + public Response createOAuth2DeviceVerifyUserCodePage() { + return createResponse(LoginFormsPages.LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE); + } + @Override public Response createCode() { return createResponse(LoginFormsPages.CODE); diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java index ffb18da6d6..7a4fb0f4a2 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/Templates.java @@ -50,6 +50,8 @@ public class Templates { return "login-reset-password.ftl"; case LOGIN_UPDATE_PASSWORD: return "login-update-password.ftl"; + case LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE: + return "login-oauth2-device-verify-user-code.ftl"; case LOGIN_SELECT_AUTHENTICATOR: return "select-authenticator.ftl"; case REGISTER: diff --git a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java index 743d4bebf1..c108649259 100755 --- a/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java +++ b/services/src/main/java/org/keycloak/forms/login/freemarker/model/UrlBean.java @@ -105,6 +105,14 @@ public class UrlBean { return Urls.realmOauthAction(baseURI, realm).toString(); } + public String getOauth2DeviceVerificationAction() { + if (this.actionuri != null) { + return this.actionuri.getPath(); + } + + return Urls.realmOAuth2DeviceVerificationAction(baseURI, realm).toString(); + } + public String getResourcesPath() { URI uri = Urls.themeRoot(baseURI); return uri.getPath() + "/" + theme.getType().toString().toLowerCase() +"/" + theme.getName(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 9ce31ed4bd..37b8426f09 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -33,6 +33,7 @@ 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; @@ -117,6 +118,10 @@ 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; @@ -184,7 +189,11 @@ public class OIDCLoginProtocol implements LoginProtocol { @Override public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) { - AuthenticatedClientSessionModel clientSession= clientSessionCtx.getClientSession(); + AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession(); + + if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) { + return approveOAuth2DeviceAuthorization(authSession, clientSession); + } String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); String responseModeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM); @@ -267,9 +276,12 @@ public class OIDCLoginProtocol implements LoginProtocol { return redirectUri.build(); } - @Override public Response sendError(AuthenticationSessionModel authSession, Error error) { + if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) { + return denyOAuth2DeviceAuthorization(authSession, error); + } + String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM); String responseModeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM); setupResponseTypeAndMode(responseTypeParam, responseModeParam); @@ -292,6 +304,47 @@ 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: diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index 323e9baf3f..dd37efc682 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -37,11 +37,13 @@ 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; @@ -50,6 +52,7 @@ 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; @@ -115,6 +118,21 @@ 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"); + } + public static UriBuilder tokenUrl(UriBuilder baseUriBuilder) { UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder); return uriBuilder.path(OIDCLoginProtocolService.class, "token"); @@ -159,6 +177,58 @@ 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 */ diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/OAuth2DeviceAuthorizationEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/OAuth2DeviceAuthorizationEndpoint.java new file mode 100644 index 0000000000..10654e6b8c --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/OAuth2DeviceAuthorizationEndpoint.java @@ -0,0 +1,343 @@ +/* + * 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.RandomString; +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.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.AuthenticationSessionManager; +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.sessions.RootAuthenticationSessionModel; +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 java.security.SecureRandom; + +import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX; + +/** + * @author Hiroyuki Wada + */ +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 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()); + + // TODO Configure user code format + String secret = new RandomString(10, new SecureRandom(), RandomString.upper).nextString(); + 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(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); + } + + OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class); + OAuth2DeviceCodeModel deviceCodeModel = store.getByUserCode(realm, userCode); + 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, userCode); + + // 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 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); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java index 5d92d5b200..dcf3a48921 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/TokenEndpoint.java @@ -39,6 +39,7 @@ 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; @@ -56,6 +57,8 @@ 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; @@ -71,6 +74,7 @@ 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; @@ -95,6 +99,7 @@ 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; @@ -148,7 +153,7 @@ public class TokenEndpoint { private Map clientAuthAttributes; private enum Action { - AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION + AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE } // https://tools.ietf.org/html/rfc7636#section-4.2 @@ -228,6 +233,8 @@ public class TokenEndpoint { return tokenExchange(); case PERMISSION: return permissionGrant(); + case OAUTH2_DEVICE_CODE: + return oauth2DeviceCodeToToken(); } throw new RuntimeException("Unknown action " + action); @@ -299,6 +306,9 @@ public class TokenEndpoint { } else if (grantType.equals(OAuth2Constants.UMA_GRANT_TYPE)) { event.event(EventType.PERMISSION_TOKEN); action = Action.PERMISSION; + } else if (grantType.equals(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)) { + event.event(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN); + action = Action.OAUTH2_DEVICE_CODE; } else { throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_GRANT_TYPE, "Unsupported " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST); @@ -1364,6 +1374,137 @@ public class TokenEndpoint { return authorizationResponse; } + 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 clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client); + if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) { + event.error(Errors.NOT_ALLOWED); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST); + } + + ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, session); + + // Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation + clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce()); + + AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx); + + TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx) + .accessToken(token) + .generateRefreshToken(); + + // KEYCLOAK-6771 Certificate Bound Token + // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 + if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) { + AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session); + if (certConf != null) { + responseBuilder.getAccessToken().setCertConf(certConf); + responseBuilder.getRefreshToken().setCertConf(certConf); + } else { + event.error(Errors.INVALID_REQUEST); + throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST); + } + } + + if (TokenUtil.isOIDCRequest(scopeParam)) { + responseBuilder.generateIDToken(); + } + + AccessTokenResponse res = responseBuilder.build(); + + event.success(); + + return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build(); + } + // https://tools.ietf.org/html/rfc7636#section-4.1 private boolean isValidPkceCodeVerifier(String codeVerifier) { if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java old mode 100644 new mode 100755 diff --git a/services/src/main/java/org/keycloak/services/Urls.java b/services/src/main/java/org/keycloak/services/Urls.java index 5630ed2135..3d5b466809 100755 --- a/services/src/main/java/org/keycloak/services/Urls.java +++ b/services/src/main/java/org/keycloak/services/Urls.java @@ -245,6 +245,10 @@ 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); diff --git a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java old mode 100644 new mode 100755 index 763a101b3c..89ead8b6e2 --- a/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java +++ b/services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java @@ -312,6 +312,9 @@ public class DescriptionConverter { if (client.isServiceAccountsEnabled()) { grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS); } + if (client.isOAuth2DeviceAuthorizationGrantEnabled()) { + grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE); + } if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) { grantTypes.add(OAuth2Constants.UMA_GRANT_TYPE); } diff --git a/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java b/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java index c5943ae1b8..809ee07783 100755 --- a/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java +++ b/services/src/main/java/org/keycloak/services/managers/ApplianceBootstrap.java @@ -81,6 +81,8 @@ 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); diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index 6827737e2b..0687ef7c4a 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -62,6 +62,7 @@ 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; @@ -572,7 +573,7 @@ public class AuthenticationManager { UserModel user = userSession.getUser(); logger.debugv("Logging out: {0} ({1})", user.getUsername(), userSession.getId()); } - + if (userSession.getState() != UserSessionModel.State.LOGGING_OUT) { userSession.setState(UserSessionModel.State.LOGGING_OUT); } @@ -925,7 +926,7 @@ public class AuthenticationManager { String actionTokenKeyToInvalidate = authSession.getAuthNote(INVALIDATE_ACTION_TOKEN); if (actionTokenKeyToInvalidate != null) { ActionTokenKeyModel actionTokenKey = DefaultActionTokenKey.from(actionTokenKeyToInvalidate); - + if (actionTokenKey != null) { ActionTokenStoreProvider actionTokenStore = session.getProvider(ActionTokenStoreProvider.class); actionTokenStore.put(actionTokenKey, null); // Token is invalidated @@ -983,7 +984,7 @@ public class AuthenticationManager { return kcAction; } - if (client.isConsentRequired()) { + if (client.isConsentRequired() || isOAuth2DeviceVerificationFlow(authSession)) { UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); @@ -1004,6 +1005,13 @@ public class AuthenticationManager { private static UserConsentModel getEffectiveGrantedConsent(KeycloakSession session, AuthenticationSessionModel authSession) { + // https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-5.4 + // The spec says "The authorization server SHOULD display information about the device", + // so we ignore existing persistent consent to display the consent screen always. + if (isOAuth2DeviceVerificationFlow(authSession)) { + return null; + } + // If prompt=consent, we ignore existing persistent consent String prompt = authSession.getClientNote(OIDCLoginProtocol.PROMPT_PARAM); if (TokenUtil.hasPrompt(prompt, OIDCLoginProtocol.PROMPT_VALUE_CONSENT)) { @@ -1038,7 +1046,10 @@ public class AuthenticationManager { action = executionActions(session, authSession, request, event, realm, user, authSession.getRequiredActions().stream()); if (action != null) return action; - if (client.isConsentRequired()) { + // https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-5.4 + // The spec says "The authorization server SHOULD display information about the device", + // so the consent is required when running a verification flow of OAuth 2.0 Device Authorization Grant. + if (client.isConsentRequired() || isOAuth2DeviceVerificationFlow(authSession)) { UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession); @@ -1069,6 +1080,11 @@ 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 getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent, AuthenticationSessionModel authSession) { // Client Scopes to be displayed on consent screen diff --git a/services/src/main/java/org/keycloak/services/messages/Messages.java b/services/src/main/java/org/keycloak/services/messages/Messages.java index d0c991f80d..46fd798660 100755 --- a/services/src/main/java/org/keycloak/services/messages/Messages.java +++ b/services/src/main/java/org/keycloak/services/messages/Messages.java @@ -261,4 +261,14 @@ public class Messages { public static final String DELETE_ACCOUNT_LACK_PRIVILEDGES = "deletingAccountForbidden"; public static final String DELETE_ACCOUNT_ERROR = "errorDeletingAccount"; + + // OAuth 2.0 Device Authorization Grant + public static final String OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED = "oauth2DeviceAuthorizationGrantDisabledMessage"; + public static final String OAUTH2_DEVICE_INVALID_USER_CODE = "oauth2DeviceInvalidUserCodeMessage"; + public static final String OAUTH2_DEVICE_EXPIRED_USER_CODE = "oauth2DeviceExpiredUserCodeMessage"; + public static final String OAUTH2_DEVICE_VERIFICATION_COMPLETE = "oauth2DeviceVerificationCompleteMessage"; + public static final String OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER = "oauth2DeviceVerificationCompleteHeader"; + public static final String OAUTH2_DEVICE_VERIFICATION_FAILED = "oauth2DeviceVerificationFailedMessage"; + public static final String OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER = "oauth2DeviceVerificationFailedHeader"; + public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage"; } diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java index 20e1a19a8c..58d186c2c1 100755 --- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java +++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java @@ -18,6 +18,7 @@ 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; @@ -67,6 +68,7 @@ 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; @@ -118,6 +120,7 @@ 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"; @@ -128,6 +131,8 @@ 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 @@ -845,6 +850,35 @@ 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 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! diff --git a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java index b93e603ef1..69b0f7fa7d 100755 --- a/services/src/main/java/org/keycloak/services/resources/RealmsResource.java +++ b/services/src/main/java/org/keycloak/services/resources/RealmsResource.java @@ -31,6 +31,7 @@ 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; @@ -45,6 +46,7 @@ import javax.ws.rs.OPTIONS; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; @@ -99,6 +101,15 @@ 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"); } @@ -268,6 +279,18 @@ 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 unknownPath. * diff --git a/services/src/main/java/org/keycloak/storage/openshift/OpenshiftSAClientAdapter.java b/services/src/main/java/org/keycloak/storage/openshift/OpenshiftSAClientAdapter.java old mode 100644 new mode 100755 diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/federation/HardcodedClientStorageProvider.java old mode 100644 new mode 100755 diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuth2DeviceVerificationPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuth2DeviceVerificationPage.java new file mode 100644 index 0000000000..c3b0f9795d --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/OAuth2DeviceVerificationPage.java @@ -0,0 +1,118 @@ +/* + * 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.testsuite.pages; + +import org.junit.Assert; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; + +/** + * @author Hiroyuki Wada + */ +public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage { + + @FindBy(id = "device-user-code") + private WebElement userCodeInput; + + @FindBy(css = "input[type=\"submit\"]") + private WebElement submitButton; + + @FindBy(className = "alert-error") + private WebElement verifyErrorMessage; + + public void submit(String userCode) { + userCodeInput.clear(); + if (userCode != null) { + userCodeInput.sendKeys(userCode); + } + submitButton.click(); + } + + public String getError() { + return verifyErrorMessage != null ? verifyErrorMessage.getText() : null; + } + + @Override + public boolean isCurrent() { + if (driver.getTitle().startsWith("Log in to ")) { + try { + driver.findElement(By.id("device-user-code")); + return true; + } catch (Throwable t) { + } + } + 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; + } + + private boolean isApprovedPage() { + if (driver.getTitle().startsWith("Log in to ")) { + try { + driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful"); + return true; + } catch (Throwable t) { + } + } + return false; + } + + private boolean isDeniedPage() { + if (driver.getTitle().startsWith("Log 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."); + return true; + } catch (Throwable t) { + } + } + return false; + } + + @Override + public void open() { + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java index 48e41d981d..db739f47c6 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/OAuthClient.java @@ -68,6 +68,8 @@ 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; @@ -860,6 +862,71 @@ public class OAuthClient { } } + public DeviceAuthorizationResponse doDeviceAuthorizationRequest(String clientId, String clientSecret) throws Exception { + try (CloseableHttpClient client = httpClient.get()) { + HttpPost post = new HttpPost(getDeviceAuthorizationUrl()); + + List parameters = new LinkedList<>(); + if (clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + } else { + parameters.add(new BasicNameValuePair("client_id", clientId)); + } + + if (origin != null) { + post.addHeader("Origin", origin); + } + + if (scope != null) { + parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scope)); + } + if (nonce != null) { + parameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, scope)); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new DeviceAuthorizationResponse(client.execute(post)); + } + } + + public AccessTokenResponse doDeviceTokenRequest(String clientId, String clientSecret, String deviceCode) throws Exception { + try (CloseableHttpClient client = httpClient.get()) { + HttpPost post = new HttpPost(getAccessTokenUrl()); + + List parameters = new LinkedList<>(); + parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.DEVICE_CODE_GRANT_TYPE)); + parameters.add(new BasicNameValuePair("device_code", deviceCode)); + if (clientSecret != null) { + String authorization = BasicAuthHelper.createHeader(clientId, clientSecret); + post.setHeader("Authorization", authorization); + } else { + parameters.add(new BasicNameValuePair("client_id", clientId)); + } + + if (origin != null) { + post.addHeader("Origin", origin); + } + + UrlEncodedFormEntity formEntity; + try { + formEntity = new UrlEncodedFormEntity(parameters, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + post.setEntity(formEntity); + + return new AccessTokenResponse(client.execute(post)); + } + } + public OIDCConfigurationRepresentation doWellKnownRequest(String realm) { try (CloseableHttpClient client = HttpClientBuilder.create().build()) { return SimpleHttp.doGet(baseUrl + "/realms/" + realm + "/.well-known/openid-configuration", client).asJson(OIDCConfigurationRepresentation.class); @@ -991,6 +1058,15 @@ 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); + } + public void openLogout() { UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl)); if (redirectUri != null) { @@ -1129,6 +1205,11 @@ public class OAuthClient { return getResourceOwnerPasswordCredentialGrantUrl(); } + public String getDeviceAuthorizationUrl() { + UriBuilder b = OIDCLoginProtocolService.oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl)); + return b.build(realm).toString(); + } + public String getRefreshTokenUrl() { UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl)); return b.build(realm).toString(); @@ -1572,4 +1653,94 @@ public class OAuthClient { } + public static class DeviceAuthorizationResponse { + private int statusCode; + + private String deviceCode; + private String userCode; + private String verificationUri; + private String verificationUriComplete; + private int expiresIn; + private int interval; + + private String error; + private String errorDescription; + + private Map headers; + + public DeviceAuthorizationResponse(CloseableHttpResponse response) throws Exception { + try { + statusCode = response.getStatusLine().getStatusCode(); + + headers = new HashMap<>(); + + for (Header h : response.getAllHeaders()) { + headers.put(h.getName(), h.getValue()); + } + + Header[] contentTypeHeaders = response.getHeaders("Content-Type"); + String contentType = (contentTypeHeaders != null && contentTypeHeaders.length > 0) ? contentTypeHeaders[0].getValue() : null; + if (!"application/json".equals(contentType)) { + Assert.fail("Invalid content type. Status: " + statusCode + ", contentType: " + contentType); + } + + String s = IOUtils.toString(response.getEntity().getContent(), "UTF-8"); + Map responseJson = JsonSerialization.readValue(s, Map.class); + + if (statusCode == 200) { + deviceCode = (String) responseJson.get("device_code"); + userCode = (String) responseJson.get("user_code"); + verificationUri = (String) responseJson.get("verification_uri"); + verificationUriComplete = (String) responseJson.get("verification_uri_complete"); + expiresIn = (Integer) responseJson.get("expires_in"); + interval = (Integer) responseJson.get("interval"); + } else { + error = (String) responseJson.get(OAuth2Constants.ERROR); + errorDescription = responseJson.containsKey(OAuth2Constants.ERROR_DESCRIPTION) ? (String) responseJson.get(OAuth2Constants.ERROR_DESCRIPTION) : null; + } + } finally { + response.close(); + } + } + + public String getError() { + return error; + } + + public String getErrorDescription() { + return errorDescription; + } + + public String getDeviceCode() { + return deviceCode; + } + + public String getUserCode() { + return userCode; + } + + public String getVerificationUri() { + return verificationUri; + } + + public String getVerificationUriComplete() { + return verificationUriComplete; + } + + public int getExpiresIn() { + return expiresIn; + } + + public int getInterval() { + return interval; + } + + public int getStatusCode() { + return statusCode; + } + + public Map getHeaders() { + return headers; + } + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java index da969d94d8..61643fdebb 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AssertEvents.java @@ -134,6 +134,34 @@ public class AssertEvents implements TestRule { .session(sessionId); } + public ExpectedEvent expectDeviceVerifyUserCode(String clientId) { + return expect(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE) + .user((String) null) + .client(clientId) + .detail(Details.CODE_ID, isUUID()); + } + + public ExpectedEvent expectDeviceLogin(String clientId, String codeId, String userId) { + return expect(EventType.LOGIN) + .user(userId) + .client(clientId) + .detail(Details.CODE_ID, codeId) + .session(codeId); +// .session((String) null); + } + + public ExpectedEvent expectDeviceCodeToToken(String clientId, String codeId, String userId) { + return expect(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN) + .client(clientId) + .user(userId) + .detail(Details.CODE_ID, codeId) + .detail(Details.TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_ID, isUUID()) + .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH) + .detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID) + .session(codeId); + } + public ExpectedEvent expectRefresh(String refreshTokenId, String sessionId) { return expect(EventType.REFRESH_TOKEN) .detail(Details.TOKEN_ID, isUUID()) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2DeviceAuthorizationGrantTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2DeviceAuthorizationGrantTest.java new file mode 100644 index 0000000000..06cfd96742 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/OAuth2DeviceAuthorizationGrantTest.java @@ -0,0 +1,289 @@ +/* + * 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.testsuite.oauth; + +import org.jboss.arquillian.graphene.page.Page; +import org.junit.*; +import org.keycloak.events.Details; +import org.keycloak.models.utils.KeycloakModelUtils; +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.AssertEvents; +import org.keycloak.testsuite.pages.ErrorPage; +import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage; +import org.keycloak.testsuite.pages.OAuthGrantPage; +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; + +/** + * @author Hiroyuki Wada + */ +public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest { + + private static String userId; + + public static final String REALM_NAME = "test"; + public static final String DEVICE_APP = "test-device"; + public static final String DEVICE_APP_PUBLIC = "test-device-public"; + + @Rule + public AssertEvents events = new AssertEvents(this); + + @Page + protected OAuth2DeviceVerificationPage verificationPage; + + @Page + protected OAuthGrantPage grantPage; + + @Page + protected ErrorPage errorPage; + + @Override + public void addTestRealms(List testRealms) { + RealmBuilder realm = RealmBuilder.create().name(REALM_NAME) + .privateKey("MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=") + .publicKey("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB") + .testEventListener(); + + + ClientRepresentation app = ClientBuilder.create() + .id(KeycloakModelUtils.generateId()) + .clientId("test-device") + .oauth2DeviceAuthorizationGrant() + .secret("secret") + .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) + .username("device-login") + .email("device-login@localhost") + .password("password") + .build(); + realm.user(user); + + testRealms.add(realm.build()); + } + + @Before + public void resetConifg() { + RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation(); + realm.setOAuth2DeviceCodeLifespan(600); + realm.setOAuth2DevicePollingInterval(5); + getAdminClient().realm(REALM_NAME).update(realm); + } + + @Test + public void publicClientTest() 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); + + 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()); + Assert.assertEquals(5, response.getInterval()); + + // Verify user code from verification page using browser + openVerificationPage(response.getVerificationUri()); + verificationPage.assertCurrent(); + verificationPage.submit(response.getUserCode()); + + EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP_PUBLIC).assertEvent(); + String codeId = verifyEvent.getDetails().get(Details.CODE_ID); + + verificationPage.assertLoginPage(); + + // 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(); + + 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()); + + 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_PUBLIC, codeId, userId).assertEvent(); + } + + @Test + public void confidentialClientTest() 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()); + Assert.assertEquals(5, response.getInterval()); + + // Verify user code from verification page using browser + openVerificationPage(response.getVerificationUri()); + verificationPage.assertCurrent(); + verificationPage.submit(response.getUserCode()); + + EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP).assertEvent(); + String codeId = verifyEvent.getDetails().get(Details.CODE_ID); + + verificationPage.assertLoginPage(); + + // 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(); + + 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 { + // 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 + WaitUtils.pause(5000); + + // 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(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 + WaitUtils.pause(5000); + + // 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 + WaitUtils.pause(5000); + + // Polling again + tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode()); + + // Not approved yet + Assert.assertEquals(400, tokenResponse.getStatusCode()); + Assert.assertEquals("authorization_pending", tokenResponse.getError()); + } + + private void openVerificationPage(String verificationUri) { + driver.navigate().to(verificationUri); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java index 2a3523840f..c2fb712fed 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/ClientBuilder.java @@ -109,6 +109,11 @@ public class ClientBuilder { return this; } + public ClientBuilder oauth2DeviceAuthorizationGrant() { + rep.setOAuth2DeviceAuthorizationGrantEnabled(true); + return this; + } + public ClientRepresentation build() { return rep; } diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 9d81b34f37..78859c2b1e 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -176,6 +176,12 @@ login-timeout=Login timeout login-timeout.tooltip=Max time a user has to complete a login. This is recommended to be relatively long, such as 30 minutes or more. login-action-timeout=Login action timeout login-action-timeout.tooltip=Max time a user has to complete login related actions like update password or configure totp. This is recommended to be relatively long, such as 5 minutes or more. + +oauth2-device-code-lifespan=OAuth 2.0 Device Code Lifespan +oauth2-device-code-lifespan.tooltip=Max time before the device code and user code are expired. This value needs to be a long enough lifetime to be usable (allowing the user to retrieve their secondary device, navigate to the verification URI, login, etc.), but should be sufficiently short to limit the usability of a code obtained for phishing. +oauth2-device-polling-interval=OAuth 2.0 Device Polling Interval +oauth2-device-polling-interval.tooltip=The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint. + headers=Headers brute-force-detection=Brute Force Detection x-frame-options=X-Frame-Options @@ -323,6 +329,8 @@ direct-access-grants-enabled=Direct Access Grants Enabled direct-access-grants-enabled.tooltip=This enables support for Direct Access Grants, which means that client has access to username/password of user and exchange it directly with Keycloak server for access token. In terms of OAuth2 specification, this enables support of 'Resource Owner Password Credentials Grant' for this client. service-accounts-enabled=Service Accounts Enabled service-accounts-enabled.tooltip=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client. +oauth2-device-authorization-grant-enabled=OAuth 2.0 Device Authorization Grant Enabled +oauth2-device-authorization-grant-enabled.tooltip=This enables support for OAuth 2.0 Device Authorization Grant, which means that client is an application on device that has limited input capabilities or lack a suitable browser. include-authnstatement=Include AuthnStatement include-authnstatement.tooltip=Should a statement specifying the method and timestamp be included in login responses? include-onetimeuse-condition=Include OneTimeUse Condition diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index c34ae1ce37..ef6dba4942 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -1317,6 +1317,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction); $scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan); $scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan); + $scope.realm.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(realm.oauth2DeviceCodeLifespan); $scope.realm.attributes = realm.attributes var oldCopy = angular.copy($scope.realm); @@ -1376,6 +1377,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http, $scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.toSeconds(); $scope.realm.actionTokenGeneratedByAdminLifespan = $scope.realm.actionTokenGeneratedByAdminLifespan.toSeconds(); $scope.realm.actionTokenGeneratedByUserLifespan = $scope.realm.actionTokenGeneratedByUserLifespan.toSeconds(); + $scope.realm.oauth2DeviceCodeLifespan = $scope.realm.oauth2DeviceCodeLifespan.toSeconds(); Realm.update($scope.realm, function () { $route.reload(); diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index e5cd2d09b7..8c2e832b5a 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -139,6 +139,13 @@ +
+ + {{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}} +
+ +
+
{{:: 'authz-authorization-services-enabled.tooltip' | translate}} diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html index deee768579..c85c68fce6 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/realm-tokens.html @@ -346,6 +346,31 @@
+
+ + +
+ + +
+ {{:: 'oauth2-device-code-lifespan.tooltip' | translate}} +
+ +
+ + +
+ +
+ {{:: 'oauth2-device-polling-interval.tooltip' | translate}} +
+
diff --git a/themes/src/main/resources/theme/base/login/login-oauth2-device-verify-user-code.ftl b/themes/src/main/resources/theme/base/login/login-oauth2-device-verify-user-code.ftl new file mode 100644 index 0000000000..dfb625fe8b --- /dev/null +++ b/themes/src/main/resources/theme/base/login/login-oauth2-device-verify-user-code.ftl @@ -0,0 +1,31 @@ +<#import "template.ftl" as layout> +<@layout.registrationLayout; section> + <#if section = "header"> + ${msg("oauth2DeviceVerificationTitle")} + <#elseif section = "form"> +
+
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ + \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/login/messages/messages_en.properties b/themes/src/main/resources/theme/base/login/messages/messages_en.properties index 78b8e2596d..9376b9603f 100755 --- a/themes/src/main/resources/theme/base/login/messages/messages_en.properties +++ b/themes/src/main/resources/theme/base/login/messages/messages_en.properties @@ -123,6 +123,17 @@ loginChooseAuthenticator=Select login method oauthGrantRequest=Do you grant these access privileges? inResource=in +oauth2DeviceVerificationTitle=Device Login +verifyOAuth2DeviceUserCode=Enter the code provided by your device and click Submit +oauth2DeviceInvalidUserCodeMessage=Invalid code, please try again. +oauth2DeviceExpiredUserCodeMessage=The code has expired. Please go back to your device and try connecting again. +oauth2DeviceVerificationCompleteHeader=Device Login Successful +oauth2DeviceVerificationCompleteMessage=You may close this browser window and go back to your device. +oauth2DeviceVerificationFailedHeader=Device Login Failed +oauth2DeviceVerificationFailedMessage=You may close this browser window and go back to your device and try connecting again. +oauth2DeviceConsentDeniedMessage=Consent denied for connecting the device. +oauth2DeviceAuthorizationGrantDisabledMessage=Client is not allowed to initiate OAuth 2.0 Device Authorization Grant. The flow is disabled for the client. + emailVerifyInstruction1=An email with instructions to verify your email address has been sent to you. emailVerifyInstruction2=Haven''t received a verification code in your email? emailVerifyInstruction3=to re-send the email.