From 375e47877efae5fe901f1019826f71151f0a4a12 Mon Sep 17 00:00:00 2001 From: Takashi Norimatsu Date: Mon, 5 Jul 2021 07:01:24 +0900 Subject: [PATCH] KEYCLOAK-18558 Client Policy - Endpoint : support Device Authorization Endpoint --- .../clientpolicy/ClientPolicyEvent.java | 4 +- .../oidc/grants/device/DeviceGrantType.java | 10 ++ .../DeviceAuthorizationRequestContext.java | 53 +++++++ .../context/DeviceTokenRequestContext.java | 52 +++++++ .../device/endpoints/DeviceEndpoint.java | 8 ++ .../executor/TestRaiseExeptionExecutor.java | 2 + .../testsuite/client/ClientPoliciesTest.java | 131 ++++++++++++++++++ 7 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceAuthorizationRequestContext.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceTokenRequestContext.java diff --git a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java index d362f31911..a59bf4721d 100644 --- a/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java +++ b/server-spi/src/main/java/org/keycloak/services/clientpolicy/ClientPolicyEvent.java @@ -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 } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java index ebf9fec637..8c02540ccc 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java @@ -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(); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceAuthorizationRequestContext.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceAuthorizationRequestContext.java new file mode 100644 index 0000000000..42a966f2c4 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceAuthorizationRequestContext.java @@ -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 Takashi Norimatsu + */ +public class DeviceAuthorizationRequestContext implements ClientPolicyContext { + + private final AuthorizationEndpointRequest request; + private final MultivaluedMap requestParameters; + + public DeviceAuthorizationRequestContext(AuthorizationEndpointRequest request, + MultivaluedMap requestParameters) { + this.request = request; + this.requestParameters = requestParameters; + } + + @Override + public ClientPolicyEvent getEvent() { + return ClientPolicyEvent.DEVICE_AUTHORIZATION_REQUEST; + } + + public AuthorizationEndpointRequest getRequest() { + return request; + } + + public MultivaluedMap getRequestParameters() { + return requestParameters; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceTokenRequestContext.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceTokenRequestContext.java new file mode 100644 index 0000000000..fd260fdd0c --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/clientpolicy/context/DeviceTokenRequestContext.java @@ -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 Takashi Norimatsu + */ +public class DeviceTokenRequestContext implements ClientPolicyContext { + + private final OAuth2DeviceCodeModel deviceCodeModel; + private final MultivaluedMap requestParameters; + + public DeviceTokenRequestContext(OAuth2DeviceCodeModel deviceCodeModel, + MultivaluedMap requestParameters) { + this.deviceCodeModel = deviceCodeModel; + this.requestParameters = requestParameters; + } + + @Override + public ClientPolicyEvent getEvent() { + return ClientPolicyEvent.DEVICE_AUTHORIZATION_REQUEST; + } + + public OAuth2DeviceCodeModel getDeviceCodeModel() { + return deviceCodeModel; + } + + public MultivaluedMap getRequestParameters() { + return requestParameters; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java index 5feacd683b..9198c0ed56 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/grants/device/endpoints/DeviceEndpoint.java @@ -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); diff --git a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/services/clientpolicy/executor/TestRaiseExeptionExecutor.java b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/services/clientpolicy/executor/TestRaiseExeptionExecutor.java index 6178097111..9c889005e6 100644 --- a/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/services/clientpolicy/executor/TestRaiseExeptionExecutor.java +++ b/testsuite/integration-arquillian/servers/auth-server/services/testsuite-providers/src/main/java/org/keycloak/testsuite/services/clientpolicy/executor/TestRaiseExeptionExecutor.java @@ -55,6 +55,8 @@ public class TestRaiseExeptionExecutor implements ClientPolicyExecutorProviderTakashi Norimatsu */ @@ -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 testRealms) { RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); @@ -179,6 +200,30 @@ public class ClientPoliciesTest extends AbstractClientPoliciesTest { realm.setUsers(users); + List 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);