KEYCLOAK-7675 Prototype Implementation of Device Authorization Grant.
Author: Hiroyuki Wada <h2-wada@nri.co.jp> Date: Thu May 2 00:22:24 2019 +0900 Signed-off-by: Łukasz Dywicki <luke@code-house.org>
This commit is contained in:
parent
d2060913be
commit
9d57b88dba
58 changed files with 2477 additions and 11 deletions
3
core/src/main/java/org/keycloak/OAuth2Constants.java
Normal file → Executable file
3
core/src/main/java/org/keycloak/OAuth2Constants.java
Normal file → Executable file
|
@ -120,6 +120,9 @@ public interface OAuth2Constants {
|
|||
|
||||
String UMA_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:uma-ticket";
|
||||
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.4
|
||||
String DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
String DEVICE_CODE = "device_code";
|
||||
|
||||
String DISPLAY_CONSOLE = "console";
|
||||
}
|
||||
|
|
|
@ -45,6 +45,12 @@ public class OAuthErrorException extends Exception {
|
|||
public static final String INVALID_REDIRECT_URI = "invalid_redirect_uri";
|
||||
public static final String INVALID_CLIENT_METADATA = "invalid_client_metadata";
|
||||
|
||||
// OAuth2 Device Authorization Grant
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.5
|
||||
public static final String AUTHORIZATION_PENDING = "authorization_pending";
|
||||
public static final String SLOW_DOWN = "slow_down";
|
||||
public static final String EXPIRED_TOKEN = "expired_token";
|
||||
|
||||
// Others
|
||||
public static final String INVALID_CLIENT = "invalid_client";
|
||||
public static final String INVALID_GRANT = "invalid_grant";
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.representations;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import org.keycloak.common.Version;
|
||||
|
||||
/**
|
||||
* Representation for <a href="https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.3">Device Authorization Response</a>.
|
||||
*
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceAuthorizationResponse {
|
||||
|
||||
/**
|
||||
* REQUIRED
|
||||
*/
|
||||
@JsonProperty("device_code")
|
||||
protected String deviceCode;
|
||||
|
||||
/**
|
||||
* REQUIRED
|
||||
*/
|
||||
@JsonProperty("user_code")
|
||||
protected String userCode;
|
||||
|
||||
/**
|
||||
* REQUIRED
|
||||
*/
|
||||
@JsonProperty("verification_uri")
|
||||
protected String verificationUri;
|
||||
|
||||
/**
|
||||
* OPTIONAL
|
||||
*/
|
||||
@JsonProperty("verification_uri_complete")
|
||||
protected String verificationUriComplete;
|
||||
|
||||
/**
|
||||
* REQUIRED
|
||||
*/
|
||||
@JsonProperty("expires_in")
|
||||
protected long expiresIn;
|
||||
|
||||
/**
|
||||
* OPTIONAL
|
||||
*/
|
||||
@JsonProperty("interval")
|
||||
protected long interval;
|
||||
|
||||
public String getDeviceCode() {
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
public void setDeviceCode(String deviceCode) {
|
||||
this.deviceCode = deviceCode;
|
||||
}
|
||||
|
||||
public String getUserCode() {
|
||||
return userCode;
|
||||
}
|
||||
|
||||
public void setUserCode(String userCode) {
|
||||
this.userCode = userCode;
|
||||
}
|
||||
|
||||
public String getVerificationUri() {
|
||||
return verificationUri;
|
||||
}
|
||||
|
||||
public void setVerificationUri(String verificationUri) {
|
||||
this.verificationUri = verificationUri;
|
||||
}
|
||||
|
||||
public String getVerificationUriComplete() {
|
||||
return verificationUriComplete;
|
||||
}
|
||||
|
||||
public void setVerificationUriComplete(String verificationUriComplete) {
|
||||
this.verificationUriComplete = verificationUriComplete;
|
||||
}
|
||||
|
||||
public long getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public void setExpiresIn(long expiresIn) {
|
||||
this.expiresIn = expiresIn;
|
||||
}
|
||||
|
||||
public long getInterval() {
|
||||
return interval;
|
||||
}
|
||||
|
||||
public void setInterval(long interval) {
|
||||
this.interval = interval;
|
||||
}
|
||||
}
|
|
@ -51,6 +51,7 @@ public class ClientRepresentation {
|
|||
protected Boolean implicitFlowEnabled;
|
||||
protected Boolean directAccessGrantsEnabled;
|
||||
protected Boolean serviceAccountsEnabled;
|
||||
protected Boolean oauth2DeviceAuthorizationGrantEnabled;
|
||||
protected Boolean authorizationServicesEnabled;
|
||||
@Deprecated
|
||||
protected Boolean directGrantsOnly;
|
||||
|
@ -267,6 +268,14 @@ public class ClientRepresentation {
|
|||
this.serviceAccountsEnabled = serviceAccountsEnabled;
|
||||
}
|
||||
|
||||
public Boolean isOAuth2DeviceAuthorizationGrantEnabled() {
|
||||
return oauth2DeviceAuthorizationGrantEnabled;
|
||||
}
|
||||
|
||||
public void setOAuth2DeviceAuthorizationGrantEnabled(Boolean oauth2DeviceAuthorizationGrantEnabled) {
|
||||
this.oauth2DeviceAuthorizationGrantEnabled = oauth2DeviceAuthorizationGrantEnabled;
|
||||
}
|
||||
|
||||
public Boolean getAuthorizationServicesEnabled() {
|
||||
if (authorizationSettings != null) {
|
||||
return true;
|
||||
|
|
0
core/src/main/java/org/keycloak/representations/idm/ClientTemplateRepresentation.java
Normal file → Executable file
0
core/src/main/java/org/keycloak/representations/idm/ClientTemplateRepresentation.java
Normal file → Executable file
|
@ -59,6 +59,8 @@ public class RealmRepresentation {
|
|||
protected Integer accessCodeLifespanLogin;
|
||||
protected Integer actionTokenGeneratedByAdminLifespan;
|
||||
protected Integer actionTokenGeneratedByUserLifespan;
|
||||
protected Integer oauth2DeviceCodeLifespan;
|
||||
protected Integer oauth2DevicePollingInterval;
|
||||
protected Boolean enabled;
|
||||
protected String sslRequired;
|
||||
@Deprecated
|
||||
|
@ -476,6 +478,22 @@ public class RealmRepresentation {
|
|||
this.actionTokenGeneratedByAdminLifespan = actionTokenGeneratedByAdminLifespan;
|
||||
}
|
||||
|
||||
public void setOAuth2DeviceCodeLifespan(Integer oauth2DeviceCodeLifespan) {
|
||||
this.oauth2DeviceCodeLifespan = oauth2DeviceCodeLifespan;
|
||||
}
|
||||
|
||||
public Integer getOAuth2DeviceCodeLifespan() {
|
||||
return oauth2DeviceCodeLifespan;
|
||||
}
|
||||
|
||||
public void setOAuth2DevicePollingInterval(Integer oauth2DevicePollingInterval) {
|
||||
this.oauth2DevicePollingInterval = oauth2DevicePollingInterval;
|
||||
}
|
||||
|
||||
public Integer getOAuth2DevicePollingInterval() {
|
||||
return oauth2DevicePollingInterval;
|
||||
}
|
||||
|
||||
public Integer getActionTokenGeneratedByUserLifespan() {
|
||||
return actionTokenGeneratedByUserLifespan;
|
||||
}
|
||||
|
|
|
@ -643,6 +643,35 @@ public class RealmAdapter implements CachedRealmModel {
|
|||
return cached.getRequiredCredentials().stream();
|
||||
}
|
||||
|
||||
public int getOAuth2DeviceCodeLifespan() {
|
||||
if (isUpdated()) return updated.getOAuth2DeviceCodeLifespan();
|
||||
return cached.getOAuth2DeviceCodeLifespan();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOAuth2DeviceCodeLifespan(int oauth2DeviceCodeLifespan) {
|
||||
getDelegateForUpdate();
|
||||
updated.setOAuth2DeviceCodeLifespan(oauth2DeviceCodeLifespan);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOAuth2DevicePollingInterval() {
|
||||
if (isUpdated()) return updated.getOAuth2DevicePollingInterval();
|
||||
return cached.getOAuth2DevicePollingInterval();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOAuth2DevicePollingInterval(int oauth2DevicePollingInterval) {
|
||||
getDelegateForUpdate();
|
||||
updated.setOAuth2DevicePollingInterval(oauth2DevicePollingInterval);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RequiredCredentialModel> getRequiredCredentials() {
|
||||
if (isUpdated()) return updated.getRequiredCredentials();
|
||||
return cached.getRequiredCredentials();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addRequiredCredential(String cred) {
|
||||
getDelegateForUpdate();
|
||||
|
|
|
@ -96,6 +96,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
protected int accessCodeLifespan;
|
||||
protected int accessCodeLifespanUserAction;
|
||||
protected int accessCodeLifespanLogin;
|
||||
protected int oauth2DeviceCodeLifespan;
|
||||
protected int oauth2DevicePollingInterval;
|
||||
protected int actionTokenGeneratedByAdminLifespan;
|
||||
protected int actionTokenGeneratedByUserLifespan;
|
||||
protected int notBefore;
|
||||
|
@ -211,6 +213,8 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
accessTokenLifespan = model.getAccessTokenLifespan();
|
||||
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
|
||||
accessCodeLifespan = model.getAccessCodeLifespan();
|
||||
oauth2DeviceCodeLifespan = model.getOAuth2DeviceCodeLifespan();
|
||||
oauth2DevicePollingInterval = model.getOAuth2DevicePollingInterval();
|
||||
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
|
||||
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
|
||||
actionTokenGeneratedByAdminLifespan = model.getActionTokenGeneratedByAdminLifespan();
|
||||
|
@ -487,6 +491,14 @@ public class CachedRealm extends AbstractExtendableRevisioned {
|
|||
return accessCodeLifespanLogin;
|
||||
}
|
||||
|
||||
public int getOAuth2DeviceCodeLifespan() {
|
||||
return oauth2DeviceCodeLifespan;
|
||||
}
|
||||
|
||||
public int getOAuth2DevicePollingInterval() {
|
||||
return oauth2DevicePollingInterval;
|
||||
}
|
||||
|
||||
public int getActionTokenGeneratedByAdminLifespan() {
|
||||
return actionTokenGeneratedByAdminLifespan;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan;
|
||||
|
||||
import org.infinispan.client.hotrod.exceptions.HotRodClientException;
|
||||
import org.infinispan.commons.api.BasicCache;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OAuth2DeviceCodeModel;
|
||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||
import org.keycloak.models.OAuth2DeviceUserCodeModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class InfinispanOAuth2DeviceTokenStoreProvider implements OAuth2DeviceTokenStoreProvider {
|
||||
|
||||
public static final Logger logger = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProvider.class);
|
||||
|
||||
private final Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
|
||||
private final KeycloakSession session;
|
||||
|
||||
public InfinispanOAuth2DeviceTokenStoreProvider(KeycloakSession session, Supplier<BasicCache<String, ActionTokenValueEntity>> actionKeyCache) {
|
||||
this.session = session;
|
||||
this.codeCache = actionKeyCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode) {
|
||||
try {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
ActionTokenValueEntity existing = cache.get(OAuth2DeviceCodeModel.createKey(realm, deviceCode));
|
||||
|
||||
if (existing == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OAuth2DeviceCodeModel.fromCache(realm, deviceCode, existing.getNotes());
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when getting device code %s", deviceCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds) {
|
||||
ActionTokenValueEntity deviceCodeValue = new ActionTokenValueEntity(deviceCode.serializeValue());
|
||||
ActionTokenValueEntity userCodeValue = new ActionTokenValueEntity(userCode.serializeValue());
|
||||
|
||||
try {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
cache.put(deviceCode.serializeKey(), deviceCodeValue, lifespanSeconds, TimeUnit.SECONDS);
|
||||
cache.put(userCode.serializeKey(), userCodeValue, lifespanSeconds, TimeUnit.SECONDS);
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when adding device code %s and user code %s",
|
||||
deviceCode.getDeviceCode(), userCode.getUserCode());
|
||||
}
|
||||
throw re;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPollingAllowed(OAuth2DeviceCodeModel deviceCode) {
|
||||
try {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
String key = deviceCode.serializePollingKey();
|
||||
ActionTokenValueEntity value = new ActionTokenValueEntity(null);
|
||||
ActionTokenValueEntity existing = cache.putIfAbsent(key, value, deviceCode.getPollingInterval(), TimeUnit.SECONDS);
|
||||
return existing == null;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when putting polling key for device code %s", deviceCode.getDeviceCode());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuth2DeviceCodeModel getByUserCode(RealmModel realm, String userCode) {
|
||||
try {
|
||||
OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode);
|
||||
if (deviceCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deviceCode;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when getting device code by user code %s", userCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private OAuth2DeviceCodeModel findDeviceCodeByUserCode(RealmModel realm, String userCode) throws HotRodClientException {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
String userCodeKey = OAuth2DeviceUserCodeModel.createKey(realm, userCode);
|
||||
ActionTokenValueEntity existing = cache.get(userCodeKey);
|
||||
|
||||
if (existing == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
OAuth2DeviceUserCodeModel data = OAuth2DeviceUserCodeModel.fromCache(realm, userCode, existing.getNotes());
|
||||
String deviceCode = data.getDeviceCode();
|
||||
|
||||
String deviceCodeKey = OAuth2DeviceCodeModel.createKey(realm, deviceCode);
|
||||
ActionTokenValueEntity existingDeviceCode = cache.get(deviceCodeKey);
|
||||
|
||||
if (existingDeviceCode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OAuth2DeviceCodeModel.fromCache(realm, deviceCode, existingDeviceCode.getNotes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean approve(RealmModel realm, String userCode, String userSessionId) {
|
||||
try {
|
||||
OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode);
|
||||
if (deviceCode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OAuth2DeviceCodeModel approved = deviceCode.approve(userSessionId);
|
||||
|
||||
// Update the device code with approved status
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
cache.replace(approved.serializeKey(), new ActionTokenValueEntity(approved.serializeApprovedValue()));
|
||||
|
||||
return true;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when verifying device user code %s", userCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deny(RealmModel realm, String userCode) {
|
||||
try {
|
||||
OAuth2DeviceCodeModel deviceCode = findDeviceCodeByUserCode(realm, userCode);
|
||||
if (deviceCode == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OAuth2DeviceCodeModel denied = deviceCode.deny();
|
||||
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
cache.replace(denied.serializeKey(), new ActionTokenValueEntity(denied.serializeDeniedValue()));
|
||||
|
||||
return true;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when denying device user code %s", userCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeDeviceCode(RealmModel realm, String deviceCode) {
|
||||
try {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
String key = OAuth2DeviceCodeModel.createKey(realm, deviceCode);
|
||||
ActionTokenValueEntity existing = cache.remove(key);
|
||||
return existing == null ? false : true;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when removing device code %s", deviceCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean removeUserCode(RealmModel realm, String userCode) {
|
||||
try {
|
||||
BasicCache<String, ActionTokenValueEntity> cache = codeCache.get();
|
||||
String key = OAuth2DeviceUserCodeModel.createKey(realm, userCode);
|
||||
ActionTokenValueEntity existing = cache.remove(key);
|
||||
return existing == null ? false : true;
|
||||
} catch (HotRodClientException re) {
|
||||
// No need to retry. The hotrod (remoteCache) has some retries in itself in case of some random network error happened.
|
||||
// In case of lock conflict, we don't want to retry anyway as there was likely an attempt to remove the code from different place.
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debugf(re, "Failed when removing user code %s", userCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models.sessions.infinispan;
|
||||
|
||||
import org.infinispan.Cache;
|
||||
import org.infinispan.client.hotrod.Flag;
|
||||
import org.infinispan.client.hotrod.RemoteCache;
|
||||
import org.infinispan.commons.api.BasicCache;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
|
||||
import org.keycloak.models.*;
|
||||
import org.keycloak.models.sessions.infinispan.entities.ActionTokenValueEntity;
|
||||
import org.keycloak.models.sessions.infinispan.util.InfinispanUtil;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class InfinispanOAuth2DeviceTokenStoreProviderFactory implements OAuth2DeviceTokenStoreProviderFactory {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(InfinispanOAuth2DeviceTokenStoreProviderFactory.class);
|
||||
|
||||
// Reuse "actionTokens" infinispan cache for now
|
||||
private volatile Supplier<BasicCache<String, ActionTokenValueEntity>> codeCache;
|
||||
|
||||
@Override
|
||||
public OAuth2DeviceTokenStoreProvider create(KeycloakSession session) {
|
||||
lazyInit(session);
|
||||
return new InfinispanOAuth2DeviceTokenStoreProvider(session, codeCache);
|
||||
}
|
||||
|
||||
private void lazyInit(KeycloakSession session) {
|
||||
if (codeCache == null) {
|
||||
synchronized (this) {
|
||||
if (codeCache == null) {
|
||||
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
|
||||
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
|
||||
|
||||
RemoteCache remoteCache = InfinispanUtil.getRemoteCache(cache);
|
||||
|
||||
if (remoteCache != null) {
|
||||
LOG.debugf("Having remote stores. Using remote cache '%s' for token of OAuth 2.0 Device Authorization Grant", remoteCache.getName());
|
||||
this.codeCache = () -> {
|
||||
// Doing this way as flag is per invocation
|
||||
return remoteCache.withFlags(Flag.FORCE_RETURN_VALUE);
|
||||
};
|
||||
} else {
|
||||
LOG.debugf("Not having remote stores. Using normal cache '%s' for token of OAuth 2.0 Device Authorization Grant", cache.getName());
|
||||
this.codeCache = () -> {
|
||||
return cache;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "infinispan";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
#
|
||||
# Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
# and other contributors as indicated by the @author tags.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
org.keycloak.models.sessions.infinispan.InfinispanOAuth2DeviceTokenStoreProviderFactory
|
|
@ -587,6 +587,26 @@ public class RealmAdapter implements RealmModel, JpaModel<RealmEntity> {
|
|||
em.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOAuth2DeviceCodeLifespan() {
|
||||
return getAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOAuth2DeviceCodeLifespan(int seconds) {
|
||||
setAttribute(RealmAttributes.OAUTH2_DEVICE_CODE_LIFESPAN, seconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOAuth2DevicePollingInterval() {
|
||||
return getAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOAuth2DevicePollingInterval(int seconds) {
|
||||
setAttribute(RealmAttributes.OAUTH2_DEVICE_POLLING_INTERVAL, seconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Integer> getUserActionTokenLifespans() {
|
||||
|
||||
|
|
|
@ -34,10 +34,16 @@ public interface RealmAttributes {
|
|||
String OFFLINE_SESSION_MAX_LIFESPAN_ENABLED = "offlineSessionMaxLifespanEnabled";
|
||||
|
||||
String OFFLINE_SESSION_MAX_LIFESPAN = "offlineSessionMaxLifespan";
|
||||
|
||||
// OAuth 2.0 Device Authorization Grant
|
||||
String OAUTH2_DEVICE_CODE_LIFESPAN = "oauth2DeviceCodeLifespan";
|
||||
String OAUTH2_DEVICE_POLLING_INTERVAL = "oauth2DevicePollingInterval";
|
||||
|
||||
String CLIENT_SESSION_IDLE_TIMEOUT = "clientSessionIdleTimeout";
|
||||
String CLIENT_SESSION_MAX_LIFESPAN = "clientSessionMaxLifespan";
|
||||
String CLIENT_OFFLINE_SESSION_IDLE_TIMEOUT = "clientOfflineSessionIdleTimeout";
|
||||
String CLIENT_OFFLINE_SESSION_MAX_LIFESPAN = "clientOfflineSessionMaxLifespan";
|
||||
|
||||
String WEBAUTHN_POLICY_RP_ENTITY_NAME = "webAuthnPolicyRpEntityName";
|
||||
String WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS = "webAuthnPolicySignatureAlgorithms";
|
||||
|
||||
|
|
|
@ -103,4 +103,9 @@ public interface Errors {
|
|||
String INVALID_PERMISSION_TICKET = "invalid_permission_ticket";
|
||||
String ACCESS_DENIED = "access_denied";
|
||||
|
||||
String INVALID_OAUTH2_DEVICE_CODE = "invalid_oauth2_device_code";
|
||||
String EXPIRED_OAUTH2_DEVICE_CODE = "expired_oauth2_device_code";
|
||||
String INVALID_OAUTH2_USER_CODE = "invalid_oauth2_user_code";
|
||||
String EXPIRED_OAUTH2_USER_CODE = "expired_oauth2_user_code";
|
||||
String SLOW_DOWN = "slow_down";
|
||||
}
|
||||
|
|
|
@ -131,6 +131,13 @@ public enum EventType {
|
|||
TOKEN_EXCHANGE(true),
|
||||
TOKEN_EXCHANGE_ERROR(true),
|
||||
|
||||
OAUTH2_DEVICE_AUTH(true),
|
||||
OAUTH2_DEVICE_AUTH_ERROR(true),
|
||||
OAUTH2_DEVICE_VERIFY_USER_CODE(true),
|
||||
OAUTH2_DEVICE_VERIFY_USER_CODE_ERROR(true),
|
||||
OAUTH2_DEVICE_CODE_TO_TOKEN(true),
|
||||
OAUTH2_DEVICE_CODE_TO_TOKEN_ERROR(true),
|
||||
|
||||
PERMISSION_TOKEN(true),
|
||||
PERMISSION_TOKEN_ERROR(false),
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ public enum LoginFormsPages {
|
|||
LOGIN, LOGIN_USERNAME, LOGIN_PASSWORD, LOGIN_TOTP, LOGIN_CONFIG_TOTP, LOGIN_WEBAUTHN, LOGIN_VERIFY_EMAIL,
|
||||
LOGIN_IDP_LINK_CONFIRM, LOGIN_IDP_LINK_EMAIL,
|
||||
OAUTH_GRANT, LOGIN_RESET_PASSWORD, LOGIN_UPDATE_PASSWORD, LOGIN_SELECT_AUTHENTICATOR, REGISTER, INFO, ERROR, ERROR_WEBAUTHN, LOGIN_UPDATE_PROFILE,
|
||||
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM;
|
||||
LOGIN_PAGE_EXPIRED, CODE, X509_CONFIRM, SAML_POST_FORM,
|
||||
LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE;
|
||||
|
||||
}
|
||||
|
|
|
@ -90,6 +90,8 @@ public interface LoginFormsProvider extends Provider {
|
|||
|
||||
Response createSelectAuthenticator();
|
||||
|
||||
Response createOAuth2DeviceVerifyUserCodePage();
|
||||
|
||||
Response createCode();
|
||||
|
||||
Response createX509ConfirmPage();
|
||||
|
|
|
@ -118,4 +118,9 @@ public final class Constants {
|
|||
* If {@code #STORAGE_BATCH_ENABLED} is set, indicates the batch size.
|
||||
*/
|
||||
public static final String STORAGE_BATCH_SIZE = "org.keycloak.storage.batch_size";
|
||||
|
||||
// 10 minutes
|
||||
public static final int DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN = 600;
|
||||
// 5 seconds
|
||||
public static final int DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL = 5;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
|
||||
|
||||
/**
|
||||
* Provides cache for OAuth2 Device Authorization Grant tokens.
|
||||
*
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public interface OAuth2DeviceTokenStoreProvider extends Provider {
|
||||
|
||||
/**
|
||||
* Stores the given device code and user code
|
||||
*
|
||||
* @param deviceCode
|
||||
* @param userCode
|
||||
* @param lifespanSeconds
|
||||
*/
|
||||
void put(OAuth2DeviceCodeModel deviceCode, OAuth2DeviceUserCodeModel userCode, int lifespanSeconds);
|
||||
|
||||
/**
|
||||
* Get the model object by the given device code
|
||||
*
|
||||
* @param realm
|
||||
* @param deviceCode
|
||||
* @return
|
||||
*/
|
||||
OAuth2DeviceCodeModel getByDeviceCode(RealmModel realm, String deviceCode);
|
||||
|
||||
/**
|
||||
* Check the device code is allowed to poll
|
||||
*
|
||||
* @param deviceCode
|
||||
* @return Return true if the given device code is allowed to poll
|
||||
*/
|
||||
boolean isPollingAllowed(OAuth2DeviceCodeModel deviceCode);
|
||||
|
||||
/**
|
||||
* Get the model object by the given user code
|
||||
*
|
||||
* @param realm
|
||||
* @param userCode
|
||||
* @return
|
||||
*/
|
||||
OAuth2DeviceCodeModel getByUserCode(RealmModel realm, String userCode);
|
||||
|
||||
/**
|
||||
* Approve the given user code
|
||||
*
|
||||
* @param realm
|
||||
* @param userCode
|
||||
* @param userSessionId
|
||||
* @return Return true if approving successful. If the code is already expired and cleared, it returns false.
|
||||
*/
|
||||
boolean approve(RealmModel realm, String userCode, String userSessionId);
|
||||
|
||||
/**
|
||||
* Deny the given user code
|
||||
*
|
||||
* @param realm
|
||||
* @param userCode
|
||||
* @return Return true if denying successful. If the code is already expired and cleared, it returns false.
|
||||
*/
|
||||
boolean deny(RealmModel realm, String userCode);
|
||||
|
||||
/**
|
||||
* Remove the given device code
|
||||
*
|
||||
* @param realm
|
||||
* @param deviceCode
|
||||
* @return Return true if removing successful. If the code is already expired and cleared, it returns false.
|
||||
*/
|
||||
boolean removeDeviceCode(RealmModel realm, String deviceCode);
|
||||
|
||||
/**
|
||||
* Remove the given user code
|
||||
*
|
||||
* @param realm
|
||||
* @param userCode
|
||||
* @return Return true if removing successful. If the code is already expired and cleared, it returns false.
|
||||
*/
|
||||
boolean removeUserCode(RealmModel realm, String userCode);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models;
|
||||
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public interface OAuth2DeviceTokenStoreProviderFactory extends ProviderFactory<OAuth2DeviceTokenStoreProvider> {
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.models;
|
||||
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.provider.Spi;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceTokenStoreSpi implements Spi {
|
||||
|
||||
public static final String NAME = "oauth2DeviceTokenStore";
|
||||
|
||||
@Override
|
||||
public boolean isInternal() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Provider> getProviderClass() {
|
||||
return OAuth2DeviceTokenStoreProvider.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends ProviderFactory> getProviderFactoryClass() {
|
||||
return OAuth2DeviceTokenStoreProviderFactory.class;
|
||||
}
|
||||
}
|
|
@ -352,6 +352,8 @@ public class ModelToRepresentation {
|
|||
rep.setAccessCodeLifespanLogin(realm.getAccessCodeLifespanLogin());
|
||||
rep.setActionTokenGeneratedByAdminLifespan(realm.getActionTokenGeneratedByAdminLifespan());
|
||||
rep.setActionTokenGeneratedByUserLifespan(realm.getActionTokenGeneratedByUserLifespan());
|
||||
rep.setOAuth2DeviceCodeLifespan(realm.getOAuth2DeviceCodeLifespan());
|
||||
rep.setOAuth2DevicePollingInterval(realm.getOAuth2DevicePollingInterval());
|
||||
rep.setSmtpServer(new HashMap<>(realm.getSmtpConfig()));
|
||||
rep.setBrowserSecurityHeaders(realm.getBrowserSecurityHeaders());
|
||||
rep.setAccountTheme(realm.getAccountTheme());
|
||||
|
@ -583,6 +585,7 @@ public class ModelToRepresentation {
|
|||
rep.setImplicitFlowEnabled(clientModel.isImplicitFlowEnabled());
|
||||
rep.setDirectAccessGrantsEnabled(clientModel.isDirectAccessGrantsEnabled());
|
||||
rep.setServiceAccountsEnabled(clientModel.isServiceAccountsEnabled());
|
||||
rep.setOAuth2DeviceAuthorizationGrantEnabled(clientModel.isOAuth2DeviceAuthorizationGrantEnabled());
|
||||
rep.setSurrogateAuthRequired(clientModel.isSurrogateAuthRequired());
|
||||
rep.setRootUrl(clientModel.getRootUrl());
|
||||
rep.setBaseUrl(clientModel.getBaseUrl());
|
||||
|
|
|
@ -249,6 +249,14 @@ public class RepresentationToModel {
|
|||
newRealm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
|
||||
else newRealm.setActionTokenGeneratedByUserLifespan(newRealm.getAccessCodeLifespanUserAction());
|
||||
|
||||
// OAuth 2.0 Device Authorization Grant
|
||||
if (rep.getOAuth2DeviceCodeLifespan() != null)
|
||||
newRealm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
||||
else newRealm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
||||
if (rep.getOAuth2DevicePollingInterval() != null)
|
||||
newRealm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
||||
else newRealm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
||||
|
||||
if (rep.getSslRequired() != null)
|
||||
newRealm.setSslRequired(SslRequired.valueOf(rep.getSslRequired().toUpperCase()));
|
||||
if (rep.isRegistrationAllowed() != null) newRealm.setRegistrationAllowed(rep.isRegistrationAllowed());
|
||||
|
@ -1110,6 +1118,10 @@ public class RepresentationToModel {
|
|||
realm.setActionTokenGeneratedByAdminLifespan(rep.getActionTokenGeneratedByAdminLifespan());
|
||||
if (rep.getActionTokenGeneratedByUserLifespan() != null)
|
||||
realm.setActionTokenGeneratedByUserLifespan(rep.getActionTokenGeneratedByUserLifespan());
|
||||
if (rep.getOAuth2DeviceCodeLifespan() != null)
|
||||
realm.setOAuth2DeviceCodeLifespan(rep.getOAuth2DeviceCodeLifespan());
|
||||
if (rep.getOAuth2DevicePollingInterval() != null)
|
||||
realm.setOAuth2DevicePollingInterval(rep.getOAuth2DevicePollingInterval());
|
||||
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
|
||||
if (rep.getDefaultSignatureAlgorithm() != null) realm.setDefaultSignatureAlgorithm(rep.getDefaultSignatureAlgorithm());
|
||||
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
|
||||
|
@ -1475,6 +1487,10 @@ public class RepresentationToModel {
|
|||
client.setFullScopeAllowed(!client.isConsentRequired());
|
||||
}
|
||||
|
||||
if (resourceRep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
|
||||
client.setOAuth2DeviceAuthorizationGrantEnabled(resourceRep.isOAuth2DeviceAuthorizationGrantEnabled());
|
||||
}
|
||||
|
||||
client.updateClient();
|
||||
resourceRep.setId(client.getId());
|
||||
|
||||
|
@ -1540,7 +1556,6 @@ public class RepresentationToModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rep.getNotBefore() != null) {
|
||||
resource.setNotBefore(rep.getNotBefore());
|
||||
}
|
||||
|
@ -1574,6 +1589,10 @@ public class RepresentationToModel {
|
|||
}
|
||||
}
|
||||
|
||||
if (rep.isOAuth2DeviceAuthorizationGrantEnabled() != null) {
|
||||
resource.setOAuth2DeviceAuthorizationGrantEnabled(rep.isOAuth2DeviceAuthorizationGrantEnabled());
|
||||
}
|
||||
|
||||
resource.updateClient();
|
||||
}
|
||||
|
||||
|
|
0
server-spi-private/src/main/java/org/keycloak/storage/client/AbstractReadOnlyClientStorageAdapter.java
Normal file → Executable file
0
server-spi-private/src/main/java/org/keycloak/storage/client/AbstractReadOnlyClientStorageAdapter.java
Normal file → Executable file
|
@ -25,6 +25,7 @@ org.keycloak.models.RealmSpi
|
|||
org.keycloak.models.RoleSpi
|
||||
org.keycloak.models.ActionTokenStoreSpi
|
||||
org.keycloak.models.CodeToTokenStoreSpi
|
||||
org.keycloak.models.OAuth2DeviceTokenStoreSpi
|
||||
org.keycloak.models.SingleUseTokenStoreSpi
|
||||
org.keycloak.models.TokenRevocationStoreSpi
|
||||
org.keycloak.models.UserSessionSpi
|
||||
|
|
|
@ -36,6 +36,7 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
|
|||
String PRIVATE_KEY = "privateKey";
|
||||
String PUBLIC_KEY = "publicKey";
|
||||
String X509CERTIFICATE = "X509Certificate";
|
||||
String OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED = "oauth2.device.authorization.grant.enabled";
|
||||
|
||||
public static class SearchableFields {
|
||||
public static final SearchableModelField<ClientModel> ID = new SearchableModelField<>("id", String.class);
|
||||
|
@ -199,6 +200,15 @@ public interface ClientModel extends ClientScopeModel, RoleContainerModel, Prot
|
|||
boolean isServiceAccountsEnabled();
|
||||
void setServiceAccountsEnabled(boolean serviceAccountsEnabled);
|
||||
|
||||
default boolean isOAuth2DeviceAuthorizationGrantEnabled() {
|
||||
String enabled = getAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED);
|
||||
return Boolean.parseBoolean(enabled);
|
||||
}
|
||||
|
||||
default void setOAuth2DeviceAuthorizationGrantEnabled(boolean oauth2DeviceAuthorizationGrantEnabled) {
|
||||
setAttribute(OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, Boolean.toString(oauth2DeviceAuthorizationGrantEnabled));
|
||||
}
|
||||
|
||||
RealmModel getRealm();
|
||||
|
||||
/**
|
||||
|
|
181
server-spi/src/main/java/org/keycloak/models/OAuth2DeviceCodeModel.java
Executable file
181
server-spi/src/main/java/org/keycloak/models/OAuth2DeviceCodeModel.java
Executable file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models;
|
||||
|
||||
import org.keycloak.common.util.Time;
|
||||
|
||||
import javax.ws.rs.core.MultivaluedHashMap;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceCodeModel {
|
||||
|
||||
private static final String CLIENT_ID = "cid";
|
||||
private static final String EXPIRATION_NOTE = "exp";
|
||||
private static final String POLLING_INTERVAL_NOTE = "int";
|
||||
private static final String NONCE_NOTE = "nonce";
|
||||
private static final String SCOPE_NOTE = "scope";
|
||||
private static final String USER_SESSION_ID_NOTE = "uid";
|
||||
private static final String DENIED_NOTE = "denied";
|
||||
|
||||
private final RealmModel realm;
|
||||
private final String clientId;
|
||||
private final String deviceCode;
|
||||
private final int expiration;
|
||||
private final int pollingInterval;
|
||||
private final String scope;
|
||||
private final String nonce;
|
||||
private final String userSessionId;
|
||||
private final boolean denied;
|
||||
|
||||
public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
|
||||
String deviceCode, String scope, String nonce) {
|
||||
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
|
||||
int expiration = Time.currentTime() + expiresIn;
|
||||
int pollingInterval = realm.getOAuth2DevicePollingInterval();
|
||||
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, null, false);
|
||||
}
|
||||
|
||||
public OAuth2DeviceCodeModel approve(String userSessionId) {
|
||||
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, userSessionId, false);
|
||||
}
|
||||
|
||||
public OAuth2DeviceCodeModel deny() {
|
||||
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, null, true);
|
||||
}
|
||||
|
||||
private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
|
||||
String deviceCode, String scope, String nonce, int expiration, int pollingInterval,
|
||||
String userSessionId, boolean denied) {
|
||||
this.realm = realm;
|
||||
this.clientId = clientId;
|
||||
this.deviceCode = deviceCode;
|
||||
this.scope = scope;
|
||||
this.nonce = nonce;
|
||||
this.expiration = expiration;
|
||||
this.pollingInterval = pollingInterval;
|
||||
this.userSessionId = userSessionId;
|
||||
this.denied = denied;
|
||||
}
|
||||
|
||||
public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) {
|
||||
return new OAuth2DeviceCodeModel(realm, deviceCode, data);
|
||||
}
|
||||
|
||||
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) {
|
||||
this.realm = realm;
|
||||
this.clientId = data.get(CLIENT_ID);
|
||||
this.deviceCode = deviceCode;
|
||||
this.scope = data.get(SCOPE_NOTE);
|
||||
this.nonce = data.get(NONCE_NOTE);
|
||||
this.expiration = Integer.parseInt(data.get(EXPIRATION_NOTE));
|
||||
this.pollingInterval = Integer.parseInt(data.get(POLLING_INTERVAL_NOTE));
|
||||
this.userSessionId = data.get(USER_SESSION_ID_NOTE);
|
||||
this.denied = Boolean.parseBoolean(data.get(DENIED_NOTE));
|
||||
}
|
||||
|
||||
public String getDeviceCode() {
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
public String getScope() {
|
||||
return scope;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public int getExpiration() {
|
||||
return expiration;
|
||||
}
|
||||
|
||||
public int getPollingInterval() {
|
||||
return pollingInterval;
|
||||
}
|
||||
|
||||
public String getClientId() {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public boolean isPending() {
|
||||
return userSessionId == null;
|
||||
}
|
||||
|
||||
public boolean isApproved() {
|
||||
return userSessionId != null && !denied;
|
||||
}
|
||||
|
||||
public boolean isDenied() {
|
||||
return userSessionId != null && denied;
|
||||
}
|
||||
|
||||
public String getUserSessionId() {
|
||||
return userSessionId;
|
||||
}
|
||||
|
||||
public static String createKey(RealmModel realm, String deviceCode) {
|
||||
return String.format("%s.dc.%s", realm.getId(), deviceCode);
|
||||
}
|
||||
|
||||
public String serializeKey() {
|
||||
return createKey(realm, deviceCode);
|
||||
}
|
||||
|
||||
public String serializePollingKey() {
|
||||
return createKey(realm, deviceCode) + ".polling";
|
||||
}
|
||||
|
||||
public Map<String, String> serializeValue() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put(CLIENT_ID, clientId);
|
||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||
result.put(SCOPE_NOTE, scope);
|
||||
result.put(NONCE_NOTE, nonce);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, String> serializeApprovedValue() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||
result.put(SCOPE_NOTE, scope);
|
||||
result.put(NONCE_NOTE, nonce);
|
||||
result.put(USER_SESSION_ID_NOTE, userSessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Map<String, String> serializeDeniedValue() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||
result.put(DENIED_NOTE, String.valueOf(denied));
|
||||
return result;
|
||||
}
|
||||
|
||||
public MultivaluedMap<String, String> getParams() {
|
||||
MultivaluedHashMap<String, String> params = new MultivaluedHashMap<>();
|
||||
params.putSingle(SCOPE_NOTE, scope);
|
||||
params.putSingle(NONCE_NOTE, nonce);
|
||||
return params;
|
||||
}
|
||||
}
|
70
server-spi/src/main/java/org/keycloak/models/OAuth2DeviceUserCodeModel.java
Executable file
70
server-spi/src/main/java/org/keycloak/models/OAuth2DeviceUserCodeModel.java
Executable file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceUserCodeModel {
|
||||
|
||||
private static final String DEVICE_CODE_NOTE = "dc";
|
||||
|
||||
private final RealmModel realm;
|
||||
private final String deviceCode;
|
||||
private final String userCode;
|
||||
|
||||
public OAuth2DeviceUserCodeModel(RealmModel realm, String deviceCode, String userCode) {
|
||||
this.realm = realm;
|
||||
this.deviceCode = deviceCode;
|
||||
this.userCode = userCode;
|
||||
}
|
||||
|
||||
public static OAuth2DeviceUserCodeModel fromCache(RealmModel realm, String userCode, Map<String, String> data) {
|
||||
return new OAuth2DeviceUserCodeModel(realm, userCode, data);
|
||||
}
|
||||
|
||||
private OAuth2DeviceUserCodeModel(RealmModel realm, String userCode, Map<String, String> data) {
|
||||
this.realm = realm;
|
||||
this.userCode = userCode;
|
||||
this.deviceCode = data.get(DEVICE_CODE_NOTE);
|
||||
}
|
||||
|
||||
public String getDeviceCode() {
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
public String getUserCode() {
|
||||
return userCode;
|
||||
}
|
||||
|
||||
public static String createKey(RealmModel realm, String userCode) {
|
||||
return String.format("%s.uc.%s", realm.getId(), userCode);
|
||||
}
|
||||
|
||||
public String serializeKey() {
|
||||
return createKey(realm, userCode);
|
||||
}
|
||||
|
||||
public Map<String, String> serializeValue() {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put(DEVICE_CODE_NOTE, deviceCode);
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -213,6 +213,12 @@ public interface RealmModel extends RoleContainerModel {
|
|||
|
||||
void setAccessCodeLifespanUserAction(int seconds);
|
||||
|
||||
int getOAuth2DeviceCodeLifespan();
|
||||
void setOAuth2DeviceCodeLifespan(int seconds);
|
||||
|
||||
int getOAuth2DevicePollingInterval();
|
||||
void setOAuth2DevicePollingInterval(int seconds);
|
||||
|
||||
/**
|
||||
* This method will return a map with all the lifespans available
|
||||
* or an empty map, but never null.
|
||||
|
|
|
@ -44,7 +44,8 @@ public interface CommonClientSessionModel {
|
|||
AUTHENTICATE,
|
||||
LOGGED_OUT,
|
||||
LOGGING_OUT,
|
||||
REQUIRED_ACTIONS
|
||||
REQUIRED_ACTIONS,
|
||||
USER_CODE_VERIFICATION
|
||||
}
|
||||
|
||||
enum ExecutionStatus {
|
||||
|
|
|
@ -573,11 +573,15 @@ public class FreeMarkerLoginFormsProvider implements LoginFormsProvider {
|
|||
return createResponse(LoginFormsPages.OAUTH_GRANT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createSelectAuthenticator() {
|
||||
return createResponse(LoginFormsPages.LOGIN_SELECT_AUTHENTICATOR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createOAuth2DeviceVerifyUserCodePage() {
|
||||
return createResponse(LoginFormsPages.LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response createCode() {
|
||||
return createResponse(LoginFormsPages.CODE);
|
||||
|
|
|
@ -50,6 +50,8 @@ public class Templates {
|
|||
return "login-reset-password.ftl";
|
||||
case LOGIN_UPDATE_PASSWORD:
|
||||
return "login-update-password.ftl";
|
||||
case LOGIN_OAUTH2_DEVICE_VERIFY_USER_CODE:
|
||||
return "login-oauth2-device-verify-user-code.ftl";
|
||||
case LOGIN_SELECT_AUTHENTICATOR:
|
||||
return "select-authenticator.ftl";
|
||||
case REGISTER:
|
||||
|
|
|
@ -105,6 +105,14 @@ public class UrlBean {
|
|||
return Urls.realmOauthAction(baseURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getOauth2DeviceVerificationAction() {
|
||||
if (this.actionuri != null) {
|
||||
return this.actionuri.getPath();
|
||||
}
|
||||
|
||||
return Urls.realmOAuth2DeviceVerificationAction(baseURI, realm).toString();
|
||||
}
|
||||
|
||||
public String getResourcesPath() {
|
||||
URI uri = Urls.themeRoot(baseURI);
|
||||
return uri.getPath() + "/" + theme.getType().toString().toLowerCase() +"/" + theme.getName();
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.keycloak.models.ClientModel;
|
|||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.LoginProtocol;
|
||||
|
@ -117,6 +118,10 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
|||
public static final String PKCE_METHOD_PLAIN = "plain";
|
||||
public static final String PKCE_METHOD_S256 = "S256";
|
||||
|
||||
// OAuth 2.0 Device Authorization Grant
|
||||
public static final String OAUTH2_DEVICE_VERIFIED_USER_CODE = "OAUTH2_DEVICE_VERIFIED_USER_CODE";
|
||||
public static final String OAUTH2_DEVICE_USER_CODE_EXPIRATION = "OAUTH2_DEVICE_USER_CODE_EXPIRATION";
|
||||
|
||||
private static final Logger logger = Logger.getLogger(OIDCLoginProtocol.class);
|
||||
|
||||
protected KeycloakSession session;
|
||||
|
@ -184,7 +189,11 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
|||
|
||||
@Override
|
||||
public Response authenticated(AuthenticationSessionModel authSession, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
|
||||
AuthenticatedClientSessionModel clientSession= clientSessionCtx.getClientSession();
|
||||
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
||||
|
||||
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
|
||||
return approveOAuth2DeviceAuthorization(authSession, clientSession);
|
||||
}
|
||||
|
||||
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
||||
String responseModeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM);
|
||||
|
@ -267,9 +276,12 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
|||
return redirectUri.build();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Response sendError(AuthenticationSessionModel authSession, Error error) {
|
||||
if (AuthenticationManager.isOAuth2DeviceVerificationFlow(authSession)) {
|
||||
return denyOAuth2DeviceAuthorization(authSession, error);
|
||||
}
|
||||
|
||||
String responseTypeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
|
||||
String responseModeParam = authSession.getClientNote(OIDCLoginProtocol.RESPONSE_MODE_PARAM);
|
||||
setupResponseTypeAndMode(responseTypeParam, responseModeParam);
|
||||
|
@ -292,6 +304,47 @@ public class OIDCLoginProtocol implements LoginProtocol {
|
|||
return redirectUri.build();
|
||||
}
|
||||
|
||||
private Response approveOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, AuthenticatedClientSessionModel clientSession) {
|
||||
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
|
||||
|
||||
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
||||
String userSessionId = clientSession.getUserSession().getId();
|
||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||
if (!store.approve(realm, verifiedUserCode, userSessionId)) {
|
||||
// Already expired and removed in the store
|
||||
return Response.status(302).location(
|
||||
uriBuilder.queryParam(OAuth2Constants.ERROR, OAuthErrorException.EXPIRED_TOKEN)
|
||||
.build(realm.getName())
|
||||
).build();
|
||||
}
|
||||
|
||||
// Now, remove the verified user code
|
||||
store.removeUserCode(realm, verifiedUserCode);
|
||||
|
||||
return Response.status(302).location(
|
||||
uriBuilder.build(realm.getName())
|
||||
).build();
|
||||
}
|
||||
|
||||
private Response denyOAuth2DeviceAuthorization(AuthenticationSessionModel authSession, Error error) {
|
||||
UriBuilder uriBuilder = OIDCLoginProtocolService.oauth2DeviceVerificationCompletedUrl(uriInfo);
|
||||
String errorType = OAuthErrorException.SERVER_ERROR;
|
||||
if (error == Error.CONSENT_DENIED) {
|
||||
String verifiedUserCode = authSession.getClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE);
|
||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||
if (!store.deny(realm, verifiedUserCode)) {
|
||||
// Already expired and removed in the store
|
||||
errorType = OAuthErrorException.EXPIRED_TOKEN;
|
||||
} else {
|
||||
errorType = OAuthErrorException.ACCESS_DENIED;
|
||||
}
|
||||
}
|
||||
return Response.status(302).location(
|
||||
uriBuilder.queryParam(OAuth2Constants.ERROR, errorType)
|
||||
.build(realm.getName())
|
||||
).build();
|
||||
}
|
||||
|
||||
private String translateError(Error error) {
|
||||
switch (error) {
|
||||
case CANCELLED_BY_USER:
|
||||
|
|
|
@ -37,11 +37,13 @@ import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
|
|||
import org.keycloak.protocol.oidc.endpoints.LoginStatusIframeEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.LogoutEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.ThirdPartyCookiesIframeEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint;
|
||||
import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint;
|
||||
import org.keycloak.protocol.oidc.ext.OIDCExtProvider;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.saml.common.util.StringUtil;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.resources.Cors;
|
||||
|
@ -50,6 +52,7 @@ import org.keycloak.services.util.CacheControlUtil;
|
|||
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.NotFoundException;
|
||||
import javax.ws.rs.OPTIONS;
|
||||
|
@ -115,6 +118,21 @@ public class OIDCLoginProtocolService {
|
|||
return uriBuilder.path(OIDCLoginProtocolService.class, "auth");
|
||||
}
|
||||
|
||||
public static UriBuilder oauth2DeviceAuthUrl(UriBuilder baseUriBuilder) {
|
||||
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
|
||||
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceAuth");
|
||||
}
|
||||
|
||||
public static UriBuilder oauth2DeviceVerificationCompletedUrl(UriInfo uriInfo) {
|
||||
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
|
||||
return uriBuilder.path(OIDCLoginProtocolService.class, "oauth2DeviceVerificationCompleted");
|
||||
}
|
||||
|
||||
public static UriBuilder delegatedUrl(UriInfo uriInfo) {
|
||||
UriBuilder uriBuilder = tokenServiceBaseUrl(uriInfo);
|
||||
return uriBuilder.path(OIDCLoginProtocolService.class, "kcinitBrowserLoginComplete");
|
||||
}
|
||||
|
||||
public static UriBuilder tokenUrl(UriBuilder baseUriBuilder) {
|
||||
UriBuilder uriBuilder = tokenServiceBaseUrl(baseUriBuilder);
|
||||
return uriBuilder.path(OIDCLoginProtocolService.class, "token");
|
||||
|
@ -159,6 +177,58 @@ public class OIDCLoginProtocolService {
|
|||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 2.0 Device Authorization endpoint
|
||||
*/
|
||||
@Path("device/auth")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Object oauth2DeviceAuth() {
|
||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Showing the result of verification process for OAuth 2.0 Device Authorization Grant.
|
||||
* This outputs login success or failure messages.
|
||||
*
|
||||
* @param error
|
||||
* @return
|
||||
*/
|
||||
@GET
|
||||
@Path("device/verification")
|
||||
public Response oauth2DeviceVerificationCompleted(@QueryParam("error") String error) {
|
||||
if (!StringUtil.isNullOrEmpty(error)) {
|
||||
String message;
|
||||
switch (error) {
|
||||
case OAuthErrorException.ACCESS_DENIED:
|
||||
// cased by CANCELLED_BY_USER or CONSENT_DENIED:
|
||||
message = Messages.OAUTH2_DEVICE_CONSENT_DENIED;
|
||||
break;
|
||||
case OAuthErrorException.EXPIRED_TOKEN:
|
||||
message = Messages.OAUTH2_DEVICE_EXPIRED_USER_CODE;
|
||||
break;
|
||||
default:
|
||||
message = Messages.OAUTH2_DEVICE_VERIFICATION_FAILED;
|
||||
}
|
||||
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
||||
String restartUri = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
|
||||
return forms
|
||||
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER))
|
||||
.setAttribute(Constants.TEMPLATE_ATTR_ACTION_URI, restartUri)
|
||||
.setError(message)
|
||||
.createInfoPage();
|
||||
} else {
|
||||
LoginFormsProvider forms = session.getProvider(LoginFormsProvider.class);
|
||||
return forms
|
||||
.setAttribute("messageHeader", forms.getMessage(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER))
|
||||
.setAttribute(Constants.SKIP_LINK, true)
|
||||
.setSuccess(Messages.OAUTH2_DEVICE_VERIFICATION_COMPLETE)
|
||||
.createInfoPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration endpoint
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,343 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oidc.endpoints;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.authentication.AuthenticationProcessor;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.common.util.RandomString;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.constants.AdapterConstants;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.events.EventType;
|
||||
import org.keycloak.forms.login.LoginFormsProvider;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.OAuth2DeviceCodeModel;
|
||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||
import org.keycloak.models.OAuth2DeviceUserCodeModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.SystemClientUtil;
|
||||
import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
||||
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
|
||||
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
|
||||
import org.keycloak.saml.common.util.StringUtil;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.ErrorPageException;
|
||||
import org.keycloak.services.ServicesLogger;
|
||||
import org.keycloak.services.Urls;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.managers.AuthenticationSessionManager;
|
||||
import org.keycloak.services.managers.ClientSessionCode;
|
||||
import org.keycloak.services.messages.Messages;
|
||||
import org.keycloak.services.resources.Cors;
|
||||
import org.keycloak.services.resources.LoginActionsService;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.services.util.CacheControlUtil;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.RootAuthenticationSessionModel;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
import javax.ws.rs.Consumes;
|
||||
import javax.ws.rs.POST;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import static org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceAuthorizationEndpoint extends AuthorizationEndpointBase {
|
||||
|
||||
private static final Logger logger = Logger.getLogger(OAuth2DeviceAuthorizationEndpoint.class);
|
||||
|
||||
private enum Action {
|
||||
OAUTH2_DEVICE_AUTH, OAUTH2_DEVICE_VERIFY_USER_CODE
|
||||
}
|
||||
|
||||
private ClientModel client;
|
||||
private AuthenticationSessionModel authenticationSession;
|
||||
private Action action;
|
||||
private AuthorizationEndpointRequest request;
|
||||
private Cors cors;
|
||||
|
||||
public OAuth2DeviceAuthorizationEndpoint(RealmModel realm, EventBuilder event) {
|
||||
super(realm, event);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
public Response buildPost() {
|
||||
logger.trace("Processing @POST request");
|
||||
cors = Cors.add(httpRequest).auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||
action = Action.OAUTH2_DEVICE_AUTH;
|
||||
event.event(EventType.OAUTH2_DEVICE_AUTH);
|
||||
return process(httpRequest.getDecodedFormParameters());
|
||||
}
|
||||
|
||||
private Response process(MultivaluedMap<String, String> params) {
|
||||
checkSsl();
|
||||
checkRealm();
|
||||
checkClient(null);
|
||||
|
||||
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, params);
|
||||
|
||||
if (!TokenUtil.isOIDCRequest(request.getScope())) {
|
||||
ServicesLogger.LOGGER.oidcScopeMissing();
|
||||
}
|
||||
|
||||
authenticationSession = createAuthenticationSession(client, request.getState());
|
||||
updateAuthenticationSession();
|
||||
|
||||
// So back button doesn't work
|
||||
CacheControlUtil.noBackButtonCacheControlHeader();
|
||||
switch (action) {
|
||||
case OAUTH2_DEVICE_AUTH:
|
||||
return buildDeviceAuthorizationResponse();
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unknown action " + action);
|
||||
}
|
||||
|
||||
public Response buildDeviceAuthorizationResponse() {
|
||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
int expiresIn = realm.getOAuth2DeviceCodeLifespan();
|
||||
int interval = realm.getOAuth2DevicePollingInterval();
|
||||
|
||||
OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
|
||||
Base64Url.encode(KeycloakModelUtils.generateSecret()), request.getScope(), request.getNonce());
|
||||
|
||||
// TODO Configure user code format
|
||||
String secret = new RandomString(10, new SecureRandom(), RandomString.upper).nextString();
|
||||
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm,
|
||||
deviceCode.getDeviceCode(),
|
||||
secret);
|
||||
|
||||
// To inform "expired_token" to the client, the lifespan of the cache provider is longer than device code
|
||||
int lifespanSeconds = expiresIn + interval + 10;
|
||||
|
||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||
store.put(deviceCode, userCode, lifespanSeconds);
|
||||
|
||||
try {
|
||||
String deviceUrl = RealmsResource.oauth2DeviceVerificationUrl(session.getContext().getUri()).build(realm.getName()).toString();
|
||||
|
||||
OAuth2DeviceAuthorizationResponse response = new OAuth2DeviceAuthorizationResponse();
|
||||
response.setDeviceCode(deviceCode.getDeviceCode());
|
||||
response.setUserCode(secret);
|
||||
response.setExpiresIn(expiresIn);
|
||||
response.setInterval(interval);
|
||||
response.setVerificationUri(deviceUrl);
|
||||
response.setVerificationUriComplete(deviceUrl + "?user_code=" + response.getUserCode());
|
||||
|
||||
return Response.ok(JsonSerialization.writeValueAsBytes(response)).type(MediaType.APPLICATION_JSON_TYPE).build();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error creating OAuth 2.0 Device Authorization Response.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkClient(String clientId) {
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-3.1
|
||||
// The spec says "The client authentication requirements of Section 3.2.1 of [RFC6749]
|
||||
// apply to requests on this endpoint".
|
||||
if (action == Action.OAUTH2_DEVICE_AUTH) {
|
||||
AuthorizeClientUtil.ClientAuthResult clientAuth = AuthorizeClientUtil.authorizeClient(session, event);
|
||||
client = clientAuth.getClient();
|
||||
clientId = client.getClientId();
|
||||
}
|
||||
|
||||
if (clientId == null) {
|
||||
event.error(Errors.INVALID_REQUEST);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
|
||||
}
|
||||
|
||||
event.client(clientId);
|
||||
|
||||
client = realm.getClientByClientId(clientId);
|
||||
if (client == null) {
|
||||
event.error(Errors.CLIENT_NOT_FOUND);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!client.isEnabled()) {
|
||||
event.error(Errors.CLIENT_DISABLED);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.CLIENT_DISABLED);
|
||||
}
|
||||
|
||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, Messages.OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED);
|
||||
}
|
||||
|
||||
if (client.isBearerOnly()) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.FORBIDDEN, Messages.BEARER_ONLY);
|
||||
}
|
||||
|
||||
String protocol = client.getProtocol();
|
||||
if (protocol == null) {
|
||||
logger.warnf("Client '%s' doesn't have protocol set. Fallback to openid-connect. Please fix client configuration", clientId);
|
||||
protocol = OIDCLoginProtocol.LOGIN_PROTOCOL;
|
||||
}
|
||||
if (!protocol.equals(OIDCLoginProtocol.LOGIN_PROTOCOL)) {
|
||||
event.error(Errors.INVALID_CLIENT);
|
||||
throw new ErrorPageException(session, authenticationSession, Response.Status.BAD_REQUEST, "Wrong client protocol.");
|
||||
}
|
||||
|
||||
session.getContext().setClient(client);
|
||||
}
|
||||
|
||||
private void updateAuthenticationSession() {
|
||||
authenticationSession.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
authenticationSession.setAction(AuthenticationSessionModel.Action.AUTHENTICATE.name());
|
||||
authenticationSession.setClientNote(OIDCLoginProtocol.ISSUER, Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName()));
|
||||
|
||||
if (request.getNonce() != null) authenticationSession.setClientNote(OIDCLoginProtocol.NONCE_PARAM, request.getNonce());
|
||||
if (request.getMaxAge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.MAX_AGE_PARAM, String.valueOf(request.getMaxAge()));
|
||||
if (request.getScope() != null) authenticationSession.setClientNote(OIDCLoginProtocol.SCOPE_PARAM, request.getScope());
|
||||
if (request.getLoginHint() != null) authenticationSession.setClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM, request.getLoginHint());
|
||||
if (request.getPrompt() != null) authenticationSession.setClientNote(OIDCLoginProtocol.PROMPT_PARAM, request.getPrompt());
|
||||
if (request.getIdpHint() != null) authenticationSession.setClientNote(AdapterConstants.KC_IDP_HINT, request.getIdpHint());
|
||||
if (request.getClaims()!= null) authenticationSession.setClientNote(OIDCLoginProtocol.CLAIMS_PARAM, request.getClaims());
|
||||
if (request.getAcr() != null) authenticationSession.setClientNote(OIDCLoginProtocol.ACR_PARAM, request.getAcr());
|
||||
if (request.getDisplay() != null) authenticationSession.setAuthNote(OAuth2Constants.DISPLAY, request.getDisplay());
|
||||
|
||||
if (request.getAdditionalReqParams() != null) {
|
||||
for (String paramName : request.getAdditionalReqParams().keySet()) {
|
||||
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Response buildVerificationResponse(String userCode) {
|
||||
this.event.event(EventType.LOGIN);
|
||||
action = Action.OAUTH2_DEVICE_VERIFY_USER_CODE;
|
||||
|
||||
checkSsl();
|
||||
checkRealm();
|
||||
|
||||
// So back button doesn't work
|
||||
CacheControlUtil.noBackButtonCacheControlHeader();
|
||||
|
||||
client = SystemClientUtil.getSystemClient(realm);
|
||||
authenticationSession = createAuthenticationSession(client, null);
|
||||
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
||||
|
||||
if (StringUtil.isNullOrEmpty(userCode)) {
|
||||
return createVerificationPage(null);
|
||||
} else {
|
||||
return processVerification(userCode);
|
||||
}
|
||||
}
|
||||
|
||||
public Response processVerification(AuthenticationSessionModel authSessionWithSystemClient, String userCode) {
|
||||
authenticationSession = authSessionWithSystemClient;
|
||||
return processVerification(userCode);
|
||||
}
|
||||
|
||||
public Response processVerification(String userCode) {
|
||||
if (userCode == null) {
|
||||
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
||||
}
|
||||
|
||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||
OAuth2DeviceCodeModel deviceCodeModel = store.getByUserCode(realm, userCode);
|
||||
if (deviceCodeModel == null) {
|
||||
event.error(Errors.INVALID_OAUTH2_USER_CODE);
|
||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
||||
}
|
||||
|
||||
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
|
||||
if (expiresIn < 0) {
|
||||
event.error(Errors.EXPIRED_OAUTH2_USER_CODE);
|
||||
return createVerificationPage(Messages.OAUTH2_DEVICE_INVALID_USER_CODE);
|
||||
}
|
||||
|
||||
// Update authentication session with requested clientId and scope from the device
|
||||
updateAuthenticationSession(deviceCodeModel);
|
||||
|
||||
// Verification OK
|
||||
authenticationSession.setClientNote(OIDCLoginProtocol.OAUTH2_DEVICE_VERIFIED_USER_CODE, userCode);
|
||||
|
||||
// Event logging for the verification
|
||||
event.client(deviceCodeModel.getClientId())
|
||||
.detail(Details.SCOPE, deviceCodeModel.getScope())
|
||||
.success();
|
||||
|
||||
return redirectToBrowserAuthentication();
|
||||
}
|
||||
|
||||
private AuthenticationSessionModel updateAuthenticationSession(OAuth2DeviceCodeModel deviceCode) {
|
||||
checkClient(deviceCode.getClientId());
|
||||
|
||||
// Create request object using parameters which is used in device authorization request from the device
|
||||
request = AuthorizationEndpointRequestParserProcessor.parseRequest(event, session, client, deviceCode.getParams());
|
||||
|
||||
// Re-create authentication session because current session doesn't have relation with the target device client
|
||||
authenticationSession = createAuthenticationSession(client, null);
|
||||
authenticationSession.setClientNote(APP_INITIATED_FLOW, LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
||||
|
||||
updateAuthenticationSession();
|
||||
|
||||
AuthenticationManager.setClientScopesInSession(authenticationSession);
|
||||
|
||||
return authenticationSession;
|
||||
}
|
||||
|
||||
public Response createVerificationPage(String errorMessage) {
|
||||
String execution = AuthenticatedClientSessionModel.Action.USER_CODE_VERIFICATION.name();
|
||||
|
||||
ClientSessionCode<AuthenticationSessionModel> accessCode = new ClientSessionCode<>(session, realm, authenticationSession);
|
||||
authenticationSession.getParentSession().setTimestamp(Time.currentTime());
|
||||
|
||||
accessCode.setAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name());
|
||||
authenticationSession.setAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION, execution);
|
||||
|
||||
LoginFormsProvider provider = session.getProvider(LoginFormsProvider.class)
|
||||
.setAuthenticationSession(authenticationSession)
|
||||
.setExecution(execution)
|
||||
.setClientSessionCode(accessCode.getOrGenerateCode());
|
||||
|
||||
if (errorMessage != null) {
|
||||
provider = provider.setError(errorMessage);
|
||||
}
|
||||
|
||||
return provider.createOAuth2DeviceVerifyUserCodePage();
|
||||
}
|
||||
|
||||
public Response redirectToBrowserAuthentication() {
|
||||
return handleBrowserAuthenticationRequest(authenticationSession, new OIDCLoginProtocol(session, realm, session.getContext().getUri(), headers, event), false, true);
|
||||
}
|
||||
}
|
|
@ -39,6 +39,7 @@ import org.keycloak.common.Profile;
|
|||
import org.keycloak.common.constants.ServiceAccountConstants;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.common.util.KeycloakUriBuilder;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.constants.AdapterConstants;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
|
@ -56,6 +57,8 @@ import org.keycloak.models.IdentityProviderMapperModel;
|
|||
import org.keycloak.models.IdentityProviderModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.OAuth2DeviceCodeModel;
|
||||
import org.keycloak.models.OAuth2DeviceTokenStoreProvider;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
|
@ -71,6 +74,7 @@ import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
|||
import org.keycloak.protocol.saml.SamlClient;
|
||||
import org.keycloak.protocol.saml.SamlProtocol;
|
||||
import org.keycloak.protocol.saml.SamlService;
|
||||
import org.keycloak.provider.ProviderFactory;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.AccessTokenResponse;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
|
@ -95,6 +99,7 @@ import org.keycloak.services.managers.ClientManager;
|
|||
import org.keycloak.protocol.oidc.utils.OAuth2Code;
|
||||
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
|
||||
import org.keycloak.services.managers.RealmManager;
|
||||
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
||||
import org.keycloak.services.resources.Cors;
|
||||
import org.keycloak.services.resources.IdentityBrokerService;
|
||||
import org.keycloak.services.resources.admin.AdminAuth;
|
||||
|
@ -148,7 +153,7 @@ public class TokenEndpoint {
|
|||
private Map<String, String> clientAuthAttributes;
|
||||
|
||||
private enum Action {
|
||||
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION
|
||||
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.2
|
||||
|
@ -228,6 +233,8 @@ public class TokenEndpoint {
|
|||
return tokenExchange();
|
||||
case PERMISSION:
|
||||
return permissionGrant();
|
||||
case OAUTH2_DEVICE_CODE:
|
||||
return oauth2DeviceCodeToToken();
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unknown action " + action);
|
||||
|
@ -299,6 +306,9 @@ public class TokenEndpoint {
|
|||
} else if (grantType.equals(OAuth2Constants.UMA_GRANT_TYPE)) {
|
||||
event.event(EventType.PERMISSION_TOKEN);
|
||||
action = Action.PERMISSION;
|
||||
} else if (grantType.equals(OAuth2Constants.DEVICE_CODE_GRANT_TYPE)) {
|
||||
event.event(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN);
|
||||
action = Action.OAUTH2_DEVICE_CODE;
|
||||
} else {
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.UNSUPPORTED_GRANT_TYPE,
|
||||
"Unsupported " + OIDCLoginProtocol.GRANT_TYPE_PARAM, Response.Status.BAD_REQUEST);
|
||||
|
@ -1364,6 +1374,137 @@ public class TokenEndpoint {
|
|||
return authorizationResponse;
|
||||
}
|
||||
|
||||
public Response oauth2DeviceCodeToToken() {
|
||||
if (!client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Client not allowed OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
String deviceCode = formParams.getFirst(OAuth2Constants.DEVICE_CODE);
|
||||
if (deviceCode == null) {
|
||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
OAuth2DeviceTokenStoreProvider store = session.getProvider(OAuth2DeviceTokenStoreProvider.class);
|
||||
OAuth2DeviceCodeModel deviceCodeModel = store.getByDeviceCode(realm, deviceCode);
|
||||
|
||||
if (deviceCodeModel == null) {
|
||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Device code not valid", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!store.isPollingAllowed(deviceCodeModel)) {
|
||||
event.error(Errors.SLOW_DOWN);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.SLOW_DOWN, "Slow down", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
int expiresIn = deviceCodeModel.getExpiration() - Time.currentTime();
|
||||
if (expiresIn < 0) {
|
||||
event.error(Errors.EXPIRED_OAUTH2_DEVICE_CODE);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "Device code is expired", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (deviceCodeModel.isPending()) {
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is still pending", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (deviceCodeModel.isDenied()) {
|
||||
event.error(Errors.ACCESS_DENIED);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED, "The end user denied the authorization request", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Approved
|
||||
|
||||
String userSessionId = deviceCodeModel.getUserSessionId();
|
||||
event.detail(Details.CODE_ID, userSessionId);
|
||||
event.session(userSessionId);
|
||||
|
||||
// Retrieve UserSession
|
||||
UserSessionModel userSession = new UserSessionCrossDCManager(session)
|
||||
.getUserSessionWithClient(realm, userSessionId, client.getId());
|
||||
|
||||
if (userSession == null) {
|
||||
userSession = session.sessions().getUserSession(realm, userSessionId);
|
||||
if (userSession == null) {
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.AUTHORIZATION_PENDING, "The authorization request is verified but can not lookup the user session yet", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Now, remove the device code
|
||||
store.removeDeviceCode(realm, deviceCode);
|
||||
|
||||
UserModel user = userSession.getUser();
|
||||
if (user == null) {
|
||||
event.error(Errors.USER_NOT_FOUND);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User not found", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
event.user(userSession.getUser());
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
event.error(Errors.USER_DISABLED);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "User disabled", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId());
|
||||
if (!client.getClientId().equals(clientSession.getClient().getClientId())) {
|
||||
event.error(Errors.INVALID_CODE);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Auth error", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!AuthenticationManager.isSessionValid(realm, userSession)) {
|
||||
event.error(Errors.USER_SESSION_NOT_FOUND);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Session not active", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
updateClientSession(clientSession);
|
||||
updateUserSessionFromClientAuth(userSession);
|
||||
|
||||
// Compute client scopes again from scope parameter. Check if user still has them granted
|
||||
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
|
||||
String scopeParam = deviceCodeModel.getScope();
|
||||
Stream<ClientScopeModel> clientScopes = TokenManager.getRequestedClientScopes(scopeParam, client);
|
||||
if (!TokenManager.verifyConsentStillAvailable(session, user, client, clientScopes)) {
|
||||
event.error(Errors.NOT_ALLOWED);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_SCOPE, "Client no longer has requested consent from user", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopes(clientSession, clientScopes, session);
|
||||
|
||||
// Set nonce as an attribute in the ClientSessionContext. Will be used for the token generation
|
||||
clientSessionCtx.setAttribute(OIDCLoginProtocol.NONCE_PARAM, deviceCodeModel.getNonce());
|
||||
|
||||
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
|
||||
|
||||
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, client, event, session, userSession, clientSessionCtx)
|
||||
.accessToken(token)
|
||||
.generateRefreshToken();
|
||||
|
||||
// KEYCLOAK-6771 Certificate Bound Token
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
|
||||
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
|
||||
AccessToken.CertConf certConf = MtlsHoKTokenUtil.bindTokenWithClientCertificate(request, session);
|
||||
if (certConf != null) {
|
||||
responseBuilder.getAccessToken().setCertConf(certConf);
|
||||
responseBuilder.getRefreshToken().setCertConf(certConf);
|
||||
} else {
|
||||
event.error(Errors.INVALID_REQUEST);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Client Certification missing for MTLS HoK Token Binding", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
||||
responseBuilder.generateIDToken();
|
||||
}
|
||||
|
||||
AccessTokenResponse res = responseBuilder.build();
|
||||
|
||||
event.success();
|
||||
|
||||
return cors.builder(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE)).build();
|
||||
}
|
||||
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.1
|
||||
private boolean isValidPkceCodeVerifier(String codeVerifier) {
|
||||
if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) {
|
||||
|
|
0
services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java
Normal file → Executable file
0
services/src/main/java/org/keycloak/protocol/oidc/utils/OAuth2Code.java
Normal file → Executable file
|
@ -245,6 +245,10 @@ public class Urls {
|
|||
return loginActionsBase(baseUri).path(LoginActionsService.class, "processConsent").build(realmName);
|
||||
}
|
||||
|
||||
public static URI realmOAuth2DeviceVerificationAction(URI baseUri, String realmName) {
|
||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "processOAuth2DeviceVerification").build(realmName);
|
||||
}
|
||||
|
||||
public static URI firstBrokerLoginProcessor(URI baseUri, String realmName) {
|
||||
return loginActionsBase(baseUri).path(LoginActionsService.class, "firstBrokerLoginGet")
|
||||
.build(realmName);
|
||||
|
|
3
services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java
Normal file → Executable file
3
services/src/main/java/org/keycloak/services/clientregistration/oidc/DescriptionConverter.java
Normal file → Executable file
|
@ -312,6 +312,9 @@ public class DescriptionConverter {
|
|||
if (client.isServiceAccountsEnabled()) {
|
||||
grantTypes.add(OAuth2Constants.CLIENT_CREDENTIALS);
|
||||
}
|
||||
if (client.isOAuth2DeviceAuthorizationGrantEnabled()) {
|
||||
grantTypes.add(OAuth2Constants.DEVICE_CODE_GRANT_TYPE);
|
||||
}
|
||||
if (client.getAuthorizationServicesEnabled() != null && client.getAuthorizationServicesEnabled()) {
|
||||
grantTypes.add(OAuth2Constants.UMA_GRANT_TYPE);
|
||||
}
|
||||
|
|
|
@ -81,6 +81,8 @@ public class ApplianceBootstrap {
|
|||
realm.setAccessCodeLifespan(60);
|
||||
realm.setAccessCodeLifespanUserAction(300);
|
||||
realm.setAccessCodeLifespanLogin(1800);
|
||||
realm.setOAuth2DeviceCodeLifespan(Constants.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN);
|
||||
realm.setOAuth2DevicePollingInterval(Constants.DEFAULT_OAUTH2_DEVICE_POLLING_INTERVAL);
|
||||
realm.setSslRequired(SslRequired.EXTERNAL);
|
||||
realm.setRegistrationAllowed(false);
|
||||
realm.setRegistrationEmailAsUsername(false);
|
||||
|
|
|
@ -62,6 +62,7 @@ import org.keycloak.models.UserSessionModel;
|
|||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.models.utils.SessionTimeoutHelper;
|
||||
import org.keycloak.models.utils.SystemClientUtil;
|
||||
import org.keycloak.protocol.AuthorizationEndpointBase;
|
||||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.LoginProtocol.Error;
|
||||
import org.keycloak.protocol.oidc.BackchannelLogoutResponse;
|
||||
|
@ -572,7 +573,7 @@ public class AuthenticationManager {
|
|||
UserModel user = userSession.getUser();
|
||||
logger.debugv("Logging out: {0} ({1})", user.getUsername(), userSession.getId());
|
||||
}
|
||||
|
||||
|
||||
if (userSession.getState() != UserSessionModel.State.LOGGING_OUT) {
|
||||
userSession.setState(UserSessionModel.State.LOGGING_OUT);
|
||||
}
|
||||
|
@ -925,7 +926,7 @@ public class AuthenticationManager {
|
|||
String actionTokenKeyToInvalidate = authSession.getAuthNote(INVALIDATE_ACTION_TOKEN);
|
||||
if (actionTokenKeyToInvalidate != null) {
|
||||
ActionTokenKeyModel actionTokenKey = DefaultActionTokenKey.from(actionTokenKeyToInvalidate);
|
||||
|
||||
|
||||
if (actionTokenKey != null) {
|
||||
ActionTokenStoreProvider actionTokenStore = session.getProvider(ActionTokenStoreProvider.class);
|
||||
actionTokenStore.put(actionTokenKey, null); // Token is invalidated
|
||||
|
@ -983,7 +984,7 @@ public class AuthenticationManager {
|
|||
return kcAction;
|
||||
}
|
||||
|
||||
if (client.isConsentRequired()) {
|
||||
if (client.isConsentRequired() || isOAuth2DeviceVerificationFlow(authSession)) {
|
||||
|
||||
UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession);
|
||||
|
||||
|
@ -1004,6 +1005,13 @@ public class AuthenticationManager {
|
|||
|
||||
|
||||
private static UserConsentModel getEffectiveGrantedConsent(KeycloakSession session, AuthenticationSessionModel authSession) {
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-5.4
|
||||
// The spec says "The authorization server SHOULD display information about the device",
|
||||
// so we ignore existing persistent consent to display the consent screen always.
|
||||
if (isOAuth2DeviceVerificationFlow(authSession)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If prompt=consent, we ignore existing persistent consent
|
||||
String prompt = authSession.getClientNote(OIDCLoginProtocol.PROMPT_PARAM);
|
||||
if (TokenUtil.hasPrompt(prompt, OIDCLoginProtocol.PROMPT_VALUE_CONSENT)) {
|
||||
|
@ -1038,7 +1046,10 @@ public class AuthenticationManager {
|
|||
action = executionActions(session, authSession, request, event, realm, user, authSession.getRequiredActions().stream());
|
||||
if (action != null) return action;
|
||||
|
||||
if (client.isConsentRequired()) {
|
||||
// https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15#section-5.4
|
||||
// The spec says "The authorization server SHOULD display information about the device",
|
||||
// so the consent is required when running a verification flow of OAuth 2.0 Device Authorization Grant.
|
||||
if (client.isConsentRequired() || isOAuth2DeviceVerificationFlow(authSession)) {
|
||||
|
||||
UserConsentModel grantedConsent = getEffectiveGrantedConsent(session, authSession);
|
||||
|
||||
|
@ -1069,6 +1080,11 @@ public class AuthenticationManager {
|
|||
|
||||
}
|
||||
|
||||
public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSessionModel authSession) {
|
||||
String flow = authSession.getClientNote(AuthorizationEndpointBase.APP_INITIATED_FLOW);
|
||||
return flow != null && flow.equals(LoginActionsService.OAUTH2_DEVICE_VERIFICATION_PATH);
|
||||
}
|
||||
|
||||
private static List<ClientScopeModel> getClientScopesToApproveOnConsentScreen(RealmModel realm, UserConsentModel grantedConsent,
|
||||
AuthenticationSessionModel authSession) {
|
||||
// Client Scopes to be displayed on consent screen
|
||||
|
|
|
@ -261,4 +261,14 @@ public class Messages {
|
|||
|
||||
public static final String DELETE_ACCOUNT_LACK_PRIVILEDGES = "deletingAccountForbidden";
|
||||
public static final String DELETE_ACCOUNT_ERROR = "errorDeletingAccount";
|
||||
|
||||
// OAuth 2.0 Device Authorization Grant
|
||||
public static final String OAUTH2_DEVICE_AUTHORIZATION_GRANT_DISABLED = "oauth2DeviceAuthorizationGrantDisabledMessage";
|
||||
public static final String OAUTH2_DEVICE_INVALID_USER_CODE = "oauth2DeviceInvalidUserCodeMessage";
|
||||
public static final String OAUTH2_DEVICE_EXPIRED_USER_CODE = "oauth2DeviceExpiredUserCodeMessage";
|
||||
public static final String OAUTH2_DEVICE_VERIFICATION_COMPLETE = "oauth2DeviceVerificationCompleteMessage";
|
||||
public static final String OAUTH2_DEVICE_VERIFICATION_COMPLETE_HEADER = "oauth2DeviceVerificationCompleteHeader";
|
||||
public static final String OAUTH2_DEVICE_VERIFICATION_FAILED = "oauth2DeviceVerificationFailedMessage";
|
||||
public static final String OAUTH2_DEVICE_VERIFICATION_FAILED_HEADER = "oauth2DeviceVerificationFailedHeader";
|
||||
public static final String OAUTH2_DEVICE_CONSENT_DENIED = "oauth2DeviceConsentDeniedMessage";
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ package org.keycloak.services.resources;
|
|||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.jboss.resteasy.spi.HttpRequest;
|
||||
import org.jboss.resteasy.spi.ResteasyProviderFactory;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.authentication.AuthenticationFlowException;
|
||||
|
@ -67,6 +68,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
|
|||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.LoginProtocol.Error;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||
import org.keycloak.protocol.oidc.utils.RedirectUtils;
|
||||
|
@ -118,6 +120,7 @@ public class LoginActionsService {
|
|||
public static final String REQUIRED_ACTION = "required-action";
|
||||
public static final String FIRST_BROKER_LOGIN_PATH = "first-broker-login";
|
||||
public static final String POST_BROKER_LOGIN_PATH = "post-broker-login";
|
||||
public static final String OAUTH2_DEVICE_VERIFICATION_PATH = "verification";
|
||||
|
||||
public static final String RESTART_PATH = "restart";
|
||||
|
||||
|
@ -128,6 +131,8 @@ public class LoginActionsService {
|
|||
|
||||
public static final String CANCEL_AIA = "cancel-aia";
|
||||
|
||||
public static final String OAUTH2_DEVICE_USER_CODE = "device_user_code";
|
||||
|
||||
private RealmModel realm;
|
||||
|
||||
@Context
|
||||
|
@ -845,6 +850,35 @@ public class LoginActionsService {
|
|||
return Response.status(302).location(redirect).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifying user code page. You should not invoked this directly!
|
||||
*
|
||||
* @param formData
|
||||
* @return
|
||||
*/
|
||||
@Path("verification")
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
public Response processOAuth2DeviceVerification(final MultivaluedMap<String, String> formData) {
|
||||
event.event(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE);
|
||||
|
||||
String code = formData.getFirst(SESSION_CODE);
|
||||
String tabId = session.getContext().getUri().getQueryParameters().getFirst(Constants.TAB_ID);
|
||||
|
||||
String systemClientId = SystemClientUtil.getSystemClient(realm).getClientId();
|
||||
|
||||
SessionCodeChecks checks = checksForCode(null, code, null, systemClientId, tabId, OAUTH2_DEVICE_VERIFICATION_PATH);
|
||||
if (!checks.verifyActiveAndValidAction(AuthenticationSessionModel.Action.USER_CODE_VERIFICATION.name(), ClientSessionCode.ActionType.LOGIN)) {
|
||||
return checks.getResponse();
|
||||
}
|
||||
|
||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
||||
|
||||
AuthenticationSessionModel authSessionWithSystemClient = checks.getAuthenticationSession();
|
||||
String userCode = formData.getFirst(OAUTH2_DEVICE_USER_CODE);
|
||||
return endpoint.processVerification(authSessionWithSystemClient, userCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth grant page. You should not invoked this directly!
|
||||
|
|
|
@ -31,6 +31,7 @@ import org.keycloak.models.RealmModel;
|
|||
import org.keycloak.protocol.LoginProtocol;
|
||||
import org.keycloak.protocol.LoginProtocolFactory;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.protocol.oidc.endpoints.OAuth2DeviceAuthorizationEndpoint;
|
||||
import org.keycloak.services.clientregistration.ClientRegistrationService;
|
||||
import org.keycloak.services.managers.RealmManager;
|
||||
import org.keycloak.services.resource.RealmResourceProvider;
|
||||
|
@ -45,6 +46,7 @@ import javax.ws.rs.OPTIONS;
|
|||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
@ -99,6 +101,15 @@ public class RealmsResource {
|
|||
return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService");
|
||||
}
|
||||
|
||||
public static UriBuilder oauth2DeviceVerificationUrl(UriInfo uriInfo) {
|
||||
UriBuilder baseUriBuilder = uriInfo.getBaseUriBuilder();
|
||||
return oauth2DeviceVerificationUrl(baseUriBuilder);
|
||||
}
|
||||
|
||||
public static UriBuilder oauth2DeviceVerificationUrl(UriBuilder baseUriBuilder) {
|
||||
return baseUriBuilder.path(RealmsResource.class).path(RealmsResource.class, "getOAuth2DeviceVerificationService");
|
||||
}
|
||||
|
||||
public static UriBuilder wellKnownProviderUrl(UriBuilder builder) {
|
||||
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
|
||||
}
|
||||
|
@ -268,6 +279,18 @@ public class RealmsResource {
|
|||
return service;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{realm}/device")
|
||||
public Object getOAuth2DeviceVerificationService(@PathParam("realm") String realmName, @QueryParam("user_code") String userCode) {
|
||||
RealmModel realm = init(realmName);
|
||||
EventBuilder event = new EventBuilder(realm, session, clientConnection);
|
||||
OAuth2DeviceAuthorizationEndpoint endpoint = new OAuth2DeviceAuthorizationEndpoint(realm, event);
|
||||
|
||||
ResteasyProviderFactory.getInstance().injectProperties(endpoint);
|
||||
|
||||
return endpoint.buildVerificationResponse(userCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* A JAX-RS sub-resource locator that uses the {@link org.keycloak.services.resource.RealmResourceSPI} to resolve sub-resources instances given an <code>unknownPath</code>.
|
||||
*
|
||||
|
|
0
services/src/main/java/org/keycloak/storage/openshift/OpenshiftSAClientAdapter.java
Normal file → Executable file
0
services/src/main/java/org/keycloak/storage/openshift/OpenshiftSAClientAdapter.java
Normal file → Executable file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceVerificationPage extends LanguageComboboxAwarePage {
|
||||
|
||||
@FindBy(id = "device-user-code")
|
||||
private WebElement userCodeInput;
|
||||
|
||||
@FindBy(css = "input[type=\"submit\"]")
|
||||
private WebElement submitButton;
|
||||
|
||||
@FindBy(className = "alert-error")
|
||||
private WebElement verifyErrorMessage;
|
||||
|
||||
public void submit(String userCode) {
|
||||
userCodeInput.clear();
|
||||
if (userCode != null) {
|
||||
userCodeInput.sendKeys(userCode);
|
||||
}
|
||||
submitButton.click();
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return verifyErrorMessage != null ? verifyErrorMessage.getText() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrent() {
|
||||
if (driver.getTitle().startsWith("Log in to ")) {
|
||||
try {
|
||||
driver.findElement(By.id("device-user-code"));
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void assertLoginPage() {
|
||||
String name = getClass().getSimpleName();
|
||||
Assert.assertTrue("Expected device login page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||
isLoginPage());
|
||||
}
|
||||
|
||||
public void assertApprovedPage() {
|
||||
String name = getClass().getSimpleName();
|
||||
Assert.assertTrue("Expected device approved page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||
isApprovedPage());
|
||||
}
|
||||
|
||||
public void assertDeniedPage() {
|
||||
String name = getClass().getSimpleName();
|
||||
Assert.assertTrue("Expected device denied page but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
|
||||
isDeniedPage());
|
||||
}
|
||||
|
||||
private boolean isLoginPage() {
|
||||
if (driver.getTitle().startsWith("Log in to ")) {
|
||||
try {
|
||||
driver.findElement(By.id("username"));
|
||||
driver.findElement(By.id("password"));
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isApprovedPage() {
|
||||
if (driver.getTitle().startsWith("Log in to ")) {
|
||||
try {
|
||||
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Successful");
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isDeniedPage() {
|
||||
if (driver.getTitle().startsWith("Log in to ")) {
|
||||
try {
|
||||
driver.findElement(By.id("kc-page-title")).getText().equals("Device Login Failed");
|
||||
driver.findElement(By.className("instruction")).getText().equals("Consent denied for connecting the device.");
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void open() {
|
||||
}
|
||||
}
|
|
@ -68,6 +68,8 @@ import org.keycloak.representations.JsonWebToken;
|
|||
import org.keycloak.representations.RefreshToken;
|
||||
import org.keycloak.representations.UserInfo;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
|
||||
import org.keycloak.testsuite.runonserver.RunOnServerException;
|
||||
import org.keycloak.util.BasicAuthHelper;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
@ -860,6 +862,71 @@ public class OAuthClient {
|
|||
}
|
||||
}
|
||||
|
||||
public DeviceAuthorizationResponse doDeviceAuthorizationRequest(String clientId, String clientSecret) throws Exception {
|
||||
try (CloseableHttpClient client = httpClient.get()) {
|
||||
HttpPost post = new HttpPost(getDeviceAuthorizationUrl());
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<>();
|
||||
if (clientSecret != null) {
|
||||
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
|
||||
post.setHeader("Authorization", authorization);
|
||||
} else {
|
||||
parameters.add(new BasicNameValuePair("client_id", clientId));
|
||||
}
|
||||
|
||||
if (origin != null) {
|
||||
post.addHeader("Origin", origin);
|
||||
}
|
||||
|
||||
if (scope != null) {
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.SCOPE, scope));
|
||||
}
|
||||
if (nonce != null) {
|
||||
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.NONCE_PARAM, scope));
|
||||
}
|
||||
|
||||
UrlEncodedFormEntity formEntity;
|
||||
try {
|
||||
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
post.setEntity(formEntity);
|
||||
|
||||
return new DeviceAuthorizationResponse(client.execute(post));
|
||||
}
|
||||
}
|
||||
|
||||
public AccessTokenResponse doDeviceTokenRequest(String clientId, String clientSecret, String deviceCode) throws Exception {
|
||||
try (CloseableHttpClient client = httpClient.get()) {
|
||||
HttpPost post = new HttpPost(getAccessTokenUrl());
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<>();
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.DEVICE_CODE_GRANT_TYPE));
|
||||
parameters.add(new BasicNameValuePair("device_code", deviceCode));
|
||||
if (clientSecret != null) {
|
||||
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
|
||||
post.setHeader("Authorization", authorization);
|
||||
} else {
|
||||
parameters.add(new BasicNameValuePair("client_id", clientId));
|
||||
}
|
||||
|
||||
if (origin != null) {
|
||||
post.addHeader("Origin", origin);
|
||||
}
|
||||
|
||||
UrlEncodedFormEntity formEntity;
|
||||
try {
|
||||
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
post.setEntity(formEntity);
|
||||
|
||||
return new AccessTokenResponse(client.execute(post));
|
||||
}
|
||||
}
|
||||
|
||||
public OIDCConfigurationRepresentation doWellKnownRequest(String realm) {
|
||||
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
||||
return SimpleHttp.doGet(baseUrl + "/realms/" + realm + "/.well-known/openid-configuration", client).asJson(OIDCConfigurationRepresentation.class);
|
||||
|
@ -991,6 +1058,15 @@ public class OAuthClient {
|
|||
driver.navigate().to(getLoginFormUrl());
|
||||
}
|
||||
|
||||
public void openOAuth2DeviceVerificationForm() {
|
||||
UriBuilder b = RealmsResource.oauth2DeviceVerificationUrl(UriBuilder.fromUri(baseUrl));
|
||||
openOAuth2DeviceVerificationForm(b.build(realm).toString());
|
||||
}
|
||||
|
||||
public void openOAuth2DeviceVerificationForm(String verificationUri) {
|
||||
driver.navigate().to(verificationUri);
|
||||
}
|
||||
|
||||
public void openLogout() {
|
||||
UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl));
|
||||
if (redirectUri != null) {
|
||||
|
@ -1129,6 +1205,11 @@ public class OAuthClient {
|
|||
return getResourceOwnerPasswordCredentialGrantUrl();
|
||||
}
|
||||
|
||||
public String getDeviceAuthorizationUrl() {
|
||||
UriBuilder b = OIDCLoginProtocolService.oauth2DeviceAuthUrl(UriBuilder.fromUri(baseUrl));
|
||||
return b.build(realm).toString();
|
||||
}
|
||||
|
||||
public String getRefreshTokenUrl() {
|
||||
UriBuilder b = OIDCLoginProtocolService.tokenUrl(UriBuilder.fromUri(baseUrl));
|
||||
return b.build(realm).toString();
|
||||
|
@ -1572,4 +1653,94 @@ public class OAuthClient {
|
|||
|
||||
}
|
||||
|
||||
public static class DeviceAuthorizationResponse {
|
||||
private int statusCode;
|
||||
|
||||
private String deviceCode;
|
||||
private String userCode;
|
||||
private String verificationUri;
|
||||
private String verificationUriComplete;
|
||||
private int expiresIn;
|
||||
private int interval;
|
||||
|
||||
private String error;
|
||||
private String errorDescription;
|
||||
|
||||
private Map<String, String> headers;
|
||||
|
||||
public DeviceAuthorizationResponse(CloseableHttpResponse response) throws Exception {
|
||||
try {
|
||||
statusCode = response.getStatusLine().getStatusCode();
|
||||
|
||||
headers = new HashMap<>();
|
||||
|
||||
for (Header h : response.getAllHeaders()) {
|
||||
headers.put(h.getName(), h.getValue());
|
||||
}
|
||||
|
||||
Header[] contentTypeHeaders = response.getHeaders("Content-Type");
|
||||
String contentType = (contentTypeHeaders != null && contentTypeHeaders.length > 0) ? contentTypeHeaders[0].getValue() : null;
|
||||
if (!"application/json".equals(contentType)) {
|
||||
Assert.fail("Invalid content type. Status: " + statusCode + ", contentType: " + contentType);
|
||||
}
|
||||
|
||||
String s = IOUtils.toString(response.getEntity().getContent(), "UTF-8");
|
||||
Map responseJson = JsonSerialization.readValue(s, Map.class);
|
||||
|
||||
if (statusCode == 200) {
|
||||
deviceCode = (String) responseJson.get("device_code");
|
||||
userCode = (String) responseJson.get("user_code");
|
||||
verificationUri = (String) responseJson.get("verification_uri");
|
||||
verificationUriComplete = (String) responseJson.get("verification_uri_complete");
|
||||
expiresIn = (Integer) responseJson.get("expires_in");
|
||||
interval = (Integer) responseJson.get("interval");
|
||||
} else {
|
||||
error = (String) responseJson.get(OAuth2Constants.ERROR);
|
||||
errorDescription = responseJson.containsKey(OAuth2Constants.ERROR_DESCRIPTION) ? (String) responseJson.get(OAuth2Constants.ERROR_DESCRIPTION) : null;
|
||||
}
|
||||
} finally {
|
||||
response.close();
|
||||
}
|
||||
}
|
||||
|
||||
public String getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public String getErrorDescription() {
|
||||
return errorDescription;
|
||||
}
|
||||
|
||||
public String getDeviceCode() {
|
||||
return deviceCode;
|
||||
}
|
||||
|
||||
public String getUserCode() {
|
||||
return userCode;
|
||||
}
|
||||
|
||||
public String getVerificationUri() {
|
||||
return verificationUri;
|
||||
}
|
||||
|
||||
public String getVerificationUriComplete() {
|
||||
return verificationUriComplete;
|
||||
}
|
||||
|
||||
public int getExpiresIn() {
|
||||
return expiresIn;
|
||||
}
|
||||
|
||||
public int getInterval() {
|
||||
return interval;
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public Map<String, String> getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,6 +134,34 @@ public class AssertEvents implements TestRule {
|
|||
.session(sessionId);
|
||||
}
|
||||
|
||||
public ExpectedEvent expectDeviceVerifyUserCode(String clientId) {
|
||||
return expect(EventType.OAUTH2_DEVICE_VERIFY_USER_CODE)
|
||||
.user((String) null)
|
||||
.client(clientId)
|
||||
.detail(Details.CODE_ID, isUUID());
|
||||
}
|
||||
|
||||
public ExpectedEvent expectDeviceLogin(String clientId, String codeId, String userId) {
|
||||
return expect(EventType.LOGIN)
|
||||
.user(userId)
|
||||
.client(clientId)
|
||||
.detail(Details.CODE_ID, codeId)
|
||||
.session(codeId);
|
||||
// .session((String) null);
|
||||
}
|
||||
|
||||
public ExpectedEvent expectDeviceCodeToToken(String clientId, String codeId, String userId) {
|
||||
return expect(EventType.OAUTH2_DEVICE_CODE_TO_TOKEN)
|
||||
.client(clientId)
|
||||
.user(userId)
|
||||
.detail(Details.CODE_ID, codeId)
|
||||
.detail(Details.TOKEN_ID, isUUID())
|
||||
.detail(Details.REFRESH_TOKEN_ID, isUUID())
|
||||
.detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_REFRESH)
|
||||
.detail(Details.CLIENT_AUTH_METHOD, ClientIdAndSecretAuthenticator.PROVIDER_ID)
|
||||
.session(codeId);
|
||||
}
|
||||
|
||||
public ExpectedEvent expectRefresh(String refreshTokenId, String sessionId) {
|
||||
return expect(EventType.REFRESH_TOKEN)
|
||||
.detail(Details.TOKEN_ID, isUUID())
|
||||
|
|
|
@ -0,0 +1,289 @@
|
|||
/*
|
||||
* Copyright 2019 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.testsuite.oauth;
|
||||
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.*;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.ClientRepresentation;
|
||||
import org.keycloak.representations.idm.EventRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.representations.idm.UserRepresentation;
|
||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.pages.ErrorPage;
|
||||
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
|
||||
import org.keycloak.testsuite.pages.OAuthGrantPage;
|
||||
import org.keycloak.testsuite.util.ClientBuilder;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.RealmBuilder;
|
||||
import org.keycloak.testsuite.util.UserBuilder;
|
||||
import org.keycloak.testsuite.util.WaitUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
*/
|
||||
public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
||||
|
||||
private static String userId;
|
||||
|
||||
public static final String REALM_NAME = "test";
|
||||
public static final String DEVICE_APP = "test-device";
|
||||
public static final String DEVICE_APP_PUBLIC = "test-device-public";
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Page
|
||||
protected OAuth2DeviceVerificationPage verificationPage;
|
||||
|
||||
@Page
|
||||
protected OAuthGrantPage grantPage;
|
||||
|
||||
@Page
|
||||
protected ErrorPage errorPage;
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
RealmBuilder realm = RealmBuilder.create().name(REALM_NAME)
|
||||
.privateKey("MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=")
|
||||
.publicKey("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB")
|
||||
.testEventListener();
|
||||
|
||||
|
||||
ClientRepresentation app = ClientBuilder.create()
|
||||
.id(KeycloakModelUtils.generateId())
|
||||
.clientId("test-device")
|
||||
.oauth2DeviceAuthorizationGrant()
|
||||
.secret("secret")
|
||||
.build();
|
||||
realm.client(app);
|
||||
|
||||
ClientRepresentation app2 = ClientBuilder.create()
|
||||
.id(KeycloakModelUtils.generateId())
|
||||
.clientId("test-device-public")
|
||||
.oauth2DeviceAuthorizationGrant()
|
||||
.publicClient()
|
||||
.build();
|
||||
realm.client(app2);
|
||||
|
||||
userId = KeycloakModelUtils.generateId();
|
||||
UserRepresentation user = UserBuilder.create()
|
||||
.id(userId)
|
||||
.username("device-login")
|
||||
.email("device-login@localhost")
|
||||
.password("password")
|
||||
.build();
|
||||
realm.user(user);
|
||||
|
||||
testRealms.add(realm.build());
|
||||
}
|
||||
|
||||
@Before
|
||||
public void resetConifg() {
|
||||
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
||||
realm.setOAuth2DeviceCodeLifespan(600);
|
||||
realm.setOAuth2DevicePollingInterval(5);
|
||||
getAdminClient().realm(REALM_NAME).update(realm);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publicClientTest() throws Exception {
|
||||
// Device Authorization Request from device
|
||||
oauth.realm(REALM_NAME);
|
||||
oauth.clientId(DEVICE_APP_PUBLIC);
|
||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC, null);
|
||||
|
||||
Assert.assertEquals(200, response.getStatusCode());
|
||||
Assert.assertNotNull(response.getDeviceCode());
|
||||
Assert.assertNotNull(response.getUserCode());
|
||||
Assert.assertNotNull(response.getVerificationUri());
|
||||
Assert.assertNotNull(response.getVerificationUriComplete());
|
||||
Assert.assertEquals(600, response.getExpiresIn());
|
||||
Assert.assertEquals(5, response.getInterval());
|
||||
|
||||
// Verify user code from verification page using browser
|
||||
openVerificationPage(response.getVerificationUri());
|
||||
verificationPage.assertCurrent();
|
||||
verificationPage.submit(response.getUserCode());
|
||||
|
||||
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP_PUBLIC).assertEvent();
|
||||
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
|
||||
|
||||
verificationPage.assertLoginPage();
|
||||
|
||||
// Do Login
|
||||
oauth.fillLoginForm("device-login", "password");
|
||||
|
||||
// Consent
|
||||
grantPage.assertCurrent();
|
||||
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
|
||||
grantPage.accept();
|
||||
|
||||
verificationPage.assertApprovedPage();
|
||||
|
||||
events.expectDeviceLogin(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
|
||||
|
||||
// Token request from device
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP_PUBLIC, null, response.getDeviceCode());
|
||||
|
||||
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
||||
|
||||
String tokenString = tokenResponse.getAccessToken();
|
||||
Assert.assertNotNull(tokenString);
|
||||
AccessToken token = oauth.verifyToken(tokenString);
|
||||
|
||||
// Check receiving access token which is bound to the user session of the verification process
|
||||
Assert.assertTrue(codeId.equals(token.getSessionState()));
|
||||
|
||||
events.expectDeviceCodeToToken(DEVICE_APP_PUBLIC, codeId, userId).assertEvent();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confidentialClientTest() throws Exception {
|
||||
// Device Authorization Request from device
|
||||
oauth.realm(REALM_NAME);
|
||||
oauth.clientId(DEVICE_APP);
|
||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||
|
||||
Assert.assertEquals(200, response.getStatusCode());
|
||||
Assert.assertNotNull(response.getDeviceCode());
|
||||
Assert.assertNotNull(response.getUserCode());
|
||||
Assert.assertNotNull(response.getVerificationUri());
|
||||
Assert.assertNotNull(response.getVerificationUriComplete());
|
||||
Assert.assertEquals(600, response.getExpiresIn());
|
||||
Assert.assertEquals(5, response.getInterval());
|
||||
|
||||
// Verify user code from verification page using browser
|
||||
openVerificationPage(response.getVerificationUri());
|
||||
verificationPage.assertCurrent();
|
||||
verificationPage.submit(response.getUserCode());
|
||||
|
||||
EventRepresentation verifyEvent = events.expectDeviceVerifyUserCode(DEVICE_APP).assertEvent();
|
||||
String codeId = verifyEvent.getDetails().get(Details.CODE_ID);
|
||||
|
||||
verificationPage.assertLoginPage();
|
||||
|
||||
// Do Login
|
||||
oauth.fillLoginForm("device-login", "password");
|
||||
|
||||
// Consent
|
||||
grantPage.assertCurrent();
|
||||
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
|
||||
grantPage.accept();
|
||||
|
||||
verificationPage.assertApprovedPage();
|
||||
|
||||
events.expectDeviceLogin(DEVICE_APP, codeId, userId).assertEvent();
|
||||
|
||||
// Token request from device
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
Assert.assertEquals(200, tokenResponse.getStatusCode());
|
||||
|
||||
String tokenString = tokenResponse.getAccessToken();
|
||||
Assert.assertNotNull(tokenString);
|
||||
AccessToken token = oauth.verifyToken(tokenString);
|
||||
|
||||
// Check receiving access token which is bound to the user session of the verification process
|
||||
Assert.assertTrue(codeId.equals(token.getSessionState()));
|
||||
|
||||
events.expectDeviceCodeToToken(DEVICE_APP, codeId, userId).assertEvent();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pollingTest() throws Exception {
|
||||
// Device Authorization Request from device
|
||||
oauth.realm(REALM_NAME);
|
||||
oauth.clientId(DEVICE_APP);
|
||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||
|
||||
Assert.assertEquals(600, response.getExpiresIn());
|
||||
Assert.assertEquals(5, response.getInterval());
|
||||
|
||||
// Polling token request from device
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Not approved yet
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||
|
||||
// Polling again without waiting
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Slow down
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||
|
||||
// Wait the interval
|
||||
WaitUtils.pause(5000);
|
||||
|
||||
// Polling again
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Not approved yet
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||
|
||||
|
||||
// Change the interval setting of the realm from 5 seconds to 10 seconds.
|
||||
RealmRepresentation realm = getAdminClient().realm(REALM_NAME).toRepresentation();
|
||||
realm.setOAuth2DevicePollingInterval(10);
|
||||
getAdminClient().realm(REALM_NAME).update(realm);
|
||||
|
||||
// Checking the new interval is applied
|
||||
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
|
||||
|
||||
Assert.assertEquals(600, response.getExpiresIn());
|
||||
Assert.assertEquals(10, response.getInterval());
|
||||
|
||||
// Polling token request from device
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Not approved yet
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||
|
||||
// Wait
|
||||
WaitUtils.pause(5000);
|
||||
|
||||
// Polling again without waiting
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Slow down
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("slow_down", tokenResponse.getError());
|
||||
|
||||
// Wait
|
||||
WaitUtils.pause(5000);
|
||||
|
||||
// Polling again
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
|
||||
|
||||
// Not approved yet
|
||||
Assert.assertEquals(400, tokenResponse.getStatusCode());
|
||||
Assert.assertEquals("authorization_pending", tokenResponse.getError());
|
||||
}
|
||||
|
||||
private void openVerificationPage(String verificationUri) {
|
||||
driver.navigate().to(verificationUri);
|
||||
}
|
||||
}
|
|
@ -109,6 +109,11 @@ public class ClientBuilder {
|
|||
return this;
|
||||
}
|
||||
|
||||
public ClientBuilder oauth2DeviceAuthorizationGrant() {
|
||||
rep.setOAuth2DeviceAuthorizationGrantEnabled(true);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ClientRepresentation build() {
|
||||
return rep;
|
||||
}
|
||||
|
|
|
@ -176,6 +176,12 @@ login-timeout=Login timeout
|
|||
login-timeout.tooltip=Max time a user has to complete a login. This is recommended to be relatively long, such as 30 minutes or more.
|
||||
login-action-timeout=Login action timeout
|
||||
login-action-timeout.tooltip=Max time a user has to complete login related actions like update password or configure totp. This is recommended to be relatively long, such as 5 minutes or more.
|
||||
|
||||
oauth2-device-code-lifespan=OAuth 2.0 Device Code Lifespan
|
||||
oauth2-device-code-lifespan.tooltip=Max time before the device code and user code are expired. This value needs to be a long enough lifetime to be usable (allowing the user to retrieve their secondary device, navigate to the verification URI, login, etc.), but should be sufficiently short to limit the usability of a code obtained for phishing.
|
||||
oauth2-device-polling-interval=OAuth 2.0 Device Polling Interval
|
||||
oauth2-device-polling-interval.tooltip=The minimum amount of time in seconds that the client should wait between polling requests to the token endpoint.
|
||||
|
||||
headers=Headers
|
||||
brute-force-detection=Brute Force Detection
|
||||
x-frame-options=X-Frame-Options
|
||||
|
@ -323,6 +329,8 @@ direct-access-grants-enabled=Direct Access Grants Enabled
|
|||
direct-access-grants-enabled.tooltip=This enables support for Direct Access Grants, which means that client has access to username/password of user and exchange it directly with Keycloak server for access token. In terms of OAuth2 specification, this enables support of 'Resource Owner Password Credentials Grant' for this client.
|
||||
service-accounts-enabled=Service Accounts Enabled
|
||||
service-accounts-enabled.tooltip=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.
|
||||
oauth2-device-authorization-grant-enabled=OAuth 2.0 Device Authorization Grant Enabled
|
||||
oauth2-device-authorization-grant-enabled.tooltip=This enables support for OAuth 2.0 Device Authorization Grant, which means that client is an application on device that has limited input capabilities or lack a suitable browser.
|
||||
include-authnstatement=Include AuthnStatement
|
||||
include-authnstatement.tooltip=Should a statement specifying the method and timestamp be included in login responses?
|
||||
include-onetimeuse-condition=Include OneTimeUse Condition
|
||||
|
|
|
@ -1317,6 +1317,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
|
|||
$scope.realm.accessCodeLifespanUserAction = TimeUnit2.asUnit(realm.accessCodeLifespanUserAction);
|
||||
$scope.realm.actionTokenGeneratedByAdminLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByAdminLifespan);
|
||||
$scope.realm.actionTokenGeneratedByUserLifespan = TimeUnit2.asUnit(realm.actionTokenGeneratedByUserLifespan);
|
||||
$scope.realm.oauth2DeviceCodeLifespan = TimeUnit2.asUnit(realm.oauth2DeviceCodeLifespan);
|
||||
$scope.realm.attributes = realm.attributes
|
||||
|
||||
var oldCopy = angular.copy($scope.realm);
|
||||
|
@ -1376,6 +1377,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
|
|||
$scope.realm.accessCodeLifespanLogin = $scope.realm.accessCodeLifespanLogin.toSeconds();
|
||||
$scope.realm.actionTokenGeneratedByAdminLifespan = $scope.realm.actionTokenGeneratedByAdminLifespan.toSeconds();
|
||||
$scope.realm.actionTokenGeneratedByUserLifespan = $scope.realm.actionTokenGeneratedByUserLifespan.toSeconds();
|
||||
$scope.realm.oauth2DeviceCodeLifespan = $scope.realm.oauth2DeviceCodeLifespan.toSeconds();
|
||||
|
||||
Realm.update($scope.realm, function () {
|
||||
$route.reload();
|
||||
|
|
|
@ -139,6 +139,13 @@
|
|||
<input ng-model="clientEdit.serviceAccountsEnabled" name="serviceAccountsEnabled" id="serviceAccountsEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
||||
<label class="col-md-2 control-label" for="oauth2DeviceAuthorizationGrantEnabled">{{:: 'oauth2-device-authorization-grant-enabled' | translate}}</label>
|
||||
<kc-tooltip>{{:: 'oauth2-device-authorization-grant-enabled.tooltip' | translate}}</kc-tooltip>
|
||||
<div class="col-md-6">
|
||||
<input ng-model="clientEdit.oauth2DeviceAuthorizationGrantEnabled" name="oauth2DeviceAuthorizationGrantEnabled" id="oauth2DeviceAuthorizationGrantEnabled" onoffswitch on-text="{{:: 'onText' | translate}}" off-text="{{:: 'offText' | translate}}"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" data-ng-show="protocol == 'openid-connect' && !clientEdit.publicClient && !clientEdit.bearerOnly">
|
||||
<label class="col-md-2 control-label" for="authorizationServicesEnabled">{{:: 'authz-authorization-services-enabled' | translate}}</label>
|
||||
<kc-tooltip>{{:: 'authz-authorization-services-enabled.tooltip' | translate}}</kc-tooltip>
|
||||
|
|
|
@ -346,6 +346,31 @@
|
|||
</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="oauth2DeviceCodeLifespan">{{:: 'oauth2-device-code-lifespan' | translate}}</label>
|
||||
|
||||
<div class="col-md-6 time-selector">
|
||||
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.oauth2DeviceCodeLifespan.time" id="oauth2DeviceCodeLifespan"
|
||||
name="oauth2DeviceCodeLifespan">
|
||||
<select class="form-control" name="oauth2DeviceCodeLifespanUnit" data-ng-model="realm.oauth2DeviceCodeLifespan.unit">
|
||||
<option value="Minutes">{{:: 'minutes' | translate}}</option>
|
||||
<option value="Hours">{{:: 'hours' | translate}}</option>
|
||||
<option value="Days">{{:: 'days' | translate}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'oauth2-device-code-lifespan.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-md-2 control-label" for="oauth2DevicePollingInterval">{{:: 'oauth2-device-polling-interval' | translate}}</label>
|
||||
|
||||
<div class="col-md-6 time-selector">
|
||||
<input class="form-control" type="number" required min="1" max="31536000" data-ng-model="realm.oauth2DevicePollingInterval" id="oauth2DevicePollingInterval"
|
||||
name="oauth2DevicePollingInterval">
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'oauth2-device-polling-interval.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageRealm">
|
||||
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<#import "template.ftl" as layout>
|
||||
<@layout.registrationLayout; section>
|
||||
<#if section = "header">
|
||||
${msg("oauth2DeviceVerificationTitle")}
|
||||
<#elseif section = "form">
|
||||
<form id="kc-user-verify-device-user-code-form" class="${properties.kcFormClass!}" action="${url.oauth2DeviceVerificationAction}" method="post">
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div class="${properties.kcLabelWrapperClass!}">
|
||||
<label for="device-user-code" class="${properties.kcLabelClass!}">${msg("verifyOAuth2DeviceUserCode")}</label>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcInputWrapperClass!}">
|
||||
<input id="device-user-code" name="device_user_code" autocomplete="off" type="text" class="${properties.kcInputClass!}" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="${properties.kcFormGroupClass!}">
|
||||
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
|
||||
<div class="${properties.kcFormOptionsWrapperClass!}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
|
||||
<div class="${properties.kcFormButtonsWrapperClass!}">
|
||||
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" type="submit" value="${msg("doSubmit")}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</#if>
|
||||
</@layout.registrationLayout>
|
|
@ -123,6 +123,17 @@ loginChooseAuthenticator=Select login method
|
|||
oauthGrantRequest=Do you grant these access privileges?
|
||||
inResource=in
|
||||
|
||||
oauth2DeviceVerificationTitle=Device Login
|
||||
verifyOAuth2DeviceUserCode=Enter the code provided by your device and click Submit
|
||||
oauth2DeviceInvalidUserCodeMessage=Invalid code, please try again.
|
||||
oauth2DeviceExpiredUserCodeMessage=The code has expired. Please go back to your device and try connecting again.
|
||||
oauth2DeviceVerificationCompleteHeader=Device Login Successful
|
||||
oauth2DeviceVerificationCompleteMessage=You may close this browser window and go back to your device.
|
||||
oauth2DeviceVerificationFailedHeader=Device Login Failed
|
||||
oauth2DeviceVerificationFailedMessage=You may close this browser window and go back to your device and try connecting again.
|
||||
oauth2DeviceConsentDeniedMessage=Consent denied for connecting the device.
|
||||
oauth2DeviceAuthorizationGrantDisabledMessage=Client is not allowed to initiate OAuth 2.0 Device Authorization Grant. The flow is disabled for the client.
|
||||
|
||||
emailVerifyInstruction1=An email with instructions to verify your email address has been sent to you.
|
||||
emailVerifyInstruction2=Haven''t received a verification code in your email?
|
||||
emailVerifyInstruction3=to re-send the email.
|
||||
|
|
Loading…
Reference in a new issue