diff --git a/samples/device-client/src/main/java/sample/web/DeviceController.java b/samples/device-client/src/main/java/sample/web/DeviceController.java index 40ae9266..a88b7006 100644 --- a/samples/device-client/src/main/java/sample/web/DeviceController.java +++ b/samples/device-client/src/main/java/sample/web/DeviceController.java @@ -105,8 +105,8 @@ public class DeviceController { Map responseParameters = this.webClient.post() .uri(clientRegistration.getProviderDetails().getAuthorizationUri()) - .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), - clientRegistration.getClientSecret())) +// .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), +// clientRegistration.getClientSecret())) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(requestParameters)) .retrieve() diff --git a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java index 7e212c41..92915f48 100644 --- a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java @@ -58,7 +58,7 @@ public final class OAuth2DeviceAccessTokenResponseClient implements OAuth2Access ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration(); HttpHeaders headers = new HttpHeaders(); - headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); +// headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); MultiValueMap requestParameters = new LinkedMultiValueMap<>(); requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue()); diff --git a/samples/device-client/src/main/resources/application.yml b/samples/device-client/src/main/resources/application.yml index e3cfff53..692d4a14 100644 --- a/samples/device-client/src/main/resources/application.yml +++ b/samples/device-client/src/main/resources/application.yml @@ -15,8 +15,8 @@ spring: registration: messaging-client-device-grant: provider: spring - client-id: messaging-client - client-secret: secret + client-id: device-messaging-client + client-authentication-method: none authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code scope: message.read,message.write client-name: messaging-client-device-grant diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java new file mode 100644 index 00000000..dc32f4d1 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationProvider.java @@ -0,0 +1,93 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * 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 + * + * https://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 sample.authentication; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.util.Assert; + +public final class DeviceClientAuthenticationProvider implements AuthenticationProvider { + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-3.2.1"; + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + + public DeviceClientAuthenticationProvider(RegisteredClientRepository registeredClientRepository) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + this.registeredClientRepository = registeredClientRepository; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + DeviceClientAuthenticationToken deviceClientAuthentication = + (DeviceClientAuthenticationToken) authentication; + + if (!ClientAuthenticationMethod.NONE.equals(deviceClientAuthentication.getClientAuthenticationMethod())) { + return null; + } + + String clientId = deviceClientAuthentication.getPrincipal().toString(); + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId); + if (registeredClient == null) { + throwInvalidClient(OAuth2ParameterNames.CLIENT_ID); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + if (!registeredClient.getClientAuthenticationMethods().contains( + deviceClientAuthentication.getClientAuthenticationMethod())) { + throwInvalidClient("authentication_method"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device client authentication parameters"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device client"); + } + + return new DeviceClientAuthenticationToken(registeredClient, + deviceClientAuthentication.getClientAuthenticationMethod(), null); + } + + @Override + public boolean supports(Class authentication) { + return DeviceClientAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static void throwInvalidClient(String parameterName) { + OAuth2Error error = new OAuth2Error( + OAuth2ErrorCodes.INVALID_CLIENT, + "Device client authentication failed: " + parameterName, + ERROR_URI + ); + throw new OAuth2AuthenticationException(error); + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java new file mode 100644 index 00000000..8b73963b --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/authentication/DeviceClientAuthenticationToken.java @@ -0,0 +1,39 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * 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 + * + * https://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 sample.authentication; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Transient; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +@Transient +public class DeviceClientAuthenticationToken extends OAuth2ClientAuthenticationToken { + + public DeviceClientAuthenticationToken(String clientId, ClientAuthenticationMethod clientAuthenticationMethod, + @Nullable Object credentials, @Nullable Map additionalParameters) { + super(clientId, clientAuthenticationMethod, credentials, additionalParameters); + } + + public DeviceClientAuthenticationToken(RegisteredClient registeredClient, ClientAuthenticationMethod clientAuthenticationMethod, + @Nullable Object credentials) { + super(registeredClient, clientAuthenticationMethod, credentials); + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java index b88b1db0..9fb0cab7 100644 --- a/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -26,6 +26,8 @@ import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import sample.authentication.DeviceClientAuthenticationProvider; +import sample.web.authentication.DeviceClientAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -68,12 +70,23 @@ public class SecurityConfig { @Bean @Order(1) - public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain authorizationServerSecurityFilterChain( + HttpSecurity http, RegisteredClientRepository registeredClientRepository, + AuthorizationServerSettings authorizationServerSettings) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint .verificationUri("/activate") ) + .clientAuthentication((clientAuthentication) -> + clientAuthentication + .authenticationConverter( + new DeviceClientAuthenticationConverter( + authorizationServerSettings.getDeviceAuthorizationEndpoint())) + .authenticationProvider( + new DeviceClientAuthenticationProvider( + registeredClientRepository)) + ) .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 // @formatter:off @@ -124,7 +137,6 @@ public class SecurityConfig { .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) - .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc") .redirectUri("http://127.0.0.1:8080/authorized") .scope(OidcScopes.OPENID) @@ -134,7 +146,16 @@ public class SecurityConfig { .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) .build(); - return new InMemoryRegisteredClientRepository(registeredClient); + RegisteredClient deviceClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("device-messaging-client") + .clientAuthenticationMethod(ClientAuthenticationMethod.NONE) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .scope("message.read") + .scope("message.write") + .build(); + + return new InMemoryRegisteredClientRepository(registeredClient, deviceClient); } @Bean diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java new file mode 100644 index 00000000..5d46f889 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/authentication/DeviceClientAuthenticationConverter.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020-2023 the original author or authors. + * + * 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 + * + * https://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 sample.web.authentication; + +import jakarta.servlet.http.HttpServletRequest; + +import sample.authentication.DeviceClientAuthenticationToken; + +import org.springframework.http.HttpMethod; +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.StringUtils; + +public final class DeviceClientAuthenticationConverter implements AuthenticationConverter { + private final RequestMatcher deviceAuthorizationRequestMatcher; + private final RequestMatcher deviceAccessTokenRequestMatcher; + + public DeviceClientAuthenticationConverter(String deviceAuthorizationEndpointUri) { + RequestMatcher clientIdParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null; + this.deviceAuthorizationRequestMatcher = new AndRequestMatcher( + new AntPathRequestMatcher( + deviceAuthorizationEndpointUri, HttpMethod.POST.name()), + clientIdParameterMatcher); + this.deviceAccessTokenRequestMatcher = request -> + AuthorizationGrantType.DEVICE_CODE.getValue().equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE)) && + request.getParameter(OAuth2ParameterNames.DEVICE_CODE) != null && + request.getParameter(OAuth2ParameterNames.CLIENT_ID) != null; + } + + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + if (!this.deviceAuthorizationRequestMatcher.matches(request) && + !this.deviceAccessTokenRequestMatcher.matches(request)) { + return null; + } + + // client_id (REQUIRED) + String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID); + if (!StringUtils.hasText(clientId) || + request.getParameterValues(OAuth2ParameterNames.CLIENT_ID).length != 1) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + return new DeviceClientAuthenticationToken(clientId, ClientAuthenticationMethod.NONE, null, null); + } + +}