KEYCLOAK-7675 Support for Device Authorization Grant
This commit is contained in:
parent
f58bf0deeb
commit
298ab0bc3e
41 changed files with 1498 additions and 1007 deletions
|
@ -142,6 +142,9 @@ public class OIDCConfigurationRepresentation {
|
||||||
@JsonProperty("backchannel_logout_session_supported")
|
@JsonProperty("backchannel_logout_session_supported")
|
||||||
private Boolean backchannelLogoutSessionSupported;
|
private Boolean backchannelLogoutSessionSupported;
|
||||||
|
|
||||||
|
@JsonProperty("device_authorization_endpoint")
|
||||||
|
private String deviceAuthorizationEndpoint;
|
||||||
|
|
||||||
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
|
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
|
||||||
|
|
||||||
public String getIssuer() {
|
public String getIssuer() {
|
||||||
|
@ -445,4 +448,12 @@ public class OIDCConfigurationRepresentation {
|
||||||
public void setOtherClaims(String name, Object value) {
|
public void setOtherClaims(String name, Object value) {
|
||||||
otherClaims.put(name, value);
|
otherClaims.put(name, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setDeviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) {
|
||||||
|
this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceAuthorizationEndpoint() {
|
||||||
|
return deviceAuthorizationEndpoint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -268,14 +268,6 @@ public class ClientRepresentation {
|
||||||
this.serviceAccountsEnabled = serviceAccountsEnabled;
|
this.serviceAccountsEnabled = serviceAccountsEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Boolean isOAuth2DeviceAuthorizationGrantEnabled() {
|
|
||||||
return oauth2DeviceAuthorizationGrantEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setOAuth2DeviceAuthorizationGrantEnabled(Boolean oauth2DeviceAuthorizationGrantEnabled) {
|
|
||||||
this.oauth2DeviceAuthorizationGrantEnabled = oauth2DeviceAuthorizationGrantEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Boolean getAuthorizationServicesEnabled() {
|
public Boolean getAuthorizationServicesEnabled() {
|
||||||
if (authorizationSettings != null) {
|
if (authorizationSettings != null) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -29,6 +29,7 @@ import org.keycloak.storage.client.ClientStorageProvider;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,18 +41,20 @@ public class RealmAdapter implements CachedRealmModel {
|
||||||
protected RealmCacheSession cacheSession;
|
protected RealmCacheSession cacheSession;
|
||||||
protected volatile RealmModel updated;
|
protected volatile RealmModel updated;
|
||||||
protected KeycloakSession session;
|
protected KeycloakSession session;
|
||||||
|
private final Supplier<RealmModel> modelSupplier;
|
||||||
|
|
||||||
public RealmAdapter(KeycloakSession session, CachedRealm cached, RealmCacheSession cacheSession) {
|
public RealmAdapter(KeycloakSession session, CachedRealm cached, RealmCacheSession cacheSession) {
|
||||||
this.cached = cached;
|
this.cached = cached;
|
||||||
this.cacheSession = cacheSession;
|
this.cacheSession = cacheSession;
|
||||||
this.session = session;
|
this.session = session;
|
||||||
|
this.modelSupplier = this::getRealm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RealmModel getDelegateForUpdate() {
|
public RealmModel getDelegateForUpdate() {
|
||||||
if (updated == null) {
|
if (updated == null) {
|
||||||
cacheSession.registerRealmInvalidation(cached.getId(), cached.getName());
|
cacheSession.registerRealmInvalidation(cached.getId(), cached.getName());
|
||||||
updated = cacheSession.getRealmDelegate().getRealm(cached.getId());
|
updated = modelSupplier.get();
|
||||||
if (updated == null) throw new IllegalStateException("Not found in database");
|
if (updated == null) throw new IllegalStateException("Not found in database");
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
|
@ -643,27 +646,11 @@ public class RealmAdapter implements CachedRealmModel {
|
||||||
return cached.getRequiredCredentials().stream();
|
return cached.getRequiredCredentials().stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getOAuth2DeviceCodeLifespan() {
|
|
||||||
if (isUpdated()) return updated.getOAuth2DeviceCodeLifespan();
|
|
||||||
return cached.getOAuth2DeviceCodeLifespan();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setOAuth2DeviceCodeLifespan(int oauth2DeviceCodeLifespan) {
|
public OAuth2DeviceConfig getOAuth2DeviceConfig() {
|
||||||
getDelegateForUpdate();
|
if (isUpdated())
|
||||||
updated.setOAuth2DeviceCodeLifespan(oauth2DeviceCodeLifespan);
|
return updated.getOAuth2DeviceConfig();
|
||||||
}
|
return cached.getOAuth2DeviceConfig(modelSupplier);
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getOAuth2DevicePollingInterval() {
|
|
||||||
if (isUpdated()) return updated.getOAuth2DevicePollingInterval();
|
|
||||||
return cached.getOAuth2DevicePollingInterval();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setOAuth2DevicePollingInterval(int oauth2DevicePollingInterval) {
|
|
||||||
getDelegateForUpdate();
|
|
||||||
updated.setOAuth2DevicePollingInterval(oauth2DevicePollingInterval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -1721,6 +1708,10 @@ public class RealmAdapter implements CachedRealmModel {
|
||||||
return Collections.unmodifiableMap(localizationTexts);
|
return Collections.unmodifiableMap(localizationTexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private RealmModel getRealm() {
|
||||||
|
return cacheSession.getRealmDelegate().getRealm(cached.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return String.format("%s@%08x", getId(), hashCode());
|
return String.format("%s@%08x", getId(), hashCode());
|
||||||
|
|
|
@ -28,12 +28,15 @@ import org.keycloak.models.ClientScopeModel;
|
||||||
import org.keycloak.models.GroupModel;
|
import org.keycloak.models.GroupModel;
|
||||||
import org.keycloak.models.IdentityProviderMapperModel;
|
import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
|
import org.keycloak.models.OAuth2DeviceConfig;
|
||||||
import org.keycloak.models.OTPPolicy;
|
import org.keycloak.models.OTPPolicy;
|
||||||
import org.keycloak.models.PasswordPolicy;
|
import org.keycloak.models.PasswordPolicy;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.RequiredActionProviderModel;
|
import org.keycloak.models.RequiredActionProviderModel;
|
||||||
import org.keycloak.models.RequiredCredentialModel;
|
import org.keycloak.models.RequiredCredentialModel;
|
||||||
import org.keycloak.models.WebAuthnPolicy;
|
import org.keycloak.models.WebAuthnPolicy;
|
||||||
|
import org.keycloak.models.cache.infinispan.DefaultLazyLoader;
|
||||||
|
import org.keycloak.models.cache.infinispan.LazyLoader;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
@ -43,6 +46,7 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -96,8 +100,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||||
protected int accessCodeLifespan;
|
protected int accessCodeLifespan;
|
||||||
protected int accessCodeLifespanUserAction;
|
protected int accessCodeLifespanUserAction;
|
||||||
protected int accessCodeLifespanLogin;
|
protected int accessCodeLifespanLogin;
|
||||||
protected int oauth2DeviceCodeLifespan;
|
protected LazyLoader<RealmModel, OAuth2DeviceConfig> deviceConfig;
|
||||||
protected int oauth2DevicePollingInterval;
|
|
||||||
protected int actionTokenGeneratedByAdminLifespan;
|
protected int actionTokenGeneratedByAdminLifespan;
|
||||||
protected int actionTokenGeneratedByUserLifespan;
|
protected int actionTokenGeneratedByUserLifespan;
|
||||||
protected int notBefore;
|
protected int notBefore;
|
||||||
|
@ -213,8 +216,7 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||||
accessTokenLifespan = model.getAccessTokenLifespan();
|
accessTokenLifespan = model.getAccessTokenLifespan();
|
||||||
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
|
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
|
||||||
accessCodeLifespan = model.getAccessCodeLifespan();
|
accessCodeLifespan = model.getAccessCodeLifespan();
|
||||||
oauth2DeviceCodeLifespan = model.getOAuth2DeviceCodeLifespan();
|
deviceConfig = new DefaultLazyLoader<>(OAuth2DeviceConfig::new, null);
|
||||||
oauth2DevicePollingInterval = model.getOAuth2DevicePollingInterval();
|
|
||||||
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
|
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
|
||||||
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
|
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
|
||||||
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
|
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
|
||||||
|
@ -491,12 +493,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
||||||
return accessCodeLifespanLogin;
|
return accessCodeLifespanLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getOAuth2DeviceCodeLifespan() {
|
public OAuth2DeviceConfig getOAuth2DeviceConfig(Supplier<RealmModel> modelSupplier) {
|
||||||
return oauth2DeviceCodeLifespan;
|
return deviceConfig.get(modelSupplier);
|
||||||
}
|
|
||||||
|
|
||||||
public int getOAuth2DevicePollingInterval() {
|
|
||||||
return oauth2DevicePollingInterval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getActionTokenGeneratedByAdminLifespan() {
|
public int getActionTokenGeneratedByAdminLifespan() {
|
||||||
|
|
|
@ -49,7 +49,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) {
|
public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) {
|
||||||
try {
|
try {
|
||||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||||
ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(realm, deviceCode));
|
ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(deviceCode));
|
||||||
|
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -74,7 +74,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) {
|
public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) {
|
||||||
ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.serializeValue());
|
ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.toMap());
|
||||||
ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue());
|
ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -142,7 +142,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes());
|
OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes());
|
||||||
String deviceCode = data.getDeviceCode();
|
String deviceCode = data.getDeviceCode();
|
||||||
|
|
||||||
String deviceCodeKey = OAuth2DeviceCodeModel.createKey(realm, deviceCode);
|
String deviceCodeKey = OAuth2DeviceCodeModel.createKey(deviceCode);
|
||||||
ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey);
|
ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey);
|
||||||
|
|
||||||
if (existingDeviceCode == null) {
|
if (existingDeviceCode == null) {
|
||||||
|
@ -164,7 +164,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
|
|
||||||
// Update the device code with approved status
|
// Update the device code with approved status
|
||||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||||
cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.serializeApprovedValue()));
|
cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.toMap()));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (HotRodClientException re) {
|
} catch (HotRodClientException re) {
|
||||||
|
@ -189,7 +189,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
OAuth2DeviceCodeModel denied = deviceCode.deny();
|
OAuth2DeviceCodeModel denied = deviceCode.deny();
|
||||||
|
|
||||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||||
cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.serializeDeniedValue()));
|
cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.toMap()));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (HotRodClientException re) {
|
} catch (HotRodClientException re) {
|
||||||
|
@ -207,7 +207,7 @@ public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTok
|
||||||
public boolean removeDeviceCode(RealmModel realm, String deviceCode) {
|
public boolean removeDeviceCode(RealmModel realm, String deviceCode) {
|
||||||
try {
|
try {
|
||||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||||
String key = OAuth2DeviceCodeModel.createKey(realm, deviceCode);
|
String key = OAuth2DeviceCodeModel.createKey(deviceCode);
|
||||||
ActionTokenValueEntity existing = cache.remove(key);
|
ActionTokenValueEntity existing = cache.remove(key);
|
||||||
return existing == null ? false : true;
|
return existing == null ? false : true;
|
||||||
} catch (HotRodClientException re) {
|
} catch (HotRodClientException re) {
|
||||||
|
|
|
@ -17,18 +17,14 @@
|
||||||
|
|
||||||
package org.keycloak.models.sessions.infinispan;
|
package org.keycloak.models.sessions.infinispan;
|
||||||
|
|
||||||
import org.infinispan.Cache;
|
|
||||||
import org.infinispan.client.hotrod.Flag;
|
|
||||||
import org.infinispan.client.hotrod.RemoteCache;
|
|
||||||
import org.infinispan.commons.api.BasicCache;
|
import org.infinispan.commons.api.BasicCache;
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
import org.keycloak.Config;
|
import org.keycloak.Config;
|
||||||
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.*;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||||
|
import org.keycloak.models.OAuth2DeviceTokenStoreProviderFactory;
|
||||||
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
|
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
|
||||||
import org.keycloak.models.sessions.infinispan.util.InfinispanUtil;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -36,8 +32,6 @@ import java.util.function.Supplier;
|
||||||
*/
|
*/
|
||||||
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
|
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
|
||||||
|
|
||||||
private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class);
|
|
||||||
|
|
||||||
// Reuse "actionTokens" infinispan cache for now
|
// Reuse "actionTokens" infinispan cache for now
|
||||||
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
|
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
|
||||||
|
|
||||||
|
@ -50,25 +44,7 @@ public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2De
|
||||||
private void lazyInit(KeycloakSession session) {
|
private void lazyInit(KeycloakSession session) {
|
||||||
if (codeCache == null) {
|
if (codeCache == null) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (codeCache == null) {
|
codeCache = InfinispanSingleUseTokenStoreProviderFactory.getActionTokenCache(session);
|
||||||
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
|
|
||||||
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
|
|
||||||
|
|
||||||
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
|
|
||||||
|
|
||||||
if (remoteCache != null) {
|
|
||||||
LOG.debugf("Having remote stores. Using remote cache '%s' for token of OAuth 2.0 Device Authorization Grant", remoteCache.getName());
|
|
||||||
this.codeCache = () -> {
|
|
||||||
// Doing this way as flag is per invocation
|
|
||||||
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
LOG.debugf("Not having remote stores. Using normal cache '%s' for token of OAuth 2.0 Device Authorization Grant", cache.getName());
|
|
||||||
this.codeCache = () -> {
|
|
||||||
return cache;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -588,23 +588,8 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int getOAuth2DeviceCodeLifespan() {
|
public OAuth2DeviceConfig getOAuth2DeviceConfig() {
|
||||||
return getAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
return new OAuth2DeviceConfig(this);
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setOAuth2DeviceCodeLifespan(int seconds) {
|
|
||||||
setAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getOAuth2DevicePollingInterval() {
|
|
||||||
return getAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setOAuth2DevicePollingInterval(int seconds) {
|
|
||||||
setAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, seconds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -35,15 +35,10 @@ public interface RealmAttributes {
|
||||||
|
|
||||||
String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan";
|
String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan";
|
||||||
|
|
||||||
// OAuth 2.0 Device Authorization Grant
|
|
||||||
String OAUTH2_DEVICE_CODE_LIFESPAN = "oauth2DeviceCodeLifespan";
|
|
||||||
String OAUTH2_DEVICE_POLLING_INTERVAL = "oauth2DevicePollingInterval";
|
|
||||||
|
|
||||||
String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout";
|
String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout";
|
||||||
String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan";
|
String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan";
|
||||||
String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout";
|
String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout";
|
||||||
String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan";
|
String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan";
|
||||||
|
|
||||||
String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName";
|
String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName";
|
||||||
String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms";
|
String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms";
|
||||||
|
|
||||||
|
|
|
@ -119,8 +119,4 @@ public final class Constants {
|
||||||
*/
|
*/
|
||||||
public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size";
|
public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size";
|
||||||
|
|
||||||
// 10 minutes
|
|
||||||
public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600;
|
|
||||||
// 5 seconds
|
|
||||||
public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,19 +16,19 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.models;
|
package org.keycloak.models;
|
||||||
|
|
||||||
import org.keycloak.common.util.Time;
|
|
||||||
|
|
||||||
import javax.ws.rs.core.MultivaluedHashMap;
|
import javax.ws.rs.core.MultivaluedHashMap;
|
||||||
import javax.ws.rs.core.MultivaluedMap;
|
import javax.ws.rs.core.MultivaluedMap;
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.keycloak.common.util.Time;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||||
*/
|
*/
|
||||||
public class OAuth2DeviceCodeModel {
|
public class OAuth2DeviceCodeModel {
|
||||||
|
|
||||||
|
private static final String REALM_ID = "rid";
|
||||||
private static final String CLIENT_ID = "cid";
|
private static final String CLIENT_ID = "cid";
|
||||||
private static final String EXPIRATION_NOTE = "exp";
|
private static final String EXPIRATION_NOTE = "exp";
|
||||||
private static final String POLLING_INTERVAL_NOTE = "int";
|
private static final String POLLING_INTERVAL_NOTE = "int";
|
||||||
|
@ -46,15 +46,14 @@ public class OAuth2DeviceCodeModel {
|
||||||
private final String scope;
|
private final String scope;
|
||||||
private final String nonce;
|
private final String nonce;
|
||||||
private final String userSessionId;
|
private final String userSessionId;
|
||||||
private final boolean denied;
|
private final Boolean denied;
|
||||||
private final Map<String, String> additionalParams;
|
private final Map<String, String> additionalParams;
|
||||||
|
|
||||||
public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
|
public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
|
||||||
String deviceCode, String scope, String nonce, Map<String, String> additionalParams) {
|
String deviceCode, String scope, String nonce, int expiresIn, int pollingInterval, Map<String, String> additionalParams) {
|
||||||
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
|
|
||||||
int expiration = Time.currentTime() + expiresIn;
|
int expiration = Time.currentTime() + expiresIn;
|
||||||
int pollingInterval = realm.getOAuth2DevicePollingInterval();
|
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, null, additionalParams);
|
||||||
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, false, additionalParams);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public OAuth2DeviceCodeModel approve(String userSessionId) {
|
public OAuth2DeviceCodeModel approve(String userSessionId) {
|
||||||
|
@ -67,7 +66,7 @@ public class OAuth2DeviceCodeModel {
|
||||||
|
|
||||||
private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
|
private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
|
||||||
String deviceCode, String scope, String nonce, int expiration, int pollingInterval,
|
String deviceCode, String scope, String nonce, int expiration, int pollingInterval,
|
||||||
String userSessionId, boolean denied, Map<String, String> additionalParams) {
|
String userSessionId, Boolean denied, Map<String, String> additionalParams) {
|
||||||
this.realm = realm;
|
this.realm = realm;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
this.deviceCode = deviceCode;
|
this.deviceCode = deviceCode;
|
||||||
|
@ -81,7 +80,13 @@ public class OAuth2DeviceCodeModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) {
|
public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) {
|
||||||
return new OAuth2DeviceCodeModel(realm, deviceCode, data);
|
OAuth2DeviceCodeModel model = new OAuth2DeviceCodeModel(realm, deviceCode, data);
|
||||||
|
|
||||||
|
if (!realm.getId().equals(data.get(REALM_ID))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) {
|
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) {
|
||||||
|
@ -128,58 +133,51 @@ public class OAuth2DeviceCodeModel {
|
||||||
return userSessionId == null;
|
return userSessionId == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isApproved() {
|
|
||||||
return userSessionId != null && !denied;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isDenied() {
|
public boolean isDenied() {
|
||||||
return userSessionId != null && denied;
|
return denied;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getUserSessionId() {
|
public String getUserSessionId() {
|
||||||
return userSessionId;
|
return userSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String createKey(RealmModel realm, String deviceCode) {
|
public static String createKey(String deviceCode) {
|
||||||
return String.format("%s.dc.%s", realm.getId(), deviceCode);
|
return String.format("dc.%s", deviceCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String serializeKey() {
|
public String serializeKey() {
|
||||||
return createKey(realm, deviceCode);
|
return createKey(deviceCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String serializePollingKey() {
|
public String serializePollingKey() {
|
||||||
return createKey(realm, deviceCode) + ".polling";
|
return createKey(deviceCode) + ".polling";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> serializeValue() {
|
public Map<String, String> toMap() {
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put(CLIENT_ID, clientId);
|
|
||||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
|
||||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
|
||||||
result.put(SCOPE_NOTE, scope);
|
|
||||||
result.put(NONCE_NOTE, nonce);
|
|
||||||
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Map<String, String> serializeApprovedValue() {
|
result.put(REALM_ID, realm.getId());
|
||||||
Map<String, String> result = new HashMap<>();
|
|
||||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
if (denied == null) {
|
||||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
result.put(CLIENT_ID, clientId);
|
||||||
result.put(SCOPE_NOTE, scope);
|
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||||
result.put(NONCE_NOTE, nonce);
|
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||||
result.put(USER_SESSION_ID_NOTE, userSessionId);
|
result.put(SCOPE_NOTE, scope);
|
||||||
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
|
result.put(NONCE_NOTE, nonce);
|
||||||
return result;
|
} else if (denied) {
|
||||||
}
|
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||||
|
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||||
|
result.put(DENIED_NOTE, String.valueOf(denied));
|
||||||
|
} else {
|
||||||
|
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||||
|
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||||
|
result.put(SCOPE_NOTE, scope);
|
||||||
|
result.put(NONCE_NOTE, nonce);
|
||||||
|
result.put(USER_SESSION_ID_NOTE, userSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
public Map<String, String> serializeDeniedValue() {
|
|
||||||
Map<String, String> result = new HashMap<>();
|
|
||||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
|
||||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
|
||||||
result.put(DENIED_NOTE, String.valueOf(denied));
|
|
||||||
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
|
additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,4 +190,8 @@ public class OAuth2DeviceCodeModel {
|
||||||
this.additionalParams.forEach(params::putSingle);
|
this.additionalParams.forEach(params::putSingle);
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isExpired() {
|
||||||
|
return getExpiration() - Time.currentTime() < 0;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -352,8 +352,8 @@ public class ModelToRepresentation {
|
||||||
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());
|
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());
|
||||||
rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan());
|
rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan());
|
||||||
rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan());
|
rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan());
|
||||||
rep.setOAuth2DeviceCodeLifespan(realm.getOAuth2DeviceCodeLifespan());
|
rep.setOAuth2DeviceCodeLifespan(realm.getOAuth2DeviceConfig().getLifespan());
|
||||||
rep.setOAuth2DevicePollingInterval(realm.getOAuth2DevicePollingInterval());
|
rep.setOAuth2DevicePollingInterval(realm.getOAuth2DeviceConfig().getPoolingInterval());
|
||||||
rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig()));
|
rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig()));
|
||||||
rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders());
|
rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders());
|
||||||
rep.setAccountTheme(realm.getAccountTheme());
|
rep.setAccountTheme(realm.getAccountTheme());
|
||||||
|
@ -585,7 +585,6 @@ public class ModelToRepresentation {
|
||||||
rep.setImplicitFlowEnabled(clientModel.isImplicitFlowEnabled());
|
rep.setImplicitFlowEnabled(clientModel.isImplicitFlowEnabled());
|
||||||
rep.setDirectAccessGrantsEnabled(clientModel.isDirectAccessGrantsEnabled());
|
rep.setDirectAccessGrantsEnabled(clientModel.isDirectAccessGrantsEnabled());
|
||||||
rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled());
|
rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled());
|
||||||
rep.setOAuth2DeviceAuthorizationGrantEnabled(clientModel.isOAuth2DeviceAuthorizationGrantEnabled());
|
|
||||||
rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired());
|
rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired());
|
||||||
rep.setRootUrl(clientModel.getRootUrl());
|
rep.setRootUrl(clientModel.getRootUrl());
|
||||||
rep.setBaseUrl(clientModel.getBaseUrl());
|
rep.setBaseUrl(clientModel.getBaseUrl());
|
||||||
|
|
|
@ -76,6 +76,7 @@ import org.keycloak.models.IdentityProviderModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.LDAPConstants;
|
import org.keycloak.models.LDAPConstants;
|
||||||
import org.keycloak.models.ModelException;
|
import org.keycloak.models.ModelException;
|
||||||
|
import org.keycloak.models.OAuth2DeviceConfig;
|
||||||
import org.keycloak.models.OTPPolicy;
|
import org.keycloak.models.OTPPolicy;
|
||||||
import org.keycloak.models.PasswordPolicy;
|
import org.keycloak.models.PasswordPolicy;
|
||||||
import org.keycloak.models.ProtocolMapperModel;
|
import org.keycloak.models.ProtocolMapperModel;
|
||||||
|
@ -250,12 +251,10 @@ public class RepresentationToModel {
|
||||||
else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction());
|
else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction());
|
||||||
|
|
||||||
// OAuth 2.0 Device Authorization Grant
|
// OAuth 2.0 Device Authorization Grant
|
||||||
if (rep.getOAuth2DeviceCodeLifespan() != null)
|
OAuth2DeviceConfig deviceConfig = newRealm.getOAuth2DeviceConfig();
|
||||||
newRealm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
|
||||||
else newRealm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
deviceConfig.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
||||||
if (rep.getOAuth2DevicePollingInterval() != null)
|
deviceConfig.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
||||||
newRealm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
|
||||||
else newRealm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
|
||||||
|
|
||||||
if (rep.getSslRequired() != null)
|
if (rep.getSslRequired() != null)
|
||||||
newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase()));
|
newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase()));
|
||||||
|
@ -1118,10 +1117,12 @@ public class RepresentationToModel {
|
||||||
realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan());
|
realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan());
|
||||||
if (rep.getActionTokenGeneratedByUserLifespan() != null)
|
if (rep.getActionTokenGeneratedByUserLifespan() != null)
|
||||||
realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
|
realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
|
||||||
if (rep.getOAuth2DeviceCodeLifespan() != null)
|
|
||||||
realm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
OAuth2DeviceConfig deviceConfig = realm.getOAuth2DeviceConfig();
|
||||||
if (rep.getOAuth2DevicePollingInterval() != null)
|
|
||||||
realm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
deviceConfig.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
||||||
|
deviceConfig.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
||||||
|
|
||||||
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
|
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
|
||||||
if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm());
|
if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm());
|
||||||
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
|
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
|
||||||
|
@ -1487,10 +1488,6 @@ public class RepresentationToModel {
|
||||||
client.setFullScopeAllowed(!client.isConsentRequired());
|
client.setFullScopeAllowed(!client.isConsentRequired());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceRep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
|
|
||||||
client.setOAuth2DeviceAuthorizationGrantEnabled(resourceRep.isOAuth2DeviceAuthorizationGrantEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
client.updateClient();
|
client.updateClient();
|
||||||
resourceRep.setId(client.getId());
|
resourceRep.setId(client.getId());
|
||||||
|
|
||||||
|
@ -1556,6 +1553,7 @@ public class RepresentationToModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rep.getNotBefore() != null) {
|
if (rep.getNotBefore() != null) {
|
||||||
resource.setNotBefore(rep.getNotBefore());
|
resource.setNotBefore(rep.getNotBefore());
|
||||||
}
|
}
|
||||||
|
@ -1589,10 +1587,6 @@ public class RepresentationToModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
|
|
||||||
resource.setOAuth2DeviceAuthorizationGrantEnabled(rep.isOAuth2DeviceAuthorizationGrantEnabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
resource.updateClient();
|
resource.updateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
|
||||||
String PRIVATE_KEY = "privateKey";
|
String PRIVATE_KEY = "privateKey";
|
||||||
String PUBLIC_KEY = "publicKey";
|
String PUBLIC_KEY = "publicKey";
|
||||||
String X509CERTIFICATE = "X509Certificate";
|
String X509CERTIFICATE = "X509Certificate";
|
||||||
String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
|
|
||||||
|
|
||||||
public static class SearchableFields {
|
public static class SearchableFields {
|
||||||
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);
|
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);
|
||||||
|
@ -200,15 +199,6 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
|
||||||
boolean isServiceAccountsEnabled();
|
boolean isServiceAccountsEnabled();
|
||||||
void setServiceAccountsEnabled(boolean serviceAccountsEnabled);
|
void setServiceAccountsEnabled(boolean serviceAccountsEnabled);
|
||||||
|
|
||||||
default boolean isOAuth2DeviceAuthorizationGrantEnabled() {
|
|
||||||
String enabled = getAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED);
|
|
||||||
return Boolean.parseBoolean(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
default void setOAuth2DeviceAuthorizationGrantEnabled(boolean oauth2DeviceAuthorizationGrantEnabled) {
|
|
||||||
setAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, Boolean.toString(oauth2DeviceAuthorizationGrantEnabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
RealmModel getRealm();
|
RealmModel getRealm();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* * Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||||
|
* * and other contributors as indicated by the @author tags.
|
||||||
|
* *
|
||||||
|
* * Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* * you may not use this file except in compliance with the License.
|
||||||
|
* * You may obtain a copy of the License at
|
||||||
|
* *
|
||||||
|
* * http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* *
|
||||||
|
* * Unless required by applicable law or agreed to in writing, software
|
||||||
|
* * distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* * See the License for the specific language governing permissions and
|
||||||
|
* * limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.models;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
||||||
|
*/
|
||||||
|
public final class OAuth2DeviceConfig implements Serializable {
|
||||||
|
|
||||||
|
// 10 minutes
|
||||||
|
public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600;
|
||||||
|
// 5 seconds
|
||||||
|
public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5;
|
||||||
|
|
||||||
|
// realm attribute names
|
||||||
|
public static String OAUTH2_DEVICE_CODE_LIFESPAN = "oauth2DeviceCodeLifespan";
|
||||||
|
public static String OAUTH2_DEVICE_POLLING_INTERVAL = "oauth2DevicePollingInterval";
|
||||||
|
|
||||||
|
// client attribute names
|
||||||
|
public static String OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT = "oauth2.device.code.lifespan";
|
||||||
|
public static String OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT = "oauth2.device.polling.interval";
|
||||||
|
public static final String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
|
||||||
|
|
||||||
|
private transient Supplier<RealmModel> realm;
|
||||||
|
|
||||||
|
private int lifespan = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
|
||||||
|
private int poolingInterval = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
|
||||||
|
|
||||||
|
public OAuth2DeviceConfig(RealmModel realm) {
|
||||||
|
this.realm = () -> realm;
|
||||||
|
|
||||||
|
String lifespan = realm.getAttribute(OAUTH2_DEVICE_CODE_LIFESPAN);
|
||||||
|
|
||||||
|
if (lifespan != null && !lifespan.trim().isEmpty()) {
|
||||||
|
setOAuth2DeviceCodeLifespan(Integer.parseInt(lifespan));
|
||||||
|
}
|
||||||
|
|
||||||
|
String pooling = realm.getAttribute(OAUTH2_DEVICE_POLLING_INTERVAL);
|
||||||
|
|
||||||
|
if (pooling != null && !pooling.trim().isEmpty()) {
|
||||||
|
setOAuth2DevicePollingInterval(Integer.parseInt(pooling));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getLifespan() {
|
||||||
|
return lifespan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOAuth2DeviceCodeLifespan(Integer seconds) {
|
||||||
|
if (seconds == null) {
|
||||||
|
seconds = DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
|
||||||
|
}
|
||||||
|
this.lifespan = seconds;
|
||||||
|
realm.get().setAttribute(OAUTH2_DEVICE_CODE_LIFESPAN, lifespan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPoolingInterval() {
|
||||||
|
return poolingInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOAuth2DevicePollingInterval(Integer seconds) {
|
||||||
|
if (seconds == null) {
|
||||||
|
seconds = DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL;
|
||||||
|
}
|
||||||
|
this.poolingInterval = seconds;
|
||||||
|
|
||||||
|
RealmModel model = getRealm();
|
||||||
|
|
||||||
|
model.setAttribute(OAUTH2_DEVICE_POLLING_INTERVAL, poolingInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getLifespan(ClientModel client) {
|
||||||
|
String lifespan = client.getAttribute(OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT);
|
||||||
|
|
||||||
|
if (lifespan != null && !lifespan.trim().isEmpty()) {
|
||||||
|
return Integer.parseInt(lifespan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getLifespan();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getPoolingInterval(ClientModel client) {
|
||||||
|
String interval = client.getAttribute(OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT);
|
||||||
|
|
||||||
|
if (interval != null && !interval.trim().isEmpty()) {
|
||||||
|
return Integer.parseInt(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPoolingInterval();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isOAuth2DeviceAuthorizationGrantEnabled(ClientModel client) {
|
||||||
|
String enabled = client.getAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED);
|
||||||
|
return Boolean.parseBoolean(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RealmModel getRealm() {
|
||||||
|
RealmModel model = realm.get();
|
||||||
|
|
||||||
|
if (model == null) {
|
||||||
|
throw new RuntimeException("Can only update after invalidating the realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
|
@ -213,11 +213,7 @@ public interface RealmModel extends RoleContainerModel {
|
||||||
|
|
||||||
void setAccessCodeLifespanUserAction(int seconds);
|
void setAccessCodeLifespanUserAction(int seconds);
|
||||||
|
|
||||||
int getOAuth2DeviceCodeLifespan();
|
OAuth2DeviceConfig getOAuth2DeviceConfig();
|
||||||
void setOAuth2DeviceCodeLifespan(int seconds);
|
|
||||||
|
|
||||||
int getOAuth2DevicePollingInterval();
|
|
||||||
void setOAuth2DevicePollingInterval(int seconds);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method will return a map with all the lifespans available
|
* This method will return a map with all the lifespans available
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.forms.login.freemarker.model;
|
package org.keycloak.forms.login.freemarker.model;
|
||||||
|
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.realmOAuth2DeviceVerificationAction;
|
||||||
|
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.services.Urls;
|
import org.keycloak.services.Urls;
|
||||||
import org.keycloak.theme.Theme;
|
import org.keycloak.theme.Theme;
|
||||||
|
@ -110,7 +112,7 @@ public class UrlBean {
|
||||||
return this.actionuri.getPath();
|
return this.actionuri.getPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Urls.realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
|
return realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getResourcesPath() {
|
public String getResourcesPath() {
|
||||||
|
|
|
@ -16,6 +16,10 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.protocol.oidc;
|
package org.keycloak.protocol.oidc;
|
||||||
|
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.approveOAuth2DeviceAuthorization;
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.denyOAuth2DeviceAuthorization;
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.isOAuth2DeviceVerificationFlow;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.OAuthErrorException;
|
import org.keycloak.OAuthErrorException;
|
||||||
|
@ -33,10 +37,10 @@ import org.keycloak.models.ClientModel;
|
||||||
import org.keycloak.models.ClientSessionContext;
|
import org.keycloak.models.ClientSessionContext;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.protocol.LoginProtocol;
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
|
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
|
@ -118,10 +122,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||||
public static final String PKCE_METHOD_PLAIN = "plain";
|
public static final String PKCE_METHOD_PLAIN = "plain";
|
||||||
public static final String PKCE_METHOD_S256 = "S256";
|
public static final String PKCE_METHOD_S256 = "S256";
|
||||||
|
|
||||||
// OAuth 2.0 Device Authorization Grant
|
|
||||||
public static final String OAUTH2_DEVICE_VERIFIED_USER_CODE = "OAUTH2_DEVICE_VERIFIED_USER_CODE";
|
|
||||||
public static final String OAUTH2_DEVICE_USER_CODE_EXPIRATION = "OAUTH2_DEVICE_USER_CODE_EXPIRATION";
|
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
|
private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
|
||||||
|
|
||||||
protected KeycloakSession session;
|
protected KeycloakSession session;
|
||||||
|
@ -191,8 +191,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||||
public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
|
public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
|
||||||
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
||||||
|
|
||||||
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
|
if (isOAuth2DeviceVerificationFlow(authSession)) {
|
||||||
return approveOAuth2DeviceAuthorization(authSession, clientSession);
|
return approveOAuth2DeviceAuthorization(authSession, clientSession, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
||||||
|
@ -278,8 +278,8 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Response sendError(AuthenticationSessionModel authSession, Error error) {
|
public Response sendError(AuthenticationSessionModel authSession, Error error) {
|
||||||
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
|
if (isOAuth2DeviceVerificationFlow(authSession)) {
|
||||||
return denyOAuth2DeviceAuthorization(authSession, error);
|
return denyOAuth2DeviceAuthorization(authSession, error, session);
|
||||||
}
|
}
|
||||||
|
|
||||||
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
||||||
|
@ -289,7 +289,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||||
String redirect = authSession.getRedirectUri();
|
String redirect = authSession.getRedirectUri();
|
||||||
String state = authSession.getClientNote(OIDCLoginProtocol.STATE_PARAM);
|
String state = authSession.getClientNote(OIDCLoginProtocol.STATE_PARAM);
|
||||||
OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode);
|
OIDCRedirectUriBuilder redirectUri = OIDCRedirectUriBuilder.fromUri(redirect, responseMode);
|
||||||
|
|
||||||
if (error != Error.CANCELLED_AIA_SILENT) {
|
if (error != Error.CANCELLED_AIA_SILENT) {
|
||||||
redirectUri.addParam(OAuth2Constants.ERROR, translateError(error));
|
redirectUri.addParam(OAuth2Constants.ERROR, translateError(error));
|
||||||
}
|
}
|
||||||
|
@ -304,47 +304,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
||||||
return redirectUri.build();
|
return redirectUri.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response approveOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, AuthenticatedClientSessionModel clientSession) {
|
|
||||||
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
|
|
||||||
|
|
||||||
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
|
||||||
String userSessionId = clientSession.getUserSession().getId();
|
|
||||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
|
||||||
if (!store.approve(realm, verifiedUserCode, userSessionId)) {
|
|
||||||
// Already expired and removed in the store
|
|
||||||
return Response.status(302).location(
|
|
||||||
uriBuilder.queryParam(OAuth2Constants.ERROR, OAuthErrorException.EXPIRED_TOKEN)
|
|
||||||
.build(realm.getName())
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now, remove the verified user code
|
|
||||||
store.removeUserCode(realm, verifiedUserCode);
|
|
||||||
|
|
||||||
return Response.status(302).location(
|
|
||||||
uriBuilder.build(realm.getName())
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Response denyOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, Error error) {
|
|
||||||
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
|
|
||||||
String errorType = OAuthErrorException.SERVER_ERROR;
|
|
||||||
if (error == Error.CONSENT_DENIED) {
|
|
||||||
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
|
||||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
|
||||||
if (!store.deny(realm, verifiedUserCode)) {
|
|
||||||
// Already expired and removed in the store
|
|
||||||
errorType = OAuthErrorException.EXPIRED_TOKEN;
|
|
||||||
} else {
|
|
||||||
errorType = OAuthErrorException.ACCESS_DENIED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Response.status(302).location(
|
|
||||||
uriBuilder.queryParam(OAuth2Constants.ERROR, errorType)
|
|
||||||
.build(realm.getName())
|
|
||||||
).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String translateError(Error error) {
|
private String translateError(Error error) {
|
||||||
switch (error) {
|
switch (error) {
|
||||||
case CANCELLED_BY_USER:
|
case CANCELLED_BY_USER:
|
||||||
|
|
|
@ -37,13 +37,11 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
|
||||||
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
|
||||||
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
|
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
|
||||||
import org.keycloak.services.CorsErrorResponseException;
|
import org.keycloak.services.CorsErrorResponseException;
|
||||||
import org.keycloak.saml.common.util.StringUtil;
|
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
import org.keycloak.services.resources.Cors;
|
import org.keycloak.services.resources.Cors;
|
||||||
|
@ -52,7 +50,6 @@ import org.keycloak.services.util.CacheControlUtil;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
import javax.ws.rs.Consumes;
|
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.NotFoundException;
|
import javax.ws.rs.NotFoundException;
|
||||||
import javax.ws.rs.OPTIONS;
|
import javax.ws.rs.OPTIONS;
|
||||||
|
@ -118,16 +115,6 @@ public class OIDCLoginProtocolService {
|
||||||
return uriBuilder.path(OIDCLoginProtocolService.class, "auth");
|
return uriBuilder.path(OIDCLoginProtocolService.class, "auth");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UriBuilder oauth2DeviceAuthUrl(UriBuilder baseUriBuilder) {
|
|
||||||
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
|
|
||||||
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceAuth");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UriBuilder oauth2DeviceVerificationCompletedUrl(UriInfo uriInfo) {
|
|
||||||
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
|
|
||||||
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceVerificationCompleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UriBuilder delegatedUrl(UriInfo uriInfo) {
|
public static UriBuilder delegatedUrl(UriInfo uriInfo) {
|
||||||
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
|
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
|
||||||
return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete");
|
return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete");
|
||||||
|
@ -177,58 +164,6 @@ public class OIDCLoginProtocolService {
|
||||||
return endpoint;
|
return endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth 2.0 Device Authorization endpoint
|
|
||||||
*/
|
|
||||||
@Path("device/auth")
|
|
||||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
public Object oauth2DeviceAuth() {
|
|
||||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
|
||||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
|
||||||
return endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Showing the result of verification process for OAuth 2.0 Device Authorization Grant.
|
|
||||||
* This outputs login success or failure messages.
|
|
||||||
*
|
|
||||||
* @param error
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("device/verification")
|
|
||||||
public Response oauth2DeviceVerificationCompleted(@QueryParam("error") String error) {
|
|
||||||
if (!StringUtil.isNullOrEmpty(error)) {
|
|
||||||
String message;
|
|
||||||
switch (error) {
|
|
||||||
case OAuthErrorException.ACCESS_DENIED:
|
|
||||||
// cased by CANCELLED_BY_USER or CONSENT_DENIED:
|
|
||||||
message = Messages.OAUTH2_DEVICE_CONSENT_DENIED;
|
|
||||||
break;
|
|
||||||
case OAuthErrorException.EXPIRED_TOKEN:
|
|
||||||
message = Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
message = Messages.OAUTH2_DEVICE_VERIFICATION_FAILED;
|
|
||||||
}
|
|
||||||
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
|
||||||
String restartUri = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
|
|
||||||
return forms
|
|
||||||
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER))
|
|
||||||
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, restartUri)
|
|
||||||
.setError(message)
|
|
||||||
.createInfoPage();
|
|
||||||
} else {
|
|
||||||
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
|
||||||
return forms
|
|
||||||
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER))
|
|
||||||
.setAttribute(Constants.SKIP_LINK, true)
|
|
||||||
.setSuccess(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE)
|
|
||||||
.createInfoPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registration endpoint
|
* Registration endpoint
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -29,7 +29,9 @@ import org.keycloak.jose.jws.Algorithm;
|
||||||
import org.keycloak.models.ClientScopeModel;
|
import org.keycloak.models.ClientScopeModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
||||||
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
|
||||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
import org.keycloak.provider.Provider;
|
import org.keycloak.provider.Provider;
|
||||||
|
@ -58,7 +60,9 @@ import java.util.stream.Stream;
|
||||||
*/
|
*/
|
||||||
public class OIDCWellKnownProvider implements WellKnownProvider {
|
public class OIDCWellKnownProvider implements WellKnownProvider {
|
||||||
|
|
||||||
public static final List<String> DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS);
|
public static final List<String> DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE,
|
||||||
|
OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS,
|
||||||
|
OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
|
||||||
|
|
||||||
public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
|
public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
|
||||||
|
|
||||||
|
@ -99,6 +103,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
|
||||||
config.setIntrospectionEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "token").path(TokenEndpoint.class, "introspect").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
config.setIntrospectionEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "token").path(TokenEndpoint.class, "introspect").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
||||||
config.setUserinfoEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "issueUserInfo").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
config.setUserinfoEndpoint(backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "issueUserInfo").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
||||||
config.setLogoutEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "logout").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
config.setLogoutEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "logout").build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
||||||
|
config.setDeviceAuthorizationEndpoint(frontendUriBuilder.clone().path(OIDCLoginProtocolService.class, "auth")
|
||||||
|
.path(AuthorizationEndpoint.class, "authorizeDevice").path(DeviceEndpoint.class, "handleDeviceRequest")
|
||||||
|
.build(realm.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL).toString());
|
||||||
URI jwksUri = backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(),
|
URI jwksUri = backendUriBuilder.clone().path(OIDCLoginProtocolService.class, "certs").build(realm.getName(),
|
||||||
OIDCLoginProtocol.LOGIN_PROTOCOL);
|
OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
package org.keycloak.protocol.oidc.endpoints;
|
package org.keycloak.protocol.oidc.endpoints;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.OAuthErrorException;
|
import org.keycloak.OAuthErrorException;
|
||||||
import org.keycloak.authentication.AuthenticationProcessor;
|
import org.keycloak.authentication.AuthenticationProcessor;
|
||||||
|
@ -37,6 +38,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.TokenManager;
|
import org.keycloak.protocol.oidc.TokenManager;
|
||||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
||||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
|
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
|
@ -57,6 +59,7 @@ import org.keycloak.util.TokenUtil;
|
||||||
import javax.ws.rs.Consumes;
|
import javax.ws.rs.Consumes;
|
||||||
import javax.ws.rs.GET;
|
import javax.ws.rs.GET;
|
||||||
import javax.ws.rs.POST;
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
import javax.ws.rs.core.MediaType;
|
import javax.ws.rs.core.MediaType;
|
||||||
import javax.ws.rs.core.MultivaluedMap;
|
import javax.ws.rs.core.MultivaluedMap;
|
||||||
import javax.ws.rs.core.Response;
|
import javax.ws.rs.core.Response;
|
||||||
|
@ -116,6 +119,16 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
|
||||||
return process(session.getContext().getUri().getQueryParameters());
|
return process(session.getContext().getUri().getQueryParameters());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth 2.0 Device Authorization endpoint
|
||||||
|
*/
|
||||||
|
@Path("device")
|
||||||
|
public Object authorizeDevice() {
|
||||||
|
DeviceEndpoint endpoint = new DeviceEndpoint(realm, event);
|
||||||
|
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
private Response process(MultivaluedMap<String, String> params) {
|
private Response process(MultivaluedMap<String, String> params) {
|
||||||
String clientId = AuthorizationEndpointRequestParserProcessor.getClientId(event, session, params);
|
String clientId = AuthorizationEndpointRequestParserProcessor.getClientId(event, session, params);
|
||||||
|
|
||||||
|
|
|
@ -1,346 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
|
||||||
* and other contributors as indicated by the @author tags.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.keycloak.protocol.oidc.endpoints;
|
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
|
||||||
import org.keycloak.OAuth2Constants;
|
|
||||||
import org.keycloak.OAuthErrorException;
|
|
||||||
import org.keycloak.authentication.AuthenticationProcessor;
|
|
||||||
import org.keycloak.common.util.Base64Url;
|
|
||||||
import org.keycloak.common.util.Time;
|
|
||||||
import org.keycloak.constants.AdapterConstants;
|
|
||||||
import org.keycloak.events.Details;
|
|
||||||
import org.keycloak.events.Errors;
|
|
||||||
import org.keycloak.events.EventBuilder;
|
|
||||||
import org.keycloak.events.EventType;
|
|
||||||
import org.keycloak.forms.login.LoginFormsProvider;
|
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
|
||||||
import org.keycloak.models.ClientModel;
|
|
||||||
import org.keycloak.models.OAuth2DeviceCodeModel;
|
|
||||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
|
||||||
import org.keycloak.models.OAuth2DeviceUserCodeModel;
|
|
||||||
import org.keycloak.models.OAuth2DeviceUserCodeProvider;
|
|
||||||
import org.keycloak.models.RealmModel;
|
|
||||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
|
||||||
import org.keycloak.models.utils.SystemClientUtil;
|
|
||||||
import org.keycloak.protocol.AuthorizationEndpointBase;
|
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
|
||||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
|
||||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
|
||||||
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
|
|
||||||
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
|
|
||||||
import org.keycloak.saml.common.util.StringUtil;
|
|
||||||
import org.keycloak.services.CorsErrorResponseException;
|
|
||||||
import org.keycloak.services.ErrorPageException;
|
|
||||||
import org.keycloak.services.ServicesLogger;
|
|
||||||
import org.keycloak.services.Urls;
|
|
||||||
import org.keycloak.services.managers.AuthenticationManager;
|
|
||||||
import org.keycloak.services.managers.ClientSessionCode;
|
|
||||||
import org.keycloak.services.messages.Messages;
|
|
||||||
import org.keycloak.services.resources.Cors;
|
|
||||||
import org.keycloak.services.resources.LoginActionsService;
|
|
||||||
import org.keycloak.services.resources.RealmsResource;
|
|
||||||
import org.keycloak.services.util.CacheControlUtil;
|
|
||||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
|
||||||
import org.keycloak.util.JsonSerialization;
|
|
||||||
import org.keycloak.util.TokenUtil;
|
|
||||||
|
|
||||||
import javax.ws.rs.Consumes;
|
|
||||||
import javax.ws.rs.POST;
|
|
||||||
import javax.ws.rs.core.MediaType;
|
|
||||||
import javax.ws.rs.core.MultivaluedMap;
|
|
||||||
import javax.ws.rs.core.Response;
|
|
||||||
|
|
||||||
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
|
||||||
*/
|
|
||||||
public class OAuth2DeviceAuthorizationEndpoint extends AuthorizationEndpointBase {
|
|
||||||
|
|
||||||
private static final Logger logger = Logger.getLogger(OAuth2DeviceAuthorizationEndpoint.class);
|
|
||||||
|
|
||||||
private enum Action {
|
|
||||||
OAUTH2_DEVICE_AUTH, OAUTH2_DEVICE_VERIFY_USER_CODE
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClientModel client;
|
|
||||||
private AuthenticationSessionModel authenticationSession;
|
|
||||||
private Action action;
|
|
||||||
private AuthorizationEndpointRequest request;
|
|
||||||
private Cors cors;
|
|
||||||
|
|
||||||
public OAuth2DeviceAuthorizationEndpoint(RealmModel realm, EventBuilder event) {
|
|
||||||
super(realm, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
@POST
|
|
||||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
|
||||||
public Response buildPost() {
|
|
||||||
logger.trace("Processing @POST request");
|
|
||||||
cors = Cors.add(httpRequest).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
|
||||||
action = Action.OAUTH2_DEVICE_AUTH;
|
|
||||||
event.event(EventType.OAUTH2_DEVICE_AUTH);
|
|
||||||
return process(httpRequest.getDecodedFormParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Response process(MultivaluedMap<String, String> params) {
|
|
||||||
checkSsl();
|
|
||||||
checkRealm();
|
|
||||||
checkClient(null);
|
|
||||||
|
|
||||||
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, params);
|
|
||||||
|
|
||||||
if (!TokenUtil.isOIDCRequest(request.getScope())) {
|
|
||||||
ServicesLogger.LOGGER.oidcScopeMissing();
|
|
||||||
}
|
|
||||||
|
|
||||||
authenticationSession = createAuthenticationSession(client, request.getState());
|
|
||||||
updateAuthenticationSession();
|
|
||||||
|
|
||||||
// So back button doesn't work
|
|
||||||
CacheControlUtil.noBackButtonCacheControlHeader();
|
|
||||||
switch (action) {
|
|
||||||
case OAUTH2_DEVICE_AUTH:
|
|
||||||
return buildDeviceAuthorizationResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RuntimeException("Unknown action " + action);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response buildDeviceAuthorizationResponse() {
|
|
||||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
|
||||||
event.error(Errors.NOT_ALLOWED);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
|
|
||||||
int interval = realm.getOAuth2DevicePollingInterval();
|
|
||||||
|
|
||||||
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
|
|
||||||
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce(), request.getAdditionalReqParams());
|
|
||||||
|
|
||||||
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
|
|
||||||
String secret = userCodeProvider.generate();
|
|
||||||
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm,
|
|
||||||
deviceCode.getDeviceCode(),
|
|
||||||
secret
|
|
||||||
);
|
|
||||||
|
|
||||||
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
|
|
||||||
int lifespanSeconds = expiresIn + interval + 10;
|
|
||||||
|
|
||||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
|
||||||
store.put(deviceCode, userCode, lifespanSeconds);
|
|
||||||
|
|
||||||
try {
|
|
||||||
String deviceUrl = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
|
|
||||||
|
|
||||||
OAuth2DeviceAuthorizationResponse response = new OAuth2DeviceAuthorizationResponse();
|
|
||||||
response.setDeviceCode(deviceCode.getDeviceCode());
|
|
||||||
response.setUserCode(userCodeProvider.display(secret));
|
|
||||||
response.setExpiresIn(expiresIn);
|
|
||||||
response.setInterval(interval);
|
|
||||||
response.setVerificationUri(deviceUrl);
|
|
||||||
response.setVerificationUriComplete(deviceUrl + "?user_code=" + response.getUserCode());
|
|
||||||
|
|
||||||
return Response.ok(JsonSerialization.writeValueAsBytes(response)).type(MediaType.APPLICATION_JSON_TYPE).build();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Error creating OAuth 2.0 Device Authorization Response.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkClient(String clientId) {
|
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.1
|
|
||||||
// The spec says "The client authentication requirements of Section 3.2.1 of [RFC6749]
|
|
||||||
// apply to requests on this endpoint".
|
|
||||||
if (action == Action.OAUTH2_DEVICE_AUTH) {
|
|
||||||
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event);
|
|
||||||
client = clientAuth.getClient();
|
|
||||||
clientId = client.getClientId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId == null) {
|
|
||||||
event.error(Errors.INVALID_REQUEST);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
|
|
||||||
}
|
|
||||||
|
|
||||||
event.client(clientId);
|
|
||||||
|
|
||||||
client = realm.getClientByClientId(clientId);
|
|
||||||
if (client == null) {
|
|
||||||
event.error(Errors.CLIENT_NOT_FOUND);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.isEnabled()) {
|
|
||||||
event.error(Errors.CLIENT_DISABLED);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_DISABLED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
|
||||||
event.error(Errors.NOT_ALLOWED);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client.isBearerOnly()) {
|
|
||||||
event.error(Errors.NOT_ALLOWED);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.FORBIDDEN, Messages.BEARER_ONLY);
|
|
||||||
}
|
|
||||||
|
|
||||||
String protocol = client.getProtocol();
|
|
||||||
if (protocol == null) {
|
|
||||||
logger.warnf("Client '%s' doesn't have protocol set. Fallback to openid-connect. Please fix client configuration", clientId);
|
|
||||||
protocol = OIDCLoginProtocol.LOGIN_PROTOCOL;
|
|
||||||
}
|
|
||||||
if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
|
||||||
event.error(Errors.INVALID_CLIENT);
|
|
||||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, "Wrong client protocol.");
|
|
||||||
}
|
|
||||||
|
|
||||||
session.getContext().setClient(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateAuthenticationSession() {
|
|
||||||
authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
|
||||||
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
|
|
||||||
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
|
||||||
|
|
||||||
if (request.getNonce() != null) authenticationSession.setClientNote(OIDCLoginProtocol.NONCE_PARAM, request.getNonce());
|
|
||||||
if (request.getMaxAge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.MAX_AGE_PARAM, String.valueOf(request.getMaxAge()));
|
|
||||||
if (request.getScope() != null) authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, request.getScope());
|
|
||||||
if (request.getLoginHint() != null) authenticationSession.setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, request.getLoginHint());
|
|
||||||
if (request.getPrompt() != null) authenticationSession.setClientNote(OIDCLoginProtocol.PROMPT_PARAM, request.getPrompt());
|
|
||||||
if (request.getIdpHint() != null) authenticationSession.setClientNote(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
|
|
||||||
if (request.getClaims()!= null) authenticationSession.setClientNote(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
|
|
||||||
if (request.getAcr() != null) authenticationSession.setClientNote(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
|
|
||||||
if (request.getDisplay() != null) authenticationSession.setAuthNote(OAuth2Constants.DISPLAY, request.getDisplay());
|
|
||||||
|
|
||||||
if (request.getAdditionalReqParams() != null) {
|
|
||||||
for (String paramName : request.getAdditionalReqParams().keySet()) {
|
|
||||||
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response buildVerificationResponse(String userCode) {
|
|
||||||
this.event.event(EventType.LOGIN);
|
|
||||||
action = Action.OAUTH2_DEVICE_VERIFY_USER_CODE;
|
|
||||||
|
|
||||||
checkSsl();
|
|
||||||
checkRealm();
|
|
||||||
|
|
||||||
// So back button doesn't work
|
|
||||||
CacheControlUtil.noBackButtonCacheControlHeader();
|
|
||||||
|
|
||||||
client = SystemClientUtil.getSystemClient(realm);
|
|
||||||
authenticationSession = createAuthenticationSession(client, null);
|
|
||||||
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
|
||||||
|
|
||||||
if (StringUtil.isNullOrEmpty(userCode)) {
|
|
||||||
return createVerificationPage(null);
|
|
||||||
} else {
|
|
||||||
return processVerification(userCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response processVerification(AuthenticationSessionModel authSessionWithSystemClient, String userCode) {
|
|
||||||
authenticationSession = authSessionWithSystemClient;
|
|
||||||
return processVerification(userCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response processVerification(String userCode) {
|
|
||||||
if (userCode == null) {
|
|
||||||
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
|
||||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format inputted user code
|
|
||||||
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
|
|
||||||
String formattedUserCode = userCodeProvider.format(userCode);
|
|
||||||
|
|
||||||
// Find the token from store
|
|
||||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
|
||||||
OAuth2DeviceCodeModel deviceCodeModel = store.getByUserCode(realm, formattedUserCode);
|
|
||||||
if (deviceCodeModel == null) {
|
|
||||||
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
|
||||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
|
|
||||||
if (expiresIn < 0) {
|
|
||||||
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
|
|
||||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update authentication session with requested clientId and scope from the device
|
|
||||||
updateAuthenticationSession(deviceCodeModel);
|
|
||||||
|
|
||||||
// Verification OK
|
|
||||||
authenticationSession.setClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE, formattedUserCode);
|
|
||||||
|
|
||||||
// Event logging for the verification
|
|
||||||
event.client(deviceCodeModel.getClientId())
|
|
||||||
.detail(Details.SCOPE, deviceCodeModel.getScope())
|
|
||||||
.success();
|
|
||||||
|
|
||||||
return redirectToBrowserAuthentication();
|
|
||||||
}
|
|
||||||
|
|
||||||
private AuthenticationSessionModel updateAuthenticationSession(OAuth2DeviceCodeModel deviceCode) {
|
|
||||||
checkClient(deviceCode.getClientId());
|
|
||||||
|
|
||||||
// Create request object using parameters which is used in device authorization request from the device
|
|
||||||
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, deviceCode.getParams());
|
|
||||||
|
|
||||||
// Re-create authentication session because current session doesn't have relation with the target device client
|
|
||||||
authenticationSession = createAuthenticationSession(client, null);
|
|
||||||
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
|
||||||
|
|
||||||
updateAuthenticationSession();
|
|
||||||
|
|
||||||
AuthenticationManager.setClientScopesInSession(authenticationSession);
|
|
||||||
|
|
||||||
return authenticationSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response createVerificationPage(String errorMessage) {
|
|
||||||
String execution = AuthenticatedClientSessionModel.Action.USER_CODE_VERIFICATION.name();
|
|
||||||
|
|
||||||
ClientSessionCode<AuthenticationSessionModel> accessCode = new ClientSessionCode<>(session, realm, authenticationSession);
|
|
||||||
authenticationSession.getParentSession().setTimestamp(Time.currentTime());
|
|
||||||
|
|
||||||
accessCode.setAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name());
|
|
||||||
authenticationSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution);
|
|
||||||
|
|
||||||
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
|
|
||||||
.setAuthenticationSession(authenticationSession)
|
|
||||||
.setExecution(execution)
|
|
||||||
.setClientSessionCode(accessCode.getOrGenerateCode());
|
|
||||||
|
|
||||||
if (errorMessage != null) {
|
|
||||||
provider = provider.setError(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider.createOAuth2DeviceVerifyUserCodePage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response redirectToBrowserAuthentication() {
|
|
||||||
return handleBrowserAuthenticationRequest(authenticationSession, new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event), false, true);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -39,7 +39,6 @@ import org.keycloak.common.Profile;
|
||||||
import org.keycloak.common.constants.ServiceAccountConstants;
|
import org.keycloak.common.constants.ServiceAccountConstants;
|
||||||
import org.keycloak.common.util.Base64Url;
|
import org.keycloak.common.util.Base64Url;
|
||||||
import org.keycloak.common.util.KeycloakUriBuilder;
|
import org.keycloak.common.util.KeycloakUriBuilder;
|
||||||
import org.keycloak.common.util.Time;
|
|
||||||
import org.keycloak.constants.AdapterConstants;
|
import org.keycloak.constants.AdapterConstants;
|
||||||
import org.keycloak.events.Details;
|
import org.keycloak.events.Details;
|
||||||
import org.keycloak.events.Errors;
|
import org.keycloak.events.Errors;
|
||||||
|
@ -57,14 +56,13 @@ import org.keycloak.models.IdentityProviderMapperModel;
|
||||||
import org.keycloak.models.IdentityProviderModel;
|
import org.keycloak.models.IdentityProviderModel;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.KeycloakSessionFactory;
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
import org.keycloak.models.OAuth2DeviceCodeModel;
|
|
||||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.models.UserModel;
|
import org.keycloak.models.UserModel;
|
||||||
import org.keycloak.models.UserSessionModel;
|
import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.models.utils.AuthenticationFlowResolver;
|
import org.keycloak.models.utils.AuthenticationFlowResolver;
|
||||||
import org.keycloak.protocol.LoginProtocol;
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
import org.keycloak.protocol.LoginProtocolFactory;
|
import org.keycloak.protocol.LoginProtocolFactory;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
|
||||||
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.TokenManager;
|
import org.keycloak.protocol.oidc.TokenManager;
|
||||||
|
@ -74,7 +72,6 @@ import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
||||||
import org.keycloak.protocol.saml.SamlClient;
|
import org.keycloak.protocol.saml.SamlClient;
|
||||||
import org.keycloak.protocol.saml.SamlProtocol;
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
import org.keycloak.protocol.saml.SamlService;
|
import org.keycloak.protocol.saml.SamlService;
|
||||||
import org.keycloak.provider.ProviderFactory;
|
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.AccessTokenResponse;
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
import org.keycloak.representations.JsonWebToken;
|
import org.keycloak.representations.JsonWebToken;
|
||||||
|
@ -99,7 +96,6 @@ import org.keycloak.services.managers.ClientManager;
|
||||||
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
||||||
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
||||||
import org.keycloak.services.managers.RealmManager;
|
import org.keycloak.services.managers.RealmManager;
|
||||||
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
|
||||||
import org.keycloak.services.resources.Cors;
|
import org.keycloak.services.resources.Cors;
|
||||||
import org.keycloak.services.resources.IdentityBrokerService;
|
import org.keycloak.services.resources.IdentityBrokerService;
|
||||||
import org.keycloak.services.resources.admin.AdminAuth;
|
import org.keycloak.services.resources.admin.AdminAuth;
|
||||||
|
@ -453,11 +449,16 @@ public class TokenEndpoint {
|
||||||
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
|
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
|
||||||
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce());
|
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, codeData.getNonce());
|
||||||
|
|
||||||
|
return codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Response codeOrDeviceCodeToToken(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
|
||||||
|
String scopeParam, boolean code) {
|
||||||
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
|
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
|
||||||
|
|
||||||
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
|
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
|
||||||
.accessToken(token)
|
.responseBuilder(realm, client, event, session, userSession, clientSessionCtx).accessToken(token)
|
||||||
.generateRefreshToken();
|
.generateRefreshToken();
|
||||||
|
|
||||||
// KEYCLOAK-6771 Certificate Bound Token
|
// KEYCLOAK-6771 Certificate Bound Token
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
||||||
|
@ -468,25 +469,30 @@ public class TokenEndpoint {
|
||||||
responseBuilder.getRefreshToken().setCertConf(certConf);
|
responseBuilder.getRefreshToken().setCertConf(certConf);
|
||||||
} else {
|
} else {
|
||||||
event.error(Errors.INVALID_REQUEST);
|
event.error(Errors.INVALID_REQUEST);
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
|
||||||
|
"Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
||||||
responseBuilder.generateIDToken().generateAccessTokenHash();
|
responseBuilder.generateIDToken().generateAccessTokenHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
AccessTokenResponse res = null;
|
AccessTokenResponse res = null;
|
||||||
try {
|
if (code) {
|
||||||
res = responseBuilder.build();
|
try {
|
||||||
} catch (RuntimeException re) {
|
res = responseBuilder.build();
|
||||||
if ("can not get encryption KEK".equals(re.getMessage())) {
|
} catch (RuntimeException re) {
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "can not get encryption KEK", Response.Status.BAD_REQUEST);
|
if ("can not get encryption KEK".equals(re.getMessage())) {
|
||||||
} else {
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
|
||||||
throw re;
|
"can not get encryption KEK", Response.Status.BAD_REQUEST);
|
||||||
|
} else {
|
||||||
|
throw re;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
res = responseBuilder.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
event.success();
|
event.success();
|
||||||
|
|
||||||
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
|
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
|
||||||
|
@ -1375,134 +1381,8 @@ public class TokenEndpoint {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Response oauth2DeviceCodeToToken() {
|
public Response oauth2DeviceCodeToToken() {
|
||||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
DeviceGrantType deviceGrantType = new DeviceGrantType(formParams, client, session, this, realm, event, cors);
|
||||||
event.error(Errors.NOT_ALLOWED);
|
return deviceGrantType.oauth2DeviceFlow();
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
String deviceCode = formParams.getFirst(OAuth2Constants.DEVICE_CODE);
|
|
||||||
if (deviceCode == null) {
|
|
||||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
|
||||||
OAuth2DeviceCodeModel deviceCodeModel = store.getByDeviceCode(realm, deviceCode);
|
|
||||||
|
|
||||||
if (deviceCodeModel == null) {
|
|
||||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Device code not valid", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!store.isPollingAllowed(deviceCodeModel)) {
|
|
||||||
event.error(Errors.SLOW_DOWN);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "Slow down", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
|
|
||||||
if (expiresIn < 0) {
|
|
||||||
event.error(Errors.EXPIRED_OAUTH2_DEVICE_CODE);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "Device code is expired", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deviceCodeModel.isPending()) {
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is still pending", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deviceCodeModel.isDenied()) {
|
|
||||||
event.error(Errors.ACCESS_DENIED);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "The end user denied the authorization request", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Approved
|
|
||||||
|
|
||||||
String userSessionId = deviceCodeModel.getUserSessionId();
|
|
||||||
event.detail(Details.CODE_ID, userSessionId);
|
|
||||||
event.session(userSessionId);
|
|
||||||
|
|
||||||
// Retrieve UserSession
|
|
||||||
UserSessionModel userSession = new UserSessionCrossDCManager(session)
|
|
||||||
.getUserSessionWithClient(realm, userSessionId, client.getId());
|
|
||||||
|
|
||||||
if (userSession == null) {
|
|
||||||
userSession = session.sessions().getUserSession(realm, userSessionId);
|
|
||||||
if (userSession == null) {
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is verified but can not lookup the user session yet", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now, remove the device code
|
|
||||||
store.removeDeviceCode(realm, deviceCode);
|
|
||||||
|
|
||||||
UserModel user = userSession.getUser();
|
|
||||||
if (user == null) {
|
|
||||||
event.error(Errors.USER_NOT_FOUND);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
event.user(userSession.getUser());
|
|
||||||
|
|
||||||
if (!user.isEnabled()) {
|
|
||||||
event.error(Errors.USER_DISABLED);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
|
||||||
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
|
|
||||||
event.error(Errors.INVALID_CODE);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Auth error", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
|
|
||||||
event.error(Errors.USER_SESSION_NOT_FOUND);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Session not active", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClientSession(clientSession);
|
|
||||||
updateUserSessionFromClientAuth(userSession);
|
|
||||||
|
|
||||||
// Compute client scopes again from scope parameter. Check if user still has them granted
|
|
||||||
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
|
|
||||||
String scopeParam = deviceCodeModel.getScope();
|
|
||||||
Stream<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
|
|
||||||
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
|
|
||||||
event.error(Errors.NOT_ALLOWED);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, session);
|
|
||||||
|
|
||||||
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
|
|
||||||
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
|
|
||||||
|
|
||||||
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
|
|
||||||
|
|
||||||
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
|
|
||||||
.accessToken(token)
|
|
||||||
.generateRefreshToken();
|
|
||||||
|
|
||||||
// KEYCLOAK-6771 Certificate Bound Token
|
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
|
||||||
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
|
|
||||||
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
|
|
||||||
if (certConf != null) {
|
|
||||||
responseBuilder.getAccessToken().setCertConf(certConf);
|
|
||||||
responseBuilder.getRefreshToken().setCertConf(certConf);
|
|
||||||
} else {
|
|
||||||
event.error(Errors.INVALID_REQUEST);
|
|
||||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
|
||||||
responseBuilder.generateIDToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
AccessTokenResponse res = responseBuilder.build();
|
|
||||||
|
|
||||||
event.success();
|
|
||||||
|
|
||||||
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://tools.ietf.org/html/rfc7636#section-4.1
|
// https://tools.ietf.org/html/rfc7636#section-4.1
|
||||||
|
|
|
@ -0,0 +1,290 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.protocol.oidc.grants.device;
|
||||||
|
|
||||||
|
import static org.keycloak.protocol.oidc.OIDCLoginProtocolService.tokenServiceBaseUrl;
|
||||||
|
|
||||||
|
import org.jboss.resteasy.spi.HttpRequest;
|
||||||
|
import org.keycloak.OAuth2Constants;
|
||||||
|
import org.keycloak.OAuthErrorException;
|
||||||
|
import org.keycloak.events.Details;
|
||||||
|
import org.keycloak.events.Errors;
|
||||||
|
import org.keycloak.events.EventBuilder;
|
||||||
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
|
import org.keycloak.models.ClientModel;
|
||||||
|
import org.keycloak.models.ClientScopeModel;
|
||||||
|
import org.keycloak.models.ClientSessionContext;
|
||||||
|
import org.keycloak.models.KeycloakContext;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.KeycloakUriInfo;
|
||||||
|
import org.keycloak.models.OAuth2DeviceCodeModel;
|
||||||
|
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.models.UserModel;
|
||||||
|
import org.keycloak.models.UserSessionModel;
|
||||||
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
||||||
|
import org.keycloak.protocol.oidc.TokenManager;
|
||||||
|
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
||||||
|
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
|
||||||
|
import org.keycloak.representations.AccessToken;
|
||||||
|
import org.keycloak.representations.AccessTokenResponse;
|
||||||
|
import org.keycloak.services.CorsErrorResponseException;
|
||||||
|
import org.keycloak.services.Urls;
|
||||||
|
import org.keycloak.services.managers.AuthenticationManager;
|
||||||
|
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
||||||
|
import org.keycloak.services.resources.Cors;
|
||||||
|
import org.keycloak.services.resources.LoginActionsService;
|
||||||
|
import org.keycloak.services.resources.RealmsResource;
|
||||||
|
import org.keycloak.services.util.DefaultClientSessionContext;
|
||||||
|
import org.keycloak.services.util.MtlsHoKTokenUtil;
|
||||||
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
import org.keycloak.util.TokenUtil;
|
||||||
|
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import javax.ws.rs.core.MultivaluedMap;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
import javax.ws.rs.core.UriBuilder;
|
||||||
|
import javax.ws.rs.core.UriInfo;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||||
|
* @author <a href="mailto:michito.okai.zn@hitachi.com">Michito Okai</a>
|
||||||
|
*/
|
||||||
|
public class DeviceGrantType {
|
||||||
|
|
||||||
|
// OAuth 2.0 Device Authorization Grant
|
||||||
|
public static final String OAUTH2_DEVICE_VERIFIED_USER_CODE = "OAUTH2_DEVICE_VERIFIED_USER_CODE";
|
||||||
|
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
|
||||||
|
public static final String OAUTH2_USER_CODE_VERIFY = "device/verify";
|
||||||
|
|
||||||
|
public static UriBuilder oauth2DeviceVerificationUrl(UriInfo uriInfo) {
|
||||||
|
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
|
||||||
|
return baseUriBuilder.path(RealmsResource.class).path("{realm}").path("device");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static URI realmOAuth2DeviceVerificationAction(URI baseUri, String realmName) {
|
||||||
|
return UriBuilder.fromUri(baseUri).path(RealmsResource.class).path("{realm}").path("device")
|
||||||
|
.build(realmName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UriBuilder oauth2DeviceAuthUrl(UriBuilder baseUriBuilder) {
|
||||||
|
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
|
||||||
|
return uriBuilder.path(OIDCLoginProtocolService.class, "auth").path(AuthorizationEndpoint.class, "authorizeDevice")
|
||||||
|
.path(DeviceEndpoint.class, "handleDeviceRequest");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UriBuilder oauth2DeviceVerificationCompletedUrl(UriInfo baseUri) {
|
||||||
|
return baseUri.getBaseUriBuilder().path(RealmsResource.class).path("{realm}").path("device").path("status");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Response denyOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, LoginProtocol.Error error, KeycloakSession session) {
|
||||||
|
KeycloakContext context = session.getContext();
|
||||||
|
RealmModel realm = context.getRealm();
|
||||||
|
KeycloakUriInfo uri = context.getUri();
|
||||||
|
UriBuilder uriBuilder = DeviceGrantType.oauth2DeviceVerificationCompletedUrl(uri);
|
||||||
|
String errorType = OAuthErrorException.SERVER_ERROR;
|
||||||
|
if (error == LoginProtocol.Error.CONSENT_DENIED) {
|
||||||
|
String verifiedUserCode = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
||||||
|
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||||
|
if (!store.deny(realm, verifiedUserCode)) {
|
||||||
|
// Already expired and removed in the store
|
||||||
|
errorType = OAuthErrorException.EXPIRED_TOKEN;
|
||||||
|
} else {
|
||||||
|
errorType = OAuthErrorException.ACCESS_DENIED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.status(302).location(
|
||||||
|
uriBuilder.queryParam(OAuth2Constants.ERROR, errorType)
|
||||||
|
.build(realm.getName())
|
||||||
|
).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Response approveOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, AuthenticatedClientSessionModel clientSession, KeycloakSession session) {
|
||||||
|
KeycloakContext context = session.getContext();
|
||||||
|
RealmModel realm = context.getRealm();
|
||||||
|
KeycloakUriInfo uriInfo = context.getUri();
|
||||||
|
UriBuilder uriBuilder = DeviceGrantType.oauth2DeviceVerificationCompletedUrl(uriInfo);
|
||||||
|
|
||||||
|
String verifiedUserCode = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
||||||
|
String userSessionId = clientSession.getUserSession().getId();
|
||||||
|
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||||
|
if (!store.approve(realm, verifiedUserCode, userSessionId)) {
|
||||||
|
// Already expired and removed in the store
|
||||||
|
return Response.status(302).location(
|
||||||
|
uriBuilder.queryParam(OAuth2Constants.ERROR, OAuthErrorException.EXPIRED_TOKEN)
|
||||||
|
.build(realm.getName())
|
||||||
|
).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, remove the verified user code
|
||||||
|
store.removeUserCode(realm, verifiedUserCode);
|
||||||
|
|
||||||
|
return Response.status(302).location(
|
||||||
|
uriBuilder.build(realm.getName())
|
||||||
|
).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSessionModel authSession) {
|
||||||
|
String flow = authSession.getClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
||||||
|
return flow != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MultivaluedMap<String, String> formParams;
|
||||||
|
private ClientModel client;
|
||||||
|
|
||||||
|
private KeycloakSession session;
|
||||||
|
|
||||||
|
private TokenEndpoint tokenEndpoint;
|
||||||
|
|
||||||
|
private final RealmModel realm;
|
||||||
|
private final EventBuilder event;
|
||||||
|
|
||||||
|
private Cors cors;
|
||||||
|
|
||||||
|
public DeviceGrantType(MultivaluedMap<String, String> formParams, ClientModel client, KeycloakSession session,
|
||||||
|
TokenEndpoint tokenEndpoint, RealmModel realm, EventBuilder event, Cors cors) {
|
||||||
|
this.formParams = formParams;
|
||||||
|
this.client = client;
|
||||||
|
this.session = session;
|
||||||
|
this.tokenEndpoint = tokenEndpoint;
|
||||||
|
this.realm = realm;
|
||||||
|
this.event = event;
|
||||||
|
this.cors = cors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Response oauth2DeviceFlow() {
|
||||||
|
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
|
||||||
|
event.error(Errors.NOT_ALLOWED);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT,
|
||||||
|
"Client not allowed OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
String deviceCode = formParams.getFirst(OAuth2Constants.DEVICE_CODE);
|
||||||
|
if (deviceCode == null) {
|
||||||
|
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
|
||||||
|
"Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||||
|
OAuth2DeviceCodeModel deviceCodeModel = store.getByDeviceCode(realm, deviceCode);
|
||||||
|
|
||||||
|
if (deviceCodeModel == null) {
|
||||||
|
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Device code not valid",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCodeModel.isExpired()) {
|
||||||
|
event.error(Errors.EXPIRED_OAUTH2_DEVICE_CODE);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "Device code is expired",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.isPollingAllowed(deviceCodeModel)) {
|
||||||
|
event.error(Errors.SLOW_DOWN);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "Slow down", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCodeModel.isDenied()) {
|
||||||
|
event.error(Errors.ACCESS_DENIED);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED,
|
||||||
|
"The end user denied the authorization request", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCodeModel.isPending()) {
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING,
|
||||||
|
"The authorization request is still pending", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approved
|
||||||
|
|
||||||
|
String userSessionId = deviceCodeModel.getUserSessionId();
|
||||||
|
event.detail(Details.CODE_ID, userSessionId);
|
||||||
|
event.session(userSessionId);
|
||||||
|
|
||||||
|
// Retrieve UserSession
|
||||||
|
UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, userSessionId,
|
||||||
|
client.getId());
|
||||||
|
|
||||||
|
if (userSession == null) {
|
||||||
|
userSession = session.sessions().getUserSession(realm, userSessionId);
|
||||||
|
if (userSession == null) {
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING,
|
||||||
|
"The authorization request is verified but can not lookup the user session yet",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, remove the device code
|
||||||
|
store.removeDeviceCode(realm, deviceCode);
|
||||||
|
|
||||||
|
UserModel user = userSession.getUser();
|
||||||
|
if (user == null) {
|
||||||
|
event.error(Errors.USER_NOT_FOUND);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.user(userSession.getUser());
|
||||||
|
|
||||||
|
if (!user.isEnabled()) {
|
||||||
|
event.error(Errors.USER_DISABLED);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
||||||
|
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
|
||||||
|
event.error(Errors.INVALID_CODE);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Auth error",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
|
||||||
|
event.error(Errors.USER_SESSION_NOT_FOUND);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Session not active",
|
||||||
|
Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute client scopes again from scope parameter. Check if user still has them granted
|
||||||
|
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
|
||||||
|
String scopeParam = deviceCodeModel.getScope();
|
||||||
|
Stream<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
|
||||||
|
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
|
||||||
|
event.error(Errors.NOT_ALLOWED);
|
||||||
|
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE,
|
||||||
|
"Client no longer has requested consent from user", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession,
|
||||||
|
clientScopes, session);
|
||||||
|
|
||||||
|
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
|
||||||
|
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
|
||||||
|
|
||||||
|
return tokenEndpoint.codeOrDeviceCodeToToken(user, userSession, clientSessionCtx, scopeParam, false);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,367 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||||
|
* and other contributors as indicated by the @author tags.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.protocol.oidc.grants.device.endpoints;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.OAuth2Constants;
|
||||||
|
import org.keycloak.OAuthErrorException;
|
||||||
|
import org.keycloak.authentication.AuthenticationProcessor;
|
||||||
|
import org.keycloak.common.util.Base64Url;
|
||||||
|
import org.keycloak.common.util.Time;
|
||||||
|
import org.keycloak.constants.AdapterConstants;
|
||||||
|
import org.keycloak.events.Details;
|
||||||
|
import org.keycloak.events.Errors;
|
||||||
|
import org.keycloak.events.EventBuilder;
|
||||||
|
import org.keycloak.events.EventType;
|
||||||
|
import org.keycloak.forms.login.LoginFormsProvider;
|
||||||
|
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||||
|
import org.keycloak.models.ClientModel;
|
||||||
|
import org.keycloak.models.Constants;
|
||||||
|
import org.keycloak.models.OAuth2DeviceCodeModel;
|
||||||
|
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||||
|
import org.keycloak.models.OAuth2DeviceUserCodeModel;
|
||||||
|
import org.keycloak.models.OAuth2DeviceUserCodeProvider;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
|
import org.keycloak.models.utils.SystemClientUtil;
|
||||||
|
import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
|
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
||||||
|
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
|
||||||
|
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
|
||||||
|
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
|
||||||
|
import org.keycloak.saml.common.util.StringUtil;
|
||||||
|
import org.keycloak.services.ErrorPageException;
|
||||||
|
import org.keycloak.services.ErrorResponseException;
|
||||||
|
import org.keycloak.services.ServicesLogger;
|
||||||
|
import org.keycloak.services.Urls;
|
||||||
|
import org.keycloak.services.messages.Messages;
|
||||||
|
import org.keycloak.services.resource.RealmResourceProvider;
|
||||||
|
import org.keycloak.services.resources.SessionCodeChecks;
|
||||||
|
import org.keycloak.services.util.CacheControlUtil;
|
||||||
|
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||||
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
import org.keycloak.util.TokenUtil;
|
||||||
|
|
||||||
|
import javax.ws.rs.Consumes;
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.HttpMethod;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.Produces;
|
||||||
|
import javax.ws.rs.QueryParam;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
import javax.ws.rs.core.MultivaluedMap;
|
||||||
|
import javax.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.OAUTH2_DEVICE_USER_CODE;
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.OAUTH2_USER_CODE_VERIFY;
|
||||||
|
import static org.keycloak.services.resources.LoginActionsService.SESSION_CODE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||||
|
*/
|
||||||
|
public class DeviceEndpoint extends AuthorizationEndpointBase implements RealmResourceProvider {
|
||||||
|
|
||||||
|
protected static final Logger logger = Logger.getLogger(DeviceEndpoint.class);
|
||||||
|
|
||||||
|
public DeviceEndpoint(RealmModel realm, EventBuilder event) {
|
||||||
|
super(realm, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles device authorization requests.
|
||||||
|
*
|
||||||
|
* @return the device authorization response.
|
||||||
|
*/
|
||||||
|
@Path("")
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public Response handleDeviceRequest() {
|
||||||
|
logger.trace("Processing @POST request");
|
||||||
|
event.event(EventType.OAUTH2_DEVICE_AUTH);
|
||||||
|
|
||||||
|
checkSsl();
|
||||||
|
checkRealm();
|
||||||
|
|
||||||
|
ClientModel client = authenticateClient();
|
||||||
|
|
||||||
|
AuthorizationEndpointRequest request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client,
|
||||||
|
httpRequest.getDecodedFormParameters());
|
||||||
|
|
||||||
|
if (!TokenUtil.isOIDCRequest(request.getScope())) {
|
||||||
|
ServicesLogger.LOGGER.oidcScopeMissing();
|
||||||
|
}
|
||||||
|
|
||||||
|
// So back button doesn't work
|
||||||
|
CacheControlUtil.noBackButtonCacheControlHeader();
|
||||||
|
|
||||||
|
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
|
||||||
|
event.error(Errors.NOT_ALLOWED);
|
||||||
|
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT,
|
||||||
|
"Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
int expiresIn = realm.getOAuth2DeviceConfig().getLifespan(client);
|
||||||
|
int interval = realm.getOAuth2DeviceConfig().getPoolingInterval(client);
|
||||||
|
|
||||||
|
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
|
||||||
|
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce(), expiresIn, interval,
|
||||||
|
request.getAdditionalReqParams());
|
||||||
|
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
|
||||||
|
String secret = userCodeProvider.generate();
|
||||||
|
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(), secret);
|
||||||
|
|
||||||
|
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
|
||||||
|
int lifespanSeconds = expiresIn + interval + 10;
|
||||||
|
|
||||||
|
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||||
|
store.put(deviceCode, userCode, lifespanSeconds);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String deviceUrl = DeviceGrantType.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName())
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
OAuth2DeviceAuthorizationResponse response = new OAuth2DeviceAuthorizationResponse();
|
||||||
|
response.setDeviceCode(deviceCode.getDeviceCode());
|
||||||
|
response.setUserCode(userCodeProvider.display(secret));
|
||||||
|
response.setExpiresIn(expiresIn);
|
||||||
|
response.setInterval(interval);
|
||||||
|
response.setVerificationUri(deviceUrl);
|
||||||
|
response.setVerificationUriComplete(deviceUrl + "?user_code=" + response.getUserCode());
|
||||||
|
|
||||||
|
return Response.ok(JsonSerialization.writeValueAsBytes(response)).type(MediaType.APPLICATION_JSON_TYPE).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Error creating OAuth 2.0 Device Authorization Response.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This endpoint is used by end-users to start the flow to authorize a device.
|
||||||
|
*
|
||||||
|
* @param userCode the user code to authorize
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
public Response verifyUserCode(@QueryParam("user_code") String userCode) {
|
||||||
|
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
|
||||||
|
|
||||||
|
checkSsl();
|
||||||
|
checkRealm();
|
||||||
|
|
||||||
|
// So back button doesn't work
|
||||||
|
CacheControlUtil.noBackButtonCacheControlHeader();
|
||||||
|
|
||||||
|
// code is not known, we can infer the client neither. ask the user to provide the code.
|
||||||
|
if (StringUtil.isNullOrEmpty(userCode)) {
|
||||||
|
return createVerificationPage(null);
|
||||||
|
} else {
|
||||||
|
// code exists, probably due to using a verification_uri_complete. Start the authentication considering the client
|
||||||
|
// that started the flow.
|
||||||
|
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||||
|
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
|
||||||
|
String formattedUserCode = userCodeProvider.format(userCode);
|
||||||
|
OAuth2DeviceCodeModel deviceCode = store.getByUserCode(realm, formattedUserCode);
|
||||||
|
|
||||||
|
if (deviceCode == null) {
|
||||||
|
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
||||||
|
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceCode.isExpired()) {
|
||||||
|
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
||||||
|
return createVerificationPage(Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processVerification(deviceCode, formattedUserCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the code provided by the end-user and start the authentication.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@Path("/")
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||||
|
public Response verifyUserCode() {
|
||||||
|
MultivaluedMap<String, String> formData = httpRequest.getDecodedFormParameters();
|
||||||
|
return verifyUserCode(formData.getFirst(OAUTH2_DEVICE_USER_CODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Showing the result of verification process for OAuth 2.0 Device Authorization Grant. This outputs login success or
|
||||||
|
* failure messages.
|
||||||
|
*
|
||||||
|
* @param error
|
||||||
|
* @return
|
||||||
|
*/
|
||||||
|
@Path("status")
|
||||||
|
@GET
|
||||||
|
public Response status(@QueryParam("error") String error) {
|
||||||
|
if (!StringUtil.isNullOrEmpty(error)) {
|
||||||
|
String message;
|
||||||
|
switch (error) {
|
||||||
|
case OAuthErrorException.ACCESS_DENIED:
|
||||||
|
// cased by CANCELLED_BY_USER or CONSENT_DENIED:
|
||||||
|
message = Messages.OAUTH2_DEVICE_CONSENT_DENIED;
|
||||||
|
break;
|
||||||
|
case OAuthErrorException.EXPIRED_TOKEN:
|
||||||
|
message = Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = Messages.OAUTH2_DEVICE_VERIFICATION_FAILED;
|
||||||
|
}
|
||||||
|
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
||||||
|
String restartUri = DeviceGrantType.oauth2DeviceVerificationUrl(session.getContext().getUri())
|
||||||
|
.build(realm.getName()).toString();
|
||||||
|
return forms.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER))
|
||||||
|
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, restartUri).setError(message).createInfoPage();
|
||||||
|
} else {
|
||||||
|
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
||||||
|
return forms.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER))
|
||||||
|
.setAttribute(Constants.SKIP_LINK, true).setSuccess(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE)
|
||||||
|
.createInfoPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response createVerificationPage(String errorMessage) {
|
||||||
|
String execution = AuthenticatedClientSessionModel.Action.USER_CODE_VERIFICATION.name();
|
||||||
|
|
||||||
|
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
|
||||||
|
.setExecution(execution);
|
||||||
|
|
||||||
|
if (errorMessage != null) {
|
||||||
|
provider = provider.setError(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.createOAuth2DeviceVerifyUserCodePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Response processVerification(OAuth2DeviceCodeModel deviceCode, String userCode) {
|
||||||
|
int expiresIn = deviceCode.getExpiration() - Time.currentTime();
|
||||||
|
if (expiresIn < 0) {
|
||||||
|
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
|
||||||
|
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClientModel client = realm.getClientByClientId(deviceCode.getClientId());
|
||||||
|
AuthenticationSessionModel authenticationSession = createAuthenticationSession(client);
|
||||||
|
|
||||||
|
// Verification OK
|
||||||
|
authenticationSession.setClientNote(DeviceGrantType.OAUTH2_DEVICE_VERIFIED_USER_CODE, userCode);
|
||||||
|
|
||||||
|
// Event logging for the verification
|
||||||
|
event.client(deviceCode.getClientId()).detail(Details.SCOPE, deviceCode.getScope()).success();
|
||||||
|
|
||||||
|
OIDCLoginProtocol protocol = new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event);
|
||||||
|
return handleBrowserAuthenticationRequest(authenticationSession, protocol, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object getResource() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientModel authenticateClient() {
|
||||||
|
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.1
|
||||||
|
// The spec says "The client authentication requirements of Section 3.2.1 of [RFC6749]
|
||||||
|
// apply to requests on this endpoint".
|
||||||
|
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event, null);
|
||||||
|
ClientModel client = clientAuth.getClient();
|
||||||
|
|
||||||
|
if (client == null) {
|
||||||
|
event.error(Errors.INVALID_REQUEST);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, Messages.MISSING_PARAMETER,
|
||||||
|
OIDCLoginProtocol.CLIENT_ID_PARAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkClient(client.getClientId());
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClientModel checkClient(String clientId) {
|
||||||
|
if (clientId == null) {
|
||||||
|
event.error(Errors.INVALID_REQUEST);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
|
||||||
|
Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.client(clientId);
|
||||||
|
|
||||||
|
ClientModel client = realm.getClientByClientId(clientId);
|
||||||
|
if (client == null) {
|
||||||
|
event.error(Errors.CLIENT_NOT_FOUND);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
|
||||||
|
Messages.CLIENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.isEnabled()) {
|
||||||
|
event.error(Errors.CLIENT_DISABLED);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, Messages.CLIENT_DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!realm.getOAuth2DeviceConfig().isOAuth2DeviceAuthorizationGrantEnabled(client)) {
|
||||||
|
event.error(Errors.NOT_ALLOWED);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST,
|
||||||
|
Messages.OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.isBearerOnly()) {
|
||||||
|
event.error(Errors.NOT_ALLOWED);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.FORBIDDEN, Messages.BEARER_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
String protocol = client.getProtocol();
|
||||||
|
if (protocol == null) {
|
||||||
|
logger.warnf("Client '%s' doesn't have protocol set. Fallback to openid-connect. Please fix client configuration",
|
||||||
|
clientId);
|
||||||
|
protocol = OIDCLoginProtocol.LOGIN_PROTOCOL;
|
||||||
|
}
|
||||||
|
if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
||||||
|
event.error(Errors.INVALID_CLIENT);
|
||||||
|
throw new ErrorPageException(session, null, Response.Status.BAD_REQUEST, "Wrong client protocol.");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.getContext().setClient(client);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected AuthenticationSessionModel createAuthenticationSession(ClientModel client) {
|
||||||
|
AuthenticationSessionModel authenticationSession = super.createAuthenticationSession(client, null);
|
||||||
|
|
||||||
|
authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||||
|
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
|
||||||
|
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER,
|
||||||
|
Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
||||||
|
|
||||||
|
return authenticationSession;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
/*
|
||||||
|
*
|
||||||
|
* * Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||||
|
* * and other contributors as indicated by the @author tags.
|
||||||
|
* *
|
||||||
|
* * Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* * you may not use this file except in compliance with the License.
|
||||||
|
* * You may obtain a copy of the License at
|
||||||
|
* *
|
||||||
|
* * http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* *
|
||||||
|
* * Unless required by applicable law or agreed to in writing, software
|
||||||
|
* * distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* * See the License for the specific language governing permissions and
|
||||||
|
* * limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.keycloak.protocol.oidc.grants.device.endpoints;
|
||||||
|
|
||||||
|
import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
||||||
|
import org.keycloak.Config;
|
||||||
|
import org.keycloak.events.EventBuilder;
|
||||||
|
import org.keycloak.models.KeycloakContext;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.models.KeycloakSessionFactory;
|
||||||
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.services.resource.RealmResourceProvider;
|
||||||
|
import org.keycloak.services.resource.RealmResourceProviderFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author <a href="mailto:psilva@redhat.com">Pedro Igor</a>
|
||||||
|
*/
|
||||||
|
public class DeviceEndpointFactory implements RealmResourceProviderFactory {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RealmResourceProvider create(KeycloakSession session) {
|
||||||
|
KeycloakContext context = session.getContext();
|
||||||
|
RealmModel realm = context.getRealm();
|
||||||
|
EventBuilder event = new EventBuilder(realm, session, context.getConnection());
|
||||||
|
DeviceEndpoint provider = new DeviceEndpoint(realm, event);
|
||||||
|
ResteasyProviderFactory.getInstance().injectProperties(provider);
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void init(Config.Scope config) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postInit(KeycloakSessionFactory factory) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getId() {
|
||||||
|
return "device";
|
||||||
|
}
|
||||||
|
}
|
|
@ -245,10 +245,6 @@ public class Urls {
|
||||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmName);
|
return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static URI realmOAuth2DeviceVerificationAction(URI baseUri, String realmName) {
|
|
||||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "processOAuth2DeviceVerification").build(realmName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) {
|
public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) {
|
||||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
|
return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
|
||||||
.build(realmName);
|
.build(realmName);
|
||||||
|
@ -262,7 +258,7 @@ public class Urls {
|
||||||
return themeBase(baseUri).path(Version.RESOURCES_VERSION).build();
|
return themeBase(baseUri).path(Version.RESOURCES_VERSION).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static UriBuilder loginActionsBase(URI baseUri) {
|
public static UriBuilder loginActionsBase(URI baseUri) {
|
||||||
return realmBase(baseUri).path(RealmsResource.class, "getLoginActionsService");
|
return realmBase(baseUri).path(RealmsResource.class, "getLoginActionsService");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,8 @@ import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import static org.keycloak.models.OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||||
*/
|
*/
|
||||||
|
@ -312,7 +314,8 @@ public class DescriptionConverter {
|
||||||
if (client.isServiceAccountsEnabled()) {
|
if (client.isServiceAccountsEnabled()) {
|
||||||
grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS);
|
grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS);
|
||||||
}
|
}
|
||||||
if (client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
boolean oauth2DeviceEnabled = client.getAttributes() != null && Boolean.parseBoolean(client.getAttributes().get(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED));
|
||||||
|
if (oauth2DeviceEnabled) {
|
||||||
grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
|
grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
|
||||||
}
|
}
|
||||||
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {
|
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {
|
||||||
|
|
|
@ -81,8 +81,6 @@ public class ApplianceBootstrap {
|
||||||
realm.setAccessCodeLifespan(60);
|
realm.setAccessCodeLifespan(60);
|
||||||
realm.setAccessCodeLifespanUserAction(300);
|
realm.setAccessCodeLifespanUserAction(300);
|
||||||
realm.setAccessCodeLifespanLogin(1800);
|
realm.setAccessCodeLifespanLogin(1800);
|
||||||
realm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
|
||||||
realm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
|
||||||
realm.setSslRequired(SslRequired.EXTERNAL);
|
realm.setSslRequired(SslRequired.EXTERNAL);
|
||||||
realm.setRegistrationAllowed(false);
|
realm.setRegistrationAllowed(false);
|
||||||
realm.setRegistrationEmailAsUsername(false);
|
realm.setRegistrationEmailAsUsername(false);
|
||||||
|
|
|
@ -62,7 +62,6 @@ import org.keycloak.models.UserSessionModel;
|
||||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
import org.keycloak.models.utils.SessionTimeoutHelper;
|
import org.keycloak.models.utils.SessionTimeoutHelper;
|
||||||
import org.keycloak.models.utils.SystemClientUtil;
|
import org.keycloak.models.utils.SystemClientUtil;
|
||||||
import org.keycloak.protocol.AuthorizationEndpointBase;
|
|
||||||
import org.keycloak.protocol.LoginProtocol;
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
import org.keycloak.protocol.LoginProtocol.Error;
|
import org.keycloak.protocol.LoginProtocol.Error;
|
||||||
import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
|
import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
|
||||||
|
@ -90,7 +89,6 @@ import javax.ws.rs.core.Response;
|
||||||
import javax.ws.rs.core.UriBuilder;
|
import javax.ws.rs.core.UriBuilder;
|
||||||
import javax.ws.rs.core.UriInfo;
|
import javax.ws.rs.core.UriInfo;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -102,6 +100,7 @@ import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
|
import static org.keycloak.common.util.ServerCookie.SameSiteAttributeValue;
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.isOAuth2DeviceVerificationFlow;
|
||||||
import static org.keycloak.services.util.CookieHelper.getCookie;
|
import static org.keycloak.services.util.CookieHelper.getCookie;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1080,11 +1079,6 @@ public class AuthenticationManager {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSessionModel authSession) {
|
|
||||||
String flow = authSession.getClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW);
|
|
||||||
return flow != null && flow.equals(LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent,
|
private static List<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent,
|
||||||
AuthenticationSessionModel authSession) {
|
AuthenticationSessionModel authSession) {
|
||||||
// Client Scopes to be displayed on consent screen
|
// Client Scopes to be displayed on consent screen
|
||||||
|
|
|
@ -18,7 +18,6 @@ package org.keycloak.services.resources;
|
||||||
|
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.jboss.resteasy.spi.HttpRequest;
|
import org.jboss.resteasy.spi.HttpRequest;
|
||||||
import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
|
||||||
import org.keycloak.OAuth2Constants;
|
import org.keycloak.OAuth2Constants;
|
||||||
import org.keycloak.TokenVerifier;
|
import org.keycloak.TokenVerifier;
|
||||||
import org.keycloak.authentication.AuthenticationFlowException;
|
import org.keycloak.authentication.AuthenticationFlowException;
|
||||||
|
@ -68,7 +67,6 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||||
import org.keycloak.protocol.LoginProtocol;
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
import org.keycloak.protocol.LoginProtocol.Error;
|
import org.keycloak.protocol.LoginProtocol.Error;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
import org.keycloak.protocol.oidc.utils.RedirectUtils;
|
import org.keycloak.protocol.oidc.utils.RedirectUtils;
|
||||||
|
@ -80,6 +78,7 @@ import org.keycloak.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.services.managers.AuthenticationSessionManager;
|
import org.keycloak.services.managers.AuthenticationSessionManager;
|
||||||
import org.keycloak.services.managers.ClientSessionCode;
|
import org.keycloak.services.managers.ClientSessionCode;
|
||||||
import org.keycloak.services.messages.Messages;
|
import org.keycloak.services.messages.Messages;
|
||||||
|
import org.keycloak.services.resource.RealmResourceProvider;
|
||||||
import org.keycloak.services.util.AuthenticationFlowURLHelper;
|
import org.keycloak.services.util.AuthenticationFlowURLHelper;
|
||||||
import org.keycloak.services.util.BrowserHistoryHelper;
|
import org.keycloak.services.util.BrowserHistoryHelper;
|
||||||
import org.keycloak.services.util.CacheControlUtil;
|
import org.keycloak.services.util.CacheControlUtil;
|
||||||
|
@ -120,7 +119,6 @@ public class LoginActionsService {
|
||||||
public static final String REQUIRED_ACTION = "required-action";
|
public static final String REQUIRED_ACTION = "required-action";
|
||||||
public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login";
|
public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login";
|
||||||
public static final String POST_BROKER_LOGIN_PATH = "post-broker-login";
|
public static final String POST_BROKER_LOGIN_PATH = "post-broker-login";
|
||||||
public static final String OAUTH2_DEVICE_VERIFICATION_PATH = "verification";
|
|
||||||
|
|
||||||
public static final String RESTART_PATH = "restart";
|
public static final String RESTART_PATH = "restart";
|
||||||
|
|
||||||
|
@ -131,8 +129,6 @@ public class LoginActionsService {
|
||||||
|
|
||||||
public static final String CANCEL_AIA = "cancel-aia";
|
public static final String CANCEL_AIA = "cancel-aia";
|
||||||
|
|
||||||
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
|
|
||||||
|
|
||||||
private RealmModel realm;
|
private RealmModel realm;
|
||||||
|
|
||||||
@Context
|
@Context
|
||||||
|
@ -850,36 +846,6 @@ public class LoginActionsService {
|
||||||
return Response.status(302).location(redirect).build();
|
return Response.status(302).location(redirect).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifying user code page. You should not invoked this directly!
|
|
||||||
*
|
|
||||||
* @param formData
|
|
||||||
* @return
|
|
||||||
*/
|
|
||||||
@Path("verification")
|
|
||||||
@POST
|
|
||||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
|
||||||
public Response processOAuth2DeviceVerification(final MultivaluedMap<String, String> formData) {
|
|
||||||
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
|
|
||||||
|
|
||||||
String code = formData.getFirst(SESSION_CODE);
|
|
||||||
String tabId = session.getContext().getUri().getQueryParameters().getFirst(Constants.TAB_ID);
|
|
||||||
|
|
||||||
String systemClientId = SystemClientUtil.getSystemClient(realm).getClientId();
|
|
||||||
|
|
||||||
SessionCodeChecks checks = checksForCode(null, code, null, systemClientId, tabId, OAUTH2_DEVICE_VERIFICATION_PATH);
|
|
||||||
if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name(), ClientSessionCode.ActionType.LOGIN)) {
|
|
||||||
return checks.getResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
|
||||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
|
||||||
|
|
||||||
AuthenticationSessionModel authSessionWithSystemClient = checks.getAuthenticationSession();
|
|
||||||
String userCode = formData.getFirst(OAUTH2_DEVICE_USER_CODE);
|
|
||||||
return endpoint.processVerification(authSessionWithSystemClient, userCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth grant page. You should not invoked this directly!
|
* OAuth grant page. You should not invoked this directly!
|
||||||
*
|
*
|
||||||
|
|
|
@ -31,7 +31,6 @@ import org.keycloak.models.RealmModel;
|
||||||
import org.keycloak.protocol.LoginProtocol;
|
import org.keycloak.protocol.LoginProtocol;
|
||||||
import org.keycloak.protocol.LoginProtocolFactory;
|
import org.keycloak.protocol.LoginProtocolFactory;
|
||||||
import org.keycloak.services.CorsErrorResponseException;
|
import org.keycloak.services.CorsErrorResponseException;
|
||||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
|
||||||
import org.keycloak.services.clientregistration.ClientRegistrationService;
|
import org.keycloak.services.clientregistration.ClientRegistrationService;
|
||||||
import org.keycloak.services.managers.RealmManager;
|
import org.keycloak.services.managers.RealmManager;
|
||||||
import org.keycloak.services.resource.RealmResourceProvider;
|
import org.keycloak.services.resource.RealmResourceProvider;
|
||||||
|
@ -101,15 +100,6 @@ public class RealmsResource {
|
||||||
return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService");
|
return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UriBuilder oauth2DeviceVerificationUrl(UriInfo uriInfo) {
|
|
||||||
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
|
|
||||||
return oauth2DeviceVerificationUrl(baseUriBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UriBuilder oauth2DeviceVerificationUrl(UriBuilder baseUriBuilder) {
|
|
||||||
return baseUriBuilder.path(RealmsResource.class).path(RealmsResource.class, "getOAuth2DeviceVerificationService");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UriBuilder wellKnownProviderUrl(UriBuilder builder) {
|
public static UriBuilder wellKnownProviderUrl(UriBuilder builder) {
|
||||||
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
|
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
|
||||||
}
|
}
|
||||||
|
@ -279,18 +269,6 @@ public class RealmsResource {
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GET
|
|
||||||
@Path("{realm}/device")
|
|
||||||
public Object getOAuth2DeviceVerificationService(@PathParam("realm") String realmName, @QueryParam("user_code") String userCode) {
|
|
||||||
RealmModel realm = init(realmName);
|
|
||||||
EventBuilder event = new EventBuilder(realm, session, clientConnection);
|
|
||||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
|
||||||
|
|
||||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
|
||||||
|
|
||||||
return endpoint.buildVerificationResponse(userCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A JAX-RS sub-resource locator that uses the {@link org.keycloak.services.resource.RealmResourceSPI} to resolve sub-resources instances given an <code>unknownPath</code>.
|
* A JAX-RS sub-resource locator that uses the {@link org.keycloak.services.resource.RealmResourceSPI} to resolve sub-resources instances given an <code>unknownPath</code>.
|
||||||
*
|
*
|
||||||
|
@ -299,9 +277,9 @@ public class RealmsResource {
|
||||||
*/
|
*/
|
||||||
@Path("{realm}/{extension}")
|
@Path("{realm}/{extension}")
|
||||||
public Object resolveRealmExtension(@PathParam("realm") String realmName, @PathParam("extension") String extension) {
|
public Object resolveRealmExtension(@PathParam("realm") String realmName, @PathParam("extension") String extension) {
|
||||||
|
init(realmName);
|
||||||
RealmResourceProvider provider = session.getProvider(RealmResourceProvider.class, extension);
|
RealmResourceProvider provider = session.getProvider(RealmResourceProvider.class, extension);
|
||||||
if (provider != null) {
|
if (provider != null) {
|
||||||
init(realmName);
|
|
||||||
Object resource = provider.getResource();
|
Object resource = provider.getResource();
|
||||||
if (resource != null) {
|
if (resource != null) {
|
||||||
return resource;
|
return resource;
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
#
|
||||||
|
# Copyright 2016 Red Hat, Inc. and/or its affiliates
|
||||||
|
# and other contributors as indicated by the @author tags.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpointFactory
|
|
@ -49,7 +49,7 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean isCurrent() {
|
public boolean isCurrent() {
|
||||||
if (driver.getTitle().startsWith("Log in to ")) {
|
if (driver.getTitle().startsWith("Sign in to ")) {
|
||||||
try {
|
try {
|
||||||
driver.findElement(By.id("device-user-code"));
|
driver.findElement(By.id("device-user-code"));
|
||||||
return true;
|
return true;
|
||||||
|
@ -59,38 +59,23 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void assertLoginPage() {
|
|
||||||
String name = getClass().getSimpleName();
|
|
||||||
Assert.assertTrue("Expected device login page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
|
||||||
isLoginPage());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void assertApprovedPage() {
|
public void assertApprovedPage() {
|
||||||
String name = getClass().getSimpleName();
|
|
||||||
Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||||
isApprovedPage());
|
isApprovedPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void assertDeniedPage() {
|
public void assertDeniedPage() {
|
||||||
String name = getClass().getSimpleName();
|
|
||||||
Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||||
isDeniedPage());
|
isDeniedPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isLoginPage() {
|
public void assertInvalidUserCodePage() {
|
||||||
if (driver.getTitle().startsWith("Log in to ")) {
|
Assert.assertTrue("Expected invalid user code page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||||
try {
|
isInvalidUserCodePage());
|
||||||
driver.findElement(By.id("username"));
|
|
||||||
driver.findElement(By.id("password"));
|
|
||||||
return true;
|
|
||||||
} catch (Throwable t) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isApprovedPage() {
|
private boolean isApprovedPage() {
|
||||||
if (driver.getTitle().startsWith("Log in to ")) {
|
if (driver.getTitle().startsWith("Sign in to ")) {
|
||||||
try {
|
try {
|
||||||
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful");
|
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful");
|
||||||
return true;
|
return true;
|
||||||
|
@ -101,7 +86,7 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isDeniedPage() {
|
private boolean isDeniedPage() {
|
||||||
if (driver.getTitle().startsWith("Log in to ")) {
|
if (driver.getTitle().startsWith("Sign in to ")) {
|
||||||
try {
|
try {
|
||||||
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Failed");
|
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Failed");
|
||||||
driver.findElement(By.className("instruction")).getText().equals("Consent denied for connecting the device.");
|
driver.findElement(By.className("instruction")).getText().equals("Consent denied for connecting the device.");
|
||||||
|
@ -112,6 +97,19 @@ public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isInvalidUserCodePage() {
|
||||||
|
if (driver.getTitle().startsWith("Sign in to ")) {
|
||||||
|
try {
|
||||||
|
driver.findElement(By.id("device-user-code"));
|
||||||
|
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login");
|
||||||
|
driver.findElement(By.className("kc-feedback-text")).getText().equals("Invalid code, please try again.");
|
||||||
|
return true;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void open() {
|
public void open() {
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
|
|
||||||
package org.keycloak.testsuite.util;
|
package org.keycloak.testsuite.util;
|
||||||
|
|
||||||
|
import static org.keycloak.protocol.oidc.grants.device.DeviceGrantType.oauth2DeviceAuthUrl;
|
||||||
import static org.keycloak.testsuite.admin.Users.getPasswordOf;
|
import static org.keycloak.testsuite.admin.Users.getPasswordOf;
|
||||||
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
|
import static org.keycloak.testsuite.util.ServerURLs.getAuthServerContextRoot;
|
||||||
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
|
import static org.keycloak.testsuite.util.ServerURLs.removeDefaultPorts;
|
||||||
|
@ -60,6 +61,7 @@ import org.keycloak.models.Constants;
|
||||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
|
||||||
|
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
|
||||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
|
@ -68,8 +70,6 @@ import org.keycloak.representations.JsonWebToken;
|
||||||
import org.keycloak.representations.RefreshToken;
|
import org.keycloak.representations.RefreshToken;
|
||||||
import org.keycloak.representations.UserInfo;
|
import org.keycloak.representations.UserInfo;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.services.resources.RealmsResource;
|
|
||||||
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
|
|
||||||
import org.keycloak.testsuite.runonserver.RunOnServerException;
|
import org.keycloak.testsuite.runonserver.RunOnServerException;
|
||||||
import org.keycloak.util.BasicAuthHelper;
|
import org.keycloak.util.BasicAuthHelper;
|
||||||
import org.keycloak.util.JsonSerialization;
|
import org.keycloak.util.JsonSerialization;
|
||||||
|
@ -1058,11 +1058,6 @@ public class OAuthClient {
|
||||||
driver.navigate().to(getLoginFormUrl());
|
driver.navigate().to(getLoginFormUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void openOAuth2DeviceVerificationForm() {
|
|
||||||
UriBuilder b = RealmsResource.oauth2DeviceVerificationUrl(UriBuilder.fromUri(baseUrl));
|
|
||||||
openOAuth2DeviceVerificationForm(b.build(realm).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void openOAuth2DeviceVerificationForm(String verificationUri) {
|
public void openOAuth2DeviceVerificationForm(String verificationUri) {
|
||||||
driver.navigate().to(verificationUri);
|
driver.navigate().to(verificationUri);
|
||||||
}
|
}
|
||||||
|
@ -1206,7 +1201,7 @@ public class OAuthClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDeviceAuthorizationUrl() {
|
public String getDeviceAuthorizationUrl() {
|
||||||
UriBuilder b = OIDCLoginProtocolService.oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
|
UriBuilder b = oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
|
||||||
return b.build(realm).toString();
|
return b.build(realm).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ import org.keycloak.events.admin.OperationType;
|
||||||
import org.keycloak.events.admin.ResourceType;
|
import org.keycloak.events.admin.ResourceType;
|
||||||
import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory;
|
import org.keycloak.events.log.JBossLoggingEventListenerProviderFactory;
|
||||||
import org.keycloak.models.Constants;
|
import org.keycloak.models.Constants;
|
||||||
|
import org.keycloak.models.OAuth2DeviceConfig;
|
||||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||||
import org.keycloak.protocol.saml.SamlProtocol;
|
import org.keycloak.protocol.saml.SamlProtocol;
|
||||||
import org.keycloak.representations.adapters.action.GlobalRequestResult;
|
import org.keycloak.representations.adapters.action.GlobalRequestResult;
|
||||||
|
@ -181,7 +182,8 @@ public class RealmTest extends AbstractAdminTest {
|
||||||
|
|
||||||
Map<String, String> attributes = rep2.getAttributes();
|
Map<String, String> attributes = rep2.getAttributes();
|
||||||
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),
|
assertTrue("Attributes expected to be present oauth2DeviceCodeLifespan, oauth2DevicePollingInterval, found: " + String.join(", ", attributes.keySet()),
|
||||||
attributes.size() == 2 && attributes.containsKey("oauth2DeviceCodeLifespan") && attributes.containsKey("oauth2DevicePollingInterval"));
|
attributes.size() == 2 && attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN)
|
||||||
|
&& attributes.containsKey(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL));
|
||||||
} finally {
|
} finally {
|
||||||
adminClient.realm("attributes").remove();
|
adminClient.realm("attributes").remove();
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,17 +16,27 @@
|
||||||
*/
|
*/
|
||||||
package org.keycloak.testsuite.oauth;
|
package org.keycloak.testsuite.oauth;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.keycloak.models.OAuth2DeviceConfig.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
|
||||||
|
import static org.keycloak.models.OAuth2DeviceConfig.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL;
|
||||||
|
|
||||||
import org.jboss.arquillian.graphene.page.Page;
|
import org.jboss.arquillian.graphene.page.Page;
|
||||||
import org.junit.*;
|
import org.junit.Before;
|
||||||
import org.keycloak.events.Details;
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.admin.client.resource.ClientResource;
|
||||||
|
import org.keycloak.admin.client.resource.RealmResource;
|
||||||
|
import org.keycloak.models.OAuth2DeviceConfig;
|
||||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||||
|
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||||
import org.keycloak.representations.AccessToken;
|
import org.keycloak.representations.AccessToken;
|
||||||
import org.keycloak.representations.idm.ClientRepresentation;
|
import org.keycloak.representations.idm.ClientRepresentation;
|
||||||
import org.keycloak.representations.idm.EventRepresentation;
|
|
||||||
import org.keycloak.representations.idm.RealmRepresentation;
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||||
|
import org.keycloak.testsuite.Assert;
|
||||||
import org.keycloak.testsuite.AssertEvents;
|
import org.keycloak.testsuite.AssertEvents;
|
||||||
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.pages.ErrorPage;
|
import org.keycloak.testsuite.pages.ErrorPage;
|
||||||
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
|
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
|
||||||
import org.keycloak.testsuite.pages.OAuthGrantPage;
|
import org.keycloak.testsuite.pages.OAuthGrantPage;
|
||||||
|
@ -34,7 +44,6 @@ import org.keycloak.testsuite.util.ClientBuilder;
|
||||||
import org.keycloak.testsuite.util.OAuthClient;
|
import org.keycloak.testsuite.util.OAuthClient;
|
||||||
import org.keycloak.testsuite.util.RealmBuilder;
|
import org.keycloak.testsuite.util.RealmBuilder;
|
||||||
import org.keycloak.testsuite.util.UserBuilder;
|
import org.keycloak.testsuite.util.UserBuilder;
|
||||||
import org.keycloak.testsuite.util.WaitUtils;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@ -72,19 +81,11 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||||
ClientRepresentation app = ClientBuilder.create()
|
ClientRepresentation app = ClientBuilder.create()
|
||||||
.id(KeycloakModelUtils.generateId())
|
.id(KeycloakModelUtils.generateId())
|
||||||
.clientId("test-device")
|
.clientId("test-device")
|
||||||
.oauth2DeviceAuthorizationGrant()
|
|
||||||
.secret("secret")
|
.secret("secret")
|
||||||
|
.attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
|
||||||
.build();
|
.build();
|
||||||
realm.client(app);
|
realm.client(app);
|
||||||
|
|
||||||
ClientRepresentation app2 = ClientBuilder.create()
|
|
||||||
.id(KeycloakModelUtils.generateId())
|
|
||||||
.clientId("test-device-public")
|
|
||||||
.oauth2DeviceAuthorizationGrant()
|
|
||||||
.publicClient()
|
|
||||||
.build();
|
|
||||||
realm.client(app2);
|
|
||||||
|
|
||||||
userId = KeycloakModelUtils.generateId();
|
userId = KeycloakModelUtils.generateId();
|
||||||
UserRepresentation user = UserBuilder.create()
|
UserRepresentation user = UserBuilder.create()
|
||||||
.id(userId)
|
.id(userId)
|
||||||
|
@ -98,26 +99,26 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void resetConifg() {
|
public void resetConfig() {
|
||||||
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
||||||
realm.setOAuth2DeviceCodeLifespan(600);
|
realm.setOAuth2DeviceCodeLifespan(60);
|
||||||
realm.setOAuth2DevicePollingInterval(5);
|
realm.setOAuth2DevicePollingInterval(5);
|
||||||
getAdminClient().realm(REALM_NAME).update(realm);
|
getAdminClient().realm(REALM_NAME).update(realm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void publicClientTest() throws Exception {
|
public void testConfidentialClient() throws Exception {
|
||||||
// Device Authorization Request from device
|
// Device Authorization Request from device
|
||||||
oauth.realm(REALM_NAME);
|
oauth.realm(REALM_NAME);
|
||||||
oauth.clientId(DEVICE_APP_PUBLIC);
|
oauth.clientId(DEVICE_APP);
|
||||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC, null);
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
Assert.assertEquals(200, response.getStatusCode());
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
Assert.assertNotNull(response.getDeviceCode());
|
assertNotNull(response.getDeviceCode());
|
||||||
Assert.assertNotNull(response.getUserCode());
|
assertNotNull(response.getUserCode());
|
||||||
Assert.assertNotNull(response.getVerificationUri());
|
assertNotNull(response.getVerificationUri());
|
||||||
Assert.assertNotNull(response.getVerificationUriComplete());
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
Assert.assertEquals(600, response.getExpiresIn());
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
Assert.assertEquals(5, response.getInterval());
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
// Verify user code from verification page using browser
|
// Verify user code from verification page using browser
|
||||||
|
@ -125,10 +126,7 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||||
verificationPage.assertCurrent();
|
verificationPage.assertCurrent();
|
||||||
verificationPage.submit(response.getUserCode());
|
verificationPage.submit(response.getUserCode());
|
||||||
|
|
||||||
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP_PUBLIC).assertEvent();
|
loginPage.assertCurrent();
|
||||||
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
|
|
||||||
|
|
||||||
verificationPage.assertLoginPage();
|
|
||||||
|
|
||||||
// Do Login
|
// Do Login
|
||||||
oauth.fillLoginForm("device-login", "password");
|
oauth.fillLoginForm("device-login", "password");
|
||||||
|
@ -140,47 +138,148 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
verificationPage.assertApprovedPage();
|
verificationPage.assertApprovedPage();
|
||||||
|
|
||||||
events.expectDeviceLogin(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
|
|
||||||
|
|
||||||
// Token request from device
|
// Token request from device
|
||||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP_PUBLIC, null, response.getDeviceCode());
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
||||||
|
|
||||||
String tokenString = tokenResponse.getAccessToken();
|
String tokenString = tokenResponse.getAccessToken();
|
||||||
Assert.assertNotNull(tokenString);
|
assertNotNull(tokenString);
|
||||||
AccessToken token = oauth.verifyToken(tokenString);
|
AccessToken token = oauth.verifyToken(tokenString);
|
||||||
|
|
||||||
// Check receiving access token which is bound to the user session of the verification process
|
assertNotNull(token);
|
||||||
Assert.assertTrue(codeId.equals(token.getSessionState()));
|
|
||||||
|
|
||||||
events.expectDeviceCodeToToken(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void confidentialClientTest() throws Exception {
|
public void testConsentCancel() throws Exception {
|
||||||
// Device Authorization Request from device
|
// Device Authorization Request from device
|
||||||
oauth.realm(REALM_NAME);
|
oauth.realm(REALM_NAME);
|
||||||
oauth.clientId(DEVICE_APP);
|
oauth.clientId(DEVICE_APP);
|
||||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
Assert.assertEquals(200, response.getStatusCode());
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
Assert.assertNotNull(response.getDeviceCode());
|
assertNotNull(response.getDeviceCode());
|
||||||
Assert.assertNotNull(response.getUserCode());
|
assertNotNull(response.getUserCode());
|
||||||
Assert.assertNotNull(response.getVerificationUri());
|
assertNotNull(response.getVerificationUri());
|
||||||
Assert.assertNotNull(response.getVerificationUriComplete());
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
Assert.assertEquals(600, response.getExpiresIn());
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
openVerificationPage(response.getVerificationUriComplete());
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
|
||||||
|
// Do Login
|
||||||
|
oauth.fillLoginForm("device-login", "password");
|
||||||
|
|
||||||
|
// Consent
|
||||||
|
grantPage.assertCurrent();
|
||||||
|
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
|
||||||
|
grantPage.cancel();
|
||||||
|
|
||||||
|
verificationPage.assertDeniedPage();
|
||||||
|
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("access_denied", tokenResponse.getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidUserCode() throws Exception {
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
Assert.assertEquals(5, response.getInterval());
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
// Verify user code from verification page using browser
|
|
||||||
openVerificationPage(response.getVerificationUri());
|
openVerificationPage(response.getVerificationUri());
|
||||||
verificationPage.assertCurrent();
|
verificationPage.submit("x");
|
||||||
verificationPage.submit(response.getUserCode());
|
|
||||||
|
|
||||||
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP).assertEvent();
|
verificationPage.assertInvalidUserCodePage();
|
||||||
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
|
}
|
||||||
|
|
||||||
verificationPage.assertLoginPage();
|
@Test
|
||||||
|
public void testExpiredUserCodeTest() throws Exception {
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
try {
|
||||||
|
setTimeOffset(610);
|
||||||
|
openVerificationPage(response.getVerificationUriComplete());
|
||||||
|
} finally {
|
||||||
|
resetTimeOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationPage.assertInvalidUserCodePage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidDeviceCode() throws Exception {
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
openVerificationPage(response.getVerificationUriComplete());
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
|
||||||
|
// Do Login
|
||||||
|
oauth.fillLoginForm("device-login", "password");
|
||||||
|
|
||||||
|
// Consent
|
||||||
|
grantPage.assertCurrent();
|
||||||
|
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
|
||||||
|
grantPage.accept();
|
||||||
|
|
||||||
|
// Token request from device
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", "x");
|
||||||
|
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("invalid_grant", tokenResponse.getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSuccessVerificationUriComplete() throws Exception {
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
openVerificationPage(response.getVerificationUriComplete());
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
|
||||||
// Do Login
|
// Do Login
|
||||||
oauth.fillLoginForm("device-login", "password");
|
oauth.fillLoginForm("device-login", "password");
|
||||||
|
@ -192,95 +291,251 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
verificationPage.assertApprovedPage();
|
verificationPage.assertApprovedPage();
|
||||||
|
|
||||||
events.expectDeviceLogin(DEVICE_APP, codeId, userId).assertEvent();
|
|
||||||
|
|
||||||
// Token request from device
|
// Token request from device
|
||||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
||||||
|
|
||||||
String tokenString = tokenResponse.getAccessToken();
|
|
||||||
Assert.assertNotNull(tokenString);
|
|
||||||
AccessToken token = oauth.verifyToken(tokenString);
|
|
||||||
|
|
||||||
// Check receiving access token which is bound to the user session of the verification process
|
|
||||||
Assert.assertTrue(codeId.equals(token.getSessionState()));
|
|
||||||
|
|
||||||
events.expectDeviceCodeToToken(DEVICE_APP, codeId, userId).assertEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void pollingTest() throws Exception {
|
public void testExpiredDeviceCode() throws Exception {
|
||||||
// Device Authorization Request from device
|
// Device Authorization Request from device
|
||||||
oauth.realm(REALM_NAME);
|
oauth.realm(REALM_NAME);
|
||||||
oauth.clientId(DEVICE_APP);
|
oauth.clientId(DEVICE_APP);
|
||||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
Assert.assertEquals(600, response.getExpiresIn());
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
Assert.assertEquals(5, response.getInterval());
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
// Polling token request from device
|
try {
|
||||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
setTimeOffset(610);
|
||||||
|
// Token request from device
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret",
|
||||||
|
response.getDeviceCode());
|
||||||
|
|
||||||
// Not approved yet
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
Assert.assertEquals("expired_token", tokenResponse.getError());
|
||||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
} finally {
|
||||||
|
resetTimeOffset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Polling again without waiting
|
@Test
|
||||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
public void testDeviceCodeLifespanPerClient() throws Exception {
|
||||||
|
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), DEVICE_APP);
|
||||||
|
ClientRepresentation clientRepresentation = client.toRepresentation();
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
// Slow down
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
assertNotNull(response.getDeviceCode());
|
||||||
Assert.assertEquals("slow_down", tokenResponse.getError());
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
// Wait the interval
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT, "120");
|
||||||
WaitUtils.pause(5000);
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "600000");
|
||||||
|
client.update(clientRepresentation);
|
||||||
|
|
||||||
// Polling again
|
|
||||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
|
||||||
|
|
||||||
// Not approved yet
|
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
|
||||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
|
||||||
|
|
||||||
|
|
||||||
// Change the interval setting of the realm from 5 seconds to 10 seconds.
|
|
||||||
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
|
||||||
realm.setOAuth2DevicePollingInterval(10);
|
|
||||||
getAdminClient().realm(REALM_NAME).update(realm);
|
|
||||||
|
|
||||||
// Checking the new interval is applied
|
|
||||||
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
Assert.assertEquals(120, response.getExpiresIn());
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse;
|
||||||
|
|
||||||
Assert.assertEquals(600, response.getExpiresIn());
|
try {
|
||||||
|
setTimeOffset(100);
|
||||||
|
// Token request from device
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
|
||||||
|
setTimeOffset(125);
|
||||||
|
// Token request from device
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("expired_token", tokenResponse.getError());
|
||||||
|
} finally {
|
||||||
|
resetTimeOffset();
|
||||||
|
}
|
||||||
|
|
||||||
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_CODE_LIFESPAN_PER_CLIENT, "");
|
||||||
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "");
|
||||||
|
client.update(clientRepresentation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDevicePollingIntervalPerClient() throws Exception {
|
||||||
|
getTestingClient().testing().setTestingInfinispanTimeService();
|
||||||
|
ClientResource client = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), DEVICE_APP);
|
||||||
|
ClientRepresentation clientRepresentation = client.toRepresentation();
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(200, response.getStatusCode());
|
||||||
|
assertNotNull(response.getDeviceCode());
|
||||||
|
assertNotNull(response.getUserCode());
|
||||||
|
assertNotNull(response.getVerificationUri());
|
||||||
|
assertNotNull(response.getVerificationUriComplete());
|
||||||
|
Assert.assertEquals(60, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "10");
|
||||||
|
client.update(clientRepresentation);
|
||||||
|
|
||||||
|
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
Assert.assertEquals(10, response.getInterval());
|
Assert.assertEquals(10, response.getInterval());
|
||||||
|
|
||||||
// Polling token request from device
|
try {
|
||||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
// Token request from device
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret",
|
||||||
|
response.getDeviceCode());
|
||||||
|
|
||||||
// Not approved yet
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
|
||||||
|
|
||||||
// Wait
|
// Token request from device
|
||||||
WaitUtils.pause(5000);
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
// Polling again without waiting
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||||
|
|
||||||
// Slow down
|
setTimeOffset(7);
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
|
||||||
Assert.assertEquals("slow_down", tokenResponse.getError());
|
|
||||||
|
|
||||||
// Wait
|
// Token request from device
|
||||||
WaitUtils.pause(5000);
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
// Polling again
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||||
|
|
||||||
// Not approved yet
|
setTimeOffset(10);
|
||||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
|
||||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
// Token request from device
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
} finally {
|
||||||
|
getTestingClient().testing().revertTestingInfinispanTimeService();
|
||||||
|
clientRepresentation.getAttributes().put(OAuth2DeviceConfig.OAUTH2_DEVICE_POLLING_INTERVAL_PER_CLIENT, "");
|
||||||
|
client.update(clientRepresentation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPooling() throws Exception {
|
||||||
|
getTestingClient().testing().setTestingInfinispanTimeService();
|
||||||
|
|
||||||
|
try {
|
||||||
|
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
||||||
|
realm.setOAuth2DeviceCodeLifespan(600);
|
||||||
|
getAdminClient().realm(REALM_NAME).update(realm);
|
||||||
|
// Device Authorization Request from device
|
||||||
|
oauth.realm(REALM_NAME);
|
||||||
|
oauth.clientId(DEVICE_APP);
|
||||||
|
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(600, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(5, response.getInterval());
|
||||||
|
|
||||||
|
// Polling token request from device
|
||||||
|
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Not approved yet
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
|
||||||
|
// Polling again without waiting
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Slow down
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||||
|
|
||||||
|
// Wait the interval
|
||||||
|
setTimeOffset(5);
|
||||||
|
|
||||||
|
// Polling again
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Not approved yet
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
|
||||||
|
// Change the interval setting of the realm from 5 seconds to 10 seconds.
|
||||||
|
realm.setOAuth2DevicePollingInterval(10);
|
||||||
|
getAdminClient().realm(REALM_NAME).update(realm);
|
||||||
|
|
||||||
|
// Checking the new interval is applied
|
||||||
|
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||||
|
|
||||||
|
Assert.assertEquals(600, response.getExpiresIn());
|
||||||
|
Assert.assertEquals(10, response.getInterval());
|
||||||
|
|
||||||
|
// Polling token request from device
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Not approved yet
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
|
||||||
|
// Wait
|
||||||
|
setTimeOffset(10);
|
||||||
|
|
||||||
|
// Polling again without waiting
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Slow down
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||||
|
|
||||||
|
// Wait
|
||||||
|
setTimeOffset(15);
|
||||||
|
|
||||||
|
// Polling again
|
||||||
|
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||||
|
|
||||||
|
// Not approved yet
|
||||||
|
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||||
|
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||||
|
} finally {
|
||||||
|
getTestingClient().testing().revertTestingInfinispanTimeService();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUpdateConfig() {
|
||||||
|
RealmResource realm = getAdminClient().realm(REALM_NAME);
|
||||||
|
RealmRepresentation rep = realm.toRepresentation();
|
||||||
|
|
||||||
|
rep.setOAuth2DevicePollingInterval(DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
||||||
|
rep.setOAuth2DeviceCodeLifespan(DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
||||||
|
|
||||||
|
realm.update(rep);
|
||||||
|
rep = realm.toRepresentation();
|
||||||
|
|
||||||
|
Assert.assertEquals(DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL, rep.getOAuth2DevicePollingInterval().intValue());
|
||||||
|
Assert.assertEquals(DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN, rep.getOAuth2DeviceCodeLifespan().intValue());
|
||||||
|
|
||||||
|
rep.setOAuth2DevicePollingInterval(10);
|
||||||
|
rep.setOAuth2DeviceCodeLifespan(15);
|
||||||
|
|
||||||
|
realm.update(rep);
|
||||||
|
rep = realm.toRepresentation();
|
||||||
|
|
||||||
|
Assert.assertEquals(10, rep.getOAuth2DevicePollingInterval().intValue());
|
||||||
|
Assert.assertEquals(15, rep.getOAuth2DeviceCodeLifespan().intValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openVerificationPage(String verificationUri) {
|
private void openVerificationPage(String verificationUri) {
|
||||||
|
|
|
@ -121,7 +121,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
|
||||||
|
|
||||||
// Support standard + implicit + hybrid flow
|
// Support standard + implicit + hybrid flow
|
||||||
assertContains(oidcConfig.getResponseTypesSupported(), OAuth2Constants.CODE, OIDCResponseType.ID_TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
|
assertContains(oidcConfig.getResponseTypesSupported(), OAuth2Constants.CODE, OIDCResponseType.ID_TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
|
||||||
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT);
|
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT,
|
||||||
|
OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
|
||||||
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
|
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
|
||||||
|
|
||||||
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public");
|
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "pairwise", "public");
|
||||||
|
@ -177,6 +178,8 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
|
||||||
Assert.assertNames(oidcConfig.getRevocationEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256,
|
Assert.assertNames(oidcConfig.getRevocationEndpointAuthSigningAlgValuesSupported(), Algorithm.PS256,
|
||||||
Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256,
|
Algorithm.PS384, Algorithm.PS512, Algorithm.RS256, Algorithm.RS384, Algorithm.RS512, Algorithm.ES256,
|
||||||
Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
|
Algorithm.ES384, Algorithm.ES512, Algorithm.HS256, Algorithm.HS384, Algorithm.HS512);
|
||||||
|
|
||||||
|
assertEquals(oidcConfig.getDeviceAuthorizationEndpoint(), oauth.getDeviceAuthorizationUrl());
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,11 +109,6 @@ public class ClientBuilder {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ClientBuilder oauth2DeviceAuthorizationGrant() {
|
|
||||||
rep.setOAuth2DeviceAuthorizationGrantEnabled(true);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClientRepresentation build() {
|
public ClientRepresentation build() {
|
||||||
return rep;
|
return rep;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1108,6 +1108,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
||||||
$scope.disableAuthorizationTab = !client.authorizationServicesEnabled;
|
$scope.disableAuthorizationTab = !client.authorizationServicesEnabled;
|
||||||
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
|
$scope.disableServiceAccountRolesTab = !client.serviceAccountsEnabled;
|
||||||
$scope.disableCredentialsTab = client.publicClient;
|
$scope.disableCredentialsTab = client.publicClient;
|
||||||
|
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
|
||||||
// KEYCLOAK-6771 Certificate Bound Token
|
// KEYCLOAK-6771 Certificate Bound Token
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
||||||
$scope.tlsClientCertificateBoundAccessTokens = false;
|
$scope.tlsClientCertificateBoundAccessTokens = false;
|
||||||
|
@ -1118,6 +1119,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
||||||
$scope.clientSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.session.max.lifespan']);
|
$scope.clientSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.session.max.lifespan']);
|
||||||
$scope.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']);
|
$scope.clientOfflineSessionIdleTimeout = TimeUnit2.asUnit(client.attributes['client.offline.session.idle.timeout']);
|
||||||
$scope.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.offline.session.max.lifespan']);
|
$scope.clientOfflineSessionMaxLifespan = TimeUnit2.asUnit(client.attributes['client.offline.session.max.lifespan']);
|
||||||
|
$scope.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(client.attributes['oauth2.device.code.lifespan']);
|
||||||
|
$scope.oauth2DevicePollingInterval = parseInt(client.attributes['oauth2.device.polling.interval']);
|
||||||
|
|
||||||
if(client.origin) {
|
if(client.origin) {
|
||||||
if ($scope.access.viewRealm) {
|
if ($scope.access.viewRealm) {
|
||||||
|
@ -1279,6 +1282,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($scope.client.attributes["oauth2.device.authorization.grant.enabled"]) {
|
||||||
|
if ($scope.client.attributes["oauth2.device.authorization.grant.enabled"] == "true") {
|
||||||
|
$scope.oauth2DeviceAuthorizationGrantEnabled = true;
|
||||||
|
} else {
|
||||||
|
$scope.oauth2DeviceAuthorizationGrantEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// KEYCLOAK-6771 Certificate Bound Token
|
// KEYCLOAK-6771 Certificate Bound Token
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
||||||
if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) {
|
if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) {
|
||||||
|
@ -1521,6 +1532,22 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$scope.updateOauth2DeviceCodeLifespan = function() {
|
||||||
|
if ($scope.oauth2DeviceCodeLifespan.time) {
|
||||||
|
$scope.clientEdit.attributes['oauth2.device.code.lifespan'] = $scope.oauth2DeviceCodeLifespan.toSeconds();
|
||||||
|
} else {
|
||||||
|
$scope.clientEdit.attributes['oauth2.device.code.lifespan'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.updateOauth2DevicePollingInterval = function() {
|
||||||
|
if ($scope.oauth2DevicePollingInterval) {
|
||||||
|
$scope.clientEdit.attributes['oauth2.device.polling.interval'] = $scope.oauth2DevicePollingInterval;
|
||||||
|
} else {
|
||||||
|
$scope.clientEdit.attributes['oauth2.device.polling.interval'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function configureAuthorizationServices() {
|
function configureAuthorizationServices() {
|
||||||
if ($scope.clientEdit.authorizationServicesEnabled) {
|
if ($scope.clientEdit.authorizationServicesEnabled) {
|
||||||
if ($scope.accessType == 'public') {
|
if ($scope.accessType == 'public') {
|
||||||
|
@ -1664,6 +1691,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($scope.oauth2DeviceAuthorizationGrantEnabled == true) {
|
||||||
|
$scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "true";
|
||||||
|
} else {
|
||||||
|
$scope.clientEdit.attributes["oauth2.device.authorization.grant.enabled"] = "false";
|
||||||
|
}
|
||||||
|
|
||||||
// KEYCLOAK-6771 Certificate Bound Token
|
// KEYCLOAK-6771 Certificate Bound Token
|
||||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
||||||
if ($scope.tlsClientCertificateBoundAccessTokens == true) {
|
if ($scope.tlsClientCertificateBoundAccessTokens == true) {
|
||||||
|
|
|
@ -139,11 +139,15 @@
|
||||||
<input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
<input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
<div class="form-group"
|
||||||
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{:: 'oauth2-device-authorization-grant-enabled' | translate}}</label>
|
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
||||||
|
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{::
|
||||||
|
'oauth2-device-authorization-grant-enabled' | translate}}</label>
|
||||||
<kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip>
|
<kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<input ng-model="clientEdit.oauth2DeviceAuthorizationGrantEnabled" name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
<input ng-model="oauth2DeviceAuthorizationGrantEnabled" ng-click="switchChange()"
|
||||||
|
name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch
|
||||||
|
on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
||||||
|
@ -669,6 +673,36 @@
|
||||||
<kc-tooltip>{{:: 'client-offline-session-max.tooltip' | translate}}</kc-tooltip>
|
<kc-tooltip>{{:: 'client-offline-session-max.tooltip' | translate}}</kc-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group"
|
||||||
|
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly && oauth2DeviceAuthorizationGrantEnabled == true">
|
||||||
|
<label class="col-md-2 control-label" for="oauth2DeviceCodeLifespan">{{:: 'oauth2-device-code-lifespan'
|
||||||
|
| translate}}</label>
|
||||||
|
<div class="col-md-6 time-selector">
|
||||||
|
<input class="form-control" type="number" min="1" max="31536000"
|
||||||
|
data-ng-model="oauth2DeviceCodeLifespan.time" id="oauth2DeviceCodeLifespan"
|
||||||
|
name="oauth2DeviceCodeLifespan" data-ng-change="updateOauth2DeviceCodeLifespan()" /> <select
|
||||||
|
class="form-control" name="oauth2DeviceCodeLifespanUnit" data-ng-model="oauth2DeviceCodeLifespan.unit"
|
||||||
|
data-ng-change="updateOauth2DeviceCodeLifespan()">
|
||||||
|
<option value="Minutes">{{:: 'minutes' | translate}}</option>
|
||||||
|
<option value="Hours">{{:: 'hours' | translate}}</option>
|
||||||
|
<option value="Days">{{:: 'days' | translate}}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<kc-tooltip>{{:: 'oauth2-device-code-lifespan.tooltip' | translate}}</kc-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group"
|
||||||
|
data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly && oauth2DeviceAuthorizationGrantEnabled == true">
|
||||||
|
<label class="col-md-2 control-label" for="oauth2DevicePollingInterval">{{::
|
||||||
|
'oauth2-device-polling-interval' | translate}}</label>
|
||||||
|
<div class="col-md-6 time-selector">
|
||||||
|
<input class="form-control" type="number" min="1" max="31536000" data-ng-model="oauth2DevicePollingInterval"
|
||||||
|
id="oauth2DevicePollingInterval" name="oauth2DevicePollingInterval"
|
||||||
|
data-ng-change="updateOauth2DevicePollingInterval()" />
|
||||||
|
</div>
|
||||||
|
<kc-tooltip>{{:: 'oauth2-device-polling-interval.tooltip' | translate}}</kc-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
|
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
|
||||||
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
|
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-6">
|
||||||
|
|
Loading…
Reference in a new issue