KEYCLOAK-7675 Prototype Implementation of Device Authorization Grant.

Author:    Hiroyuki Wada <h2-wada@nri.co.jp>
Date:      Thu May 2 00:22:24 2019 +0900

Signed-off-by: Łukasz Dywicki <luke@code-house.org>
This commit is contained in:
Hiroyuki Wada 2019-05-02 00:22:24 +09:00 committed by Pedro Igor
parent d2060913be
commit 9d57b88dba
58 changed files with 2477 additions and 11 deletions

3
core/src/main/java/org/keycloak/OAuth2Constants.java Normal file → Executable file
View file

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

View file

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

View file

@ -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 <a href="https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.3">Device Authorization Response</a>.
*
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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;
}
}

View file

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

View file

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

View file

@ -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<RequiredCredentialModel> getRequiredCredentials() {
if (isUpdated()) return updated.getRequiredCredentials();
return cached.getRequiredCredentials();
}
@Override
public void addRequiredCredential(String cred) {
getDelegateForUpdate();

View file

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

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTokenStoreProvider {
public static final Logger logger = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProvider.class);
private final Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
private final KeycloakSession session;
public InfinispanOAuth2DeviceTokenStoreProvider(KeycloakSession session, Supplier<BasicCache<String, ActionTokenValueEntity>> actionKeyCache) {
this.session = session;
this.codeCache = actionKeyCache;
}
@Override
public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) {
try {
BasicCache<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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<String, ActionTokenValueEntity> 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;
}
}
}

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class);
// Reuse "actionTokens" infinispan cache for now
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
@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";
}
}

View file

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

View file

@ -587,6 +587,26 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
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<String, Integer> getUserActionTokenLifespans() {

View file

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

View file

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

View file

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

View file

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

View file

@ -90,6 +90,8 @@ public interface LoginFormsProvider extends Provider {
Response createSelectAuthenticator();
Response createOAuth2DeviceVerifyUserCodePage();
Response createCode();
Response createX509ConfirmPage();

View file

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

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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);
}

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public interface OAuth2DeviceTokenStoreProviderFactory extends ProviderFactory<OAuth2DeviceTokenStoreProvider> {
}

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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<? extends Provider> getProviderClass() {
return OAuth2DeviceTokenStoreProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return OAuth2DeviceTokenStoreProviderFactory.class;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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<String, String> data) {
return new OAuth2DeviceCodeModel(realm, deviceCode, data);
}
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> 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<String, String> serializeValue() {
Map<String, String> result = new HashMap<>();
result.put(CLIENT_ID, clientId);
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(SCOPE_NOTE, scope);
result.put(NONCE_NOTE, nonce);
return result;
}
public Map<String, String> serializeApprovedValue() {
Map<String, String> result = new HashMap<>();
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(SCOPE_NOTE, scope);
result.put(NONCE_NOTE, nonce);
result.put(USER_SESSION_ID_NOTE, userSessionId);
return result;
}
public Map<String, String> serializeDeniedValue() {
Map<String, String> result = new HashMap<>();
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
result.put(DENIED_NOTE, String.valueOf(denied));
return result;
}
public MultivaluedMap<String, String> getParams() {
MultivaluedHashMap<String, String> params = new MultivaluedHashMap<>();
params.putSingle(SCOPE_NOTE, scope);
params.putSingle(NONCE_NOTE, nonce);
return params;
}
}

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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<String, String> data) {
return new OAuth2DeviceUserCodeModel(realm, userCode, data);
}
private OAuth2DeviceUserCodeModel(RealmModel realm, String userCode, Map<String, String> 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<String, String> serializeValue() {
Map<String, String> result = new HashMap<>();
result.put(DEVICE_CODE_NOTE, deviceCode);
return result;
}
}

View file

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

View file

@ -44,7 +44,8 @@ public interface CommonClientSessionModel {
AUTHENTICATE,
LOGGED_OUT,
LOGGING_OUT,
REQUIRED_ACTIONS
REQUIRED_ACTIONS,
USER_CODE_VERIFICATION
}
enum ExecutionStatus {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
public class OAuth2DeviceAuthorizationEndpoint extends AuthorizationEndpointBase {
private static final Logger logger = Logger.getLogger(OAuth2DeviceAuthorizationEndpoint.class);
private enum Action {
OAUTH2_DEVICE_AUTH, OAUTH2_DEVICE_VERIFY_USER_CODE
}
private ClientModel client;
private AuthenticationSessionModel authenticationSession;
private Action action;
private AuthorizationEndpointRequest request;
private Cors cors;
public OAuth2DeviceAuthorizationEndpoint(RealmModel realm, EventBuilder event) {
super(realm, event);
}
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response buildPost() {
logger.trace("Processing @POST request");
cors = Cors.add(httpRequest).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
action = Action.OAUTH2_DEVICE_AUTH;
event.event(EventType.OAUTH2_DEVICE_AUTH);
return process(httpRequest.getDecodedFormParameters());
}
private Response process(MultivaluedMap<String, String> params) {
checkSsl();
checkRealm();
checkClient(null);
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, params);
if (!TokenUtil.isOIDCRequest(request.getScope())) {
ServicesLogger.LOGGER.oidcScopeMissing();
}
authenticationSession = createAuthenticationSession(client, request.getState());
updateAuthenticationSession();
// So back button doesn't work
CacheControlUtil.noBackButtonCacheControlHeader();
switch (action) {
case OAUTH2_DEVICE_AUTH:
return buildDeviceAuthorizationResponse();
}
throw new RuntimeException("Unknown action " + action);
}
public Response buildDeviceAuthorizationResponse() {
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
int interval = realm.getOAuth2DevicePollingInterval();
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce());
// 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<AuthenticationSessionModel> accessCode = new ClientSessionCode<>(session, realm, authenticationSession);
authenticationSession.getParentSession().setTimestamp(Time.currentTime());
accessCode.setAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name());
authenticationSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution);
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
.setAuthenticationSession(authenticationSession)
.setExecution(execution)
.setClientSessionCode(accessCode.getOrGenerateCode());
if (errorMessage != null) {
provider = provider.setError(errorMessage);
}
return provider.createOAuth2DeviceVerifyUserCodePage();
}
public Response redirectToBrowserAuthentication() {
return handleBrowserAuthenticationRequest(authenticationSession, new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event), false, true);
}
}

View file

@ -39,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<String, String> 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<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST);
}
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, session);
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
.accessToken(token)
.generateRefreshToken();
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
if (certConf != null) {
responseBuilder.getAccessToken().setCertConf(certConf);
responseBuilder.getRefreshToken().setCertConf(certConf);
} else {
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
}
}
if (TokenUtil.isOIDCRequest(scopeParam)) {
responseBuilder.generateIDToken();
}
AccessTokenResponse res = responseBuilder.build();
event.success();
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
}
// https://tools.ietf.org/html/rfc7636#section-4.1
private boolean isValidPkceCodeVerifier(String codeVerifier) {
if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) {

View file

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

View file

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

View file

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

View file

@ -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;
@ -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<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent,
AuthenticationSessionModel authSession) {
// Client Scopes to be displayed on consent screen

View file

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

View file

@ -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<String, String> formData) {
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
String code = formData.getFirst(SESSION_CODE);
String tabId = session.getContext().getUri().getQueryParameters().getFirst(Constants.TAB_ID);
String systemClientId = SystemClientUtil.getSystemClient(realm).getClientId();
SessionCodeChecks checks = checksForCode(null, code, null, systemClientId, tabId, OAUTH2_DEVICE_VERIFICATION_PATH);
if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name(), ClientSessionCode.ActionType.LOGIN)) {
return checks.getResponse();
}
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
AuthenticationSessionModel authSessionWithSystemClient = checks.getAuthenticationSession();
String userCode = formData.getFirst(OAUTH2_DEVICE_USER_CODE);
return endpoint.processVerification(authSessionWithSystemClient, userCode);
}
/**
* OAuth grant page. You should not invoked this directly!

View file

@ -31,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 <code>unknownPath</code>.
*

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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() {
}
}

View file

@ -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<NameValuePair> 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<NameValuePair> 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<String, String> 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<String, String> getHeaders() {
return headers;
}
}
}

View file

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

View file

@ -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 <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
*/
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<RealmRepresentation> 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);
}
}

View file

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

View file

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

View file

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

View file

@ -139,6 +139,13 @@
<input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{:: 'oauth2-device-authorization-grant-enabled' | translate}}</label>
<kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip>
<div class="col-md-6">
<input ng-model="clientEdit.oauth2DeviceAuthorizationGrantEnabled" name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
</div>
</div>
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
<label class="col-md-2 control-label" for="authorizationServicesEnabled">{{:: 'authz-authorization-services-enabled' | translate}}</label>
<kc-tooltip>{{:: 'authz-authorization-services-enabled.tooltip' | translate}}</kc-tooltip>

View file

@ -346,6 +346,31 @@
</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="oauth2DeviceCodeLifespan">{{:: 'oauth2-device-code-lifespan' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.oauth2DeviceCodeLifespan.time" id="oauth2DeviceCodeLifespan"
name="oauth2DeviceCodeLifespan">
<select class="form-control" name="oauth2DeviceCodeLifespanUnit" data-ng-model="realm.oauth2DeviceCodeLifespan.unit">
<option value="Minutes">{{:: 'minutes' | translate}}</option>
<option value="Hours">{{:: 'hours' | translate}}</option>
<option value="Days">{{:: 'days' | translate}}</option>
</select>
</div>
<kc-tooltip>{{:: 'oauth2-device-code-lifespan.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="oauth2DevicePollingInterval">{{:: 'oauth2-device-polling-interval' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.oauth2DevicePollingInterval" id="oauth2DevicePollingInterval"
name="oauth2DevicePollingInterval">
</div>
<kc-tooltip>{{:: 'oauth2-device-polling-interval.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>

View file

@ -0,0 +1,31 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout; section>
<#if section = "header">
${msg("oauth2DeviceVerificationTitle")}
<#elseif section = "form">
<form id="kc-user-verify-device-user-code-form" class="${properties.kcFormClass!}" action="${url.oauth2DeviceVerificationAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="device-user-code" class="${properties.kcLabelClass!}">${msg("verifyOAuth2DeviceUserCode")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="device-user-code" name="device_user_code" autocomplete="off" type="text" class="${properties.kcInputClass!}" autofocus />
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
<div class="${properties.kcFormOptionsWrapperClass!}">
</div>
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<div class="${properties.kcFormButtonsWrapperClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}"/>
</div>
</div>
</div>
</form>
</#if>
</@layout.registrationLayout>

View file

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