diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java index 3880f4a4..5f92ddf3 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/gettingStarted/SecurityConfig.java +++ b/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) diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java index 95a29e00..356e2eaf 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/authorization/Authorization.java +++ b/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 } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java index 5f542985..11b08ab0 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java +++ b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java @@ -31,11 +31,15 @@ public interface AuthorizationRepository extends JpaRepository findByAccessTokenValue(String accessToken); Optional findByRefreshTokenValue(String refreshToken); Optional findByOidcIdTokenValue(String idToken); + Optional findByUserCodeValue(String userCode); + Optional 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 findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(@Param("token") String token); + Optional findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValueOrUserCodeValueOrDeviceCodeValue(@Param("token") String token); } diff --git a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java b/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java index a4428a87..f24810f0 100644 --- a/docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java +++ b/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 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 userCode = + authorization.getToken(OAuth2UserCode.class); + setTokenValues( + userCode, + entity::setUserCodeValue, + entity::setUserCodeIssuedAt, + entity::setUserCodeExpiresAt, + entity::setUserCodeMetadata + ); + + OAuth2Authorization.Token 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 } diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java index 7339946e..e58a9208 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/AuthorizationCodeGrantFlow.java +++ b/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 { diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/DeviceAuthorizationGrantFlow.java new file mode 100644 index 00000000..0adcdfc2 --- /dev/null +++ b/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(".+.+"); + private static final TypeReference> JSON_RESPONSE_TYPE_REFERENCE = new TypeReference<>() { + }; + + private final MockMvc mockMvc; + + private String username = "user"; + + private Set 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 authorize(RegisteredClient registeredClient) throws Exception { + MultiValueMap 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 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 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 getTokenResponse(RegisteredClient registeredClient, String deviceCode) throws Exception { + MultiValueMap 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); + } +} diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java index ed04a043..fe494967 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java +++ b/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 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 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 assertThatAuthorization(String token, String tokenType) { return assertThat(findAuthorization(token, tokenType)); } diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java index a11b78df..c223318c 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java +++ b/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 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 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 assertThatAuthorization(String token, String tokenType) { return assertThat(findAuthorization(token, tokenType)); } diff --git a/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java b/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java index 28d96b85..e9ccb162 100644 --- a/docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java +++ b/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) diff --git a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc b/docs/src/docs/asciidoc/guides/how-to-jpa.adoc index 69b7376d..9f17c990 100644 --- a/docs/src/docs/asciidoc/guides/how-to-jpa.adoc +++ b/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) ); ---- diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java index 5b08c520..4321aace 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java +++ b/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 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 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 refreshToken = authorization.getRefreshToken(); List refreshTokenSqlParameters = toSqlParameterList(refreshToken); parameters.addAll(refreshTokenSqlParameters); + + OAuth2Authorization.Token userCode = authorization.getToken(OAuth2UserCode.class); + List userCodeSqlParameters = toSqlParameterList(userCode); + parameters.addAll(userCodeSqlParameters); + + OAuth2Authorization.Token deviceCode = authorization.getToken(OAuth2DeviceCode.class); + List deviceCodeSqlParameters = toSqlParameterList(deviceCode); + parameters.addAll(deviceCodeSqlParameters); + return parameters; } diff --git a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql index 3161d302..c32430dc 100644 --- a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql +++ b/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) ); diff --git a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql b/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql index a704b460..462240db 100644 --- a/oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql +++ b/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) );