diff --git a/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle b/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle index 402a31ab8..20c5f7f2a 100644 --- a/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle +++ b/docs/src/docs/asciidoc/examples/spring-authorization-server-docs-examples.gradle @@ -8,18 +8,18 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { implementation platform("org.springframework.boot:spring-boot-dependencies:3.0.0") + implementation platform("org.springframework.security:spring-security-bom:6.1.0-SNAPSHOT") implementation "org.springframework.boot:spring-boot-starter-web" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" implementation "org.springframework.boot:spring-boot-starter-security" implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server" implementation "org.springframework.boot:spring-boot-starter-data-jpa" - implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" implementation project(":spring-security-oauth2-authorization-server") runtimeOnly "com.h2database:h2" testImplementation "org.springframework.boot:spring-boot-starter-test" diff --git a/gradle.properties b/gradle.properties index 2f6d04a9e..c75357943 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.parallel=true org.gradle.caching=true springFrameworkVersion=6.0.5 -springSecurityVersion=6.1.0-M1 +springSecurityVersion=6.1.0-SNAPSHOT springJavaformatVersion=0.0.35 springJavaformatExcludePackages=org/springframework/security/config org/springframework/security/oauth2 checkstyleToolVersion=8.34 diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java index 2d2ca5fb1..abdc15d78 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java @@ -24,7 +24,9 @@ import org.springframework.lang.Nullable; import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2UserCode; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.oidc.OidcIdToken; import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames; @@ -164,6 +166,10 @@ private static boolean hasToken(OAuth2Authorization authorization, String token, return matchesIdToken(authorization, token); } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { return matchesRefreshToken(authorization, token); + } else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) { + return matchesDeviceCode(authorization, token); + } else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) { + return matchesUserCode(authorization, token); } return false; } @@ -196,6 +202,18 @@ private static boolean matchesIdToken(OAuth2Authorization authorization, String return idToken != null && idToken.getToken().getTokenValue().equals(token); } + private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) { + OAuth2Authorization.Token deviceCode = + authorization.getToken(OAuth2DeviceCode.class); + return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token); + } + + private static boolean matchesUserCode(OAuth2Authorization authorization, String token) { + OAuth2Authorization.Token userCode = + authorization.getToken(OAuth2UserCode.class); + return userCode != null && userCode.getToken().getTokenValue().equals(token); + } + private static final class MaxSizeHashMap extends LinkedHashMap { private final int maxSize; diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java index ef8bb69dd..afdfc3a60 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -253,6 +253,18 @@ public static class Token implements Serializable { */ public static final String INVALIDATED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("invalidated"); + /** + * The name of the metadata that indicates if access has been denied by the resource owner. + * Used with the OAuth 2.0 Device Authorization Grant. + */ + public static final String ACCESS_DENIED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_denied"); + + /** + * The name of the metadata that indicates if access has been denied by the resource owner. + * Used with the OAuth 2.0 Device Authorization Grant. + */ + public static final String ACCESS_GRANTED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_granted"); + /** * The name of the metadata used for the claims of the token. */ diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java index c8e572c7a..343526fda 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -113,6 +113,10 @@ private Builder(OAuth2AuthorizationConsentAuthenticationToken authentication) { super(authentication); } + private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) { + super(authentication); + } + /** * Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java new file mode 100644 index 000000000..e0b0a006f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java @@ -0,0 +1,267 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +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.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Consent + * used in the Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceAuthorizationConsentAuthenticationToken + * @see OAuth2AuthorizationConsent + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see RegisteredClientRepository + * @see OAuth2AuthorizationService + * @see OAuth2AuthorizationConsentService + */ +public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider { + + private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE); + + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + private final OAuth2AuthorizationConsentService authorizationConsentService; + private Consumer authorizationConsentCustomizer; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2DeviceAuthorizationConsentAuthenticationProvider( + RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, + OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.authorizationConsentService = authorizationConsentService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE); + if (authorization == null) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with device authorization consent state"); + } + + Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal(); + + RegisteredClient registeredClient = this.registeredClientRepository.findByClientId( + deviceAuthorizationConsentAuthentication.getClientId()); + if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) { + throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( + OAuth2AuthorizationRequest.class.getName()); + Set requestedScopes = authorizationRequest.getScopes(); + Set authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ? + new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) : + new HashSet<>(); + if (!requestedScopes.containsAll(authorizedScopes)) { + throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device authorization consent request parameters"); + } + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + authorization.getRegisteredClientId(), principal.getName()); + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : Collections.emptySet(); + + if (!currentAuthorizedScopes.isEmpty()) { + for (String requestedScope : requestedScopes) { + if (currentAuthorizedScopes.contains(requestedScope)) { + authorizedScopes.add(requestedScope); + } + } + } + + OAuth2AuthorizationConsent.Builder authorizationConsentBuilder; + if (currentAuthorizationConsent != null) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved existing authorization consent"); + } + authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent); + } else { + authorizationConsentBuilder = OAuth2AuthorizationConsent.withId( + authorization.getRegisteredClientId(), principal.getName()); + } + authorizedScopes.forEach(authorizationConsentBuilder::scope); + + if (this.authorizationConsentCustomizer != null) { + // @formatter:off + OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext = + OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication) + .authorizationConsent(authorizationConsentBuilder) + .registeredClient(registeredClient) + .authorization(authorization) + .authorizationRequest(authorizationRequest) + .build(); + // @formatter:on + this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Customized authorization consent"); + } + } + + Set authorities = new HashSet<>(); + authorizationConsentBuilder.authorities(authorities::addAll); + + OAuth2Authorization.Token deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class); + OAuth2Authorization.Token userCodeToken = authorization.getToken(OAuth2UserCode.class); + + if (authorities.isEmpty()) { + // Authorization consent denied (or revoked) + if (currentAuthorizationConsent != null) { + this.authorizationConsentService.remove(currentAuthorizationConsent); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Revoked authorization consent"); + } + } + authorization = OAuth2Authorization.from(authorization) + .token(deviceCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true)) + .token(userCodeToken.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .build(); + this.authorizationService.save(authorization); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Invalidated device code and user code because authorization consent was denied"); + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED); + } + + OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build(); + if (!authorizationConsent.equals(currentAuthorizationConsent)) { + this.authorizationConsentService.save(authorizationConsent); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization consent"); + } + } + + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .authorizedScopes(authorizedScopes) + .token(deviceCodeToken.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) + .token(userCodeToken.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attribute(Principal.class.getName(), principal) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .build(); + this.authorizationService.save(updatedAuthorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization with authorized scopes"); + // This log is kept separate for consistency with other providers + this.logger.trace("Authenticated authorization consent request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, + deviceAuthorizationConsentAuthentication.getUserCode()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext} + * containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information. + * + *

+ * The following context attributes are available: + *

    + *
  • The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent + * prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.
  • + *
  • The {@link Authentication} of type + * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.
  • + *
  • The {@link RegisteredClient} associated with the authorization request.
  • + *
  • The {@link OAuth2Authorization} associated with the state token presented in the + * authorization consent request.
  • + *
  • The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.
  • + *
+ * + * @param authorizationConsentCustomizer the {@code Consumer} providing access to the + * {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder} + */ + public void setAuthorizationConsentCustomizer(Consumer authorizationConsentCustomizer) { + Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null"); + this.authorizationConsentCustomizer = authorizationConsentCustomizer; + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI); + throw new OAuth2AuthorizationException(error); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java new file mode 100644 index 000000000..20cf0b0cc --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java @@ -0,0 +1,103 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Authorization Consent used + * in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + */ +public class OAuth2DeviceAuthorizationConsentAuthenticationToken extends OAuth2AuthorizationConsentAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final String userCode; + private final Set requestedScopes; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters. + * + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization request + * @param state the state + * @param authorizedScopes the authorized scope(s) + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, String userCode, String state, @Nullable Set authorizedScopes, + @Nullable Map additionalParameters) { + super(authorizationUri, clientId, principal, state, authorizedScopes, additionalParameters); + Assert.hasText(userCode, "userCode cannot be empty"); + this.userCode = userCode; + this.requestedScopes = null; + setAuthenticated(false); + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationToken} using the provided parameters. + * + * @param authorizationUri the authorization URI + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization request + * @param state the state + * @param requestedScopes the requested scope(s) + * @param authorizedScopes the authorized scope(s) + */ + public OAuth2DeviceAuthorizationConsentAuthenticationToken(String authorizationUri, String clientId, + Authentication principal, String userCode, String state, @Nullable Set requestedScopes, + @Nullable Set authorizedScopes) { + super(authorizationUri, clientId, principal, state, authorizedScopes, null); + Assert.hasText(userCode, "userCode cannot be empty"); + this.userCode = userCode; + this.requestedScopes = Collections.unmodifiableSet( + requestedScopes != null ? + new HashSet<>(requestedScopes) : + Collections.emptySet()); + setAuthenticated(true); + } + + /** + * Returns the user code. + * + * @return the user code + */ + public String getUserCode() { + return this.userCode; + } + + /** + * Returns the requested scopes. + * + * @return the requested scopes + */ + public Set getRequestedScopes() { + return this.requestedScopes; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java new file mode 100644 index 000000000..12706dff5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java @@ -0,0 +1,274 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.time.Instant; +import java.util.Base64; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.BytesKeyGenerator; +import org.springframework.security.crypto.keygen.KeyGenerators; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; + +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient; + +/** + * An {@link AuthenticationProvider} implementation for the Device Authorization Request + * used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceAuthorizationRequestAuthenticationToken + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see OAuth2AuthorizationService + * @see OAuth2TokenGenerator + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.1 Device Authorization Request + */ +public final class OAuth2DeviceAuthorizationRequestAuthenticationProvider implements AuthenticationProvider { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + + private final Log logger = LogFactory.getLog(getClass()); + private final OAuth2AuthorizationService authorizationService; + private OAuth2TokenGenerator deviceCodeGenerator = new OAuth2DeviceCodeGenerator(); + private OAuth2TokenGenerator userCodeGenerator = new OAuth2UserCodeGenerator(); + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + */ + public OAuth2DeviceAuthorizationRequestAuthenticationProvider(OAuth2AuthorizationService authorizationService) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + this.authorizationService = authorizationService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(deviceAuthorizationRequestAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + // Validate client grant types has device_code grant type + if (!registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.DEVICE_CODE)) { + throwError(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated device authorization request parameters"); + } + + // @formatter:off + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(clientPrincipal) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrant(deviceAuthorizationRequestAuthentication); + // @formatter:on + + // Generate a high-entropy string to use as the device code + OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(DEVICE_CODE_TOKEN_TYPE).build(); + OAuth2DeviceCode deviceCode = this.deviceCodeGenerator.generate(tokenContext); + if (deviceCode == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the device code.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + logger.trace("Generated device code"); + } + + // Generate a low-entropy string to use as the user code + tokenContext = tokenContextBuilder.tokenType(USER_CODE_TOKEN_TYPE).build(); + OAuth2UserCode userCode = this.userCodeGenerator.generate(tokenContext); + if (userCode == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the user code.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + logger.trace("Generated user code"); + } + + String authorizationUri = deviceAuthorizationRequestAuthentication.getAuthorizationUri(); + + Set requestedScopes = deviceAuthorizationRequestAuthentication.getScopes(); + + // @formatter:off + OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() + .authorizationUri(authorizationUri) + .clientId(registeredClient.getClientId()) + .scopes(requestedScopes) + .build(); + // @formatter:on + + // @formatter:off + OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(clientPrincipal.getName()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .token(deviceCode) + .token(userCode) + .attribute(Principal.class.getName(), clientPrincipal) + .attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest) + .build(); + // @formatter:on + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated device authorization request"); + } + + return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, requestedScopes, deviceCode, userCode); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceAuthorizationRequestAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode}. + * + * @param deviceCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2DeviceCode} + */ + public void setDeviceCodeGenerator(OAuth2TokenGenerator deviceCodeGenerator) { + Assert.notNull(deviceCodeGenerator, "deviceCodeGenerator cannot be null"); + this.deviceCodeGenerator = deviceCodeGenerator; + } + + /** + * Sets the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode}. + * + * @param userCodeGenerator the {@link OAuth2TokenGenerator} that generates the {@link OAuth2UserCode} + */ + public void setUserCodeGenerator(OAuth2TokenGenerator userCodeGenerator) { + Assert.notNull(userCodeGenerator, "userCodeGenerator cannot be null"); + this.userCodeGenerator = userCodeGenerator; + } + + private static void throwError(String errorCode, String parameterName) { + OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + private static final class OAuth2DeviceCodeGenerator implements OAuth2TokenGenerator { + + private final StringKeyGenerator deviceCodeGenerator = + new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96); + + @Nullable + @Override + public OAuth2DeviceCode generate(OAuth2TokenContext context) { + if (context.getTokenType() == null || + !OAuth2ParameterNames.DEVICE_CODE.equals(context.getTokenType().getValue())) { + return null; + } + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive()); + return new OAuth2DeviceCode(this.deviceCodeGenerator.generateKey(), issuedAt, expiresAt); + } + + } + + private static final class UserCodeStringKeyGenerator implements StringKeyGenerator { + + // @formatter:off + private static final char[] VALID_CHARS = { + 'B', 'C', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', + 'N', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Z' + }; + // @formatter:on + + private final BytesKeyGenerator keyGenerator = KeyGenerators.secureRandom(8); + + @Override + public String generateKey() { + byte[] bytes = this.keyGenerator.generateKey(); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + int offset = Math.abs(b % 20); + sb.append(VALID_CHARS[offset]); + } + sb.insert(4, '-'); + return sb.toString(); + } + + } + + private static final class OAuth2UserCodeGenerator implements OAuth2TokenGenerator { + + private final StringKeyGenerator userCodeGenerator = new UserCodeStringKeyGenerator(); + + @Nullable + @Override + public OAuth2UserCode generate(OAuth2TokenContext context) { + if (context.getTokenType() == null || + !OAuth2ParameterNames.USER_CODE.equals(context.getTokenType().getValue())) { + return null; + } + Instant issuedAt = Instant.now(); + Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getDeviceCodeTimeToLive()); + return new OAuth2UserCode(this.userCodeGenerator.generateKey(), issuedAt, expiresAt); + } + + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java new file mode 100644 index 000000000..414293646 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java @@ -0,0 +1,154 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the OAuth 2.0 Device Authorization Request + * used in the Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + */ +public class OAuth2DeviceAuthorizationRequestAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final Authentication clientPrincipal; + private final String authorizationUri; + private final Set scopes; + private final OAuth2DeviceCode deviceCode; + private final OAuth2UserCode userCode; + private final Map additionalParameters; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters. + * + * @param clientPrincipal the authenticated client principal + * @param authorizationUri the authorization {@code URI} + * @param scopes the requested scope(s) + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, String authorizationUri, + @Nullable Set scopes, @Nullable Map additionalParameters) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.hasText(authorizationUri, "authorizationUri cannot be empty"); + this.clientPrincipal = clientPrincipal; + this.authorizationUri = authorizationUri; + this.scopes = Collections.unmodifiableSet( + scopes != null ? + new HashSet<>(scopes) : + Collections.emptySet()); + this.additionalParameters = additionalParameters; + this.deviceCode = null; + this.userCode = null; + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationRequestAuthenticationToken} using the provided parameters. + * + * @param clientPrincipal the authenticated client principal + * @param scopes the requested scope(s) + * @param deviceCode the {@link OAuth2DeviceCode} + * @param userCode the {@link OAuth2UserCode} + */ + public OAuth2DeviceAuthorizationRequestAuthenticationToken(Authentication clientPrincipal, @Nullable Set scopes, + OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) { + super(Collections.emptyList()); + Assert.notNull(clientPrincipal, "clientPrincipal cannot be null"); + Assert.notNull(deviceCode, "deviceCode cannot be null"); + Assert.notNull(userCode, "userCode cannot be null"); + this.clientPrincipal = clientPrincipal; + this.scopes = Collections.unmodifiableSet( + scopes != null ? + new HashSet<>(scopes) : + Collections.emptySet()); + this.deviceCode = deviceCode; + this.userCode = userCode; + this.authorizationUri = null; + this.additionalParameters = null; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.clientPrincipal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the authorization {@code URI}. + * + * @return the authorization {@code URI}. + */ + public String getAuthorizationUri() { + return authorizationUri; + } + + /** + * Returns the requested scope(s). + * + * @return the requested scope(s). + */ + public Set getScopes() { + return this.scopes; + } + + /** + * Returns the device code. + * + * @return the device code + */ + public OAuth2DeviceCode getDeviceCode() { + return this.deviceCode; + } + + /** + * Returns the user code. + * + * @return the user code + */ + public OAuth2UserCode getUserCode() { + return this.userCode; + } + + /** + * Returns the additional parameters. + * + * @return the additional parameters + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java new file mode 100644 index 000000000..cc3b296fd --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java @@ -0,0 +1,259 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.core.log.LogMessage; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceCodeAuthenticationToken + * @see OAuth2AccessTokenAuthenticationToken + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2AuthorizationService + * @see OAuth2TokenGenerator + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.4 Device Access Token Request + * @see Section 3.5 Device Access Token Response + */ +public final class OAuth2DeviceCodeAuthenticationProvider implements AuthenticationProvider { + + private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.5"; + private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE); + + private final Log logger = LogFactory.getLog(getClass()); + private final OAuth2AuthorizationService authorizationService; + private final OAuth2TokenGenerator tokenGenerator; + + /** + * Constructs an {@code OAuth2DeviceCodeAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + * @param tokenGenerator the token generator + */ + public OAuth2DeviceCodeAuthenticationProvider( + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenGenerator, "tokenGenerator cannot be null"); + this.authorizationService = authorizationService; + this.tokenGenerator = tokenGenerator; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceCodeAuthenticationToken deviceCodeAuthentication = + (OAuth2DeviceCodeAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = OAuth2AuthenticationProviderUtils + .getAuthenticatedClientElseThrowInvalidClient(deviceCodeAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceCodeAuthentication.getDeviceCode(), DEVICE_CODE_TOKEN_TYPE); + if (authorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with device code"); + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute( + OAuth2AuthorizationRequest.class.getName()); + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + + if (!registeredClient.getClientId().equals(authorizationRequest.getClientId())) { + if (!deviceCode.isInvalidated()) { + // Invalidate the device code given that a different client is attempting to use it + authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, deviceCode.getToken()); + this.authorizationService.save(authorization); + if (this.logger.isWarnEnabled()) { + this.logger.warn(LogMessage.format( + "Invalidated device code used by registered client '%s'", registeredClient.getId())); + } + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + // In https://www.rfc-editor.org/rfc/rfc8628.html#section-3.5, + // the following error codes are defined: + + // access_denied + // The authorization request was denied. + if (Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME))) { + OAuth2Error error = new OAuth2Error("access_denied", null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + // expired_token + // The "device_code" has expired, and the device authorization + // session has concluded. The client MAY commence a new device + // authorization request but SHOULD wait for user interaction before + // restarting to avoid unnecessary polling. + if (deviceCode.isExpired()) { + OAuth2Error error = new OAuth2Error("expired_token", null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + // slow_down + // A variant of "authorization_pending", the authorization request is + // still pending and polling should continue, but the interval MUST + // be increased by 5 seconds for this and all subsequent requests. + // Note: This error is not handled in the framework. + + // authorization_pending + // The authorization request is still pending as the end user hasn't + // yet completed the user-interaction steps (Section 3.3). The + // client SHOULD repeat the access token request to the token + // endpoint (a process known as polling). Before each new request, + // the client MUST wait at least the number of seconds specified by + // the "interval" parameter of the device authorization response (see + // Section 3.2), or 5 seconds if none was provided, and respect any + // increase in the polling interval required by the "slow_down" + // error. + if (!Boolean.TRUE.equals(deviceCode.getMetadata(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME))) { + OAuth2Error error = new OAuth2Error("authorization_pending", null, DEVICE_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (!deviceCode.isActive()) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated token request parameters"); + } + + // @formatter:off + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .principal(authorization.getAttribute(Principal.class.getName())) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorization(authorization) + .authorizedScopes(authorization.getAuthorizedScopes()) + .authorizationGrantType(AuthorizationGrantType.DEVICE_CODE) + .authorizationGrant(deviceCodeAuthentication); + // @formatter:on + + // @formatter:off + OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization) + // Invalidate the device code as it can only be used (successfully) once + .token(deviceCode.getToken(), metadata -> + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)); + // @formatter:on + + // ----- Access token ----- + OAuth2TokenContext tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.ACCESS_TOKEN).build(); + OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the access token.", DEFAULT_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated access token"); + } + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes()); + if (generatedAccessToken instanceof ClaimAccessor) { + authorizationBuilder.token(accessToken, (metadata) -> + metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims())); + } else { + authorizationBuilder.accessToken(accessToken); + } + + // ----- Refresh token ----- + OAuth2RefreshToken refreshToken = null; + if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN) && + // Do not issue refresh token to public client + !clientPrincipal.getClientAuthenticationMethod().equals(ClientAuthenticationMethod.NONE)) { + + tokenContext = tokenContextBuilder.tokenType(OAuth2TokenType.REFRESH_TOKEN).build(); + OAuth2Token generatedRefreshToken = this.tokenGenerator.generate(tokenContext); + if (!(generatedRefreshToken instanceof OAuth2RefreshToken)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the refresh token.", DEFAULT_ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated refresh token"); + } + + refreshToken = (OAuth2RefreshToken) generatedRefreshToken; + authorizationBuilder.refreshToken(refreshToken); + } + + authorization = authorizationBuilder.build(); + + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + return new OAuth2AccessTokenAuthenticationToken(registeredClient, clientPrincipal, accessToken, refreshToken); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceCodeAuthenticationToken.class.isAssignableFrom(authentication); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java new file mode 100644 index 000000000..2df84805a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java @@ -0,0 +1,59 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationGrantAuthenticationToken + * @see OAuth2DeviceCodeAuthenticationProvider + */ +public class OAuth2DeviceCodeAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + + private final String deviceCode; + + /** + * Constructs an {@code OAuth2DeviceCodeAuthenticationToken} using the provided parameters. + * + * @param deviceCode the device code + * @param clientPrincipal the authenticated client principal + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceCodeAuthenticationToken(String deviceCode, Authentication clientPrincipal, @Nullable Map additionalParameters) { + super(AuthorizationGrantType.DEVICE_CODE, clientPrincipal, additionalParameters); + Assert.hasText(deviceCode, "deviceCode cannot be empty"); + this.deviceCode = deviceCode; + } + + /** + * Returns the device code. + * + * @return the device code + */ + public String getDeviceCode() { + return this.deviceCode; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java new file mode 100644 index 000000000..92777c123 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java @@ -0,0 +1,203 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.security.Principal; +import java.util.Base64; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.keygen.Base64StringKeyGenerator; +import org.springframework.security.crypto.keygen.StringKeyGenerator; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} implementation for the Verification {@code URI} + * (submission of the user code)} used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2DeviceVerificationAuthenticationToken + * @see OAuth2AuthorizationConsent + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth2DeviceCodeAuthenticationProvider + * @see RegisteredClientRepository + * @see OAuth2AuthorizationService + * @see OAuth2AuthorizationConsentService + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.3 User Interaction + */ +public final class OAuth2DeviceVerificationAuthenticationProvider implements AuthenticationProvider { + + private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE); + private static final StringKeyGenerator DEFAULT_STATE_GENERATOR = + new Base64StringKeyGenerator(Base64.getUrlEncoder()); + + private final Log logger = LogFactory.getLog(getClass()); + private final RegisteredClientRepository registeredClientRepository; + private final OAuth2AuthorizationService authorizationService; + private final OAuth2AuthorizationConsentService authorizationConsentService; + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationProvider} using the provided parameters. + * + * @param registeredClientRepository the repository of registered clients + * @param authorizationService the authorization service + * @param authorizationConsentService the authorization consent service + */ + public OAuth2DeviceVerificationAuthenticationProvider( + RegisteredClientRepository registeredClientRepository, + OAuth2AuthorizationService authorizationService, + OAuth2AuthorizationConsentService authorizationConsentService) { + Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null"); + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null"); + this.registeredClientRepository = registeredClientRepository; + this.authorizationService = authorizationService; + this.authorizationConsentService = authorizationConsentService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication = + (OAuth2DeviceVerificationAuthenticationToken) authentication; + + OAuth2Authorization authorization = this.authorizationService.findByToken( + deviceVerificationAuthentication.getUserCode(), USER_CODE_TOKEN_TYPE); + if (authorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with user code"); + } + + RegisteredClient registeredClient = this.registeredClientRepository.findById( + authorization.getRegisteredClientId()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + Authentication principal = (Authentication) deviceVerificationAuthentication.getPrincipal(); + if (!isPrincipalAuthenticated(principal)) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Did not authenticate device authorization request since principal not authenticated"); + } + // Return the authorization request as-is where isAuthenticated() is false + return deviceVerificationAuthentication; + } + + OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(OAuth2AuthorizationRequest.class.getName()); + + OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById( + registeredClient.getId(), principal.getName()); + + Set currentAuthorizedScopes = currentAuthorizationConsent != null ? + currentAuthorizationConsent.getScopes() : null; + + if (requiresAuthorizationConsent(registeredClient, authorizationRequest, currentAuthorizationConsent)) { + String state = DEFAULT_STATE_GENERATOR.generateKey(); + authorization = OAuth2Authorization.from(authorization) + .attribute(OAuth2ParameterNames.STATE, state) + .build(); + + if (this.logger.isTraceEnabled()) { + logger.trace("Generated authorization consent state"); + } + + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationRequest.getAuthorizationUri(), + registeredClient.getClientId(), principal, deviceVerificationAuthentication.getUserCode(), state, + authorizationRequest.getScopes(), currentAuthorizedScopes); + } + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization) + .principalName(principal.getName()) + .authorizedScopes(currentAuthorizedScopes) + .token(deviceCode.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true)) + .token(userCode.getToken(), metadata -> metadata + .put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true)) + .attribute(Principal.class.getName(), principal) + .attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE)) + .build(); + this.authorizationService.save(updatedAuthorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization with authorized scopes"); + // This log is kept separate for consistency with other providers + this.logger.trace("Authenticated authorization consent request"); + } + + return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal, + deviceVerificationAuthentication.getUserCode()); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication); + } + + private static boolean requiresAuthorizationConsent(RegisteredClient registeredClient, + OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationConsent authorizationConsent) { + + if (!registeredClient.getClientSettings().isRequireAuthorizationConsent()) { + return false; + } + + if (authorizationConsent != null && + authorizationConsent.getScopes().containsAll(authorizationRequest.getScopes())) { + return false; + } + + return true; + } + + private static boolean isPrincipalAuthenticated(Authentication principal) { + return principal != null && + !AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) && + principal.isAuthenticated(); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java new file mode 100644 index 000000000..f24b06001 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java @@ -0,0 +1,117 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation for the Verification {@code URI} + * (submission of the user code) used in the OAuth 2.0 Device Authorization Grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AbstractAuthenticationToken + * @see OAuth2DeviceVerificationAuthenticationProvider + */ +public class OAuth2DeviceVerificationAuthenticationToken extends AbstractAuthenticationToken { + private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID; + private final String clientId; + private final Authentication principal; + private final String userCode; + private final Map additionalParameters; + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. + * + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization request + * @param additionalParameters the additional parameters + */ + public OAuth2DeviceVerificationAuthenticationToken(Authentication principal, String userCode, + @Nullable Map additionalParameters) { + super(Collections.emptyList()); + Assert.notNull(principal, "principal cannot be null"); + Assert.notNull(userCode, "userCode cannot be null"); + this.clientId = null; + this.principal = principal; + this.userCode = userCode; + this.additionalParameters = additionalParameters; + } + + /** + * Constructs an {@code OAuth2DeviceVerificationAuthenticationToken} using the provided parameters. + * + * @param clientId the client identifier + * @param principal the {@code Principal} (Resource Owner) + * @param userCode the user code associated with the device authorization request + */ + public OAuth2DeviceVerificationAuthenticationToken(String clientId, Authentication principal, String userCode) { + super(Collections.emptyList()); + Assert.hasText(clientId, "clientId cannot be empty"); + Assert.notNull(principal, "principal cannot be null"); + Assert.notNull(userCode, "userCode cannot be null"); + this.clientId = clientId; + this.principal = principal; + this.userCode = userCode; + this.additionalParameters = null; + setAuthenticated(true); + } + + @Override + public Object getPrincipal() { + return this.principal; + } + + @Override + public Object getCredentials() { + return ""; + } + + /** + * Returns the client identifier. + * + * @return the client identifier + */ + public String getClientId() { + return this.clientId; + } + + /** + * Returns the user code. + * + * @return the user code + */ + public String getUserCode() { + return this.userCode; + } + + /** + * Returns the additional parameters. + * + * @return the additional parameters, or an empty {@code Map} if not available + */ + public Map getAdditionalParameters() { + return this.additionalParameters; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java index 19c2fd9eb..121d15a20 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java @@ -210,6 +210,30 @@ public OAuth2AuthorizationServerConfigurer tokenRevocationEndpoint(Customizer deviceAuthorizationEndpointCustomizer) { + deviceAuthorizationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceAuthorizationEndpointConfigurer.class)); + return this; + } + + /** + * Configures the OAuth 2.0 Device Verification Endpoint. + * + * @param deviceVerificationEndpointCustomizer the {@link Customizer} providing access to the {@link OAuth2DeviceVerificationEndpointConfigurer} + * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration + * @since 1.1 + */ + public OAuth2AuthorizationServerConfigurer deviceVerificationEndpoint(Customizer deviceVerificationEndpointCustomizer) { + deviceVerificationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceVerificationEndpointConfigurer.class)); + return this; + } + /** * Configures OpenID Connect 1.0 support (disabled by default). * @@ -326,6 +350,8 @@ private Map, AbstractOAuth2Configurer> configurers.put(OAuth2TokenEndpointConfigurer.class, new OAuth2TokenEndpointConfigurer(this::postProcess)); configurers.put(OAuth2TokenIntrospectionEndpointConfigurer.class, new OAuth2TokenIntrospectionEndpointConfigurer(this::postProcess)); configurers.put(OAuth2TokenRevocationEndpointConfigurer.class, new OAuth2TokenRevocationEndpointConfigurer(this::postProcess)); + configurers.put(OAuth2DeviceAuthorizationEndpointConfigurer.class, new OAuth2DeviceAuthorizationEndpointConfigurer(this::postProcess)); + configurers.put(OAuth2DeviceVerificationEndpointConfigurer.class, new OAuth2DeviceVerificationEndpointConfigurer(this::postProcess)); return configurers; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java index 4bd1d874e..7e37982f3 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -168,6 +168,9 @@ void init(HttpSecurity httpSecurity) { HttpMethod.POST.name()), new AntPathRequestMatcher( authorizationServerSettings.getTokenRevocationEndpoint(), + HttpMethod.POST.name()), + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceAuthorizationEndpoint(), HttpMethod.POST.name())); List authenticationProviders = createDefaultAuthenticationProviders(httpSecurity); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java new file mode 100644 index 000000000..b9e5d6155 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java @@ -0,0 +1,229 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +/** + * Configurer for the OAuth 2.0 Device Authorization Endpoint. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationServerConfigurer#deviceAuthorizationEndpoint + * @see OAuth2DeviceAuthorizationEndpointFilter + */ +public final class OAuth2DeviceAuthorizationEndpointConfigurer extends AbstractOAuth2Configurer { + + private RequestMatcher requestMatcher; + private final List authenticationConverters = new ArrayList<>(); + private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + private final List authenticationProviders = new ArrayList<>(); + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; + private AuthenticationSuccessHandler deviceAuthorizationResponseHandler; + private AuthenticationFailureHandler errorResponseHandler; + private String verificationUri; + + /** + * Restrict for internal use only. + */ + OAuth2DeviceAuthorizationEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request. + * + * @param deviceAuthorizationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverter(AuthenticationConverter deviceAuthorizationRequestConverter) { + Assert.notNull(deviceAuthorizationRequestConverter, "deviceAuthorizationRequestConverter cannot be null"); + this.authenticationConverters.add(deviceAuthorizationRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #deviceAuthorizationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}. + * + * @param deviceAuthorizationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationRequestConverters( + Consumer> deviceAuthorizationRequestConvertersConsumer) { + Assert.notNull(deviceAuthorizationRequestConvertersConsumer, "deviceAuthorizationRequestConvertersConsumer cannot be null"); + this.authenticationConvertersConsumer = deviceAuthorizationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}. + * + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the Device Authorization Response. + * + * @param deviceAuthorizationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer deviceAuthorizationResponseHandler(AuthenticationSuccessHandler deviceAuthorizationResponseHandler) { + this.deviceAuthorizationResponseHandler = deviceAuthorizationResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + /** + * Sets the end-user verification {@code URI} on the authorization server. + * + * @param verificationUri the end-user verification {@code URI} on the authorization server + * @return the {@link OAuth2DeviceAuthorizationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceAuthorizationEndpointConfigurer verificationUri(String verificationUri) { + this.verificationUri = verificationUri; + return this; + } + + @Override + public void init(HttpSecurity builder) { + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + this.requestMatcher = new AntPathRequestMatcher( + authorizationServerSettings.getDeviceAuthorizationEndpoint(), HttpMethod.POST.name()); + + List authenticationProviders = createDefaultAuthenticationProviders(builder); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach(authenticationProvider -> + builder.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + public void configure(HttpSecurity builder) { + AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); + AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + + OAuth2DeviceAuthorizationEndpointFilter deviceAuthorizationEndpointFilter = + new OAuth2DeviceAuthorizationEndpointFilter( + authenticationManager, authorizationServerSettings.getDeviceAuthorizationEndpoint()); + + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.authenticationConverters.isEmpty()) { + authenticationConverters.addAll(0, this.authenticationConverters); + } + this.authenticationConvertersConsumer.accept(authenticationConverters); + deviceAuthorizationEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.deviceAuthorizationResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationSuccessHandler(this.deviceAuthorizationResponseHandler); + } + if (this.errorResponseHandler != null) { + deviceAuthorizationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + if (this.verificationUri != null) { + deviceAuthorizationEndpointFilter.setVerificationUri(this.verificationUri); + } + builder.addFilterAfter(postProcess(deviceAuthorizationEndpointFilter), AuthorizationFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + authenticationConverters.add(new OAuth2DeviceAuthorizationRequestAuthenticationConverter()); + + return authenticationConverters; + } + + private List createDefaultAuthenticationProviders(HttpSecurity builder) { + List authenticationProviders = new ArrayList<>(); + + OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(builder); + + OAuth2DeviceAuthorizationRequestAuthenticationProvider deviceAuthorizationRequestAuthenticationProvider = + new OAuth2DeviceAuthorizationRequestAuthenticationProvider(authorizationService); + authenticationProviders.add(deviceAuthorizationRequestAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java new file mode 100644 index 000000000..c973bddd5 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java @@ -0,0 +1,283 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.ObjectPostProcessor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Configurer for the OAuth 2.0 Device Verification Endpoint. + * + * @author Steve Riesenberg + * @since 1.1 + * @see OAuth2AuthorizationServerConfigurer#deviceVerificationEndpoint + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceVerificationEndpointConfigurer extends AbstractOAuth2Configurer { + + private RequestMatcher requestMatcher; + private final List authenticationConverters = new ArrayList<>(); + private Consumer> authenticationConvertersConsumer = (authenticationConverters) -> {}; + private final List authenticationProviders = new ArrayList<>(); + private Consumer> authenticationProvidersConsumer = (authenticationProviders) -> {}; + private AuthenticationSuccessHandler deviceVerificationResponseHandler; + private AuthenticationFailureHandler errorResponseHandler; + private String consentPage; + + /** + * Restrict for internal use only. + */ + OAuth2DeviceVerificationEndpointConfigurer(ObjectPostProcessor objectPostProcessor) { + super(objectPostProcessor); + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Verification Request (or Consent) from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for authenticating the request. + * + * @param deviceVerificationRequestConverter the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverter(AuthenticationConverter deviceVerificationRequestConverter) { + Assert.notNull(deviceVerificationRequestConverter, "deviceVerificationRequestConverter cannot be null"); + this.authenticationConverters.add(deviceVerificationRequestConverter); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #deviceVerificationRequestConverter(AuthenticationConverter) AuthenticationConverter}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}. + * + * @param deviceVerificationRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationRequestConverters( + Consumer> deviceVerificationRequestConvertersConsumer) { + Assert.notNull(deviceVerificationRequestConvertersConsumer, "deviceVerificationRequestConvertersConsumer cannot be null"); + this.authenticationConvertersConsumer = deviceVerificationRequestConvertersConsumer; + return this; + } + + /** + * Adds an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken}. + * + * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OAuth2DeviceVerificationAuthenticationToken} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProviders.add(authenticationProvider); + return this; + } + + /** + * Sets the {@code Consumer} providing access to the {@code List} of default + * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s + * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}. + * + * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer authenticationProviders( + Consumer> authenticationProvidersConsumer) { + Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null"); + this.authenticationProvidersConsumer = authenticationProvidersConsumer; + return this; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} + * and returning the response. + * + * @param deviceVerificationResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer deviceVerificationResponseHandler(AuthenticationSuccessHandler deviceVerificationResponseHandler) { + this.deviceVerificationResponseHandler = deviceVerificationResponseHandler; + return this; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * and returning the {@link OAuth2Error Error Response}. + * + * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException} + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) { + this.errorResponseHandler = errorResponseHandler; + return this; + } + + /** + * Specify the URI to redirect Resource Owners to if consent is required during + * the {@code device_code} flow. A default consent page will be generated when + * this attribute is not specified. + * + * If a URI is specified, applications are required to process the specified URI to generate + * a consent page. The query string will contain the following parameters: + * + *
    + *
  • {@code client_id} - the client identifier
  • + *
  • {@code scope} - a space-delimited list of scopes present in the authorization request
  • + *
  • {@code state} - a CSRF protection token
  • + *
  • @code code} - the user code
  • + *
+ * + * In general, the consent page should create a form that submits + * a request with the following requirements: + * + *
    + *
  • It must be an HTTP POST
  • + *
  • It must be submitted to {@link AuthorizationServerSettings#getDeviceVerificationEndpoint()}
  • + *
  • It must include the received {@code client_id} as an HTTP parameter
  • + *
  • It must include the received {@code state} as an HTTP parameter
  • + *
  • It must include the list of {@code scope}s the {@code Resource Owner} + * consented to as an HTTP parameter
  • + *
  • It must include the user {@code code} as an HTTP parameter
  • + *
+ * + * @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent") + * @return the {@link OAuth2DeviceVerificationEndpointConfigurer} for further configuration + */ + public OAuth2DeviceVerificationEndpointConfigurer consentPage(String consentPage) { + this.consentPage = consentPage; + return this; + } + + @Override + public void init(HttpSecurity builder) { + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + this.requestMatcher = new OrRequestMatcher( + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.GET.name()), + new AntPathRequestMatcher( + authorizationServerSettings.getDeviceVerificationEndpoint(), HttpMethod.POST.name())); + + List authenticationProviders = createDefaultAuthenticationProviders(builder); + if (!this.authenticationProviders.isEmpty()) { + authenticationProviders.addAll(0, this.authenticationProviders); + } + this.authenticationProvidersConsumer.accept(authenticationProviders); + authenticationProviders.forEach(authenticationProvider -> + builder.authenticationProvider(postProcess(authenticationProvider))); + } + + @Override + public void configure(HttpSecurity builder) { + AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); + + AuthorizationServerSettings authorizationServerSettings = + OAuth2ConfigurerUtils.getAuthorizationServerSettings(builder); + + OAuth2DeviceVerificationEndpointFilter deviceVerificationEndpointFilter = + new OAuth2DeviceVerificationEndpointFilter( + authenticationManager, authorizationServerSettings.getDeviceVerificationEndpoint()); + List authenticationConverters = createDefaultAuthenticationConverters(); + if (!this.authenticationConverters.isEmpty()) { + authenticationConverters.addAll(0, this.authenticationConverters); + } + this.authenticationConvertersConsumer.accept(authenticationConverters); + deviceVerificationEndpointFilter.setAuthenticationConverter( + new DelegatingAuthenticationConverter(authenticationConverters)); + if (this.deviceVerificationResponseHandler != null) { + deviceVerificationEndpointFilter.setAuthenticationSuccessHandler(this.deviceVerificationResponseHandler); + } + if (this.errorResponseHandler != null) { + deviceVerificationEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler); + } + if (StringUtils.hasText(this.consentPage)) { + deviceVerificationEndpointFilter.setConsentPage(this.consentPage); + } + builder.addFilterAfter(postProcess(deviceVerificationEndpointFilter), AuthorizationFilter.class); + } + + @Override + RequestMatcher getRequestMatcher() { + return this.requestMatcher; + } + + private static List createDefaultAuthenticationConverters() { + List authenticationConverters = new ArrayList<>(); + authenticationConverters.add(new OAuth2DeviceVerificationAuthenticationConverter()); + authenticationConverters.add(new OAuth2DeviceAuthorizationConsentAuthenticationConverter()); + + return authenticationConverters; + } + + private static List createDefaultAuthenticationProviders(HttpSecurity builder) { + RegisteredClientRepository registeredClientRepository = + OAuth2ConfigurerUtils.getRegisteredClientRepository(builder); + OAuth2AuthorizationService authorizationService = + OAuth2ConfigurerUtils.getAuthorizationService(builder); + OAuth2AuthorizationConsentService authorizationConsentService = + OAuth2ConfigurerUtils.getAuthorizationConsentService(builder); + + List authenticationProviders = new ArrayList<>(); + + // @formatter:off + OAuth2DeviceVerificationAuthenticationProvider deviceVerificationAuthenticationProvider = + new OAuth2DeviceVerificationAuthenticationProvider( + registeredClientRepository, authorizationService, authorizationConsentService); + // @formatter:on + authenticationProviders.add(deviceVerificationAuthenticationProvider); + + // @formatter:off + OAuth2DeviceAuthorizationConsentAuthenticationProvider deviceAuthorizationConsentAuthenticationProvider = + new OAuth2DeviceAuthorizationConsentAuthenticationProvider( + registeredClientRepository, authorizationService, authorizationConsentService); + // @formatter:on + authenticationProviders.add(deviceAuthorizationConsentAuthenticationProvider); + + return authenticationProviders; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java index 1fb5813e7..7229ae336 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -36,6 +36,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; @@ -43,6 +44,7 @@ import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -208,6 +210,7 @@ private static List createDefaultAuthenticationConverte authenticationConverters.add(new OAuth2AuthorizationCodeAuthenticationConverter()); authenticationConverters.add(new OAuth2RefreshTokenAuthenticationConverter()); authenticationConverters.add(new OAuth2ClientCredentialsAuthenticationConverter()); + authenticationConverters.add(new OAuth2DeviceCodeAuthenticationConverter()); return authenticationConverters; } @@ -232,6 +235,10 @@ private static List createDefaultAuthenticationProviders new OAuth2ClientCredentialsAuthenticationProvider(authorizationService, tokenGenerator); authenticationProviders.add(clientCredentialsAuthenticationProvider); + OAuth2DeviceCodeAuthenticationProvider deviceCodeAuthenticationProvider = + new OAuth2DeviceCodeAuthenticationProvider(authorizationService, tokenGenerator); + authenticationProviders.add(deviceCodeAuthenticationProvider); + return authenticationProviders; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java index a2bb0c80d..7dc502315 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java @@ -52,6 +52,24 @@ public String getAuthorizationEndpoint() { return getSetting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT); } + /** + * Returns the OAuth 2.0 Device Authorization endpoint. The default is {@code /oauth2/device_authorization}. + * + * @return the Authorization endpoint + */ + public String getDeviceAuthorizationEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT); + } + + /** + * Returns the OAuth 2.0 Device VERIFICATION endpoint. The default is {@code /oauth2/device_verification}. + * + * @return the Authorization endpoint + */ + public String getDeviceVerificationEndpoint() { + return getSetting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT); + } + /** * Returns the OAuth 2.0 Token endpoint. The default is {@code /oauth2/token}. * @@ -124,6 +142,8 @@ public String getOidcLogoutEndpoint() { public static Builder builder() { return new Builder() .authorizationEndpoint("/oauth2/authorize") + .deviceAuthorizationEndpoint("/oauth2/device_authorization") + .deviceVerificationEndpoint("/oauth2/device_verification") .tokenEndpoint("/oauth2/token") .jwkSetEndpoint("/oauth2/jwks") .tokenRevocationEndpoint("/oauth2/revoke") @@ -173,6 +193,26 @@ public Builder authorizationEndpoint(String authorizationEndpoint) { return setting(ConfigurationSettingNames.AuthorizationServer.AUTHORIZATION_ENDPOINT, authorizationEndpoint); } + /** + * Sets the OAuth 2.0 Device Authorization endpoint. + * + * @param deviceAuthorizationEndpoint the Device Authorization endpoint + * @return the {@link Builder} for further configuration + */ + public Builder deviceAuthorizationEndpoint(String deviceAuthorizationEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_AUTHORIZATION_ENDPOINT, deviceAuthorizationEndpoint); + } + + /** + * Sets the OAuth 2.0 Device Verification endpoint. + * + * @param deviceVerificationEndpoint the Device Verification endpoint + * @return the {@link Builder} for further configuration + */ + public Builder deviceVerificationEndpoint(String deviceVerificationEndpoint) { + return setting(ConfigurationSettingNames.AuthorizationServer.DEVICE_VERIFICATION_ENDPOINT, deviceVerificationEndpoint); + } + /** * Sets the OAuth 2.0 Token endpoint. * diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java index e7cc341dd..0e970ddd1 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java @@ -86,6 +86,16 @@ public static final class AuthorizationServer { */ public static final String AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("authorization-endpoint"); + /** + * Set the OAuth 2.0 Device Authorization endpoint. + */ + public static final String DEVICE_AUTHORIZATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-authorization-endpoint"); + + /** + * Set the OAuth 2.0 Device Verification endpoint. + */ + public static final String DEVICE_VERIFICATION_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("device-verification-endpoint"); + /** * Set the OAuth 2.0 Token endpoint. */ @@ -150,6 +160,12 @@ public static final class Token { */ public static final String ACCESS_TOKEN_FORMAT = TOKEN_SETTINGS_NAMESPACE.concat("access-token-format"); + /** + * Set the time-to-live for a device code. + * @since 1.1 + */ + public static final String DEVICE_CODE_TIME_TO_LIVE = TOKEN_SETTINGS_NAMESPACE.concat("device-code-time-to-live"); + /** * Set to {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java index 887bdca80..16c364b3c 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -66,6 +66,16 @@ public OAuth2TokenFormat getAccessTokenFormat() { return getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT); } + /** + * Returns the time-to-live for a device code. The default is 30 minutes. + * + * @return the time-to-live for an authorization code + * @since 1.1 + */ + public Duration getDeviceCodeTimeToLive() { + return getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE); + } + /** * Returns {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. The default is {@code true}. @@ -103,6 +113,7 @@ public static Builder builder() { .authorizationCodeTimeToLive(Duration.ofMinutes(5)) .accessTokenTimeToLive(Duration.ofMinutes(5)) .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) + .deviceCodeTimeToLive(Duration.ofMinutes(30)) .reuseRefreshTokens(true) .refreshTokenTimeToLive(Duration.ofMinutes(60)) .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256); @@ -166,6 +177,19 @@ public Builder accessTokenFormat(OAuth2TokenFormat accessTokenFormat) { return setting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, accessTokenFormat); } + /** + * Set the time-to-live for a device code. Must be greater than {@code Duration.ZERO}. + * + * @param deviceCodeTimeToLive the time-to-live for a device code + * @return the {@link Builder} for further configuration + * @since 1.1 + */ + public Builder deviceCodeTimeToLive(Duration deviceCodeTimeToLive) { + Assert.notNull(deviceCodeTimeToLive, "deviceCodeTimeToLive cannot be null"); + Assert.isTrue(deviceCodeTimeToLive.getSeconds() > 0, "deviceCodeTimeToLive must be greater than Duration.ZERO"); + return setting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, deviceCodeTimeToLive); + } + /** * Set to {@code true} if refresh tokens are reused when returning the access token response, * or {@code false} if a new refresh token is issued. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java new file mode 100644 index 000000000..620a1ec5c --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java @@ -0,0 +1,155 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.oidc.OidcScopes; + +/** + * For internal use only. + */ +class DefaultConsentPage { + private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); + + private DefaultConsentPage() { + } + + static void displayConsent(HttpServletRequest request, HttpServletResponse response, String clientId, + Authentication principal, Set requestedScopes, Set authorizedScopes, String state, + Map additionalParameters) throws IOException { + + String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters); + response.setContentType(TEXT_HTML_UTF8.toString()); + response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); + response.getWriter().write(consentPage); + } + + private static String generateConsentPage(HttpServletRequest request, + String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state, + Map additionalParameters) { + Set scopesToAuthorize = new HashSet<>(); + Set scopesPreviouslyAuthorized = new HashSet<>(); + for (String scope : requestedScopes) { + if (authorizedScopes.contains(scope)) { + scopesPreviouslyAuthorized.add(scope); + } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent + scopesToAuthorize.add(scope); + } + } + + // https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1 + // The server SHOULD display + // the "user_code" to the user and ask them to verify that it matches + // the "user_code" being displayed on the device to confirm they are + // authorizing the correct device. + String userCode = additionalParameters.get(OAuth2ParameterNames.USER_CODE); + + StringBuilder builder = new StringBuilder(); + + builder.append(""); + builder.append(""); + builder.append(""); + builder.append(" "); + builder.append(" "); + builder.append(" "); + builder.append(" Consent required"); + builder.append(" "); + builder.append(""); + builder.append(""); + builder.append("
"); + builder.append("
"); + builder.append("

Consent required

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); + builder.append("
"); + builder.append("
"); + if (userCode != null) { + builder.append("
"); + builder.append("
"); + builder.append("

You have provided the code " + userCode + ". Verify that this code matches what is shown on your device.

"); + builder.append("
"); + builder.append("
"); + } + builder.append("
"); + builder.append("
"); + builder.append("

The following permissions are requested by the above app.
Please review these and consent if you approve.

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append(" "); + builder.append(" "); + if (userCode != null) { + builder.append(" "); + } + + for (String scope : scopesToAuthorize) { + builder.append("
"); + builder.append(" "); + builder.append(" "); + builder.append("
"); + } + + if (!scopesPreviouslyAuthorized.isEmpty()) { + builder.append("

You have already granted the following permissions to the above app:

"); + for (String scope : scopesPreviouslyAuthorized) { + builder.append("
"); + builder.append(" "); + builder.append(" "); + builder.append("
"); + } + } + + builder.append("
"); + builder.append(" "); + builder.append("
"); + builder.append("
"); + builder.append(" "); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append("

Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.

"); + builder.append("
"); + builder.append("
"); + builder.append("
"); + builder.append(""); + builder.append(""); + + return builder.toString(); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java index 451ceef58..d32d6d161 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java @@ -18,7 +18,7 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import java.util.HashSet; +import java.util.Collections; import java.util.Set; import jakarta.servlet.FilterChain; @@ -29,7 +29,6 @@ import org.springframework.core.log.LogMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.authentication.AuthenticationDetailsSource; import org.springframework.security.authentication.AuthenticationManager; @@ -288,7 +287,7 @@ private void sendAuthorizationConsent(HttpServletRequest request, HttpServletRes if (this.logger.isTraceEnabled()) { this.logger.trace("Displaying generated consent screen"); } - DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state); + DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, Collections.emptyMap()); } } @@ -367,107 +366,4 @@ private void sendErrorResponse(HttpServletRequest request, HttpServletResponse r this.redirectStrategy.sendRedirect(request, response, redirectUri); } - /** - * For internal use only. - */ - private static class DefaultConsentPage { - private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8); - - private static void displayConsent(HttpServletRequest request, HttpServletResponse response, - String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) - throws IOException { - - String consentPage = generateConsentPage(request, clientId, principal, requestedScopes, authorizedScopes, state); - response.setContentType(TEXT_HTML_UTF8.toString()); - response.setContentLength(consentPage.getBytes(StandardCharsets.UTF_8).length); - response.getWriter().write(consentPage); - } - - private static String generateConsentPage(HttpServletRequest request, - String clientId, Authentication principal, Set requestedScopes, Set authorizedScopes, String state) { - Set scopesToAuthorize = new HashSet<>(); - Set scopesPreviouslyAuthorized = new HashSet<>(); - for (String scope : requestedScopes) { - if (authorizedScopes.contains(scope)) { - scopesPreviouslyAuthorized.add(scope); - } else if (!scope.equals(OidcScopes.OPENID)) { // openid scope does not require consent - scopesToAuthorize.add(scope); - } - } - - StringBuilder builder = new StringBuilder(); - - builder.append(""); - builder.append(""); - builder.append(""); - builder.append(" "); - builder.append(" "); - builder.append(" "); - builder.append(" Consent required"); - builder.append(" "); - builder.append(""); - builder.append(""); - builder.append("
"); - builder.append("
"); - builder.append("

Consent required

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

" + clientId + " wants to access your account " + principal.getName() + "

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

The following permissions are requested by the above app.
Please review these and consent if you approve.

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append(" "); - builder.append(" "); - - for (String scope : scopesToAuthorize) { - builder.append("
"); - builder.append(" "); - builder.append(" "); - builder.append("
"); - } - - if (!scopesPreviouslyAuthorized.isEmpty()) { - builder.append("

You have already granted the following permissions to the above app:

"); - for (String scope : scopesPreviouslyAuthorized) { - builder.append("
"); - builder.append(" "); - builder.append(" "); - builder.append("
"); - } - } - - builder.append("
"); - builder.append(" "); - builder.append("
"); - builder.append("
"); - builder.append(" "); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append("

Your consent to provide access is required.
If you do not approve, click Cancel, in which case no information will be shared with the app.

"); - builder.append("
"); - builder.append("
"); - builder.append("
"); - builder.append(""); - builder.append(""); - - return builder.toString(); - } - } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java new file mode 100644 index 000000000..13207d16a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java @@ -0,0 +1,241 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2UserCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter; +import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationException; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationRequestAuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@code Filter} for the OAuth 2.0 Device Authorization Grant, + * which handles the processing of the OAuth 2.0 Device Authorization Request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationManager + * @see OAuth2DeviceAuthorizationRequestAuthenticationConverter + * @see OAuth2DeviceAuthorizationRequestAuthenticationProvider + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.1 Device Authorization Request + * @see Section 3.2 Device Authorization Response + */ +public final class OAuth2DeviceAuthorizationEndpointFilter extends OncePerRequestFilter { + + private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorize"; + + private static final String DEFAULT_DEVICE_VERIFICATION_URI = "/oauth2/device_verification"; + + private final AuthenticationManager authenticationManager; + private final RequestMatcher deviceAuthorizationEndpointMatcher; + private final HttpMessageConverter deviceAuthorizationHttpResponseConverter = + new OAuth2DeviceAuthorizationResponseHttpMessageConverter(); + private final HttpMessageConverter errorHttpResponseConverter = + new OAuth2ErrorHttpMessageConverter(); + private AuthenticationConverter authenticationConverter; + private AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendDeviceAuthorizationResponse; + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private String verificationUri = DEFAULT_DEVICE_VERIFICATION_URI; + + /** + * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + */ + public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager) { + this(authenticationManager, DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI); + } + + /** + * Constructs an {@code OAuth2DeviceAuthorizationEndpointFilter} using the provided parameters. + * + * @param authenticationManager the authentication manager + * @param deviceAuthorizationEndpointUri the endpoint {@code URI} for device authorization requests + */ + public OAuth2DeviceAuthorizationEndpointFilter(AuthenticationManager authenticationManager, String deviceAuthorizationEndpointUri) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + Assert.hasText(deviceAuthorizationEndpointUri, "deviceAuthorizationEndpointUri cannot be empty"); + this.authenticationManager = authenticationManager; + this.deviceAuthorizationEndpointMatcher = new AntPathRequestMatcher(deviceAuthorizationEndpointUri, + HttpMethod.POST.name()); + this.authenticationConverter = new OAuth2DeviceAuthorizationRequestAuthenticationConverter(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.deviceAuthorizationEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationConverter.convert(request); + deviceAuthorizationRequestAuthenticationToken.setDetails( + this.authenticationDetailsSource.buildDetails(request)); + + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationTokenResult = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationManager.authenticate( + deviceAuthorizationRequestAuthenticationToken); + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, + deviceAuthorizationRequestAuthenticationTokenResult); + } catch (OAuth2AuthenticationException ex) { + SecurityContextHolder.clearContext(); + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Device authorization request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract a Device Authorization Request from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating the request. + * + * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a DeviceAuthorization Request from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}. + * + * @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest} + */ + public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) { + Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null"); + this.authenticationDetailsSource = authenticationDetailsSource; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the Device Authorization Response. + * + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationException} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + /** + * Sets the end-user verification {@code URI} on the authorization server. + * + * @param verificationUri the end-user verification {@code URI} on the authorization server + * @see Section 3.2 Device Authorization Response + */ + public void setVerificationUri(String verificationUri) { + Assert.hasText(verificationUri, "verificationUri cannot be empty"); + this.verificationUri = verificationUri; + } + + private void sendDeviceAuthorizationResponse(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthenticationToken = + (OAuth2DeviceAuthorizationRequestAuthenticationToken) authentication; + + OAuth2DeviceCode deviceCode = deviceAuthorizationRequestAuthenticationToken.getDeviceCode(); + OAuth2UserCode userCode = deviceAuthorizationRequestAuthenticationToken.getUserCode(); + + // Generate the fully-qualified verification URI + String issuerUri = AuthorizationServerContextHolder.getContext().getIssuer(); + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromHttpUrl(issuerUri) + .path(this.verificationUri); + String verificationUri = uriComponentsBuilder.build().toUriString(); + // @formatter:off + String verificationUriComplete = uriComponentsBuilder + .queryParam(OAuth2ParameterNames.USER_CODE, userCode.getTokenValue()) + .build().toUriString(); + // @formatter:on + + // @formatter:off + OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = + OAuth2DeviceAuthorizationResponse.with(deviceCode, userCode) + .verificationUri(verificationUri) + .verificationUriComplete(verificationUriComplete) + .build(); + // @formatter:on + + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + this.deviceAuthorizationHttpResponseConverter.write(deviceAuthorizationResponse, null, httpResponse); + } + + private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + + OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError(); + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response); + httpResponse.setStatusCode(HttpStatus.BAD_REQUEST); + this.errorHttpResponseConverter.write(error, null, httpResponse); + } + +} + + diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java new file mode 100644 index 000000000..1c256bc41 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java @@ -0,0 +1,266 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.core.log.LogMessage; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceAuthorizationConsentAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceVerificationAuthenticationConverter; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.util.RedirectUrlBuilder; +import org.springframework.security.web.util.UrlUtils; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * A {@code Filter} for the OAuth 2.0 Device Authorization Grant, which handles + * the processing of the Verification {@code URI} (submission of the user code) + * and OAuth 2.0 Authorization Consent. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationManager + * @see OAuth2DeviceVerificationAuthenticationConverter + * @see OAuth2DeviceVerificationAuthenticationProvider + * @see OAuth2DeviceAuthorizationConsentAuthenticationConverter + * @see OAuth2DeviceAuthorizationConsentAuthenticationProvider + * @see OAuth 2.0 Device Authorization Grant + * @see Section 3.3 User Interaction + */ +public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequestFilter { + + private final AuthenticationManager authenticationManager; + private final RequestMatcher deviceVerificationEndpointMatcher; + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + private AuthenticationDetailsSource authenticationDetailsSource = + new WebAuthenticationDetailsSource(); + private AuthenticationConverter authenticationConverter; + private AuthenticationSuccessHandler authenticationSuccessHandler = + new SimpleUrlAuthenticationSuccessHandler("/?success"); + private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse; + private String consentPage; + + public OAuth2DeviceVerificationEndpointFilter(AuthenticationManager authenticationManager, String deviceVerificationEndpointUri) { + this.authenticationManager = authenticationManager; + this.deviceVerificationEndpointMatcher = createDefaultRequestMatcher(deviceVerificationEndpointUri); + this.authenticationConverter = new DelegatingAuthenticationConverter( + Arrays.asList( + new OAuth2DeviceVerificationAuthenticationConverter(), + new OAuth2DeviceAuthorizationConsentAuthenticationConverter())); + } + + private RequestMatcher createDefaultRequestMatcher(String deviceVerificationEndpointUri) { + RequestMatcher verificationRequestGetMatcher = new AntPathRequestMatcher( + deviceVerificationEndpointUri, HttpMethod.GET.name()); + RequestMatcher verificationRequestPostMatcher = new AntPathRequestMatcher( + deviceVerificationEndpointUri, HttpMethod.POST.name()); + RequestMatcher userCodeParameterMatcher = request -> + request.getParameter(OAuth2ParameterNames.USER_CODE) != null; + + return new AndRequestMatcher( + new OrRequestMatcher(verificationRequestGetMatcher, verificationRequestPostMatcher), + userCodeParameterMatcher); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + if (!this.deviceVerificationEndpointMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + + try { + Authentication authentication = this.authenticationConverter.convert(request); + if (authentication instanceof AbstractAuthenticationToken) { + ((AbstractAuthenticationToken) authentication) + .setDetails(this.authenticationDetailsSource.buildDetails(request)); + } + + Authentication authenticationResult = this.authenticationManager.authenticate(authentication); + if (!authenticationResult.isAuthenticated()) { + // If the Principal (Resource Owner) is not authenticated then + // pass through the chain with the expectation that the authentication process + // will commence via AuthenticationEntryPoint + filterChain.doFilter(request, response); + return; + } + + if (authenticationResult instanceof OAuth2DeviceAuthorizationConsentAuthenticationToken) { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Device authorization consent is required"); + } + sendAuthorizationConsent(request, response, authenticationResult); + return; + } + + this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, authenticationResult); + } catch (OAuth2AuthenticationException ex) { + if (this.logger.isTraceEnabled()) { + this.logger.trace(LogMessage.format("Device verification request failed: %s", ex.getError()), ex); + } + this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex); + } + } + + /** + * Sets the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest}. + * + * @param authenticationDetailsSource the {@link AuthenticationDetailsSource} used for building an authentication details instance from {@link HttpServletRequest} + */ + public void setAuthenticationDetailsSource(AuthenticationDetailsSource authenticationDetailsSource) { + Assert.notNull(authenticationDetailsSource, "authenticationDetailsSource cannot be null"); + this.authenticationDetailsSource = authenticationDetailsSource; + } + + /** + * Sets the {@link AuthenticationConverter} used when attempting to extract an Authorization Request (or Consent) from {@link HttpServletRequest} + * to an instance of {@link OAuth2DeviceVerificationAuthenticationToken} or {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} + * used for authenticating the request. + * + * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract an Authorization Request (or Consent) from {@link HttpServletRequest} + */ + public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + + /** + * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + * and returning the response. + * + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + */ + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + /** + * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + * and returning the {@link OAuth2Error Error Response}. + * + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2DeviceVerificationAuthenticationToken} + */ + public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + } + + /** + * Specify the URI to redirect Resource Owners to if consent is required. A default consent + * page will be generated when this attribute is not specified. + * + * @param consentPage the URI of the custom consent page to redirect to if consent is required (e.g. "/oauth2/consent") + */ + public void setConsentPage(String consentPage) { + this.consentPage = consentPage; + } + + private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2DeviceAuthorizationConsentAuthenticationToken authorizationConsentAuthentication = + (OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication; + + String clientId = authorizationConsentAuthentication.getClientId(); + Authentication principal = (Authentication) authorizationConsentAuthentication.getPrincipal(); + Set requestedScopes = authorizationConsentAuthentication.getRequestedScopes(); + Set authorizedScopes = authorizationConsentAuthentication.getScopes(); + String state = authorizationConsentAuthentication.getState(); + String userCode = authorizationConsentAuthentication.getUserCode(); + + if (hasConsentUri()) { + String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request)) + .queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes)) + .queryParam(OAuth2ParameterNames.CLIENT_ID, clientId) + .queryParam(OAuth2ParameterNames.STATE, state) + .queryParam(OAuth2ParameterNames.USER_CODE, userCode) + .toUriString(); + this.redirectStrategy.sendRedirect(request, response, redirectUri); + } else { + if (this.logger.isTraceEnabled()) { + this.logger.trace("Displaying generated consent screen"); + } + Map additionalParameters = new HashMap<>(); + additionalParameters.put(OAuth2ParameterNames.USER_CODE, userCode); + DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state, additionalParameters); + } + } + + private boolean hasConsentUri() { + return StringUtils.hasText(this.consentPage); + } + + private String resolveConsentUri(HttpServletRequest request) { + if (UrlUtils.isAbsoluteUrl(this.consentPage)) { + return this.consentPage; + } + RedirectUrlBuilder urlBuilder = new RedirectUrlBuilder(); + urlBuilder.setScheme(request.getScheme()); + urlBuilder.setServerName(request.getServerName()); + urlBuilder.setPort(request.getServerPort()); + urlBuilder.setContextPath(request.getContextPath()); + urlBuilder.setPathInfo(this.consentPage); + return urlBuilder.getUrl(); + } + + private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authenticationException) throws IOException { + + OAuth2Error error = ((OAuth2AuthenticationException) authenticationException).getError(); + response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString()); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java index 547977a23..91d67a90e 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -53,6 +53,7 @@ import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -136,7 +137,8 @@ public OAuth2TokenEndpointFilter(AuthenticationManager authenticationManager, St Arrays.asList( new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), - new OAuth2ClientCredentialsAuthenticationConverter())); + new OAuth2ClientCredentialsAuthenticationConverter(), + new OAuth2DeviceCodeAuthenticationConverter())); } @Override diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java new file mode 100644 index 000000000..b4c180b2a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java @@ -0,0 +1,115 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract an Authorization Consent from {@link HttpServletRequest} + * for the OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken} used for + * authenticating the request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceAuthorizationConsentAuthenticationToken + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceAuthorizationConsentAuthenticationConverter implements AuthenticationConverter { + + private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1"; + private static final String DEVICE_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3"; + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + @Override + public Authentication convert(HttpServletRequest request) { + if (!"POST".equals(request.getMethod())) { + return null; + } + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + String authorizationUri = request.getRequestURL().toString(); + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.USER_CODE, + DEVICE_ERROR_URI); + } + + // client_id (REQUIRED) + String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID); + if (!StringUtils.hasText(clientId) || parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) { + OAuth2EndpointUtils.throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, DEFAULT_ERROR_URI); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + // state (REQUIRED) + String state = parameters.getFirst(OAuth2ParameterNames.STATE); + if (!StringUtils.hasText(state) || parameters.get(OAuth2ParameterNames.STATE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.STATE, + DEFAULT_ERROR_URI); + } + + // scope (OPTIONAL) + Set scopes = null; + if (parameters.containsKey(OAuth2ParameterNames.SCOPE)) { + scopes = new HashSet<>(parameters.get(OAuth2ParameterNames.SCOPE)); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.STATE) && + !key.equals(OAuth2ParameterNames.SCOPE) && + !key.equals(OAuth2ParameterNames.USER_CODE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + return new OAuth2DeviceAuthorizationConsentAuthenticationToken(authorizationUri, clientId, principal, + OAuth2EndpointUtils.normalizeUserCode(userCode), state, scopes, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java new file mode 100644 index 000000000..ff795879f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java @@ -0,0 +1,82 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract a Device Authorization Request from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceAuthorizationRequestAuthenticationToken} used for authenticating + * the request. + * + * @author Steve Riesenberg + * @since 1.1 + */ +public final class OAuth2DeviceAuthorizationRequestAuthenticationConverter implements AuthenticationConverter { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.1"; + + @Override + public Authentication convert(HttpServletRequest request) { + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + String authorizationUri = request.getRequestURL().toString(); + + // scope (OPTIONAL) + String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); + if (StringUtils.hasText(scope) && + parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.SCOPE, + ERROR_URI); + } + Set requestedScopes = null; + if (StringUtils.hasText(scope)) { + requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.SCOPE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, authorizationUri, + requestedScopes, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java new file mode 100644 index 000000000..0652459ec --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java @@ -0,0 +1,81 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceAuthorizationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract an Access Token Request from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceCodeAuthenticationToken} used for authenticating the + * authorization grant. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceCodeAuthenticationToken + * @see OAuth2DeviceAuthorizationEndpointFilter + */ +public final class OAuth2DeviceCodeAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + // grant_type (REQUIRED) + String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE); + if (!AuthorizationGrantType.DEVICE_CODE.getValue().equals(grantType)) { + return null; + } + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + // device_code (REQUIRED) + String deviceCode = parameters.getFirst(OAuth2ParameterNames.DEVICE_CODE); + if (!StringUtils.hasText(deviceCode) || parameters.get(OAuth2ParameterNames.DEVICE_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.DEVICE_CODE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && + !key.equals(OAuth2ParameterNames.CLIENT_ID) && + !key.equals(OAuth2ParameterNames.DEVICE_CODE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + return new OAuth2DeviceCodeAuthenticationToken(deviceCode, clientPrincipal, additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java new file mode 100644 index 000000000..4d3bf3e24 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java @@ -0,0 +1,90 @@ +/* + * 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 org.springframework.security.oauth2.server.authorization.web.authentication; + +import java.util.HashMap; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2DeviceVerificationEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract a user code from {@link HttpServletRequest} for the + * OAuth 2.0 Device Authorization Grant and then converts it to an + * {@link OAuth2DeviceVerificationAuthenticationToken} used for authenticating + * the request. + * + * @author Steve Riesenberg + * @since 1.1 + * @see AuthenticationConverter + * @see OAuth2DeviceVerificationAuthenticationToken + * @see OAuth2DeviceVerificationEndpointFilter + */ +public final class OAuth2DeviceVerificationAuthenticationConverter implements AuthenticationConverter { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc8628#section-3.3"; + private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken( + "anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); + + @Override + public Authentication convert(HttpServletRequest request) { + if (!("GET".equals(request.getMethod()) || "POST".equals(request.getMethod()))) { + return null; + } + if (request.getParameter(OAuth2ParameterNames.STATE) != null + || request.getParameter(OAuth2ParameterNames.USER_CODE) == null) { + return null; + } + + MultiValueMap parameters = OAuth2EndpointUtils.getParameters(request); + + // user_code (REQUIRED) + String userCode = parameters.getFirst(OAuth2ParameterNames.USER_CODE); + if (!StringUtils.hasText(userCode) || parameters.get(OAuth2ParameterNames.USER_CODE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.USER_CODE, + ERROR_URI); + } + + Authentication principal = SecurityContextHolder.getContext().getAuthentication(); + if (principal == null) { + principal = ANONYMOUS_AUTHENTICATION; + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.USER_CODE)) { + additionalParameters.put(key, value.get(0)); + } + }); + + return new OAuth2DeviceVerificationAuthenticationToken(principal, + OAuth2EndpointUtils.normalizeUserCode(userCode), additionalParameters); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java index 92c488a9c..8d8ca979d 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -26,6 +26,7 @@ import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -81,4 +82,12 @@ static void throwError(String errorCode, String parameterName, String errorUri) throw new OAuth2AuthenticationException(error); } + static String normalizeUserCode(String userCode) { + Assert.hasText(userCode, "userCode cannot be empty"); + StringBuilder sb = new StringBuilder(userCode.toUpperCase().replaceAll("[^A-Z\\d]+", "")); + Assert.isTrue(sb.length() == 8, "userCode must be exactly 8 alpha/numeric characters"); + sb.insert(4, '-'); + return sb.toString(); + } + } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java index c84d35527..4fb563f24 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java @@ -24,13 +24,12 @@ import java.util.List; import java.util.function.Consumer; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -72,6 +71,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; @@ -90,6 +90,7 @@ import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; @@ -291,7 +292,8 @@ public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { converter == authenticationConverter || converter instanceof OAuth2AuthorizationCodeAuthenticationConverter || converter instanceof OAuth2RefreshTokenAuthenticationConverter || - converter instanceof OAuth2ClientCredentialsAuthenticationConverter); + converter instanceof OAuth2ClientCredentialsAuthenticationConverter || + converter instanceof OAuth2DeviceCodeAuthenticationConverter); verify(authenticationProvider).authenticate(eq(clientCredentialsAuthentication)); @@ -303,7 +305,8 @@ public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception { provider == authenticationProvider || provider instanceof OAuth2AuthorizationCodeAuthenticationProvider || provider instanceof OAuth2RefreshTokenAuthenticationProvider || - provider instanceof OAuth2ClientCredentialsAuthenticationProvider); + provider instanceof OAuth2ClientCredentialsAuthenticationProvider || + provider instanceof OAuth2DeviceCodeAuthenticationProvider); verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication)); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java index ccf3884fb..9eb902aa3 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java @@ -86,7 +86,7 @@ public void settingWhenCustomThenSet() { .settings(settings -> settings.put("name2", "value2")) .build(); - assertThat(authorizationServerSettings.getSettings()).hasSize(10); + assertThat(authorizationServerSettings.getSettings()).hasSize(12); assertThat(authorizationServerSettings.getSetting("name1")).isEqualTo("value1"); assertThat(authorizationServerSettings.getSetting("name2")).isEqualTo("value2"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java index 743d73342..230f3ca38 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * 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. @@ -34,8 +34,9 @@ public class TokenSettingsTests { @Test public void buildWhenDefaultThenDefaultsAreSet() { TokenSettings tokenSettings = TokenSettings.builder().build(); - assertThat(tokenSettings.getSettings()).hasSize(6); + assertThat(tokenSettings.getSettings()).hasSize(7); assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5)); + assertThat(tokenSettings.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(30)); assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(Duration.ofMinutes(5)); assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.SELF_CONTAINED); assertThat(tokenSettings.isReuseRefreshTokens()).isTrue(); @@ -163,7 +164,7 @@ public void settingWhenCustomThenSet() { .setting("name1", "value1") .settings(settings -> settings.put("name2", "value2")) .build(); - assertThat(tokenSettings.getSettings()).hasSize(8); + assertThat(tokenSettings.getSettings()).hasSize(9); assertThat(tokenSettings.getSetting("name1")).isEqualTo("value1"); assertThat(tokenSettings.getSetting("name2")).isEqualTo("value2"); } diff --git a/samples/custom-consent-authorizationserver/gradle.properties b/samples/custom-consent-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/custom-consent-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle index 6465a94c5..1b8b5373f 100644 --- a/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle +++ b/samples/custom-consent-authorizationserver/samples-custom-consent-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/default-authorizationserver/gradle.properties b/samples/default-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/default-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/default-authorizationserver/samples-default-authorizationserver.gradle b/samples/default-authorizationserver/samples-default-authorizationserver.gradle index 341e0c206..4a3e850ea 100644 --- a/samples/default-authorizationserver/samples-default-authorizationserver.gradle +++ b/samples/default-authorizationserver/samples-default-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/device-client/gradle.properties b/samples/device-client/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/device-client/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/device-client/samples-device-client.gradle b/samples/device-client/samples-device-client.gradle new file mode 100644 index 000000000..5b8971009 --- /dev/null +++ b/samples/device-client/samples-device-client.gradle @@ -0,0 +1,28 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group = project.rootProject.group +version = project.rootProject.version +sourceCompatibility = "17" + +repositories { + mavenCentral() + maven { url "https://repo.spring.io/snapshot" } +} + +dependencies { + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-thymeleaf" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + implementation "org.springframework:spring-webflux" + implementation "org.webjars:webjars-locator-core" + implementation "org.webjars:bootstrap:3.4.1" + implementation "org.webjars:jquery:3.4.1" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.security:spring-security-test" +} diff --git a/samples/device-client/src/main/java/sample/DeviceClientApplication.java b/samples/device-client/src/main/java/sample/DeviceClientApplication.java new file mode 100644 index 000000000..b045c6ef7 --- /dev/null +++ b/samples/device-client/src/main/java/sample/DeviceClientApplication.java @@ -0,0 +1,32 @@ +/* + * 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; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@SpringBootApplication +public class DeviceClientApplication { + + public static void main(String[] args) { + SpringApplication.run(DeviceClientApplication.class, args); + } + +} diff --git a/samples/device-client/src/main/java/sample/config/SecurityConfig.java b/samples/device-client/src/main/java/sample/config/SecurityConfig.java new file mode 100644 index 000000000..4f823847c --- /dev/null +++ b/samples/device-client/src/main/java/sample/config/SecurityConfig.java @@ -0,0 +1,56 @@ +/* + * 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.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**"); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/", "/authorize").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/")) + ) + .oauth2Client(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + +} diff --git a/samples/device-client/src/main/java/sample/config/WebClientConfig.java b/samples/device-client/src/main/java/sample/config/WebClientConfig.java new file mode 100644 index 000000000..e4b895d06 --- /dev/null +++ b/samples/device-client/src/main/java/sample/config/WebClientConfig.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.config; + +import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; +import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) { + ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = + new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager); + // @formatter:off + return WebClient.builder() + .apply(oauth2Client.oauth2Configuration()) + .build(); + // @formatter:on + } + + @Bean + public OAuth2AuthorizedClientManager authorizedClientManager( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientRepository authorizedClientRepository) { + + OAuth2AuthorizedClientProvider authorizedClientProvider = + OAuth2AuthorizedClientProviderBuilder.builder() + .provider(new DeviceCodeOAuth2AuthorizedClientProvider()) + .authorizationCode() + .refreshToken() + .build(); + DefaultOAuth2AuthorizedClientManager authorizedClientManager = + new DefaultOAuth2AuthorizedClientManager( + clientRegistrationRepository, authorizedClientRepository); + authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider); + // Set a contextAttributesMapper to obtain device_code from the request + authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider + .deviceCodeContextAttributesMapper()); + + return authorizedClientManager; + } + +} diff --git a/samples/device-client/src/main/java/sample/web/DeviceController.java b/samples/device-client/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..40ae92666 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/DeviceController.java @@ -0,0 +1,192 @@ +/* + * 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; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.OAuth2DeviceCode; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DeviceController { + + private static final ParameterizedTypeReference> TYPE_REFERENCE = + new ParameterizedTypeReference<>() {}; + + private final ClientRegistrationRepository clientRegistrationRepository; + + private final WebClient webClient; + + private final String messagesBaseUri; + + private final SecurityContextRepository securityContextRepository = + new HttpSessionSecurityContextRepository(); + + private final SecurityContextHolderStrategy securityContextHolderStrategy = + SecurityContextHolder.getContextHolderStrategy(); + + public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient, + @Value("${messages.base-uri}") String messagesBaseUri) { + + this.clientRegistrationRepository = clientRegistrationRepository; + this.webClient = webClient; + this.messagesBaseUri = messagesBaseUri; + } + + @GetMapping("/") + public String index() { + return "index"; + } + + @GetMapping("/authorize") + public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) { + // @formatter:off + ClientRegistration clientRegistration = + this.clientRegistrationRepository.findByRegistrationId( + "messaging-client-device-grant"); + // @formatter:on + + MultiValueMap requestParameters = new LinkedMultiValueMap<>(); + requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString( + clientRegistration.getScopes(), " ")); + + // @formatter:off + Map responseParameters = + this.webClient.post() + .uri(clientRegistration.getProviderDetails().getAuthorizationUri()) + .headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(), + clientRegistration.getClientSecret())) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(requestParameters)) + .retrieve() + .bodyToMono(TYPE_REFERENCE) + .block(); + // @formatter:on + + Objects.requireNonNull(responseParameters, "Device Authorization Response cannot be null"); + Instant issuedAt = Instant.now(); + Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN); + Instant expiresAt = issuedAt.plusSeconds(expiresIn); + String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE); + + OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt); + saveSecurityContext(deviceCode, request, response); + + model.addAttribute("deviceCode", deviceCode.getTokenValue()); + model.addAttribute("expiresAt", deviceCode.getExpiresAt()); + model.addAttribute("userCode", responseParameters.get(OAuth2ParameterNames.USER_CODE)); + model.addAttribute("verificationUri", responseParameters.get(OAuth2ParameterNames.VERIFICATION_URI)); + // Note: You could use a QR-code to display this URL + model.addAttribute("verificationUriComplete", responseParameters.get( + OAuth2ParameterNames.VERIFICATION_URI_COMPLETE)); + + return "authorize"; + } + + /** + * @see DeviceControllerAdvice + */ + @PostMapping("/authorize") + public ResponseEntity poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-grant") + OAuth2AuthorizedClient authorizedClient) { + + // The client will repeatedly poll until authorization is granted. + // + // The OAuth2AuthorizedClientManager uses the device_code parameter + // to make a token request, which returns authorization_pending until + // the user has granted authorization. + // + // If the user has denied authorization, access_denied is returned and + // polling should stop. + // + // If the device code expires, expired_token is returned and polling + // should stop. + // + // This endpoint simply returns 200 OK when client is authorized. + return ResponseEntity.status(HttpStatus.OK).build(); + } + + @GetMapping("/authorized") + public String authorized(Model model, + @RegisteredOAuth2AuthorizedClient("messaging-client-device-grant") + OAuth2AuthorizedClient authorizedClient) { + + String[] messages = this.webClient.get() + .uri(this.messagesBaseUri) + .attributes(oauth2AuthorizedClient(authorizedClient)) + .retrieve() + .bodyToMono(String[].class) + .block(); + model.addAttribute("messages", messages); + + return "authorized"; + } + + private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request, + HttpServletResponse response) { + + // @formatter:off + UsernamePasswordAuthenticationToken deviceAuthentication = + UsernamePasswordAuthenticationToken.authenticated( + deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE")); + // @formatter:on + + SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext(); + securityContext.setAuthentication(deviceAuthentication); + this.securityContextHolderStrategy.setContext(securityContext); + this.securityContextRepository.saveContext(securityContext, request, response); + } + +} diff --git a/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java b/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java new file mode 100644 index 000000000..06a61422b --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java @@ -0,0 +1,52 @@ +/* + * 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; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@ControllerAdvice +public class DeviceControllerAdvice { + + private static final Set DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList( + "authorization_pending", + "slow_down", + "access_denied", + "expired_token" + )); + + @ExceptionHandler(OAuth2AuthorizationException.class) + public ResponseEntity handleError(OAuth2AuthorizationException ex) { + String errorCode = ex.getError().getErrorCode(); + if (DEVICE_GRANT_ERRORS.contains(errorCode)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError()); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError()); + } + +} diff --git a/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java b/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java new file mode 100644 index 000000000..4e75c06c9 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java @@ -0,0 +1,122 @@ +/* + * 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 java.time.Clock; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.oauth2.client.ClientAuthorizationException; +import org.springframework.security.oauth2.client.OAuth2AuthorizationContext; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider { + + private OAuth2AccessTokenResponseClient accessTokenResponseClient = + new OAuth2DeviceAccessTokenResponseClient(); + + private Duration clockSkew = Duration.ofSeconds(60); + + private Clock clock = Clock.systemUTC(); + + public DeviceCodeOAuth2AuthorizedClientProvider() { + } + + public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient accessTokenResponseClient) { + this.accessTokenResponseClient = accessTokenResponseClient; + } + + public void setClockSkew(Duration clockSkew) { + this.clockSkew = clockSkew; + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) { + Assert.notNull(context, "context cannot be null"); + ClientRegistration clientRegistration = context.getClientRegistration(); + if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) { + return null; + } + OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient(); + if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) { + // If client is already authorized but access token is NOT expired than no + // need for re-authorization + return null; + } + if (authorizedClient != null && authorizedClient.getRefreshToken() != null) { + // If client is already authorized but access token is expired and a + // refresh token is available, delegate to refresh_token. + return null; + } + // ***************************************************************** + // Get device_code set via DefaultOAuth2AuthorizedClientManager#setContextAttributesMapper() + // ***************************************************************** + String deviceCode = context.getAttribute(OAuth2ParameterNames.DEVICE_CODE); + // Attempt to authorize the client, which will repeatedly fail until the user grants authorization + OAuth2DeviceGrantRequest deviceGrantRequest = new OAuth2DeviceGrantRequest(clientRegistration, deviceCode); + OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceGrantRequest); + return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(), + tokenResponse.getAccessToken(), tokenResponse.getRefreshToken()); + } + + private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration, + OAuth2DeviceGrantRequest deviceGrantRequest) { + try { + return this.accessTokenResponseClient.getTokenResponse(deviceGrantRequest); + } catch (OAuth2AuthorizationException ex) { + throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex); + } + } + + private boolean hasTokenExpired(OAuth2Token token) { + return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew)); + } + + public static Function> deviceCodeContextAttributesMapper() { + return (authorizeRequest) -> { + HttpServletRequest request = authorizeRequest.getAttribute(HttpServletRequest.class.getName()); + Assert.notNull(request, "request cannot be null"); + + // Obtain device code from request + String deviceCode = request.getParameter(OAuth2ParameterNames.DEVICE_CODE); + return (deviceCode != null) ? Collections.singletonMap(OAuth2ParameterNames.DEVICE_CODE, deviceCode) : + Collections.emptyMap(); + }; + } + +} 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 new file mode 100644 index 000000000..7e212c416 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java @@ -0,0 +1,85 @@ +/* + * 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 java.util.Arrays; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.RequestEntity; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AuthorizationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.springframework.web.client.RestTemplate; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class OAuth2DeviceAccessTokenResponseClient implements OAuth2AccessTokenResponseClient { + + private RestOperations restOperations; + + public OAuth2DeviceAccessTokenResponseClient() { + RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), + new OAuth2AccessTokenResponseHttpMessageConverter())); + restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); + this.restOperations = restTemplate; + } + + public void setRestOperations(RestOperations restOperations) { + this.restOperations = restOperations; + } + + @Override + public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest deviceGrantRequest) { + ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration(); + + HttpHeaders headers = new HttpHeaders(); + headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret()); + + MultiValueMap requestParameters = new LinkedMultiValueMap<>(); + requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue()); + requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId()); + requestParameters.add(OAuth2ParameterNames.DEVICE_CODE, deviceGrantRequest.getDeviceCode()); + + // @formatter:off + RequestEntity> requestEntity = + RequestEntity.post(deviceGrantRequest.getClientRegistration().getProviderDetails().getTokenUri()) + .headers(headers) + .body(requestParameters); + // @formatter:on + + try { + return this.restOperations.exchange(requestEntity, OAuth2AccessTokenResponse.class).getBody(); + } catch (RestClientException ex) { + OAuth2Error oauth2Error = new OAuth2Error("invalid_token_response", + "An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: " + + ex.getMessage(), null); + throw new OAuth2AuthorizationException(oauth2Error, ex); + } + } + +} diff --git a/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java new file mode 100644 index 000000000..7a4172cd9 --- /dev/null +++ b/samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java @@ -0,0 +1,41 @@ +/* + * 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 org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.util.Assert; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +public final class OAuth2DeviceGrantRequest extends AbstractOAuth2AuthorizationGrantRequest { + + private final String deviceCode; + + public OAuth2DeviceGrantRequest(ClientRegistration clientRegistration, String deviceCode) { + super(AuthorizationGrantType.DEVICE_CODE, clientRegistration); + Assert.hasText(deviceCode, "deviceCode cannot be empty"); + this.deviceCode = deviceCode; + } + + public String getDeviceCode() { + return deviceCode; + } + +} diff --git a/samples/device-client/src/main/resources/application.yml b/samples/device-client/src/main/resources/application.yml new file mode 100644 index 000000000..e3cfff535 --- /dev/null +++ b/samples/device-client/src/main/resources/application.yml @@ -0,0 +1,29 @@ +server: + port: 8080 + +logging: + level: + root: INFO + org.springframework.security: trace + +spring: + thymeleaf: + cache: false + security: + oauth2: + client: + registration: + messaging-client-device-grant: + provider: spring + client-id: messaging-client + client-secret: secret + authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code + scope: message.read,message.write + client-name: messaging-client-device-grant + provider: + spring: + issuer-uri: http://localhost:9000 + authorization-uri: ${spring.security.oauth2.client.provider.spring.issuer-uri}/oauth2/device_authorization + +messages: + base-uri: http://127.0.0.1:8090/messages diff --git a/samples/device-client/src/main/resources/static/assets/css/style.css b/samples/device-client/src/main/resources/static/assets/css/style.css new file mode 100644 index 000000000..d50ee00e9 --- /dev/null +++ b/samples/device-client/src/main/resources/static/assets/css/style.css @@ -0,0 +1,13 @@ +html, body, .container, .jumbotron { + height: 100%; +} +.jumbotron { + margin-bottom: 0; +} +.gap { + margin-top: 70px; +} +.code { + font-size: 2em; + letter-spacing: 2rem; +} \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/authorize.html b/samples/device-client/src/main/resources/templates/authorize.html new file mode 100644 index 000000000..01d497e6d --- /dev/null +++ b/samples/device-client/src/main/resources/templates/authorize.html @@ -0,0 +1,87 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Device Activation

+

Please visit on another device to continue.

+

Activation Code

+
+ +
+ +
+
+
+
+ Devices +
+
+
+
+ + + + \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/authorized.html b/samples/device-client/src/main/resources/templates/authorized.html new file mode 100644 index 000000000..dd3cca3f1 --- /dev/null +++ b/samples/device-client/src/main/resources/templates/authorized.html @@ -0,0 +1,35 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Success!

+

This device has been activated.

+
+
+ Devices +
+
+

Messages:

+ + + + + + +
message
+
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-client/src/main/resources/templates/index.html b/samples/device-client/src/main/resources/templates/index.html new file mode 100644 index 000000000..b91baa396 --- /dev/null +++ b/samples/device-client/src/main/resources/templates/index.html @@ -0,0 +1,26 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Activation Required

+

You must activate this device. Please log in to continue.

+ Log In +
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/gradle.properties b/samples/device-grant-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/device-grant-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle new file mode 100644 index 000000000..8530b31c4 --- /dev/null +++ b/samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle @@ -0,0 +1,31 @@ +plugins { + id "org.springframework.boot" version "3.0.0" + id "io.spring.dependency-management" version "1.0.11.RELEASE" + id "java" +} + +group = project.rootProject.group +version = project.rootProject.version +sourceCompatibility = "17" + +repositories { + mavenCentral() + maven { url = "https://repo.spring.io/snapshot" } +} + +dependencies { + implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-jdbc" + implementation project(":spring-security-oauth2-authorization-server") + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + implementation "org.springframework.boot:spring-boot-starter-thymeleaf" + implementation "org.springframework:spring-webflux" + implementation "org.webjars:webjars-locator-core" + implementation "org.webjars:bootstrap:3.4.1" + implementation "org.webjars:jquery:3.4.1" + runtimeOnly "com.h2database:h2" + + testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation "org.springframework.security:spring-security-test" +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java b/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java new file mode 100644 index 000000000..88df73211 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java @@ -0,0 +1,32 @@ +/* + * 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; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@SpringBootApplication +public class DeviceGrantAuthorizationServerApplication { + + public static void main(String[] args) { + SpringApplication.run(DeviceGrantAuthorizationServerApplication.class, args); + } + +} 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 new file mode 100644 index 000000000..ab66f4b87 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java @@ -0,0 +1,170 @@ +/* + * 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.config; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; +import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) + .deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint + .verificationUri("/activate") + ) + .oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + + // @formatter:off + http + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint( + new LoginUrlAuthenticationEntryPoint("/login")) + ) + .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt); + // @formatter:on + + return http.build(); + } + + @Bean + @Order(2) + public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + + return http.build(); + } + + @Bean + public UserDetailsService userDetailsService() { + // @formatter:off + UserDetails userDetails = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + // @formatter:on + + return new InMemoryUserDetailsManager(userDetails); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("messaging-client") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .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) + .scope(OidcScopes.PROFILE) + .scope("message.read") + .scope("message.write") + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()) + .build(); + + return new InMemoryRegisteredClientRepository(registeredClient); + } + + @Bean + public JWKSource jwkSource() { + KeyPair keyPair = generateRsaKey(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + RSAKey rsaKey = new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + JWKSet jwkSet = new JWKSet(rsaKey); + return new ImmutableJWKSet<>(jwkSet); + } + + private static KeyPair generateRsaKey() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return keyPair; + } + + @Bean + public JwtDecoder jwtDecoder(JWKSource jwkSource) { + return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder().build(); + } + +} \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java new file mode 100644 index 000000000..3b40e2b2c --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2002-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; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * @author Steve Riesenberg + */ +@Controller +public class DeviceController { + + @GetMapping("/activate") + public String activate(@RequestParam(value = "user_code", required = false) String userCode) { + if (userCode != null) { + return "redirect:/oauth2/device_verification?user_code=" + userCode; + } + return "activate"; + } + + @GetMapping("/activated") + public String activated() { + return "activated"; + } + + @GetMapping(value = "/", params = "success") + public String success() { + return "activated"; + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java new file mode 100644 index 000000000..7ad9750ee --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java @@ -0,0 +1,48 @@ +/* + * 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; + +import java.util.Map; + +import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.servlet.ModelAndView; + +/** + * @author Steve Riesenberg + * @since 1.1 + */ +@Controller +public class DeviceErrorController implements ErrorController { + + @RequestMapping("/error") + public ModelAndView handleError(HttpServletRequest request) { + String message = getErrorMessage(request); + if (message.startsWith("[access_denied]")) { + return new ModelAndView("access-denied"); + } + return new ModelAndView("error", Map.of("message", message)); + } + + private String getErrorMessage(HttpServletRequest request) { + return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE); + } + +} diff --git a/samples/device-grant-authorizationserver/src/main/resources/application.yml b/samples/device-grant-authorizationserver/src/main/resources/application.yml new file mode 100644 index 000000000..8865684c7 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/application.yml @@ -0,0 +1,6 @@ +server: + port: 9000 + +logging: + level: + org.springframework.security: trace diff --git a/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css b/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css new file mode 100644 index 000000000..d50ee00e9 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css @@ -0,0 +1,13 @@ +html, body, .container, .jumbotron { + height: 100%; +} +.jumbotron { + margin-bottom: 0; +} +.gap { + margin-top: 70px; +} +.code { + font-size: 2em; + letter-spacing: 2rem; +} \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html b/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html new file mode 100644 index 000000000..e69a32c8b --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Access Denied

+

You have denied access. Please return to your device to continue.

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html new file mode 100644 index 000000000..fa5d76cb7 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/activate.html @@ -0,0 +1,33 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+
+

Device Activation

+

Enter the activation code to authorize the device.

+

Activation Code

+
+ + +
+ +
+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html b/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html new file mode 100644 index 000000000..02598da2f --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/activated.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Success!

+

You have successfully activated your device. Please return to your device to continue.

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/device-grant-authorizationserver/src/main/resources/templates/error.html b/samples/device-grant-authorizationserver/src/main/resources/templates/error.html new file mode 100644 index 000000000..110886461 --- /dev/null +++ b/samples/device-grant-authorizationserver/src/main/resources/templates/error.html @@ -0,0 +1,25 @@ + + + + + + Device Grant Example + + + + +
+
+
+
+

Error

+

+
+
+ Devices +
+
+
+
+ + \ No newline at end of file diff --git a/samples/federated-identity-authorizationserver/gradle.properties b/samples/federated-identity-authorizationserver/gradle.properties new file mode 100644 index 000000000..658d3b529 --- /dev/null +++ b/samples/federated-identity-authorizationserver/gradle.properties @@ -0,0 +1 @@ +spring-security.version=6.1.0-SNAPSHOT diff --git a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle index ab8c75838..5c503b27f 100644 --- a/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle +++ b/samples/federated-identity-authorizationserver/samples-federated-identity-authorizationserver.gradle @@ -10,7 +10,7 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } + maven { url "https://repo.spring.io/snapshot" } } dependencies { diff --git a/samples/messages-client/samples-messages-client.gradle b/samples/messages-client/samples-messages-client.gradle index 024b1b8b3..d3ce2c845 100644 --- a/samples/messages-client/samples-messages-client.gradle +++ b/samples/messages-client/samples-messages-client.gradle @@ -10,7 +10,6 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } } dependencies { @@ -20,7 +19,6 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework:spring-webflux" implementation "io.projectreactor.netty:reactor-netty" - implementation "org.thymeleaf.extras:thymeleaf-extras-springsecurity6" implementation "org.webjars:webjars-locator-core" implementation "org.webjars:bootstrap:3.4.1" implementation "org.webjars:jquery:3.4.1" diff --git a/samples/messages-resource/samples-messages-resource.gradle b/samples/messages-resource/samples-messages-resource.gradle index fbfcaba89..02dc36f1e 100644 --- a/samples/messages-resource/samples-messages-resource.gradle +++ b/samples/messages-resource/samples-messages-resource.gradle @@ -10,7 +10,6 @@ sourceCompatibility = "17" repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } } dependencies {