Add support for OAuth 2.0 Device Authorization Grant

Closes gh-44
This commit is contained in:
Steve Riesenberg
2023-03-09 13:15:45 -06:00
committed by Steve Riesenberg
parent ad01779479
commit 291ba8c92d
57 changed files with 4492 additions and 124 deletions

View File

@@ -0,0 +1,27 @@
plugins {
id "org.springframework.boot" version "3.0.0"
id "io.spring.dependency-management" version "1.0.11.RELEASE"
id "java"
}
group = project.rootProject.group
version = project.rootProject.version
sourceCompatibility = "17"
repositories {
mavenCentral()
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework:spring-webflux"
implementation "org.webjars:webjars-locator-core"
implementation "org.webjars:bootstrap:3.4.1"
implementation "org.webjars:jquery:3.4.1"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test"
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@SpringBootApplication
public class DeviceClientApplication {
public static void main(String[] args) {
SpringApplication.run(DeviceClientApplication.class, args);
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> web.ignoring().requestMatchers("/webjars/**", "/assets/**");
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/", "/authorize").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/"))
)
.oauth2Client(Customizer.withDefaults());
// @formatter:on
return http.build();
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import sample.web.authentication.DeviceCodeOAuth2AuthorizedClientProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
// @formatter:off
return WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
// @formatter:on
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.provider(new DeviceCodeOAuth2AuthorizedClientProvider())
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
// Set a contextAttributesMapper to obtain device_code from the request
authorizedClientManager.setContextAttributesMapper(DeviceCodeOAuth2AuthorizedClientProvider
.deviceCodeContextAttributesMapper());
return authorizedClientManager;
}
}

View File

@@ -0,0 +1,192 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContextHolderStrategy;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import static org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Controller
public class DeviceController {
private static final ParameterizedTypeReference<Map<String, Object>> TYPE_REFERENCE =
new ParameterizedTypeReference<>() {};
private final ClientRegistrationRepository clientRegistrationRepository;
private final WebClient webClient;
private final String messagesBaseUri;
private final SecurityContextRepository securityContextRepository =
new HttpSessionSecurityContextRepository();
private final SecurityContextHolderStrategy securityContextHolderStrategy =
SecurityContextHolder.getContextHolderStrategy();
public DeviceController(ClientRegistrationRepository clientRegistrationRepository, WebClient webClient,
@Value("${messages.base-uri}") String messagesBaseUri) {
this.clientRegistrationRepository = clientRegistrationRepository;
this.webClient = webClient;
this.messagesBaseUri = messagesBaseUri;
}
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/authorize")
public String authorize(Model model, HttpServletRequest request, HttpServletResponse response) {
// @formatter:off
ClientRegistration clientRegistration =
this.clientRegistrationRepository.findByRegistrationId(
"messaging-client-device-grant");
// @formatter:on
MultiValueMap<String, String> requestParameters = new LinkedMultiValueMap<>();
requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
requestParameters.add(OAuth2ParameterNames.SCOPE, StringUtils.collectionToDelimitedString(
clientRegistration.getScopes(), " "));
// @formatter:off
Map<String, Object> responseParameters =
this.webClient.post()
.uri(clientRegistration.getProviderDetails().getAuthorizationUri())
.headers(headers -> headers.setBasicAuth(clientRegistration.getClientId(),
clientRegistration.getClientSecret()))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(requestParameters))
.retrieve()
.bodyToMono(TYPE_REFERENCE)
.block();
// @formatter:on
Objects.requireNonNull(responseParameters, "Device Authorization Response cannot be null");
Instant issuedAt = Instant.now();
Integer expiresIn = (Integer) responseParameters.get(OAuth2ParameterNames.EXPIRES_IN);
Instant expiresAt = issuedAt.plusSeconds(expiresIn);
String deviceCodeValue = (String) responseParameters.get(OAuth2ParameterNames.DEVICE_CODE);
OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(deviceCodeValue, issuedAt, expiresAt);
saveSecurityContext(deviceCode, request, response);
model.addAttribute("deviceCode", deviceCode.getTokenValue());
model.addAttribute("expiresAt", deviceCode.getExpiresAt());
model.addAttribute("userCode", responseParameters.get(OAuth2ParameterNames.USER_CODE));
model.addAttribute("verificationUri", responseParameters.get(OAuth2ParameterNames.VERIFICATION_URI));
// Note: You could use a QR-code to display this URL
model.addAttribute("verificationUriComplete", responseParameters.get(
OAuth2ParameterNames.VERIFICATION_URI_COMPLETE));
return "authorize";
}
/**
* @see DeviceControllerAdvice
*/
@PostMapping("/authorize")
public ResponseEntity<Void> poll(@RequestParam(OAuth2ParameterNames.DEVICE_CODE) String deviceCode,
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
OAuth2AuthorizedClient authorizedClient) {
// The client will repeatedly poll until authorization is granted.
//
// The OAuth2AuthorizedClientManager uses the device_code parameter
// to make a token request, which returns authorization_pending until
// the user has granted authorization.
//
// If the user has denied authorization, access_denied is returned and
// polling should stop.
//
// If the device code expires, expired_token is returned and polling
// should stop.
//
// This endpoint simply returns 200 OK when client is authorized.
return ResponseEntity.status(HttpStatus.OK).build();
}
@GetMapping("/authorized")
public String authorized(Model model,
@RegisteredOAuth2AuthorizedClient("messaging-client-device-grant")
OAuth2AuthorizedClient authorizedClient) {
String[] messages = this.webClient.get()
.uri(this.messagesBaseUri)
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(String[].class)
.block();
model.addAttribute("messages", messages);
return "authorized";
}
private void saveSecurityContext(OAuth2DeviceCode deviceCode, HttpServletRequest request,
HttpServletResponse response) {
// @formatter:off
UsernamePasswordAuthenticationToken deviceAuthentication =
UsernamePasswordAuthenticationToken.authenticated(
deviceCode, null, AuthorityUtils.createAuthorityList("ROLE_DEVICE"));
// @formatter:on
SecurityContext securityContext = this.securityContextHolderStrategy.createEmptyContext();
securityContext.setAuthentication(deviceAuthentication);
this.securityContextHolderStrategy.setContext(securityContext);
this.securityContextRepository.saveContext(securityContext, request, response);
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@ControllerAdvice
public class DeviceControllerAdvice {
private static final Set<String> DEVICE_GRANT_ERRORS = new HashSet<>(Arrays.asList(
"authorization_pending",
"slow_down",
"access_denied",
"expired_token"
));
@ExceptionHandler(OAuth2AuthorizationException.class)
public ResponseEntity<OAuth2Error> handleError(OAuth2AuthorizationException ex) {
String errorCode = ex.getError().getErrorCode();
if (DEVICE_GRANT_ERRORS.contains(errorCode)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ex.getError());
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getError());
}
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web.authentication;
import java.time.Clock;
import java.time.Duration;
import java.util.Collections;
import java.util.Map;
import java.util.function.Function;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.oauth2.client.ClientAuthorizationException;
import org.springframework.security.oauth2.client.OAuth2AuthorizationContext;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.Assert;
/**
* @author Steve Riesenberg
* @since 1.1
*/
public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {
private OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> accessTokenResponseClient =
new OAuth2DeviceAccessTokenResponseClient();
private Duration clockSkew = Duration.ofSeconds(60);
private Clock clock = Clock.systemUTC();
public DeviceCodeOAuth2AuthorizedClientProvider() {
}
public void setAccessTokenResponseClient(OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> accessTokenResponseClient) {
this.accessTokenResponseClient = accessTokenResponseClient;
}
public void setClockSkew(Duration clockSkew) {
this.clockSkew = clockSkew;
}
public void setClock(Clock clock) {
this.clock = clock;
}
@Override
public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
Assert.notNull(context, "context cannot be null");
ClientRegistration clientRegistration = context.getClientRegistration();
if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
return null;
}
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
// If client is already authorized but access token is NOT expired than no
// need for re-authorization
return null;
}
if (authorizedClient != null && authorizedClient.getRefreshToken() != null) {
// If client is already authorized but access token is expired and a
// refresh token is available, delegate to refresh_token.
return null;
}
// *****************************************************************
// Get device_code set via DefaultOAuth2AuthorizedClientManager#setContextAttributesMapper()
// *****************************************************************
String deviceCode = context.getAttribute(OAuth2ParameterNames.DEVICE_CODE);
// Attempt to authorize the client, which will repeatedly fail until the user grants authorization
OAuth2DeviceGrantRequest deviceGrantRequest = new OAuth2DeviceGrantRequest(clientRegistration, deviceCode);
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceGrantRequest);
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
tokenResponse.getAccessToken(), tokenResponse.getRefreshToken());
}
private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
OAuth2DeviceGrantRequest deviceGrantRequest) {
try {
return this.accessTokenResponseClient.getTokenResponse(deviceGrantRequest);
} catch (OAuth2AuthorizationException ex) {
throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
}
}
private boolean hasTokenExpired(OAuth2Token token) {
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
}
public static Function<OAuth2AuthorizeRequest, Map<String, Object>> deviceCodeContextAttributesMapper() {
return (authorizeRequest) -> {
HttpServletRequest request = authorizeRequest.getAttribute(HttpServletRequest.class.getName());
Assert.notNull(request, "request cannot be null");
// Obtain device code from request
String deviceCode = request.getParameter(OAuth2ParameterNames.DEVICE_CODE);
return (deviceCode != null) ? Collections.singletonMap(OAuth2ParameterNames.DEVICE_CODE, deviceCode) :
Collections.emptyMap();
};
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web.authentication;
import java.util.Arrays;
import org.springframework.http.HttpHeaders;
import org.springframework.http.RequestEntity;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;
/**
* @author Steve Riesenberg
* @since 1.1
*/
public final class OAuth2DeviceAccessTokenResponseClient implements OAuth2AccessTokenResponseClient<OAuth2DeviceGrantRequest> {
private RestOperations restOperations;
public OAuth2DeviceAccessTokenResponseClient() {
RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(),
new OAuth2AccessTokenResponseHttpMessageConverter()));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
this.restOperations = restTemplate;
}
public void setRestOperations(RestOperations restOperations) {
this.restOperations = restOperations;
}
@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceGrantRequest deviceGrantRequest) {
ClientRegistration clientRegistration = deviceGrantRequest.getClientRegistration();
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth(clientRegistration.getClientId(), clientRegistration.getClientSecret());
MultiValueMap<String, Object> requestParameters = new LinkedMultiValueMap<>();
requestParameters.add(OAuth2ParameterNames.GRANT_TYPE, deviceGrantRequest.getGrantType().getValue());
requestParameters.add(OAuth2ParameterNames.CLIENT_ID, clientRegistration.getClientId());
requestParameters.add(OAuth2ParameterNames.DEVICE_CODE, deviceGrantRequest.getDeviceCode());
// @formatter:off
RequestEntity<MultiValueMap<String, Object>> requestEntity =
RequestEntity.post(deviceGrantRequest.getClientRegistration().getProviderDetails().getTokenUri())
.headers(headers)
.body(requestParameters);
// @formatter:on
try {
return this.restOperations.exchange(requestEntity, OAuth2AccessTokenResponse.class).getBody();
} catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error("invalid_token_response",
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+ ex.getMessage(), null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web.authentication;
import org.springframework.security.oauth2.client.endpoint.AbstractOAuth2AuthorizationGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.util.Assert;
/**
* @author Steve Riesenberg
* @since 1.1
*/
public final class OAuth2DeviceGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
private final String deviceCode;
public OAuth2DeviceGrantRequest(ClientRegistration clientRegistration, String deviceCode) {
super(AuthorizationGrantType.DEVICE_CODE, clientRegistration);
Assert.hasText(deviceCode, "deviceCode cannot be empty");
this.deviceCode = deviceCode;
}
public String getDeviceCode() {
return deviceCode;
}
}

View File

@@ -0,0 +1,29 @@
server:
port: 8080
logging:
level:
root: INFO
org.springframework.security: trace
spring:
thymeleaf:
cache: false
security:
oauth2:
client:
registration:
messaging-client-device-grant:
provider: spring
client-id: messaging-client
client-secret: secret
authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code
scope: message.read,message.write
client-name: messaging-client-device-grant
provider:
spring:
issuer-uri: http://localhost:9000
authorization-uri: ${spring.security.oauth2.client.provider.spring.issuer-uri}/oauth2/device_authorization
messages:
base-uri: http://127.0.0.1:8090/messages

View File

@@ -0,0 +1,13 @@
html, body, .container, .jumbotron {
height: 100%;
}
.jumbotron {
margin-bottom: 0;
}
.gap {
margin-top: 70px;
}
.code {
font-size: 2em;
letter-spacing: 2rem;
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
plugins {
id "org.springframework.boot" version "3.0.0"
id "io.spring.dependency-management" version "1.0.11.RELEASE"
id "java"
}
group = project.rootProject.group
version = project.rootProject.version
sourceCompatibility = "17"
repositories {
maven {
url = "https://repo.spring.io/snapshot"
}
mavenCentral()
}
// Temporarily use SNAPSHOT version
// TODO: Use 6.1.0-M2 version after release
ext["spring-security.version"] = "6.1.0-SNAPSHOT"
dependencies {
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-jdbc"
implementation project(":spring-security-oauth2-authorization-server")
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework:spring-webflux"
implementation "org.webjars:webjars-locator-core"
implementation "org.webjars:bootstrap:3.4.1"
implementation "org.webjars:jquery:3.4.1"
runtimeOnly "com.h2database:h2"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test"
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@SpringBootApplication
public class DeviceGrantAuthorizationServerApplication {
public static void main(String[] args) {
SpringApplication.run(DeviceGrantAuthorizationServerApplication.class, args);
}
}

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.config;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.deviceAuthorizationEndpoint((deviceAuthorizationEndpoint) -> deviceAuthorizationEndpoint
.verificationUri("/activate")
)
.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0
// @formatter:off
http
.exceptionHandling((exceptions) -> exceptions
.authenticationEntryPoint(
new LoginUrlAuthenticationEntryPoint("/login"))
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
// @formatter:on
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
// @formatter:on
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// @formatter:off
UserDetails userDetails = User.withDefaultPasswordEncoder()
.username("user")
.password("password")
.roles("USER")
.build();
// @formatter:on
return new InMemoryUserDetailsManager(userDetails);
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("messaging-client")
.clientSecret("{noop}secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
.redirectUri("http://127.0.0.1:8080/authorized")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("message.read")
.scope("message.write")
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
RSAKey rsaKey = new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2002-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* @author Steve Riesenberg
*/
@Controller
public class DeviceController {
@GetMapping("/activate")
public String activate(@RequestParam(value = "user_code", required = false) String userCode) {
if (userCode != null) {
return "redirect:/oauth2/device_verification?user_code=" + userCode;
}
return "activate";
}
@GetMapping("/activated")
public String activated() {
return "activated";
}
@GetMapping(value = "/", params = "success")
public String success() {
return "activated";
}
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2020-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package sample.web;
import java.util.Map;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
/**
* @author Steve Riesenberg
* @since 1.1
*/
@Controller
public class DeviceErrorController implements ErrorController {
@RequestMapping("/error")
public ModelAndView handleError(HttpServletRequest request) {
String message = getErrorMessage(request);
if (message.startsWith("[access_denied]")) {
return new ModelAndView("access-denied");
}
return new ModelAndView("error", Map.of("message", message));
}
private String getErrorMessage(HttpServletRequest request) {
return (String) request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
}
}

View File

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

View File

@@ -0,0 +1,13 @@
html, body, .container, .jumbotron {
height: 100%;
}
.jumbotron {
margin-bottom: 0;
}
.gap {
margin-top: 70px;
}
.code {
font-size: 2em;
letter-spacing: 2rem;
}

View File

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

View File

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

View File

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

View File

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