Browse Source

Add support for OAuth 2.0 Device Authorization Grant

Closes gh-44
Steve Riesenberg 2 years ago
parent
commit
291ba8c92d
57 changed files with 4492 additions and 124 deletions
  1. 18 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java
  2. 13 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java
  3. 5 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java
  4. 267 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProvider.java
  5. 103 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationToken.java
  6. 274 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProvider.java
  7. 154 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationToken.java
  8. 259 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProvider.java
  9. 59 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationToken.java
  10. 203 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProvider.java
  11. 117 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationToken.java
  12. 26 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java
  13. 4 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientAuthenticationConfigurer.java
  14. 229 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceAuthorizationEndpointConfigurer.java
  15. 283 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceVerificationEndpointConfigurer.java
  16. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java
  17. 40 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java
  18. 16 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java
  19. 25 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettings.java
  20. 155 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/DefaultConsentPage.java
  21. 2 106
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java
  22. 241 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilter.java
  23. 266 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilter.java
  24. 4 2
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java
  25. 115 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverter.java
  26. 82 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverter.java
  27. 81 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverter.java
  28. 90 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverter.java
  29. 10 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2EndpointUtils.java
  30. 9 6
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java
  31. 1 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java
  32. 4 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java
  33. 27 0
      samples/device-client/samples-device-client.gradle
  34. 32 0
      samples/device-client/src/main/java/sample/DeviceClientApplication.java
  35. 56 0
      samples/device-client/src/main/java/sample/config/SecurityConfig.java
  36. 71 0
      samples/device-client/src/main/java/sample/config/WebClientConfig.java
  37. 192 0
      samples/device-client/src/main/java/sample/web/DeviceController.java
  38. 52 0
      samples/device-client/src/main/java/sample/web/DeviceControllerAdvice.java
  39. 122 0
      samples/device-client/src/main/java/sample/web/authentication/DeviceCodeOAuth2AuthorizedClientProvider.java
  40. 85 0
      samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceAccessTokenResponseClient.java
  41. 41 0
      samples/device-client/src/main/java/sample/web/authentication/OAuth2DeviceGrantRequest.java
  42. 29 0
      samples/device-client/src/main/resources/application.yml
  43. 13 0
      samples/device-client/src/main/resources/static/assets/css/style.css
  44. 87 0
      samples/device-client/src/main/resources/templates/authorize.html
  45. 35 0
      samples/device-client/src/main/resources/templates/authorized.html
  46. 26 0
      samples/device-client/src/main/resources/templates/index.html
  47. 37 0
      samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle
  48. 32 0
      samples/device-grant-authorizationserver/src/main/java/sample/DeviceGrantAuthorizationServerApplication.java
  49. 170 0
      samples/device-grant-authorizationserver/src/main/java/sample/config/SecurityConfig.java
  50. 47 0
      samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceController.java
  51. 48 0
      samples/device-grant-authorizationserver/src/main/java/sample/web/DeviceErrorController.java
  52. 6 0
      samples/device-grant-authorizationserver/src/main/resources/application.yml
  53. 13 0
      samples/device-grant-authorizationserver/src/main/resources/static/assets/css/style.css
  54. 25 0
      samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html
  55. 33 0
      samples/device-grant-authorizationserver/src/main/resources/templates/activate.html
  56. 25 0
      samples/device-grant-authorizationserver/src/main/resources/templates/activated.html
  57. 25 0
      samples/device-grant-authorizationserver/src/main/resources/templates/error.html

+ 18 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java

@@ -24,7 +24,9 @@ import java.util.concurrent.ConcurrentHashMap;
 
 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 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
 			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 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
 		return idToken != null && idToken.getToken().getTokenValue().equals(token);
 	}
 
+	private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
+		OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode =
+				authorization.getToken(OAuth2DeviceCode.class);
+		return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
+	}
+
+	private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
+		OAuth2Authorization.Token<OAuth2UserCode> userCode =
+				authorization.getToken(OAuth2UserCode.class);
+		return userCode != null && userCode.getToken().getTokenValue().equals(token);
+	}
+
 	private static final class MaxSizeHashMap<K, V> extends LinkedHashMap<K, V> {
 		private final int maxSize;
 

+ 13 - 1
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 class OAuth2Authorization 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.
 		 */

+ 5 - 1
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 @@ public final class OAuth2AuthorizationConsentAuthenticationContext implements OA
 			super(authentication);
 		}
 
+		private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) {
+			super(authentication);
+		}
+
 		/**
 		 * Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}.
 		 *

+ 267 - 0
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<OAuth2AuthorizationConsentAuthenticationContext> 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<String> requestedScopes = authorizationRequest.getScopes();
+		Set<String> 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<String> 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<GrantedAuthority> authorities = new HashSet<>();
+		authorizationConsentBuilder.authorities(authorities::addAll);
+
+		OAuth2Authorization.Token<OAuth2DeviceCode> deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class);
+		OAuth2Authorization.Token<OAuth2UserCode> 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.
+	 *
+	 * <p>
+	 * The following context attributes are available:
+	 * <ul>
+	 * <li>The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent
+	 * prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.</li>
+	 * <li>The {@link Authentication} of type
+	 * {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.</li>
+	 * <li>The {@link RegisteredClient} associated with the authorization request.</li>
+	 * <li>The {@link OAuth2Authorization} associated with the state token presented in the
+	 * authorization consent request.</li>
+	 * <li>The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.</li>
+	 * </ul>
+	 *
+	 * @param authorizationConsentCustomizer the {@code Consumer} providing access to the
+	 * {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder}
+	 */
+	public void setAuthorizationConsentCustomizer(Consumer<OAuth2AuthorizationConsentAuthenticationContext> 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);
+	}
+
+}

+ 103 - 0
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<String> 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<String> authorizedScopes,
+			@Nullable Map<String, Object> 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<String> requestedScopes,
+			@Nullable Set<String> 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<String> getRequestedScopes() {
+		return this.requestedScopes;
+	}
+
+}

+ 274 - 0
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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.1">Section 3.1 Device Authorization Request</a>
+ */
+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<OAuth2DeviceCode> deviceCodeGenerator = new OAuth2DeviceCodeGenerator();
+	private OAuth2TokenGenerator<OAuth2UserCode> 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<String> 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<OAuth2DeviceCode> 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<OAuth2UserCode> 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<OAuth2DeviceCode> {
+
+		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<OAuth2UserCode> {
+
+		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);
+		}
+
+	}
+
+}

+ 154 - 0
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<String> scopes;
+	private final OAuth2DeviceCode deviceCode;
+	private final OAuth2UserCode userCode;
+	private final Map<String, Object> 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<String> scopes, @Nullable Map<String, Object> 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<String> 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<String> 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<String, Object> getAdditionalParameters() {
+		return this.additionalParameters;
+	}
+
+}

+ 259 - 0
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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access Token Request</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.5">Section 3.5 Device Access Token Response</a>
+ */
+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<? extends OAuth2Token> tokenGenerator;
+
+	/**
+	 * Constructs an {@code OAuth2DeviceCodeAuthenticationProvider} using the provided parameters.
+	 *
+	 * @param authorizationService the authorization service
+	 * @param tokenGenerator the token generator
+	 */
+	public OAuth2DeviceCodeAuthenticationProvider(
+			OAuth2AuthorizationService authorizationService,
+			OAuth2TokenGenerator<? extends OAuth2Token> 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<OAuth2DeviceCode> 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);
+	}
+
+}

+ 59 - 0
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<String, Object> 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;
+	}
+
+}

+ 203 - 0
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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3">Section 3.3 User Interaction</a>
+ */
+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<String> 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<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
+		OAuth2Authorization.Token<OAuth2UserCode> 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();
+	}
+
+}

+ 117 - 0
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<String, Object> 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<String, Object> 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<String, Object> getAdditionalParameters() {
+		return this.additionalParameters;
+	}
+
+}

+ 26 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java

@@ -210,6 +210,30 @@ public final class OAuth2AuthorizationServerConfigurer
 		return this;
 	}
 
+	/**
+	 * Configures the OAuth 2.0 Device Authorization Endpoint.
+	 *
+	 * @param deviceAuthorizationEndpointCustomizer the {@link Customizer} providing access to the {@link OAuth2DeviceAuthorizationEndpointConfigurer}
+	 * @return the {@link OAuth2AuthorizationServerConfigurer} for further configuration
+	 * @since 1.1
+	 */
+	public OAuth2AuthorizationServerConfigurer deviceAuthorizationEndpoint(Customizer<OAuth2DeviceAuthorizationEndpointConfigurer> 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<OAuth2DeviceVerificationEndpointConfigurer> deviceVerificationEndpointCustomizer) {
+		deviceVerificationEndpointCustomizer.customize(getConfigurer(OAuth2DeviceVerificationEndpointConfigurer.class));
+		return this;
+	}
+
 	/**
 	 * Configures OpenID Connect 1.0 support (disabled by default).
 	 *
@@ -326,6 +350,8 @@ public final class OAuth2AuthorizationServerConfigurer
 		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;
 	}
 

+ 4 - 1
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 @@ public final class OAuth2ClientAuthenticationConfigurer extends AbstractOAuth2Co
 						HttpMethod.POST.name()),
 				new AntPathRequestMatcher(
 						authorizationServerSettings.getTokenRevocationEndpoint(),
+						HttpMethod.POST.name()),
+				new AntPathRequestMatcher(
+						authorizationServerSettings.getDeviceAuthorizationEndpoint(),
 						HttpMethod.POST.name()));
 
 		List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(httpSecurity);

+ 229 - 0
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<AuthenticationConverter> authenticationConverters = new ArrayList<>();
+	private Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer = (authenticationConverters) -> {};
+	private final List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
+	private Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer = (authenticationProviders) -> {};
+	private AuthenticationSuccessHandler deviceAuthorizationResponseHandler;
+	private AuthenticationFailureHandler errorResponseHandler;
+	private String verificationUri;
+
+	/**
+	 * Restrict for internal use only.
+	 */
+	OAuth2DeviceAuthorizationEndpointConfigurer(ObjectPostProcessor<Object> 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<List<AuthenticationConverter>> 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<List<AuthenticationProvider>> 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<AuthenticationProvider> 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<AuthenticationConverter> 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<AuthenticationConverter> createDefaultAuthenticationConverters() {
+		List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
+		authenticationConverters.add(new OAuth2DeviceAuthorizationRequestAuthenticationConverter());
+
+		return authenticationConverters;
+	}
+
+	private List<AuthenticationProvider> createDefaultAuthenticationProviders(HttpSecurity builder) {
+		List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
+
+		OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(builder);
+
+		OAuth2DeviceAuthorizationRequestAuthenticationProvider deviceAuthorizationRequestAuthenticationProvider =
+				new OAuth2DeviceAuthorizationRequestAuthenticationProvider(authorizationService);
+		authenticationProviders.add(deviceAuthorizationRequestAuthenticationProvider);
+
+		return authenticationProviders;
+	}
+
+}

+ 283 - 0
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<AuthenticationConverter> authenticationConverters = new ArrayList<>();
+	private Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer = (authenticationConverters) -> {};
+	private final List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
+	private Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer = (authenticationProviders) -> {};
+	private AuthenticationSuccessHandler deviceVerificationResponseHandler;
+	private AuthenticationFailureHandler errorResponseHandler;
+	private String consentPage;
+
+	/**
+	 * Restrict for internal use only.
+	 */
+	OAuth2DeviceVerificationEndpointConfigurer(ObjectPostProcessor<Object> 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<List<AuthenticationConverter>> 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<List<AuthenticationProvider>> 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:
+	 *
+	 * <ul>
+	 * <li>{@code client_id} - the client identifier</li>
+	 * <li>{@code scope} - a space-delimited list of scopes present in the authorization request</li>
+	 * <li>{@code state} - a CSRF protection token</li>
+	 * <li>@code code} - the user code</li>
+	 * </ul>
+	 *
+	 * In general, the consent page should create a form that submits
+	 * a request with the following requirements:
+	 *
+	 * <ul>
+	 * <li>It must be an HTTP POST</li>
+	 * <li>It must be submitted to {@link AuthorizationServerSettings#getDeviceVerificationEndpoint()}</li>
+	 * <li>It must include the received {@code client_id} as an HTTP parameter</li>
+	 * <li>It must include the received {@code state} as an HTTP parameter</li>
+	 * <li>It must include the list of {@code scope}s the {@code Resource Owner}
+	 * consented to as an HTTP parameter</li>
+	 * <li>It must include the user {@code code} as an HTTP parameter</li>
+	 * </ul>
+	 *
+	 * @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<AuthenticationProvider> 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<AuthenticationConverter> 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<AuthenticationConverter> createDefaultAuthenticationConverters() {
+		List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
+		authenticationConverters.add(new OAuth2DeviceVerificationAuthenticationConverter());
+		authenticationConverters.add(new OAuth2DeviceAuthorizationConsentAuthenticationConverter());
+
+		return authenticationConverters;
+	}
+
+	private static List<AuthenticationProvider> createDefaultAuthenticationProviders(HttpSecurity builder) {
+		RegisteredClientRepository registeredClientRepository =
+				OAuth2ConfigurerUtils.getRegisteredClientRepository(builder);
+		OAuth2AuthorizationService authorizationService =
+				OAuth2ConfigurerUtils.getAuthorizationService(builder);
+		OAuth2AuthorizationConsentService authorizationConsentService =
+				OAuth2ConfigurerUtils.getAuthorizationConsentService(builder);
+
+		List<AuthenticationProvider> 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;
+	}
+
+}

+ 8 - 1
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.O
 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.OAuth2TokenE
 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 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure
 		authenticationConverters.add(new OAuth2AuthorizationCodeAuthenticationConverter());
 		authenticationConverters.add(new OAuth2RefreshTokenAuthenticationConverter());
 		authenticationConverters.add(new OAuth2ClientCredentialsAuthenticationConverter());
+		authenticationConverters.add(new OAuth2DeviceCodeAuthenticationConverter());
 
 		return authenticationConverters;
 	}
@@ -232,6 +235,10 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure
 				new OAuth2ClientCredentialsAuthenticationProvider(authorizationService, tokenGenerator);
 		authenticationProviders.add(clientCredentialsAuthenticationProvider);
 
+		OAuth2DeviceCodeAuthenticationProvider deviceCodeAuthenticationProvider =
+				new OAuth2DeviceCodeAuthenticationProvider(authorizationService, tokenGenerator);
+		authenticationProviders.add(deviceCodeAuthenticationProvider);
+
 		return authenticationProviders;
 	}
 

+ 40 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java

@@ -52,6 +52,24 @@ public final class AuthorizationServerSettings extends AbstractSettings {
 		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 final class AuthorizationServerSettings extends AbstractSettings {
 	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 final class AuthorizationServerSettings extends AbstractSettings {
 			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.
 		 *

+ 16 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java

@@ -86,6 +86,16 @@ public final class ConfigurationSettingNames {
 		 */
 		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 final class ConfigurationSettingNames {
 		 */
 		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.

+ 25 - 1
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 final class TokenSettings extends AbstractSettings {
 		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 final class TokenSettings extends AbstractSettings {
 				.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 final class TokenSettings extends AbstractSettings {
 			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.

+ 155 - 0
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<String> requestedScopes, Set<String> authorizedScopes, String state,
+			Map<String, String> 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<String> requestedScopes, Set<String> authorizedScopes, String state,
+			Map<String, String> additionalParameters) {
+		Set<String> scopesToAuthorize = new HashSet<>();
+		Set<String> 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("<!DOCTYPE html>");
+		builder.append("<html lang=\"en\">");
+		builder.append("<head>");
+		builder.append("    <meta charset=\"utf-8\">");
+		builder.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">");
+		builder.append("    <link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\" integrity=\"sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z\" crossorigin=\"anonymous\">");
+		builder.append("    <title>Consent required</title>");
+		builder.append("	<script>");
+		builder.append("		function cancelConsent() {");
+		builder.append("			document.consent_form.reset();");
+		builder.append("			document.consent_form.submit();");
+		builder.append("		}");
+		builder.append("	</script>");
+		builder.append("</head>");
+		builder.append("<body>");
+		builder.append("<div class=\"container\">");
+		builder.append("    <div class=\"py-5\">");
+		builder.append("        <h1 class=\"text-center\">Consent required</h1>");
+		builder.append("    </div>");
+		builder.append("    <div class=\"row\">");
+		builder.append("        <div class=\"col text-center\">");
+		builder.append("            <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
+		builder.append("        </div>");
+		builder.append("    </div>");
+		if (userCode != null) {
+			builder.append("    <div class=\"row\">");
+			builder.append("        <div class=\"col text-center\">");
+			builder.append("            <p class=\"alert alert-warning\">You have provided the code <span class=\"font-weight-bold\">" + userCode + "</span>. Verify that this code matches what is shown on your device.</p>");
+			builder.append("        </div>");
+			builder.append("    </div>");
+		}
+		builder.append("    <div class=\"row pb-3\">");
+		builder.append("        <div class=\"col text-center\">");
+		builder.append("            <p>The following permissions are requested by the above app.<br/>Please review these and consent if you approve.</p>");
+		builder.append("        </div>");
+		builder.append("    </div>");
+		builder.append("    <div class=\"row\">");
+		builder.append("        <div class=\"col text-center\">");
+		builder.append("            <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
+		builder.append("                <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
+		builder.append("                <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
+		if (userCode != null) {
+			builder.append("                <input type=\"hidden\" name=\"user_code\" value=\"" + userCode + "\">");
+		}
+
+		for (String scope : scopesToAuthorize) {
+			builder.append("                <div class=\"form-group form-check py-1\">");
+			builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
+			builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
+			builder.append("                </div>");
+		}
+
+		if (!scopesPreviouslyAuthorized.isEmpty()) {
+			builder.append("                <p>You have already granted the following permissions to the above app:</p>");
+			for (String scope : scopesPreviouslyAuthorized) {
+				builder.append("                <div class=\"form-group form-check py-1\">");
+				builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
+				builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
+				builder.append("                </div>");
+			}
+		}
+
+		builder.append("                <div class=\"form-group pt-3\">");
+		builder.append("                    <button class=\"btn btn-primary btn-lg\" type=\"submit\" id=\"submit-consent\">Submit Consent</button>");
+		builder.append("                </div>");
+		builder.append("                <div class=\"form-group\">");
+		builder.append("                    <button class=\"btn btn-link regular\" type=\"button\" onclick=\"cancelConsent();\" id=\"cancel-consent\">Cancel</button>");
+		builder.append("                </div>");
+		builder.append("            </form>");
+		builder.append("        </div>");
+		builder.append("    </div>");
+		builder.append("    <div class=\"row pt-4\">");
+		builder.append("        <div class=\"col text-center\">");
+		builder.append("            <p><small>Your consent to provide access is required.<br/>If you do not approve, click Cancel, in which case no information will be shared with the app.</small></p>");
+		builder.append("        </div>");
+		builder.append("    </div>");
+		builder.append("</div>");
+		builder.append("</body>");
+		builder.append("</html>");
+
+		return builder.toString();
+	}
+}

+ 2 - 106
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java

@@ -18,7 +18,7 @@ package org.springframework.security.oauth2.server.authorization.web;
 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 jakarta.servlet.http.HttpServletResponse;
 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 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
 			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 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
 		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<String> requestedScopes, Set<String> 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<String> requestedScopes, Set<String> authorizedScopes, String state) {
-			Set<String> scopesToAuthorize = new HashSet<>();
-			Set<String> 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("<!DOCTYPE html>");
-			builder.append("<html lang=\"en\">");
-			builder.append("<head>");
-			builder.append("    <meta charset=\"utf-8\">");
-			builder.append("    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1, shrink-to-fit=no\">");
-			builder.append("    <link rel=\"stylesheet\" href=\"https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css\" integrity=\"sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z\" crossorigin=\"anonymous\">");
-			builder.append("    <title>Consent required</title>");
-			builder.append("	<script>");
-			builder.append("		function cancelConsent() {");
-			builder.append("			document.consent_form.reset();");
-			builder.append("			document.consent_form.submit();");
-			builder.append("		}");
-			builder.append("	</script>");
-			builder.append("</head>");
-			builder.append("<body>");
-			builder.append("<div class=\"container\">");
-			builder.append("    <div class=\"py-5\">");
-			builder.append("        <h1 class=\"text-center\">Consent required</h1>");
-			builder.append("    </div>");
-			builder.append("    <div class=\"row\">");
-			builder.append("        <div class=\"col text-center\">");
-			builder.append("            <p><span class=\"font-weight-bold text-primary\">" + clientId + "</span> wants to access your account <span class=\"font-weight-bold\">" + principal.getName() + "</span></p>");
-			builder.append("        </div>");
-			builder.append("    </div>");
-			builder.append("    <div class=\"row pb-3\">");
-			builder.append("        <div class=\"col text-center\">");
-			builder.append("            <p>The following permissions are requested by the above app.<br/>Please review these and consent if you approve.</p>");
-			builder.append("        </div>");
-			builder.append("    </div>");
-			builder.append("    <div class=\"row\">");
-			builder.append("        <div class=\"col text-center\">");
-			builder.append("            <form name=\"consent_form\" method=\"post\" action=\"" + request.getRequestURI() + "\">");
-			builder.append("                <input type=\"hidden\" name=\"client_id\" value=\"" + clientId + "\">");
-			builder.append("                <input type=\"hidden\" name=\"state\" value=\"" + state + "\">");
-
-			for (String scope : scopesToAuthorize) {
-				builder.append("                <div class=\"form-group form-check py-1\">");
-				builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"" + scope + "\" id=\"" + scope + "\">");
-				builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
-				builder.append("                </div>");
-			}
-
-			if (!scopesPreviouslyAuthorized.isEmpty()) {
-				builder.append("                <p>You have already granted the following permissions to the above app:</p>");
-				for (String scope : scopesPreviouslyAuthorized) {
-					builder.append("                <div class=\"form-group form-check py-1\">");
-					builder.append("                    <input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"" + scope + "\" checked disabled>");
-					builder.append("                    <label class=\"form-check-label\" for=\"" + scope + "\">" + scope + "</label>");
-					builder.append("                </div>");
-				}
-			}
-
-			builder.append("                <div class=\"form-group pt-3\">");
-			builder.append("                    <button class=\"btn btn-primary btn-lg\" type=\"submit\" id=\"submit-consent\">Submit Consent</button>");
-			builder.append("                </div>");
-			builder.append("                <div class=\"form-group\">");
-			builder.append("                    <button class=\"btn btn-link regular\" type=\"button\" onclick=\"cancelConsent();\" id=\"cancel-consent\">Cancel</button>");
-			builder.append("                </div>");
-			builder.append("            </form>");
-			builder.append("        </div>");
-			builder.append("    </div>");
-			builder.append("    <div class=\"row pt-4\">");
-			builder.append("        <div class=\"col text-center\">");
-			builder.append("            <p><small>Your consent to provide access is required.<br/>If you do not approve, click Cancel, in which case no information will be shared with the app.</small></p>");
-			builder.append("        </div>");
-			builder.append("    </div>");
-			builder.append("</div>");
-			builder.append("</body>");
-			builder.append("</html>");
-
-			return builder.toString();
-		}
-	}
 }

+ 241 - 0
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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.1">Section 3.1 Device Authorization Request</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.2">Section 3.2 Device Authorization Response</a>
+ */
+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<OAuth2DeviceAuthorizationResponse> deviceAuthorizationHttpResponseConverter =
+			new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter =
+			new OAuth2ErrorHttpMessageConverter();
+	private AuthenticationConverter authenticationConverter;
+	private AuthenticationDetailsSource<HttpServletRequest, ?> 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<HttpServletRequest, ?> 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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.2">Section 3.2 Device Authorization Response</a>
+	 */
+	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);
+	}
+
+}
+
+

+ 266 - 0
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 <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628">OAuth 2.0 Device Authorization Grant</a>
+ * @see <a target="_blank" href="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3">Section 3.3 User Interaction</a>
+ */
+public final class OAuth2DeviceVerificationEndpointFilter extends OncePerRequestFilter {
+
+	private final AuthenticationManager authenticationManager;
+	private final RequestMatcher deviceVerificationEndpointMatcher;
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+	private AuthenticationDetailsSource<HttpServletRequest, ?> 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<HttpServletRequest, ?> 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<String> requestedScopes = authorizationConsentAuthentication.getRequestedScopes();
+		Set<String> 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<String, String> 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());
+	}
+
+}

+ 4 - 2
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.authentication.O
 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 final class OAuth2TokenEndpointFilter extends OncePerRequestFilter {
 				Arrays.asList(
 						new OAuth2AuthorizationCodeAuthenticationConverter(),
 						new OAuth2RefreshTokenAuthenticationConverter(),
-						new OAuth2ClientCredentialsAuthenticationConverter()));
+						new OAuth2ClientCredentialsAuthenticationConverter(),
+						new OAuth2DeviceCodeAuthenticationConverter()));
 	}
 
 	@Override

+ 115 - 0
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<String, String> 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<String> scopes = null;
+		if (parameters.containsKey(OAuth2ParameterNames.SCOPE)) {
+			scopes = new HashSet<>(parameters.get(OAuth2ParameterNames.SCOPE));
+		}
+
+		Map<String, Object> 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);
+	}
+
+}

+ 82 - 0
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<String, String> 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<String> requestedScopes = null;
+		if (StringUtils.hasText(scope)) {
+			requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
+		}
+
+		Map<String, Object> 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);
+	}
+
+}

+ 81 - 0
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<String, String> 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<String, Object> 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);
+	}
+
+}

+ 90 - 0
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<String, String> 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<String, Object> 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);
+	}
+
+}

+ 10 - 1
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.OAuth2AuthenticationException;
 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 @@ final class OAuth2EndpointUtils {
 		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();
+	}
+
 }

+ 9 - 6
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.Base64;
 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.O
 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.authenticati
 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 class OAuth2ClientCredentialsGrantTests {
 				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 class OAuth2ClientCredentialsGrantTests {
 				provider == authenticationProvider ||
 						provider instanceof OAuth2AuthorizationCodeAuthenticationProvider ||
 						provider instanceof OAuth2RefreshTokenAuthenticationProvider ||
-						provider instanceof OAuth2ClientCredentialsAuthenticationProvider);
+						provider instanceof OAuth2ClientCredentialsAuthenticationProvider ||
+						provider instanceof OAuth2DeviceCodeAuthenticationProvider);
 
 		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication));
 	}

+ 1 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java

@@ -86,7 +86,7 @@ public class AuthorizationServerSettingsTests {
 				.settings(settings -> settings.put("name2", "value2"))
 				.build();
 
-		assertThat(authorizationServerSettings.getSettings()).hasSize(10);
+		assertThat(authorizationServerSettings.getSettings()).hasSize(12);
 		assertThat(authorizationServerSettings.<String>getSetting("name1")).isEqualTo("value1");
 		assertThat(authorizationServerSettings.<String>getSetting("name2")).isEqualTo("value2");
 	}

+ 4 - 3
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 class TokenSettingsTests {
 				.setting("name1", "value1")
 				.settings(settings -> settings.put("name2", "value2"))
 				.build();
-		assertThat(tokenSettings.getSettings()).hasSize(8);
+		assertThat(tokenSettings.getSettings()).hasSize(9);
 		assertThat(tokenSettings.<String>getSetting("name1")).isEqualTo("value1");
 		assertThat(tokenSettings.<String>getSetting("name2")).isEqualTo("value2");
 	}

+ 27 - 0
samples/device-client/samples-device-client.gradle

@@ -0,0 +1,27 @@
+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()
+}
+
+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"
+}

+ 32 - 0
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);
+	}
+
+}

+ 56 - 0
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();
+	}
+
+}

+ 71 - 0
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;
+	}
+
+}

+ 192 - 0
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<Map<String, Object>> 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<String, String> requestParameters = new LinkedMultiValueMap<>();
+		requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
+		requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(
+				clientRegistration.getScopes(), " "));
+
+		// @formatter:off
+		Map<String, Object> 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<Void> 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);
+	}
+
+}

+ 52 - 0
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<String> DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList(
+			"authorization_pending",
+			"slow_down",
+			"access_denied",
+			"expired_token"
+	));
+
+	@ExceptionHandler(OAuth2AuthorizationException.class)
+	public ResponseEntity<OAuth2Error> 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());
+	}
+
+}

+ 122 - 0
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<OAuth2DeviceGrantRequest> accessTokenResponseClient =
+			new OAuth2DeviceAccessTokenResponseClient();
+
+	private Duration clockSkew = Duration.ofSeconds(60);
+
+	private Clock clock = Clock.systemUTC();
+
+	public DeviceCodeOAuth2AuthorizedClientProvider() {
+	}
+
+	public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> 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<OAuth2AuthorizeRequest, Map<String, Object>> 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();
+		};
+	}
+
+}

+ 85 - 0
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<OAuth2DeviceGrantRequest> {
+
+	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<String, Object> 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<MultiValueMap<String, Object>> 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);
+		}
+	}
+
+}

+ 41 - 0
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;
+	}
+
+}

+ 29 - 0
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

+ 13 - 0
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;
+}

+ 87 - 0
samples/device-client/src/main/resources/templates/authorize.html

@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Device Activation</h2>
+                        <p>Please visit <a th:href="${verificationUri}" th:text="${verificationUri?.replaceFirst('https?://', '')}"></a> on another device to continue.</p>
+                        <p class="gap">Activation Code</p>
+                        <div class="well">
+                            <span class="code" th:text="${userCode}"></span>
+                            <form id="authorize-form" th:action="@{/authorize}" method="post">
+                                <input type="hidden" id="device_code" name="device_code" th:value="${deviceCode}" />
+                            </form>
+                        </div>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+        <script src="/webjars/jquery/jquery.min.js" th:src="@{/webjars/jquery/jquery.min.js}"></script>
+        <script type="text/javascript">
+            function authorize() {
+                let deviceCode = $('#device_code').val();
+                let csrfToken = $('[name=_csrf]').val();
+                if (deviceCode) {
+                    $.ajax({
+                        url: '/authorize',
+                        method: 'POST',
+                        data: {
+                            device_code: deviceCode,
+                            _csrf: csrfToken
+                        },
+                        timeout: 0
+                    }).fail((err) => {
+                        let response = err.responseJSON;
+                        if (response.errorCode === 'authorization_pending') {
+                            console.log('authorization pending, continuing to poll...');
+                        } else if (response.errorCode === 'slow_down') {
+                            console.log('slowing down...');
+                            slowDown();
+                        } else if (response.errorCode === 'token_expired') {
+                            console.log('token expired, stopping...');
+                            clear();
+                            location.href = '/';
+                        } else if (response.errorCode === 'access_denied') {
+                            console.log('access denied, stopping...');
+                            clear();
+                            location.href = '/';
+                        }
+                    }).done(() => window.location.href = '/authorized');
+                }
+            }
+
+            function schedule() {
+                authorize.handler = window.setInterval(authorize, authorize.interval * 1000);
+            }
+
+            function clear() {
+                if (authorize.handler !== null) {
+                    window.clearInterval(authorize.handler);
+                }
+            }
+
+            function slowDown() {
+                authorize.interval += 5;
+                clear();
+                schedule();
+            }
+
+            authorize.interval = 5;
+            authorize.handler = null;
+
+            window.addEventListener('load', schedule);
+        </script>
+    </body>
+</html>

+ 35 - 0
samples/device-client/src/main/resources/templates/authorized.html

@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Success!</h2>
+                        <p>This device has been activated.</p>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                    <div class="col-md-12" th:if="${messages}">
+                        <h4>Messages:</h4>
+                        <table class="table table-condensed">
+                            <tbody>
+                            <tr class="row" th:each="message : ${messages}">
+                                <td th:text="${message}">message</td>
+                            </tr>
+                            </tbody>
+                        </table>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>

+ 26 - 0
samples/device-client/src/main/resources/templates/index.html

@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Activation Required</h2>
+                        <p>You must activate this device. Please log in to continue.</p>
+                        <a th:href="@{/authorize}" class="btn btn-primary" role="button">Log In</a>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>

+ 37 - 0
samples/device-grant-authorizationserver/samples-device-grant-authorizationserver.gradle

@@ -0,0 +1,37 @@
+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 {
+	maven {
+		url = "https://repo.spring.io/snapshot"
+	}
+	mavenCentral()
+}
+
+// Temporarily use SNAPSHOT version
+// TODO: Use 6.1.0-M2 version after release
+ext["spring-security.version"] = "6.1.0-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"
+}

+ 32 - 0
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);
+	}
+
+}

+ 170 - 0
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<SecurityContext> 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<SecurityContext> jwkSource) {
+		return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+	}
+
+	@Bean
+	public AuthorizationServerSettings authorizationServerSettings() {
+		return AuthorizationServerSettings.builder().build();
+	}
+
+}

+ 47 - 0
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";
+	}
+
+}

+ 48 - 0
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);
+	}
+
+}

+ 6 - 0
samples/device-grant-authorizationserver/src/main/resources/application.yml

@@ -0,0 +1,6 @@
+server:
+  port: 9000
+
+logging:
+  level:
+    org.springframework.security: trace

+ 13 - 0
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;
+}

+ 25 - 0
samples/device-grant-authorizationserver/src/main/resources/templates/access-denied.html

@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Access Denied</h2>
+                        <p>You have denied access. Please return to your device to continue.</p>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>

+ 33 - 0
samples/device-grant-authorizationserver/src/main/resources/templates/activate.html

@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <form th:action="@{/oauth2/device_verification}" method="get">
+                            <h2>Device Activation</h2>
+                            <p>Enter the activation code to authorize the device.</p>
+                            <p class="gap">Activation Code</p>
+                            <div class="form-group">
+                                <label class="sr-only" for="user_code">Activation Code</label>
+                                <input type="text" class="form-control" id="user_code" name="user_code" placeholder="Activation Code" autofocus>
+                            </div>
+                            <button type="submit" class="btn btn-default">Submit</button>
+                        </form>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>

+ 25 - 0
samples/device-grant-authorizationserver/src/main/resources/templates/activated.html

@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Success!</h2>
+                        <p>You have successfully activated your device. Please return to your device to continue.</p>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>

+ 25 - 0
samples/device-grant-authorizationserver/src/main/resources/templates/error.html

@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+        <title>Device Grant Example</title>
+        <link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.css" th:href="@{/webjars/bootstrap/css/bootstrap.css}" />
+        <link rel="stylesheet" href="/assets/css/style.css" th:href="@{/assets/css/style.css}" />
+    </head>
+    <body>
+        <div class="jumbotron">
+            <div class="container">
+                <div class="row">
+                    <div class="col-md-8">
+                        <h2>Error</h2>
+                        <p th:text="${message}"></p>
+                    </div>
+                    <div class="col-md-4">
+                        <img src="https://cdn.pixabay.com/photo/2017/07/03/15/20/technology-2468063_1280.png" class="img-responsive" alt="Devices">
+                    </div>
+                </div>
+            </div>
+        </div>
+    </body>
+</html>