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,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>