Просмотр исходного кода

Add tests for OAuth 2.0 Device Authorization Grant

This commit adds tests for the following components:
* AuthenticationConverters
* AuthenticationProviders
* Endpoint Filters

Issue gh-44
Closes gh-1127
Steve Riesenberg 2 лет назад
Родитель
Сommit
8e04da773d
10 измененных файлов с 3144 добавлено и 0 удалено
  1. 444 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java
  2. 351 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java
  3. 431 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java
  4. 326 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java
  5. 423 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java
  6. 460 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java
  7. 295 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java
  8. 120 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java
  9. 126 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java
  10. 168 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java

+ 444 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java

@@ -0,0 +1,444 @@
+/*
+ * 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.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+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.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+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.TestOAuth2Authorizations;
+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.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationConsentAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationConsentAuthenticationProviderTests {
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+	private static final String STATE = "abc123";
+
+	private RegisteredClientRepository registeredClientRepository;
+	private OAuth2AuthorizationService authorizationService;
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+	private OAuth2DeviceAuthorizationConsentAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						null, this.authorizationService, this.authorizationConsentService))
+				.withMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						this.registeredClientRepository, null, this.authorizationConsentService))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						this.registeredClientRepository, this.authorizationService, null))
+				.withMessage("authorizationConsentService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationConsentCustomizerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setAuthorizationConsentCustomizer(null))
+				.withMessageContaining("authorizationConsentCustomizer cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationConsentAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalIsNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		TestingAuthenticationToken principal = new TestingAuthenticationToken(authorization.getPrincipalName(), null);
+		Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap());
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNameDoesNotMatchThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("invalid", null, Collections.emptyList());
+		Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap());
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientDoesNotMatchAuthorizationThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRequestedScopesNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear)
+				.scope("invalid").build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient2);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.SCOPE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenAuthoritiesIsEmptyThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear).build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		Authentication authentication = createAuthentication(registeredClient2);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthoritiesIsNotEmptyThenAuthorizationConsentSaved() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult =
+				(OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(any(OAuth2AuthorizationConsent.class));
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		assertThat(updatedAuthorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(false);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenExistingAuthorizationConsentThenUpdated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope("additional").build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear)
+				.scope("additional").build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		Authentication authentication = createAuthentication(registeredClient2);
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope("scope1").build();
+		// @formatter:on
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+		when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult =
+				(OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor.forClass(
+				OAuth2AuthorizationConsent.class);
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue();
+		assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorizationConsent.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentCustomizerSetThenUsed() {
+		SimpleGrantedAuthority customAuthority = new SimpleGrantedAuthority("test");
+		this.authenticationProvider.setAuthorizationConsentCustomizer((context) -> context.getAuthorizationConsent()
+				.authority(customAuthority));
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes(Set::clear).build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.registeredClientRepository.findByClientId(anyString())).thenReturn(registeredClient);
+		when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(null);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult =
+				(OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor.forClass(
+				OAuth2AuthorizationConsent.class);
+		verify(this.authorizationService).findByToken(STATE, STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue();
+		assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorizationConsent.getAuthorities()).containsExactly(customAuthority);
+	}
+
+	private static OAuth2Authorization createAuthorization(RegisteredClient registeredClient) {
+		// @formatter:off
+		return TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+	}
+
+	private static OAuth2DeviceAuthorizationConsentAuthenticationToken createAuthentication(RegisteredClient registeredClient) {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", null, Collections.emptyList());
+		Set<String> authorizedScopes = registeredClient.getScopes();
+		if (authorizedScopes.isEmpty()) {
+			authorizedScopes = null;
+		}
+		Map<String, Object> additionalParameters = null;
+		return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, authorizedScopes, additionalParameters);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+}

+ 351 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java

@@ -0,0 +1,351 @@
+/*
+ * 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.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.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.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationProvider.USER_CODE_TOKEN_TYPE;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationRequestAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationRequestAuthenticationProviderTests {
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private OAuth2AuthorizationService authorizationService;
+	private OAuth2DeviceAuthorizationRequestAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2DeviceAuthorizationRequestAuthenticationProvider(
+				this.authorizationService);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationRequestAuthenticationProvider(null))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setDeviceCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setDeviceCodeGenerator(null))
+				.withMessage("deviceCodeGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setUserCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setUserCodeGenerator(null))
+				.withMessage("userCodeGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationRequestAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		OAuth2ClientAuthenticationToken clientPrincipal =
+				new OAuth2ClientAuthenticationToken("client-1", ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authentication =
+				new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, null, null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidGrantTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopesThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Authentication authentication = new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal,
+				AUTHORIZATION_URI, Collections.singleton("invalid"), null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.SCOPE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeIsNullThenThrowOAuth2AuthenticationException() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator = mock(OAuth2TokenGenerator.class);
+		when(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null);
+		this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining("The token generator failed to generate the device code.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(deviceCodeGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(deviceCodeGenerator);
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsNullThenThrowOAuth2AuthenticationException() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator = mock(OAuth2TokenGenerator.class);
+		when(userCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null);
+		this.authenticationProvider.setUserCodeGenerator(userCodeGenerator);
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining("The token generator failed to generate the user code.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(userCodeGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(userCodeGenerator);
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenScopesRequestedThenReturnDeviceCodeAndUserCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull();
+		assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull();
+		assertThat(authorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE))
+				.hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenNoScopesRequestedThenReturnDeviceCodeAndUserCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes(Set::clear)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull();
+		assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull();
+		assertThat(authorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE))
+				.hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeGeneratorSetThenUsed() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator = mock(OAuth2TokenGenerator.class);
+		when(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createDeviceCode());
+		this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).isEqualTo(DEVICE_CODE);
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9); // 8 chars + 1 dash
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(deviceCodeGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.authorizationService, deviceCodeGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getTokenType()).isEqualTo(DEVICE_CODE_TOKEN_TYPE);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeGeneratorSetThenUsed() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator = mock(OAuth2TokenGenerator.class);
+		when(userCodeGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createUserCode());
+		this.authenticationProvider.setUserCodeGenerator(userCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE).build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		assertThat(authenticationResult.getUserCode().getTokenValue()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(userCodeGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.authorizationService, userCodeGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getTokenType()).isEqualTo(USER_CODE_TOKEN_TYPE);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceAuthorizationRequestAuthenticationToken createAuthentication(RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Set<String> requestedScopes = registeredClient.getScopes();
+		if (requestedScopes.isEmpty()) {
+			requestedScopes = null;
+		}
+		return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, requestedScopes, null);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+}

+ 431 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java

@@ -0,0 +1,431 @@
+/*
+ * 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.time.temporal.ChronoUnit;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.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.OAuth2UserCode;
+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.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.AUTHORIZATION_PENDING;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider.EXPIRED_TOKEN;
+
+/**
+ * Tests for {@link OAuth2DeviceCodeAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceCodeAuthenticationProviderTests {
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+	private static final String ACCESS_TOKEN = "abc123";
+	private static final String REFRESH_TOKEN = "xyz456";
+
+	private OAuth2AuthorizationService authorizationService;
+	private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
+	private OAuth2DeviceCodeAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	@SuppressWarnings("unchecked")
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.tokenGenerator = mock(OAuth2TokenGenerator.class);
+		this.authenticationProvider = new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService,
+				this.tokenGenerator);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(null, this.tokenGenerator))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService, null))
+				.withMessage("tokenGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceCodeAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceCodeAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		OAuth2ClientAuthenticationToken clientPrincipal =
+				new OAuth2ClientAuthenticationToken("client-1", ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		Authentication authentication = new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientDoesNotMatchClientIdThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient2)
+				.token(createDeviceCode()).build();
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsNotInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createUserCode()).build();
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(AUTHORIZATION_PENDING);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeIsInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode(), withInvalidated()).token(createUserCode(), withInvalidated()).build();
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeIsExpiredThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createExpiredDeviceCode()).token(createUserCode(), withInvalidated()).build();
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(EXPIRED_TOKEN);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenIsNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the access token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenRefreshTokenIsNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(createAccessToken(),
+				(OAuth2RefreshToken) null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the refresh token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenTokenGeneratorReturnsWrongTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		OAuth2AccessToken accessToken = createAccessToken();
+		when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken, accessToken);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the refresh token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenValidDeviceCodeThenReturnAccessTokenAndRefreshToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		OAuth2AccessToken accessToken = createAccessToken();
+		OAuth2RefreshToken refreshToken = createRefreshToken();
+		when(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).thenReturn(accessToken, refreshToken);
+		OAuth2AccessTokenAuthenticationToken authenticationResult =
+				(OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken);
+		assertThat(authenticationResult.getRefreshToken()).isEqualTo(refreshToken);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE, DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.tokenGenerator, times(2)).generate(tokenContextCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+		assertThat(updatedAuthorization.getAccessToken().getToken()).isEqualTo(accessToken);
+		assertThat(updatedAuthorization.getRefreshToken().getToken()).isEqualTo(refreshToken);
+
+		for (OAuth2TokenContext tokenContext : tokenContextCaptor.getAllValues()) {
+			assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+			assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+			assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+			assertThat(tokenContext.getAuthorization()).isEqualTo(authorization);
+			assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+			assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+			assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		}
+		assertThat(tokenContextCaptor.getAllValues().get(0).getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContextCaptor.getAllValues().get(1).getTokenType()).isEqualTo(OAuth2TokenType.REFRESH_TOKEN);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceCodeAuthenticationToken createAuthentication(RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		return new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2DeviceCode createExpiredDeviceCode() {
+		Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES);
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2AccessToken createAccessToken() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2RefreshToken createRefreshToken() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2RefreshToken(REFRESH_TOKEN, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Consumer<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	public static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+}

+ 326 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java

@@ -0,0 +1,326 @@
+/*
+ * 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.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+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.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+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.TestOAuth2Authorizations;
+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.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE;
+
+/**
+ * Tests for {@link OAuth2DeviceVerificationAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceVerificationAuthenticationProviderTests {
+	private static final String AUTHORIZATION_URI = "/oauth2/device_verification";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private RegisteredClientRepository registeredClientRepository;
+	private OAuth2AuthorizationService authorizationService;
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+	private OAuth2DeviceVerificationAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2DeviceVerificationAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+		mockAuthorizationServerContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						null, this.authorizationService, this.authorizationConsentService))
+				.withMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						this.registeredClientRepository, null, this.authorizationConsentService))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						this.registeredClientRepository, this.authorizationService, null))
+				.withMessage("authorizationConsentService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceVerificationAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(null);
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenReturnUnauthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null);
+		Authentication authentication = new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE, Collections.emptyMap());
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult =
+				(OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult).isEqualTo(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isFalse();
+
+		verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentDoesNotExistThenReturnAuthorizationConsentWithState() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(null);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authenticationResult.getState()).hasSize(44);
+		assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getScopes()).isEmpty();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+				.isEqualTo(authenticationResult.getState());
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesMatchThenReturnDeviceVerification() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope(registeredClient.getScopes().iterator().next())
+						.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult =
+				(OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(updatedAuthorization.<Authentication>getAttribute(Principal.class.getName()))
+				.isEqualTo(authentication.getPrincipal());
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(false);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesDoNotMatchThenReturnAuthorizationConsentWithState() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope("previous")
+						.build();
+		// @formatter:on
+		when(this.registeredClientRepository.findById(anyString())).thenReturn(registeredClient);
+		when(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).thenReturn(authorization);
+		when(this.authorizationConsentService.findById(anyString(), anyString())).thenReturn(authorizationConsent);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authenticationResult.getState()).hasSize(44);
+		assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getScopes()).containsExactly("previous");
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE, USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+				.isEqualTo(authenticationResult.getState());
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceVerificationAuthenticationToken createAuthentication() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null,
+				AuthorityUtils.createAuthorityList("USER"));
+		return new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE, Collections.emptyMap());
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+}

+ 423 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceAuthorizationEndpointFilterTests.java

@@ -0,0 +1,423 @@
+/*
+ * 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.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationDetailsSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+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.OAuth2ErrorCodes;
+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.OAuth2DeviceAuthorizationRequestAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+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.WebAuthenticationDetails;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationEndpointFilter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationEndpointFilterTests {
+	private static final String ISSUER_URI = "https://provider.com";
+	private static final String REMOTE_ADDRESS = "remote-address";
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+	private static final String VERIFICATION_URI = "/oauth2/device_verification";
+	private static final String CLIENT_ID = "client-1";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private AuthenticationManager authenticationManager;
+	private OAuth2DeviceAuthorizationEndpointFilter filter;
+
+	private final HttpMessageConverter<OAuth2DeviceAuthorizationResponse> deviceAuthorizationHttpResponseConverter =
+			new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter =
+			new OAuth2ErrorHttpMessageConverter();
+
+	@BeforeEach
+	public void setUp() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthenticationMangerIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationEndpointFilter(null))
+				.withMessage("authenticationManager cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenDeviceAuthorizationEndpointUriIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager, null))
+				.withMessage("deviceAuthorizationEndpointUri cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationConverter(null))
+				.withMessage("authenticationConverter cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationDetailsSourceWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationDetailsSource(null))
+				.withMessage("authenticationDetailsSource cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
+				.withMessage("authenticationSuccessHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
+				.withMessage("authenticationFailureHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setVerificationUriWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setVerificationUri(null))
+				.withMessage("verificationUri cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void doFilterWhenNotDeviceAuthorizationRequestThenNotProcessed() throws Exception {
+		MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		verify(filterChain).doFilter(request, response);
+		verifyNoInteractions(this.authenticationManager);
+	}
+
+	@Test
+	public void doFilterWhenDeviceAuthorizationRequestGetThenNotProcessed() throws Exception {
+		MockHttpServletRequest request = createRequest();
+		request.setMethod(HttpMethod.GET.name());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		verify(filterChain).doFilter(request, response);
+		verifyNoInteractions(this.authenticationManager);
+	}
+
+	@Test
+	public void doFilterWhenDeviceAuthorizationRequestThenDeviceAuthorizationResponse() throws Exception {
+		Authentication authenticationResult = createAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter("custom-param-1", "custom-value-1");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		ArgumentCaptor<OAuth2DeviceAuthorizationRequestAuthenticationToken> deviceAuthorizationRequestAuthenticationCaptor =
+				ArgumentCaptor.forClass(OAuth2DeviceAuthorizationRequestAuthenticationToken.class);
+		verify(this.authenticationManager).authenticate(deviceAuthorizationRequestAuthenticationCaptor.capture());
+		verifyNoInteractions(filterChain);
+
+		OAuth2DeviceAuthorizationRequestAuthenticationToken deviceAuthorizationRequestAuthentication =
+				deviceAuthorizationRequestAuthenticationCaptor.getValue();
+		assertThat(deviceAuthorizationRequestAuthentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI);
+		assertThat(deviceAuthorizationRequestAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(deviceAuthorizationRequestAuthentication.getScopes()).isEmpty();
+		assertThat(deviceAuthorizationRequestAuthentication.getAdditionalParameters())
+				.containsExactly(entry("custom-param-1", "custom-value-1"));
+		// @formatter:off
+		assertThat(deviceAuthorizationRequestAuthentication.getDetails())
+				.asInstanceOf(type(WebAuthenticationDetails.class))
+				.extracting(WebAuthenticationDetails::getRemoteAddress)
+				.isEqualTo(REMOTE_ADDRESS);
+		// @formatter:on
+
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = readDeviceAuthorizationResponse(response);
+		String verificationUri = ISSUER_URI + VERIFICATION_URI;
+		assertThat(deviceAuthorizationResponse.getVerificationUri()).isEqualTo(verificationUri);
+		assertThat(deviceAuthorizationResponse.getVerificationUriComplete())
+				.isEqualTo("%s?%s=%s".formatted(verificationUri, OAuth2ParameterNames.USER_CODE, USER_CODE));
+		OAuth2DeviceCode deviceCode = deviceAuthorizationResponse.getDeviceCode();
+		assertThat(deviceCode.getTokenValue()).isEqualTo(DEVICE_CODE);
+		assertThat(deviceCode.getExpiresAt()).isAfter(deviceCode.getIssuedAt());
+		OAuth2UserCode userCode = deviceAuthorizationResponse.getUserCode();
+		assertThat(userCode.getTokenValue()).isEqualTo(USER_CODE);
+		assertThat(deviceCode.getExpiresAt()).isAfter(deviceCode.getIssuedAt());
+	}
+
+	@Test
+	public void doFilterWhenInvalidRequestErrorThenBadRequest() throws Exception {
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		OAuth2AuthenticationException authenticationException = new OAuth2AuthenticationException(
+				new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST, "Invalid request", "error-uri"));
+		when(authenticationConverter.convert(any(HttpServletRequest.class))).thenThrow(authenticationException);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		MockHttpServletRequest request = createRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
+
+		verify(authenticationConverter).convert(request);
+		verifyNoInteractions(filterChain, this.authenticationManager);
+
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		assertThat(error.getDescription()).isEqualTo("Invalid request");
+		assertThat(error.getUri()).isEqualTo("error-uri");
+	}
+
+	@Test
+	public void doFilterWhenCustomDeviceAuthorizationEndpointUriThenUsed() throws Exception {
+		Authentication authenticationResult = createAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		MockHttpServletRequest request = createRequest();
+		request.setRequestURI("/device");
+		request.setServletPath("/device");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter = new OAuth2DeviceAuthorizationEndpointFilter(this.authenticationManager, "/device");
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationConverterSetThenUsed() throws Exception {
+		Authentication authenticationResult = createAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationRequest =
+				new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI, null, null);
+		when(authenticationConverter.convert(any(HttpServletRequest.class))).thenReturn(authenticationRequest);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		MockHttpServletRequest request = createRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(authenticationConverter).convert(request);
+		verify(this.authenticationManager).authenticate(authenticationRequest);
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationDetailsSourceSetThenUsed() throws Exception {
+		Authentication authenticationResult = createAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		MockHttpServletRequest request = createRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		@SuppressWarnings("unchecked")
+		AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource = mock(AuthenticationDetailsSource.class);
+		when(authenticationDetailsSource.buildDetails(any(HttpServletRequest.class))).thenReturn(new WebAuthenticationDetails(request));
+		this.filter.setAuthenticationDetailsSource(authenticationDetailsSource);
+
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationDetailsSource).buildDetails(request);
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationSuccessHandlerSetThenUsed() throws Exception {
+		Authentication authenticationResult = createAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
+
+		MockHttpServletRequest request = createRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(request, response, authenticationResult);
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationFailureHandlerSetThenUsed() throws Exception {
+		OAuth2AuthenticationException authenticationException =
+				new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenThrow(authenticationException);
+
+		Authentication clientPrincipal = (Authentication) createAuthentication().getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		this.filter.setAuthenticationFailureHandler(authenticationFailureHandler);
+
+		MockHttpServletRequest request = createRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationFailureHandler).onAuthenticationFailure(request, response, authenticationException);
+		verifyNoInteractions(filterChain);
+	}
+
+	private OAuth2DeviceAuthorizationResponse readDeviceAuthorizationResponse(MockHttpServletResponse response) throws IOException {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(
+				response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus()));
+		return this.deviceAuthorizationHttpResponseConverter.read(OAuth2DeviceAuthorizationResponse.class, httpResponse);
+	}
+
+	private OAuth2Error readError(MockHttpServletResponse response) throws IOException {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(
+				response.getContentAsByteArray(), HttpStatus.valueOf(response.getStatus()));
+		return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> ISSUER_URI);
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static void mockSecurityContext(Authentication clientPrincipal) {
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(clientPrincipal);
+		SecurityContextHolder.setContext(securityContext);
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.POST.name());
+		request.setRequestURI(AUTHORIZATION_URI);
+		request.setServletPath(AUTHORIZATION_URI);
+		request.setRemoteAddr(REMOTE_ADDRESS);
+		return request;
+	}
+
+	private static OAuth2DeviceAuthorizationRequestAuthenticationToken createAuthentication() {
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(CLIENT_ID, null);
+		return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, null, createDeviceCode(),
+				createUserCode());
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+}

+ 460 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2DeviceVerificationEndpointFilterTests.java

@@ -0,0 +1,460 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationDetailsSource;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+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.WebAuthenticationDetails;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link OAuth2DeviceVerificationEndpointFilter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceVerificationEndpointFilterTests {
+	private static final String ISSUER_URI = "https://provider.com";
+	private static final String REMOTE_ADDRESS = "remote-address";
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+	private static final String VERIFICATION_URI = "/oauth2/device_verification";
+	private static final String CLIENT_ID = "client-1";
+	private static final String STATE = "12345";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private AuthenticationManager authenticationManager;
+	private OAuth2DeviceVerificationEndpointFilter filter;
+
+	@BeforeEach
+	public void setUp() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OAuth2DeviceVerificationEndpointFilter(this.authenticationManager);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthenticationMangerIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationEndpointFilter(null))
+				.withMessage("authenticationManager cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenDeviceVerificationEndpointUriIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationEndpointFilter(this.authenticationManager, null))
+				.withMessage("deviceVerificationEndpointUri cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationConverter(null))
+				.withMessage("authenticationConverter cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationDetailsSourceWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationDetailsSource(null))
+				.withMessage("authenticationDetailsSource cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
+				.withMessage("authenticationSuccessHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
+				.withMessage("authenticationFailureHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void doFilterWhenNotDeviceVerificationRequestThenNotProcessed() throws Exception {
+		MockHttpServletRequest request = new MockHttpServletRequest(HttpMethod.GET.name(), "/path");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		verify(filterChain).doFilter(request, response);
+		verifyNoInteractions(this.authenticationManager);
+	}
+
+	@Test
+	public void doFilterWhenUnauthenticatedThenPassThrough() throws Exception {
+		TestingAuthenticationToken unauthenticatedResult = new TestingAuthenticationToken("user", null);
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(unauthenticatedResult);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(filterChain).doFilter(request, response);
+	}
+
+	@Test
+	public void doFilterWhenDeviceAuthorizationConsentRequestThenSuccess() throws Exception {
+		Authentication authenticationResult = createDeviceVerificationAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		MockHttpServletRequest request = createRequest();
+		request.setMethod(HttpMethod.POST.name());
+		request.addParameter(OAuth2ParameterNames.SCOPE, "scope-1");
+		request.addParameter(OAuth2ParameterNames.SCOPE, "scope-2");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter("custom-param-1", "custom-value-1");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success");
+
+		ArgumentCaptor<OAuth2DeviceAuthorizationConsentAuthenticationToken> authenticationCaptor =
+				ArgumentCaptor.forClass(OAuth2DeviceAuthorizationConsentAuthenticationToken.class);
+		verify(this.authenticationManager).authenticate(authenticationCaptor.capture());
+		verifyNoInteractions(filterChain);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication =
+				authenticationCaptor.getValue();
+		assertThat(deviceAuthorizationConsentAuthentication.getAuthorizationUri()).endsWith(VERIFICATION_URI);
+		assertThat(deviceAuthorizationConsentAuthentication.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(deviceAuthorizationConsentAuthentication.getPrincipal())
+				.isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(deviceAuthorizationConsentAuthentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(deviceAuthorizationConsentAuthentication.getScopes()).containsExactly("scope-1", "scope-2");
+		assertThat(deviceAuthorizationConsentAuthentication.getAdditionalParameters())
+				.containsExactly(entry("custom-param-1", "custom-value-1"));
+	}
+
+	@Test
+	public void doFilterWhenDeviceVerificationRequestAndConsentNotRequiredThenSuccess() throws Exception {
+		Authentication authenticationResult = createDeviceVerificationAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		Authentication clientPrincipal = (Authentication) authenticationResult.getPrincipal();
+		mockSecurityContext(clientPrincipal);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter("custom-param-1", "custom-value-1");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success");
+
+		ArgumentCaptor<OAuth2DeviceVerificationAuthenticationToken> authenticationCaptor =
+				ArgumentCaptor.forClass(OAuth2DeviceVerificationAuthenticationToken.class);
+		verify(this.authenticationManager).authenticate(authenticationCaptor.capture());
+		verifyNoInteractions(filterChain);
+
+		OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication = authenticationCaptor.getValue();
+		assertThat(deviceVerificationAuthentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(deviceVerificationAuthentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(deviceVerificationAuthentication.getAdditionalParameters())
+				.containsExactly(entry("custom-param-1", "custom-value-1"));
+	}
+
+	@Test
+	public void doFilterWhenDeviceVerificationRequestAndConsentRequiredThenConsentScreen() throws Exception {
+		Authentication authenticationResult = createDeviceAuthorizationConsentAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		assertThat(response.getContentType())
+				.isEqualTo(new MediaType("text", "html", StandardCharsets.UTF_8).toString());
+		assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-1"));
+		assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-2"));
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenDeviceVerificationRequestAndConsentRequiredWithPreviouslyApprovedThenConsentScreen() throws Exception {
+		Authentication authenticationResult = createDeviceAuthorizationConsentAuthenticationWithAuthorizedScopes();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		assertThat(response.getContentType())
+				.isEqualTo(new MediaType("text", "html", StandardCharsets.UTF_8).toString());
+		assertThat(response.getContentAsString()).contains(disabledScopeCheckbox("scope-1"));
+		assertThat(response.getContentAsString()).contains(scopeCheckbox("scope-2"));
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenDeviceVerificationRequestAndConsentRequiredAndConsentPageSetThenRedirect() throws Exception {
+		Authentication authentication = createDeviceAuthorizationConsentAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authentication);
+
+		MockHttpServletRequest request = createRequest();
+		request.setScheme("https");
+		request.setServerPort(443);
+		request.setServerName("provider.com");
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.setConsentPage("/consent");
+		this.filter.doFilter(request, response, filterChain);
+		String redirectUri = UriComponentsBuilder.fromUriString("https://provider.com/consent")
+				.queryParam(OAuth2ParameterNames.SCOPE, "scope-1 scope-2")
+				.queryParam(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID)
+				.queryParam(OAuth2ParameterNames.STATE, STATE)
+				.queryParam(OAuth2ParameterNames.USER_CODE, USER_CODE)
+				.toUriString();
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo(redirectUri);
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationConverterSetThenUsed() throws Exception {
+		Authentication authenticationResult = createDeviceVerificationAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		OAuth2DeviceVerificationAuthenticationToken deviceVerificationAuthentication =
+				new OAuth2DeviceVerificationAuthenticationToken((Authentication) authenticationResult.getPrincipal(),
+						USER_CODE, Collections.emptyMap());
+		when(authenticationConverter.convert(any(HttpServletRequest.class))).thenReturn(deviceVerificationAuthentication);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success");
+
+		verify(authenticationConverter).convert(request);
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationDetailsSourceSetThenUsed() throws Exception {
+		Authentication authenticationResult = createDeviceVerificationAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		@SuppressWarnings("unchecked")
+		AuthenticationDetailsSource<HttpServletRequest, WebAuthenticationDetails> authenticationDetailsSource = mock(AuthenticationDetailsSource.class);
+		when(authenticationDetailsSource.buildDetails(any(HttpServletRequest.class))).thenReturn(new WebAuthenticationDetails(request));
+		this.filter.setAuthenticationDetailsSource(authenticationDetailsSource);
+
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success");
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationDetailsSource).buildDetails(request);
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationSuccessHandlerSetThenUsed() throws Exception {
+		Authentication authenticationResult = createDeviceVerificationAuthentication();
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenReturn(authenticationResult);
+
+		AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(request, response, authenticationResult);
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenAuthenticationFailureHandlerSetThenUsed() throws Exception {
+		OAuth2AuthenticationException authenticationException =
+				new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST);
+		when(this.authenticationManager.authenticate(any(Authentication.class))).thenThrow(authenticationException);
+
+		AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		this.filter.setAuthenticationFailureHandler(authenticationFailureHandler);
+
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+		this.filter.doFilter(request, response, filterChain);
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+
+		verify(this.authenticationManager).authenticate(any(Authentication.class));
+		verify(authenticationFailureHandler).onAuthenticationFailure(request, response, authenticationException);
+		verifyNoInteractions(filterChain);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> ISSUER_URI);
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static void mockSecurityContext(Authentication clientPrincipal) {
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(clientPrincipal);
+		SecurityContextHolder.setContext(securityContext);
+	}
+
+	private static OAuth2DeviceVerificationAuthenticationToken createDeviceVerificationAuthentication() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null);
+		return new OAuth2DeviceVerificationAuthenticationToken(principal, CLIENT_ID, USER_CODE);
+	}
+
+	private static Authentication createDeviceAuthorizationConsentAuthentication() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null);
+		Set<String> requestedScopes = new HashSet<>();
+		requestedScopes.add("scope-1");
+		requestedScopes.add("scope-2");
+		return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, CLIENT_ID, principal,
+				USER_CODE, STATE, requestedScopes, new HashSet<>());
+	}
+
+	private static Authentication createDeviceAuthorizationConsentAuthenticationWithAuthorizedScopes() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null);
+		Set<String> requestedScopes = new HashSet<>();
+		requestedScopes.add("scope-1");
+		requestedScopes.add("scope-2");
+		Set<String> authorizedScopes = new HashSet<>();
+		authorizedScopes.add("scope-1");
+		return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI, CLIENT_ID, principal,
+				USER_CODE, STATE, requestedScopes, authorizedScopes);
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.GET.name());
+		request.setRequestURI(VERIFICATION_URI);
+		request.setServletPath(VERIFICATION_URI);
+		request.setRemoteAddr(REMOTE_ADDRESS);
+		return request;
+	}
+
+	private static String scopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\">",
+				scope
+		);
+	}
+
+	private static String disabledScopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" id=\"{0}\" checked disabled>",
+				scope
+		);
+	}
+}

+ 295 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationConsentAuthenticationConverterTests.java

@@ -0,0 +1,295 @@
+/*
+ * 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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationConsentAuthenticationToken;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationConsentAuthenticationConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationConsentAuthenticationConverterTests {
+	private static final String VERIFICATION_URI = "/oauth2/device_verification";
+	private static final String USER_CODE = "BCDF-GHJK";
+	private static final String CLIENT_ID = "client-1";
+	private static final String STATE = "abc123";
+
+	private OAuth2DeviceAuthorizationConsentAuthenticationConverter converter;
+
+	@BeforeEach
+	public void setUp() {
+		this.converter = new OAuth2DeviceAuthorizationConsentAuthenticationConverter();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void convertWhenGetThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		request.setMethod(HttpMethod.GET.name());
+		assertThat(this.converter.convert(request)).isNull();
+	}
+
+	@Test
+	public void convertWhenMissingStateThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		assertThat(this.converter.convert(request)).isNull();
+	}
+
+	@Test
+	public void convertWhenMissingClientIdThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenBlankClientIdThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMultipleClientIdParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "another");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMissingUserCodeThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.USER_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenBlankUserCodeThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, "");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.USER_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMultipleUserCodeParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, "another");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.USER_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenBlankStateParameterThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, "");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMultipleStateParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.STATE, "another");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMissingPrincipalThenReturnDeviceAuthorizationConsentAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(authentication.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getScopes()).isEmpty();
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	@Test
+	public void convertWhenMissingScopeThenReturnDeviceAuthorizationConsentAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken("user", null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getScopes()).isEmpty();
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	@Test
+	public void convertWhenAllParametersThenReturnDeviceAuthorizationConsentAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter(OAuth2ParameterNames.SCOPE, "message.read");
+		request.addParameter(OAuth2ParameterNames.SCOPE, "message.write");
+		request.addParameter("param-1", "value-1");
+		request.addParameter("param-2", "value-2");
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken("user", null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getScopes()).containsExactly("message.read", "message.write");
+		assertThat(authentication.getAdditionalParameters())
+				.containsExactly(entry("param-1", "value-1"), entry("param-2", "value-2"));
+	}
+
+	@Test
+	public void convertWhenNonNormalizedUserCodeThenReturnDeviceAuthorizationConsentAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.STATE, STATE);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . "));
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken("user", null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationConsentAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getAuthorizationUri()).endsWith(VERIFICATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getScopes()).isEmpty();
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.POST.name());
+		request.setRequestURI(VERIFICATION_URI);
+		return request;
+	}
+}

+ 120 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceAuthorizationRequestAuthenticationConverterTests.java

@@ -0,0 +1,120 @@
+/*
+ * 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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceAuthorizationRequestAuthenticationToken;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationRequestAuthenticationConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationRequestAuthenticationConverterTests {
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+	private static final String CLIENT_ID = "client-1";
+
+	private OAuth2DeviceAuthorizationRequestAuthenticationConverter converter;
+
+	@BeforeEach
+	public void setUp() {
+		this.converter = new OAuth2DeviceAuthorizationRequestAuthenticationConverter();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void convertWhenMultipleScopeParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.SCOPE, "message.read");
+		request.addParameter(OAuth2ParameterNames.SCOPE, "message.write");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.SCOPE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMissingScopeThenReturnDeviceAuthorizationRequestAuthenticationToken() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI);
+		assertThat(authentication.getScopes()).isEmpty();
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	@Test
+	public void convertWhenAllParametersThenReturnDeviceAuthorizationRequestAuthenticationToken() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.SCOPE, "message.read message.write");
+		request.addParameter("param-1", "value-1");
+		request.addParameter("param-2", "value-2");
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authentication =
+				(OAuth2DeviceAuthorizationRequestAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getAuthorizationUri()).endsWith(AUTHORIZATION_URI);
+		assertThat(authentication.getScopes()).containsExactly("message.read", "message.write");
+		assertThat(authentication.getAdditionalParameters())
+				.containsExactly(entry("param-1", "value-1"), entry("param-2", "value-2"));
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.POST.name());
+		request.setRequestURI(AUTHORIZATION_URI);
+		return request;
+	}
+}

+ 126 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceCodeAuthenticationConverterTests.java

@@ -0,0 +1,126 @@
+/*
+ * 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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationToken;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link OAuth2DeviceCodeAuthenticationConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceCodeAuthenticationConverterTests {
+	private static final String CLIENT_ID = "client-1";
+	private static final String TOKEN_URI = "/oauth2/token";
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+
+	private OAuth2DeviceCodeAuthenticationConverter converter;
+
+	@BeforeEach
+	public void setUp() {
+		this.converter = new OAuth2DeviceCodeAuthenticationConverter();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void convertWhenMissingGrantTypeThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenMissingDeviceCodeThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.DEVICE_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMultipleDeviceCodeParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		request.addParameter(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+		request.addParameter(OAuth2ParameterNames.DEVICE_CODE, "another");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.DEVICE_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenAllParametersThenReturnDeviceCodeAuthenticationToken() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, CLIENT_ID);
+		request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		request.addParameter(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+		request.addParameter("param-1", "value-1");
+		request.addParameter("param-2", "value-2");
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceCodeAuthenticationToken authentication =
+				(OAuth2DeviceCodeAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getDeviceCode()).isEqualTo(DEVICE_CODE);
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getAdditionalParameters())
+				.containsExactly(entry("param-1", "value-1"), entry("param-2", "value-2"));
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.POST.name());
+		request.setRequestURI(TOKEN_URI);
+		return request;
+	}
+}

+ 168 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2DeviceVerificationAuthenticationConverterTests.java

@@ -0,0 +1,168 @@
+/*
+ * 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 org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextImpl;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceVerificationAuthenticationToken;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Tests for {@link OAuth2DeviceVerificationAuthenticationConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceVerificationAuthenticationConverterTests {
+	private static final String VERIFICATION_URI = "/oauth2/device_verification";
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private OAuth2DeviceVerificationAuthenticationConverter converter;
+
+	@BeforeEach
+	public void setUp() {
+		this.converter = new OAuth2DeviceVerificationAuthenticationConverter();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void convertWhenPutThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		request.setMethod(HttpMethod.PUT.name());
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenStateThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.STATE, "abc123");
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenMissingUserCodeThenReturnNull() {
+		MockHttpServletRequest request = createRequest();
+		Authentication authentication = this.converter.convert(request);
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	public void convertWhenBlankUserCodeParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, "");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.USER_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMultipleUserCodeParametersThenInvalidRequestError() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter(OAuth2ParameterNames.USER_CODE, "another");
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.converter.convert(request))
+				.withMessageContaining(OAuth2ParameterNames.USER_CODE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void convertWhenMissingPrincipalThenReturnDeviceVerificationAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . "));
+
+		OAuth2DeviceVerificationAuthenticationToken authentication =
+				(OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getPrincipal()).isInstanceOf(AnonymousAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	@Test
+	public void convertWhenNonNormalizedUserCodeThenReturnDeviceVerificationAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE.toLowerCase().replace("-", " . "));
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken("user", null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceVerificationAuthenticationToken authentication =
+				(OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+	}
+
+	@Test
+	public void convertWhenAllParametersThenReturnDeviceVerificationAuthentication() {
+		MockHttpServletRequest request = createRequest();
+		request.addParameter(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		request.addParameter("param-1", "value-1");
+		request.addParameter("param-2", "value-2");
+
+		SecurityContextImpl securityContext = new SecurityContextImpl();
+		securityContext.setAuthentication(new TestingAuthenticationToken("user", null));
+		SecurityContextHolder.setContext(securityContext);
+
+		OAuth2DeviceVerificationAuthenticationToken authentication =
+				(OAuth2DeviceVerificationAuthenticationToken) this.converter.convert(request);
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getPrincipal()).isInstanceOf(TestingAuthenticationToken.class);
+		assertThat(authentication.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authentication.getAdditionalParameters())
+				.containsExactly(entry("param-1", "value-1"), entry("param-2", "value-2"));
+	}
+
+	private static MockHttpServletRequest createRequest() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setMethod(HttpMethod.GET.name());
+		request.setRequestURI(VERIFICATION_URI);
+		return request;
+	}
+}