瀏覽代碼

Add tests and update examples in docs

Closes gh-1156
Steve Riesenberg 2 年之前
父節點
當前提交
13a61034ed
共有 13 個文件被更改,包括 533 次插入10 次删除
  1. 1 0
      docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java
  2. 79 1
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java
  3. 6 2
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java
  4. 45 1
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java
  5. 2 2
      docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java
  6. 188 0
      docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java
  7. 48 0
      docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java
  8. 48 0
      docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java
  9. 1 0
      docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java
  10. 8 0
      docs/src/docs/asciidoc/guides/how-to-jpa.adoc
  11. 91 4
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java
  12. 8 0
      oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql
  13. 8 0
      oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql

+ 1 - 0
docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java

@@ -116,6 +116,7 @@ public class SecurityConfig {
 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
 				.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
 				.redirectUri("http://127.0.0.1:8080/authorized")
 				.scope(OidcScopes.OPENID)

+ 79 - 1
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -70,6 +70,20 @@ public class Authorization {
 	@Column(length = 2000)
 	private String oidcIdTokenClaims;
 
+	@Column(length = 4000)
+	private String userCodeValue;
+	private Instant userCodeIssuedAt;
+	private Instant userCodeExpiresAt;
+	@Column(length = 2000)
+	private String userCodeMetadata;
+
+	@Column(length = 4000)
+	private String deviceCodeValue;
+	private Instant deviceCodeIssuedAt;
+	private Instant deviceCodeExpiresAt;
+	@Column(length = 2000)
+	private String deviceCodeMetadata;
+
 	// @fold:on
 	public String getId() {
 		return id;
@@ -278,5 +292,69 @@ public class Authorization {
 	public void setOidcIdTokenClaims(String idTokenClaims) {
 		this.oidcIdTokenClaims = idTokenClaims;
 	}
+
+	public String getUserCodeValue() {
+		return this.userCodeValue;
+	}
+
+	public void setUserCodeValue(String userCodeValue) {
+		this.userCodeValue = userCodeValue;
+	}
+
+	public Instant getUserCodeIssuedAt() {
+		return this.userCodeIssuedAt;
+	}
+
+	public void setUserCodeIssuedAt(Instant userCodeIssuedAt) {
+		this.userCodeIssuedAt = userCodeIssuedAt;
+	}
+
+	public Instant getUserCodeExpiresAt() {
+		return this.userCodeExpiresAt;
+	}
+
+	public void setUserCodeExpiresAt(Instant userCodeExpiresAt) {
+		this.userCodeExpiresAt = userCodeExpiresAt;
+	}
+
+	public String getUserCodeMetadata() {
+		return this.userCodeMetadata;
+	}
+
+	public void setUserCodeMetadata(String userCodeMetadata) {
+		this.userCodeMetadata = userCodeMetadata;
+	}
+
+	public String getDeviceCodeValue() {
+		return this.deviceCodeValue;
+	}
+
+	public void setDeviceCodeValue(String deviceCodeValue) {
+		this.deviceCodeValue = deviceCodeValue;
+	}
+
+	public Instant getDeviceCodeIssuedAt() {
+		return this.deviceCodeIssuedAt;
+	}
+
+	public void setDeviceCodeIssuedAt(Instant deviceCodeIssuedAt) {
+		this.deviceCodeIssuedAt = deviceCodeIssuedAt;
+	}
+
+	public Instant getDeviceCodeExpiresAt() {
+		return this.deviceCodeExpiresAt;
+	}
+
+	public void setDeviceCodeExpiresAt(Instant deviceCodeExpiresAt) {
+		this.deviceCodeExpiresAt = deviceCodeExpiresAt;
+	}
+
+	public String getDeviceCodeMetadata() {
+		return this.deviceCodeMetadata;
+	}
+
+	public void setDeviceCodeMetadata(String deviceCodeMetadata) {
+		this.deviceCodeMetadata = deviceCodeMetadata;
+	}
 	// @fold:off
 }

+ 6 - 2
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java

@@ -31,11 +31,15 @@ public interface AuthorizationRepository extends JpaRepository<Authorization, St
 	Optional<Authorization> findByAccessTokenValue(String accessToken);
 	Optional<Authorization> findByRefreshTokenValue(String refreshToken);
 	Optional<Authorization> findByOidcIdTokenValue(String idToken);
+	Optional<Authorization> findByUserCodeValue(String userCode);
+	Optional<Authorization> findByDeviceCodeValue(String deviceCode);
 	@Query("select a from Authorization a where a.state = :token" +
 			" or a.authorizationCodeValue = :token" +
 			" or a.accessTokenValue = :token" +
 			" or a.refreshTokenValue = :token" +
-			" or a.oidcIdTokenValue = :token"
+			" or a.oidcIdTokenValue = :token" +
+			" or a.userCodeValue = :token" +
+			" or a.deviceCodeValue = :token"
 	)
-	Optional<Authorization> findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(@Param("token") String token);
+	Optional<Authorization> findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(@Param("token") String token);
 }

+ 45 - 1
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java

@@ -31,8 +31,10 @@ import org.springframework.dao.DataRetrievalFailureException;
 import org.springframework.security.jackson2.SecurityJackson2Modules;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
@@ -89,7 +91,7 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 
 		Optional<Authorization> result;
 		if (tokenType == null) {
-			result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(token);
+			result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(token);
 		} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {
 			result = this.authorizationRepository.findByState(token);
 		} else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) {
@@ -100,6 +102,10 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 			result = this.authorizationRepository.findByRefreshTokenValue(token);
 		} else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) {
 			result = this.authorizationRepository.findByOidcIdTokenValue(token);
+		} else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) {
+			result = this.authorizationRepository.findByUserCodeValue(token);
+		} else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) {
+			result = this.authorizationRepository.findByDeviceCodeValue(token);
 		} else {
 			result = Optional.empty();
 		}
@@ -159,6 +165,22 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 			builder.token(idToken, metadata -> metadata.putAll(parseMap(entity.getOidcIdTokenMetadata())));
 		}
 
+		if (entity.getUserCodeValue() != null) {
+			OAuth2UserCode userCode = new OAuth2UserCode(
+					entity.getUserCodeValue(),
+					entity.getUserCodeIssuedAt(),
+					entity.getUserCodeExpiresAt());
+			builder.token(userCode, metadata -> metadata.putAll(parseMap(entity.getUserCodeMetadata())));
+		}
+
+		if (entity.getUserCodeValue() != null) {
+			OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(
+					entity.getDeviceCodeValue(),
+					entity.getDeviceCodeIssuedAt(),
+					entity.getDeviceCodeExpiresAt());
+			builder.token(deviceCode, metadata -> metadata.putAll(parseMap(entity.getDeviceCodeMetadata())));
+		}
+
 		return builder.build();
 	}
 
@@ -218,6 +240,26 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 			entity.setOidcIdTokenClaims(writeMap(oidcIdToken.getClaims()));
 		}
 
+		OAuth2Authorization.Token<OAuth2UserCode> userCode =
+				authorization.getToken(OAuth2UserCode.class);
+		setTokenValues(
+				userCode,
+				entity::setUserCodeValue,
+				entity::setUserCodeIssuedAt,
+				entity::setUserCodeExpiresAt,
+				entity::setUserCodeMetadata
+		);
+
+		OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode =
+				authorization.getToken(OAuth2DeviceCode.class);
+		setTokenValues(
+				deviceCode,
+				entity::setDeviceCodeValue,
+				entity::setDeviceCodeIssuedAt,
+				entity::setDeviceCodeExpiresAt,
+				entity::setDeviceCodeMetadata
+		);
+
 		return entity;
 	}
 
@@ -260,6 +302,8 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 			return AuthorizationGrantType.CLIENT_CREDENTIALS;
 		} else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {
 			return AuthorizationGrantType.REFRESH_TOKEN;
+		} else if (AuthorizationGrantType.DEVICE_CODE.getValue().equals(authorizationGrantType)) {
+			return AuthorizationGrantType.DEVICE_CODE;
 		}
 		return new AuthorizationGrantType(authorizationGrantType);              // Custom authorization grant type
 	}

+ 2 - 2
docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -109,7 +109,7 @@ public class AuthorizationCodeGrantFlow {
 	 * Submit consent for the authorization request and obtain an authorization code.
 	 *
 	 * @param registeredClient The registered client
-	 * @param state The state paramter from the authorization request
+	 * @param state The state parameter from the authorization request
 	 * @return An authorization code
 	 */
 	public String submitConsent(RegisteredClient registeredClient, String state) throws Exception {

+ 188 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java

@@ -0,0 +1,188 @@
+/*
+ * Copyright 2020-2023 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package sample;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Helper class that performs steps of the {@code urn:ietf:params:oauth:grant-type:device_code}
+ * flow using {@link MockMvc} for testing.
+ *
+ * @author Steve Riesenberg
+ */
+public class DeviceAuthorizationGrantFlow {
+	private static final Pattern HIDDEN_STATE_INPUT_PATTERN = Pattern.compile(".+<input type=\"hidden\" name=\"state\" value=\"([^\"]+)\">.+");
+	private static final TypeReference<Map<String, Object>> JSON_RESPONSE_TYPE_REFERENCE = new TypeReference<>() {
+	};
+
+	private final MockMvc mockMvc;
+
+	private String username = "user";
+
+	private Set<String> scopes = new HashSet<>();
+
+	public DeviceAuthorizationGrantFlow(MockMvc mockMvc) {
+		this.mockMvc = mockMvc;
+	}
+
+	public void setUsername(String username) {
+		this.username = username;
+	}
+
+	public void addScope(String scope) {
+		this.scopes.add(scope);
+	}
+
+	/**
+	 * Perform the device authorization request and obtain the response
+	 * containing a user code and device code.
+	 *
+	 * @param registeredClient The registered client
+	 * @return The device authorization response
+	 */
+	public Map<String, Object> authorize(RegisteredClient registeredClient) throws Exception {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		HttpHeaders basicAuth = new HttpHeaders();
+		basicAuth.setBasicAuth(registeredClient.getClientId(), "secret");
+
+		MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/device_authorization")
+				.params(parameters)
+				.headers(basicAuth))
+				.andExpect(status().isOk())
+				.andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE)))
+				.andExpect(jsonPath("$.user_code").isNotEmpty())
+				.andExpect(jsonPath("$.device_code").isNotEmpty())
+				.andExpect(jsonPath("$.verification_uri").isNotEmpty())
+				.andExpect(jsonPath("$.verification_uri_complete").isNotEmpty())
+				.andExpect(jsonPath("$.expires_in").isNotEmpty())
+				.andReturn();
+
+		ObjectMapper objectMapper = new ObjectMapper();
+		String responseJson = mvcResult.getResponse().getContentAsString();
+		return objectMapper.readValue(responseJson, JSON_RESPONSE_TYPE_REFERENCE);
+	}
+
+	/**
+	 * Submit the user code and obtain a state parameter from the consent screen.
+	 *
+	 * @param userCode The user code from the device authorization request
+	 * @return The state parameter for submitting consent for authorization
+	 */
+	public String submitCode(String userCode) throws Exception {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.USER_CODE, userCode);
+
+		MvcResult mvcResult = this.mockMvc.perform(get("/oauth2/device_verification")
+				.params(parameters)
+				.with(user(this.username).roles("USER")))
+				.andExpect(status().isOk())
+				.andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.TEXT_HTML_VALUE)))
+				.andReturn();
+		String responseHtml = mvcResult.getResponse().getContentAsString();
+		Matcher matcher = HIDDEN_STATE_INPUT_PATTERN.matcher(responseHtml);
+
+		return matcher.matches() ? matcher.group(1) : null;
+	}
+
+	/**
+	 * Submit consent for the device authorization request.
+	 *
+	 * @param registeredClient The registered client
+	 * @param state The state parameter from the consent screen
+	 * @param userCode The user code from the device authorization request
+	 */
+	public void submitConsent(RegisteredClient registeredClient, String state, String userCode) throws Exception {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.STATE, state);
+		for (String scope : this.scopes) {
+			parameters.add(OAuth2ParameterNames.SCOPE, scope);
+		}
+		parameters.set(OAuth2ParameterNames.USER_CODE, userCode);
+
+		MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/device_verification")
+				.params(parameters)
+				.with(user(this.username).roles("USER")))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl).isNotNull();
+		assertThat(redirectedUrl).isEqualTo("/?success");
+	}
+
+	/**
+	 * Exchange a device code for an access token.
+	 *
+	 * @param registeredClient The registered client
+	 * @param deviceCode The device code obtained from the device authorization request
+	 * @return The token response
+	 */
+	public Map<String, Object> getTokenResponse(RegisteredClient registeredClient, String deviceCode) throws Exception {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.DEVICE_CODE, deviceCode);
+
+		HttpHeaders basicAuth = new HttpHeaders();
+		basicAuth.setBasicAuth(registeredClient.getClientId(), "secret");
+
+		MvcResult mvcResult = this.mockMvc.perform(post("/oauth2/token")
+				.params(parameters)
+				.headers(basicAuth))
+				.andExpect(status().isOk())
+				.andExpect(header().string(HttpHeaders.CONTENT_TYPE, containsString(MediaType.APPLICATION_JSON_VALUE)))
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").isNotEmpty())
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.expires_in").isNotEmpty())
+				.andReturn();
+
+		ObjectMapper objectMapper = new ObjectMapper();
+		String responseJson = mvcResult.getResponse().getContentAsString();
+		return objectMapper.readValue(responseJson, JSON_RESPONSE_TYPE_REFERENCE);
+	}
+}

+ 48 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java

@@ -21,6 +21,7 @@ import org.assertj.core.api.ObjectAssert;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import sample.AuthorizationCodeGrantFlow;
+import sample.DeviceAuthorizationGrantFlow;
 import sample.test.SpringTestContext;
 import sample.test.SpringTestContextExtension;
 
@@ -116,6 +117,53 @@ public class SecurityConfigTests {
 				StringUtils.delimitedListToStringArray(scopes, " "));
 	}
 
+	@Test
+	public void deviceAuthorizationWhenGettingStartedConfigUsedThenSuccess() throws Exception {
+		this.spring.register(AuthorizationServerConfig.class).autowire();
+		assertThat(this.registeredClientRepository).isInstanceOf(InMemoryRegisteredClientRepository.class);
+		assertThat(this.authorizationService).isInstanceOf(InMemoryOAuth2AuthorizationService.class);
+		assertThat(this.authorizationConsentService).isInstanceOf(InMemoryOAuth2AuthorizationConsentService.class);
+
+		RegisteredClient registeredClient = this.registeredClientRepository.findByClientId("messaging-client");
+		assertThat(registeredClient).isNotNull();
+
+		DeviceAuthorizationGrantFlow deviceAuthorizationGrantFlow = new DeviceAuthorizationGrantFlow(this.mockMvc);
+		deviceAuthorizationGrantFlow.setUsername("user");
+		deviceAuthorizationGrantFlow.addScope("message.read");
+		deviceAuthorizationGrantFlow.addScope("message.write");
+
+		Map<String, Object> deviceAuthorizationResponse = deviceAuthorizationGrantFlow.authorize(registeredClient);
+		String userCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.USER_CODE);
+		assertThatAuthorization(userCode, OAuth2ParameterNames.USER_CODE).isNotNull();
+		assertThatAuthorization(userCode, null).isNotNull();
+
+		String deviceCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.DEVICE_CODE);
+		assertThatAuthorization(deviceCode, OAuth2ParameterNames.DEVICE_CODE).isNotNull();
+		assertThatAuthorization(deviceCode, null).isNotNull();
+
+		String state = deviceAuthorizationGrantFlow.submitCode(userCode);
+		assertThatAuthorization(state, OAuth2ParameterNames.STATE).isNotNull();
+		assertThatAuthorization(state, null).isNotNull();
+
+		deviceAuthorizationGrantFlow.submitConsent(registeredClient, state, userCode);
+
+		Map<String, Object> tokenResponse = deviceAuthorizationGrantFlow.getTokenResponse(registeredClient, deviceCode);
+		String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN);
+		assertThatAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN).isNotNull();
+		assertThatAuthorization(accessToken, null).isNotNull();
+
+		String refreshToken = (String) tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN);
+		assertThatAuthorization(refreshToken, OAuth2ParameterNames.REFRESH_TOKEN).isNotNull();
+		assertThatAuthorization(refreshToken, null).isNotNull();
+
+		String scopes = (String) tokenResponse.get(OAuth2ParameterNames.SCOPE);
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService.findById(
+				registeredClient.getId(), "user");
+		assertThat(authorizationConsent).isNotNull();
+		assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrder(
+				StringUtils.delimitedListToStringArray(scopes, " "));
+	}
+
 	private ObjectAssert<OAuth2Authorization> assertThatAuthorization(String token, String tokenType) {
 		return assertThat(findAuthorization(token, tokenType));
 	}

+ 48 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java

@@ -25,6 +25,7 @@ import org.assertj.core.api.ObjectAssert;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import sample.AuthorizationCodeGrantFlow;
+import sample.DeviceAuthorizationGrantFlow;
 import sample.jose.TestJwks;
 import sample.jpa.service.authorization.JpaOAuth2AuthorizationService;
 import sample.jpa.service.authorizationConsent.JpaOAuth2AuthorizationConsentService;
@@ -131,6 +132,53 @@ public class JpaTests {
 				StringUtils.delimitedListToStringArray(scopes, " "));
 	}
 
+	@Test
+	public void deviceAuthorizationWhenJpaCoreServicesAutowiredThenSuccess() throws Exception {
+		this.spring.register(AuthorizationServerConfig.class).autowire();
+		assertThat(this.registeredClientRepository).isInstanceOf(JpaRegisteredClientRepository.class);
+		assertThat(this.authorizationService).isInstanceOf(JpaOAuth2AuthorizationService.class);
+		assertThat(this.authorizationConsentService).isInstanceOf(JpaOAuth2AuthorizationConsentService.class);
+
+		RegisteredClient registeredClient = messagingClient();
+		this.registeredClientRepository.save(registeredClient);
+
+		DeviceAuthorizationGrantFlow deviceAuthorizationGrantFlow = new DeviceAuthorizationGrantFlow(this.mockMvc);
+		deviceAuthorizationGrantFlow.setUsername("user");
+		deviceAuthorizationGrantFlow.addScope("message.read");
+		deviceAuthorizationGrantFlow.addScope("message.write");
+
+		Map<String, Object> deviceAuthorizationResponse = deviceAuthorizationGrantFlow.authorize(registeredClient);
+		String userCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.USER_CODE);
+		assertThatAuthorization(userCode, OAuth2ParameterNames.USER_CODE).isNotNull();
+		assertThatAuthorization(userCode, null).isNotNull();
+
+		String deviceCode = (String) deviceAuthorizationResponse.get(OAuth2ParameterNames.DEVICE_CODE);
+		assertThatAuthorization(deviceCode, OAuth2ParameterNames.DEVICE_CODE).isNotNull();
+		assertThatAuthorization(deviceCode, null).isNotNull();
+
+		String state = deviceAuthorizationGrantFlow.submitCode(userCode);
+		assertThatAuthorization(state, OAuth2ParameterNames.STATE).isNotNull();
+		assertThatAuthorization(state, null).isNotNull();
+
+		deviceAuthorizationGrantFlow.submitConsent(registeredClient, state, userCode);
+
+		Map<String, Object> tokenResponse = deviceAuthorizationGrantFlow.getTokenResponse(registeredClient, deviceCode);
+		String accessToken = (String) tokenResponse.get(OAuth2ParameterNames.ACCESS_TOKEN);
+		assertThatAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN).isNotNull();
+		assertThatAuthorization(accessToken, null).isNotNull();
+
+		String refreshToken = (String) tokenResponse.get(OAuth2ParameterNames.REFRESH_TOKEN);
+		assertThatAuthorization(refreshToken, OAuth2ParameterNames.REFRESH_TOKEN).isNotNull();
+		assertThatAuthorization(refreshToken, null).isNotNull();
+
+		String scopes = (String) tokenResponse.get(OAuth2ParameterNames.SCOPE);
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService.findById(
+				registeredClient.getId(), "user");
+		assertThat(authorizationConsent).isNotNull();
+		assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrder(
+				StringUtils.delimitedListToStringArray(scopes, " "));
+	}
+
 	private ObjectAssert<OAuth2Authorization> assertThatAuthorization(String token, String tokenType) {
 		return assertThat(findAuthorization(token, tokenType));
 	}

+ 1 - 0
docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java

@@ -36,6 +36,7 @@ public class RegisteredClients {
 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
 				.redirectUri("http://127.0.0.1:8080/authorized")
 				.postLogoutRedirectUri("http://127.0.0.1:8080/index")
 				.scope(OidcScopes.OPENID)

+ 8 - 0
docs/src/docs/asciidoc/guides/how-to-jpa.adoc

@@ -95,6 +95,14 @@ CREATE TABLE authorization (
     oidcIdTokenExpiresAt timestamp DEFAULT NULL,
     oidcIdTokenMetadata varchar(2000) DEFAULT NULL,
     oidcIdTokenClaims varchar(2000) DEFAULT NULL,
+    userCodeValue varchar(4000) DEFAULT NULL,
+    userCodeIssuedAt timestamp DEFAULT NULL,
+    userCodeExpiresAt timestamp DEFAULT NULL,
+    userCodeMetadata varchar(2000) DEFAULT NULL,
+    deviceCodeValue varchar(4000) DEFAULT NULL,
+    deviceCodeIssuedAt timestamp DEFAULT NULL,
+    deviceCodeExpiresAt timestamp DEFAULT NULL,
+    deviceCodeMetadata varchar(2000) DEFAULT NULL,
     PRIMARY KEY (id)
 );
 ----

+ 91 - 4
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java

@@ -45,8 +45,10 @@ import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
 import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
 import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
@@ -70,6 +72,7 @@ import static org.mockito.Mockito.when;
  * Tests for {@link JdbcOAuth2AuthorizationService}.
  *
  * @author Ovidiu Popa
+ * @author Steve Riesenberg
  */
 public class JdbcOAuth2AuthorizationServiceTests {
 	private static final String OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql";
@@ -78,6 +81,8 @@ public class JdbcOAuth2AuthorizationServiceTests {
 	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
 	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
 	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
+	private static final OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
+	private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
 	private static final String ID = "id";
 	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
 	private static final String PRINCIPAL_NAME = "principal";
@@ -394,6 +399,50 @@ public class JdbcOAuth2AuthorizationServiceTests {
 		assertThat(authorization).isEqualTo(result);
 	}
 
+	@Test
+	public void findByTokenWhenDeviceCodeExistsThenFound() {
+		when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId())))
+				.thenReturn(REGISTERED_CLIENT);
+		OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code",
+				Instant.now().truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.token(deviceCode)
+				.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(
+				deviceCode.getTokenValue(), DEVICE_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenUserCodeExistsThenFound() {
+		when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId())))
+				.thenReturn(REGISTERED_CLIENT);
+		OAuth2UserCode userCode = new OAuth2UserCode("user-code",
+				Instant.now().truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.token(userCode)
+				.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(
+				userCode.getTokenValue(), USER_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
 	@Test
 	public void findByTokenWhenWrongTokenTypeThenNotFound() {
 		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now().truncatedTo(ChronoUnit.MILLIS));
@@ -515,14 +564,23 @@ public class JdbcOAuth2AuthorizationServiceTests {
 				+ "refreshTokenValue,"
 				+ "refreshTokenIssuedAt,"
 				+ "refreshTokenExpiresAt,"
-				+ "refreshTokenMetadata";
+				+ "refreshTokenMetadata,"
+				+ "userCodeValue,"
+				+ "userCodeIssuedAt,"
+				+ "userCodeExpiresAt,"
+				+ "userCodeMetadata,"
+				+ "deviceCodeValue,"
+				+ "deviceCodeIssuedAt,"
+				+ "deviceCodeExpiresAt,"
+				+ "deviceCodeMetadata";
 		// @formatter:on
 
 		private static final String TABLE_NAME = "oauth2Authorization";
 
 		private static final String PK_FILTER = "id = ?";
 		private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorizationCodeValue = ? OR " +
-				"accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ?";
+				"accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ? OR userCodeValue = ? OR " +
+				"deviceCodeValue = ?";
 
 		// @formatter:off
 		private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES
@@ -532,7 +590,7 @@ public class JdbcOAuth2AuthorizationServiceTests {
 
 		// @formatter:off
 		private static final String SAVE_AUTHORIZATION_SQL = "INSERT INTO " + TABLE_NAME
-				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
 		// @formatter:on
 
 		private static final String REMOVE_AUTHORIZATION_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + PK_FILTER;
@@ -567,7 +625,7 @@ public class JdbcOAuth2AuthorizationServiceTests {
 
 		@Override
 		public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
-			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token);
+			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token, token, token);
 		}
 
 		private OAuth2Authorization findBy(String filter, Object... args) {
@@ -672,6 +730,26 @@ public class JdbcOAuth2AuthorizationServiceTests {
 					builder.token(refreshToken, (metadata) -> metadata.putAll(refreshTokenMetadata));
 				}
 
+				tokenValue = rs.getString("userCodeValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("userCodeIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("userCodeExpiresAt").toInstant();
+					Map<String, Object> userCodeMetadata = parseMap(rs.getString("userCodeMetadata"));
+
+					OAuth2UserCode userCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt);
+					builder.token(userCode, (metadata) -> metadata.putAll(userCodeMetadata));
+				}
+
+				tokenValue = rs.getString("deviceCodeValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("deviceCodeIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("deviceCodeExpiresAt").toInstant();
+					Map<String, Object> deviceCodeMetadata = parseMap(rs.getString("deviceCodeMetadata"));
+
+					OAuth2UserCode deviceCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt);
+					builder.token(deviceCode, (metadata) -> metadata.putAll(deviceCodeMetadata));
+				}
+
 				return builder.build();
 			}
 
@@ -738,6 +816,15 @@ public class JdbcOAuth2AuthorizationServiceTests {
 				OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getRefreshToken();
 				List<SqlParameterValue> refreshTokenSqlParameters = toSqlParameterList(refreshToken);
 				parameters.addAll(refreshTokenSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
+				List<SqlParameterValue> userCodeSqlParameters = toSqlParameterList(userCode);
+				parameters.addAll(userCodeSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
+				List<SqlParameterValue> deviceCodeSqlParameters = toSqlParameterList(deviceCode);
+				parameters.addAll(deviceCodeSqlParameters);
+
 				return parameters;
 			}
 

+ 8 - 0
oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql

@@ -24,5 +24,13 @@ CREATE TABLE oauth2_authorization (
     refresh_token_issued_at timestamp DEFAULT NULL,
     refresh_token_expires_at timestamp DEFAULT NULL,
     refresh_token_metadata varchar(2000) DEFAULT NULL,
+    user_code_value clob DEFAULT NULL,
+    user_code_issued_at timestamp DEFAULT NULL,
+    user_code_expires_at timestamp DEFAULT NULL,
+    user_code_metadata varchar(2000) DEFAULT NULL,
+    device_code_value clob DEFAULT NULL,
+    device_code_issued_at timestamp DEFAULT NULL,
+    device_code_expires_at timestamp DEFAULT NULL,
+    device_code_metadata varchar(2000) DEFAULT NULL,
     PRIMARY KEY (id)
 );

+ 8 - 0
oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql

@@ -24,5 +24,13 @@ CREATE TABLE oauth2Authorization (
     refreshTokenIssuedAt timestamp DEFAULT NULL,
     refreshTokenExpiresAt timestamp DEFAULT NULL,
     refreshTokenMetadata varchar(2000) DEFAULT NULL,
+    userCodeValue varchar(1000) DEFAULT NULL,
+    userCodeIssuedAt timestamp DEFAULT NULL,
+    userCodeExpiresAt timestamp DEFAULT NULL,
+    userCodeMetadata varchar(2000) DEFAULT NULL,
+    deviceCodeValue varchar(1000) DEFAULT NULL,
+    deviceCodeIssuedAt timestamp DEFAULT NULL,
+    deviceCodeExpiresAt timestamp DEFAULT NULL,
+    deviceCodeMetadata varchar(2000) DEFAULT NULL,
     PRIMARY KEY (id)
 );