KEYCLOAK-18558 Client Policy - Endpoint : support Device Authorization Endpoint

This commit is contained in:
Takashi Norimatsu 2021-07-05 07:01:24 +09:00 committed by Marek Posolda
parent b4fe7bbda2
commit 375e47877e
7 changed files with 259 additions and 1 deletions

View file

@ -40,6 +40,8 @@ public enum ClientPolicyEvent {
LOGOUT_REQUEST,
BACKCHANNEL_AUTHENTICATION_REQUEST,
BACKCHANNEL_TOKEN_REQUEST,
PUSHED_AUTHORIZATION_REQUEST
PUSHED_AUTHORIZATION_REQUEST,
DEVICE_AUTHORIZATION_REQUEST,
DEVICE_TOKEN_REQUEST
}

View file

@ -42,8 +42,10 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint;
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.device.clientpolicy.context.DeviceTokenRequestContext;
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.UserSessionCrossDCManager;
import org.keycloak.services.resources.Cors;
@ -260,6 +262,14 @@ public class DeviceGrantType {
Response.Status.BAD_REQUEST);
}
try {
session.clientPolicy().triggerOnEvent(new DeviceTokenRequestContext(deviceCodeModel, formParams));
} catch (ClientPolicyException cpe) {
event.error(cpe.getError());
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, cpe.getErrorDetail(),
Response.Status.BAD_REQUEST);
}
// Compute client scopes again from scope parameter. Check if user still has them granted
// (but in device_code-to-token request, it could just theoretically happen that they are not available)
String scopeParam = deviceCodeModel.getScope();

View file

@ -0,0 +1,53 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.device.clientpolicy.context;
import javax.ws.rs.core.MultivaluedMap;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class DeviceAuthorizationRequestContext implements ClientPolicyContext {
private final AuthorizationEndpointRequest request;
private final MultivaluedMap<String, String> requestParameters;
public DeviceAuthorizationRequestContext(AuthorizationEndpointRequest request,
MultivaluedMap<String, String> requestParameters) {
this.request = request;
this.requestParameters = requestParameters;
}
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.DEVICE_AUTHORIZATION_REQUEST;
}
public AuthorizationEndpointRequest getRequest() {
return request;
}
public MultivaluedMap<String, String> getRequestParameters() {
return requestParameters;
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oidc.grants.device.clientpolicy.context;
import javax.ws.rs.core.MultivaluedMap;
import org.keycloak.models.OAuth2DeviceCodeModel;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyEvent;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class DeviceTokenRequestContext implements ClientPolicyContext {
private final OAuth2DeviceCodeModel deviceCodeModel;
private final MultivaluedMap<String, String> requestParameters;
public DeviceTokenRequestContext(OAuth2DeviceCodeModel deviceCodeModel,
MultivaluedMap<String, String> requestParameters) {
this.deviceCodeModel = deviceCodeModel;
this.requestParameters = requestParameters;
}
@Override
public ClientPolicyEvent getEvent() {
return ClientPolicyEvent.DEVICE_AUTHORIZATION_REQUEST;
}
public OAuth2DeviceCodeModel getDeviceCodeModel() {
return deviceCodeModel;
}
public MultivaluedMap<String, String> getRequestParameters() {
return requestParameters;
}
}

View file

@ -46,6 +46,7 @@ import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
import org.keycloak.protocol.oidc.grants.device.clientpolicy.context.DeviceAuthorizationRequestContext;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.representations.OAuth2DeviceAuthorizationResponse;
import org.keycloak.saml.common.util.StringUtil;
@ -53,6 +54,7 @@ import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resources.SessionCodeChecks;
@ -132,6 +134,12 @@ public class DeviceEndpoint extends AuthorizationEndpointBase implements RealmRe
"Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}
try {
session.clientPolicy().triggerOnEvent(new DeviceAuthorizationRequestContext(request, httpRequest.getDecodedFormParameters()));
} catch (ClientPolicyException cpe) {
throw new ErrorResponseException(cpe.getError(), cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
}
int expiresIn = realm.getOAuth2DeviceConfig().getLifespan(client);
int interval = realm.getOAuth2DeviceConfig().getPoolingInterval(client);

View file

@ -55,6 +55,8 @@ public class TestRaiseExeptionExecutor implements ClientPolicyExecutorProvider<C
case BACKCHANNEL_AUTHENTICATION_REQUEST:
case BACKCHANNEL_TOKEN_REQUEST:
case PUSHED_AUTHORIZATION_REQUEST:
case DEVICE_AUTHORIZATION_REQUEST:
case DEVICE_TOKEN_REQUEST:
return true;
default :
return false;

View file

@ -22,6 +22,7 @@ import org.apache.http.HttpResponse;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.impl.client.CloseableHttpClient;
import org.hamcrest.Matchers;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.logging.Logger;
import org.junit.Assert;
import org.junit.Assume;
@ -44,6 +45,7 @@ import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AdminRoles;
import org.keycloak.models.CibaConfig;
import org.keycloak.models.Constants;
import org.keycloak.models.OAuth2DeviceConfig;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
@ -88,14 +90,19 @@ import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer;
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
import org.keycloak.testsuite.client.resources.TestOIDCEndpointsApplicationResource;
import org.keycloak.testsuite.pages.ErrorPage;
import org.keycloak.testsuite.pages.OAuth2DeviceVerificationPage;
import org.keycloak.testsuite.pages.OAuthGrantPage;
import org.keycloak.testsuite.rest.resource.TestingOIDCEndpointsApplicationResource.AuthorizationEndpointRequestObject;
import org.keycloak.testsuite.services.clientpolicy.condition.TestRaiseExeptionConditionFactory;
import org.keycloak.testsuite.services.clientpolicy.executor.TestRaiseExeptionExecutorFactory;
import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.util.ClientBuilder;
import org.keycloak.testsuite.util.MutualTLSUtils;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.testsuite.util.ServerURLs;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
@ -137,6 +144,7 @@ import static org.keycloak.testsuite.util.ClientPoliciesUtil.createSecureSigning
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createTestRaiseExeptionConditionConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createFullScopeDisabledExecutorConfig;
/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
@ -148,6 +156,19 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
private static final String TEST_USER_NAME = "test-user@localhost";
private static final String TEST_USER_PASSWORD = "password";
public static final String DEVICE_APP = "test-device";
public static final String DEVICE_APP_PUBLIC = "test-device-public";
private static String userId;
@Page
protected OAuth2DeviceVerificationPage verificationPage;
@Page
protected OAuthGrantPage grantPage;
@Page
protected ErrorPage errorPage;
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
@ -179,6 +200,30 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
realm.setUsers(users);
List<ClientRepresentation> clients = realm.getClients();
ClientRepresentation app = ClientBuilder.create()
.id(KeycloakModelUtils.generateId())
.clientId("test-device")
.secret("secret")
.attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
.build();
clients.add(app);
ClientRepresentation appPublic = ClientBuilder.create().id(KeycloakModelUtils.generateId()).publicClient()
.clientId(DEVICE_APP_PUBLIC).attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
.build();
clients.add(appPublic);
userId = KeycloakModelUtils.generateId();
UserRepresentation deviceUser = UserBuilder.create()
.id(userId)
.username("device-login")
.email("device-login@localhost")
.password("password")
.build();
users.add(deviceUser);
testRealms.add(realm);
}
@ -2452,6 +2497,92 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest {
}
}
@Test
public void testExtendedClientPolicyIntefacesForDeviceAuthorizationRequest() throws Exception {
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Den Forste Profilen")
.addExecutor(TestRaiseExeptionExecutorFactory.PROVIDER_ID, null)
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE)
.addCondition(AnyClientConditionFactory.PROVIDER_ID, createAnyClientConditionConfig())
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
assertEquals(400, response.getStatusCode());
assertEquals(ClientPolicyEvent.DEVICE_AUTHORIZATION_REQUEST.toString(), response.getError());
assertEquals("Exception thrown intentionally", response.getErrorDescription());
}
@Test
public void testExtendedClientPolicyIntefacesForDeviceTokenRequest() throws Exception {
// Device Authorization Request from device
oauth.realm(REALM_NAME);
oauth.clientId(DEVICE_APP);
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP, "secret");
Assert.assertEquals(200, response.getStatusCode());
assertNotNull(response.getDeviceCode());
assertNotNull(response.getUserCode());
assertNotNull(response.getVerificationUri());
assertNotNull(response.getVerificationUriComplete());
// Verify user code from verification page using browser
openVerificationPage(response.getVerificationUri());
verificationPage.assertCurrent();
verificationPage.submit(response.getUserCode());
loginPage.assertCurrent();
// Do Login
oauth.fillLoginForm("device-login", "password");
// Consent
grantPage.assertCurrent();
grantPage.assertGrants(OAuthGrantPage.PROFILE_CONSENT_TEXT, OAuthGrantPage.EMAIL_CONSENT_TEXT, OAuthGrantPage.ROLES_CONSENT_TEXT);
grantPage.accept();
verificationPage.assertApprovedPage();
// register profiles
String json = (new ClientProfilesBuilder()).addProfile(
(new ClientProfileBuilder()).createProfile(PROFILE_NAME, "Den Forste Profilen")
.addExecutor(TestRaiseExeptionExecutorFactory.PROVIDER_ID, null)
.toRepresentation()
).toString();
updateProfiles(json);
// register policies
json = (new ClientPoliciesBuilder()).addPolicy(
(new ClientPolicyBuilder()).createPolicy(POLICY_NAME, "La Premiere Politique", Boolean.TRUE)
.addCondition(AnyClientConditionFactory.PROVIDER_ID, createAnyClientConditionConfig())
.addProfile(PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
// Token request from device
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP, "secret", response.getDeviceCode());
assertEquals(400, tokenResponse.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, tokenResponse.getError());
assertEquals("Exception thrown intentionally", tokenResponse.getErrorDescription());
}
private void openVerificationPage(String verificationUri) {
driver.navigate().to(verificationUri);
}
private void checkMtlsFlow() throws IOException {
// Check login.
OAuthClient.AuthorizationEndpointResponse loginResponse = oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);