Skip to content

Commit f2a6672

Browse files
author
Steve Riesenberg
committed
Add support for OAuth 2.0 Device Authorization Grant
Closes spring-projectsgh-44
1 parent ad01779 commit f2a6672

File tree

54 files changed

+4399
-124
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+4399
-124
lines changed

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424

2525
import org.springframework.lang.Nullable;
2626
import org.springframework.security.oauth2.core.OAuth2AccessToken;
27+
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
2728
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
29+
import org.springframework.security.oauth2.core.OAuth2UserCode;
2830
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
2931
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
3032
import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
@@ -164,6 +166,10 @@ private static boolean hasToken(OAuth2Authorization authorization, String token,
164166
return matchesIdToken(authorization, token);
165167
} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
166168
return matchesRefreshToken(authorization, token);
169+
} else if (OAuth2ParameterNames.DEVICE_CODE.equals(tokenType.getValue())) {
170+
return matchesDeviceCode(authorization, token);
171+
} else if (OAuth2ParameterNames.USER_CODE.equals(tokenType.getValue())) {
172+
return matchesUserCode(authorization, token);
167173
}
168174
return false;
169175
}
@@ -196,6 +202,18 @@ private static boolean matchesIdToken(OAuth2Authorization authorization, String
196202
return idToken != null && idToken.getToken().getTokenValue().equals(token);
197203
}
198204

205+
private static boolean matchesDeviceCode(OAuth2Authorization authorization, String token) {
206+
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode =
207+
authorization.getToken(OAuth2DeviceCode.class);
208+
return deviceCode != null && deviceCode.getToken().getTokenValue().equals(token);
209+
}
210+
211+
private static boolean matchesUserCode(OAuth2Authorization authorization, String token) {
212+
OAuth2Authorization.Token<OAuth2UserCode> userCode =
213+
authorization.getToken(OAuth2UserCode.class);
214+
return userCode != null && userCode.getToken().getTokenValue().equals(token);
215+
}
216+
199217
private static final class MaxSizeHashMap<K, V> extends LinkedHashMap<K, V> {
200218
private final int maxSize;
201219

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/OAuth2Authorization.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2022 the original author or authors.
2+
* Copyright 2020-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -253,6 +253,18 @@ public static class Token<T extends OAuth2Token> implements Serializable {
253253
*/
254254
public static final String INVALIDATED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("invalidated");
255255

256+
/**
257+
* The name of the metadata that indicates if access has been denied by the resource owner.
258+
* Used with the OAuth 2.0 Device Authorization Grant.
259+
*/
260+
public static final String ACCESS_DENIED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_denied");
261+
262+
/**
263+
* The name of the metadata that indicates if access has been denied by the resource owner.
264+
* Used with the OAuth 2.0 Device Authorization Grant.
265+
*/
266+
public static final String ACCESS_GRANTED_METADATA_NAME = TOKEN_METADATA_NAMESPACE.concat("access_granted");
267+
256268
/**
257269
* The name of the metadata used for the claims of the token.
258270
*/

oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContext.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2022 the original author or authors.
2+
* Copyright 2020-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -113,6 +113,10 @@ private Builder(OAuth2AuthorizationConsentAuthenticationToken authentication) {
113113
super(authentication);
114114
}
115115

116+
private Builder(OAuth2DeviceAuthorizationConsentAuthenticationToken authentication) {
117+
super(authentication);
118+
}
119+
116120
/**
117121
* Sets the {@link OAuth2AuthorizationConsent.Builder authorization consent builder}.
118122
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Copyright 2020-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.server.authorization.authentication;
17+
18+
import java.security.Principal;
19+
import java.util.Collections;
20+
import java.util.HashSet;
21+
import java.util.Set;
22+
import java.util.function.Consumer;
23+
24+
import org.apache.commons.logging.Log;
25+
import org.apache.commons.logging.LogFactory;
26+
27+
import org.springframework.security.authentication.AuthenticationProvider;
28+
import org.springframework.security.core.Authentication;
29+
import org.springframework.security.core.AuthenticationException;
30+
import org.springframework.security.core.GrantedAuthority;
31+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
32+
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
33+
import org.springframework.security.oauth2.core.OAuth2DeviceCode;
34+
import org.springframework.security.oauth2.core.OAuth2Error;
35+
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
36+
import org.springframework.security.oauth2.core.OAuth2UserCode;
37+
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
38+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
39+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
40+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
41+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
42+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
43+
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
44+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
45+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
46+
import org.springframework.util.Assert;
47+
48+
/**
49+
* An {@link AuthenticationProvider} implementation for the OAuth 2.0 Authorization Consent
50+
* used in the Device Authorization Grant.
51+
*
52+
* @author Steve Riesenberg
53+
* @since 1.1
54+
* @see OAuth2DeviceAuthorizationConsentAuthenticationToken
55+
* @see OAuth2AuthorizationConsent
56+
* @see OAuth2DeviceAuthorizationRequestAuthenticationProvider
57+
* @see OAuth2DeviceVerificationAuthenticationProvider
58+
* @see OAuth2DeviceCodeAuthenticationProvider
59+
* @see RegisteredClientRepository
60+
* @see OAuth2AuthorizationService
61+
* @see OAuth2AuthorizationConsentService
62+
*/
63+
public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider {
64+
65+
private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
66+
private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
67+
68+
private final Log logger = LogFactory.getLog(getClass());
69+
private final RegisteredClientRepository registeredClientRepository;
70+
private final OAuth2AuthorizationService authorizationService;
71+
private final OAuth2AuthorizationConsentService authorizationConsentService;
72+
private Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer;
73+
74+
/**
75+
* Constructs an {@code OAuth2DeviceAuthorizationConsentAuthenticationProvider} using the provided parameters.
76+
*
77+
* @param registeredClientRepository the repository of registered clients
78+
* @param authorizationService the authorization service
79+
* @param authorizationConsentService the authorization consent service
80+
*/
81+
public OAuth2DeviceAuthorizationConsentAuthenticationProvider(
82+
RegisteredClientRepository registeredClientRepository,
83+
OAuth2AuthorizationService authorizationService,
84+
OAuth2AuthorizationConsentService authorizationConsentService) {
85+
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
86+
Assert.notNull(authorizationService, "authorizationService cannot be null");
87+
Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null");
88+
this.registeredClientRepository = registeredClientRepository;
89+
this.authorizationService = authorizationService;
90+
this.authorizationConsentService = authorizationConsentService;
91+
}
92+
93+
@Override
94+
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
95+
OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication =
96+
(OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication;
97+
98+
OAuth2Authorization authorization = this.authorizationService.findByToken(
99+
deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE);
100+
if (authorization == null) {
101+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
102+
}
103+
104+
if (this.logger.isTraceEnabled()) {
105+
this.logger.trace("Retrieved authorization with device authorization consent state");
106+
}
107+
108+
Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal();
109+
110+
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(
111+
deviceAuthorizationConsentAuthentication.getClientId());
112+
if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) {
113+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
114+
}
115+
116+
if (this.logger.isTraceEnabled()) {
117+
this.logger.trace("Retrieved registered client");
118+
}
119+
120+
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
121+
OAuth2AuthorizationRequest.class.getName());
122+
Set<String> requestedScopes = authorizationRequest.getScopes();
123+
Set<String> authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ?
124+
new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) :
125+
new HashSet<>();
126+
if (!requestedScopes.containsAll(authorizedScopes)) {
127+
throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE);
128+
}
129+
130+
if (this.logger.isTraceEnabled()) {
131+
this.logger.trace("Validated device authorization consent request parameters");
132+
}
133+
134+
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(
135+
authorization.getRegisteredClientId(), principal.getName());
136+
Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ?
137+
currentAuthorizationConsent.getScopes() : Collections.emptySet();
138+
139+
if (!currentAuthorizedScopes.isEmpty()) {
140+
for (String requestedScope : requestedScopes) {
141+
if (currentAuthorizedScopes.contains(requestedScope)) {
142+
authorizedScopes.add(requestedScope);
143+
}
144+
}
145+
}
146+
147+
OAuth2AuthorizationConsent.Builder authorizationConsentBuilder;
148+
if (currentAuthorizationConsent != null) {
149+
if (this.logger.isTraceEnabled()) {
150+
this.logger.trace("Retrieved existing authorization consent");
151+
}
152+
authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent);
153+
} else {
154+
authorizationConsentBuilder = OAuth2AuthorizationConsent.withId(
155+
authorization.getRegisteredClientId(), principal.getName());
156+
}
157+
authorizedScopes.forEach(authorizationConsentBuilder::scope);
158+
159+
if (this.authorizationConsentCustomizer != null) {
160+
// @formatter:off
161+
OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext =
162+
OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication)
163+
.authorizationConsent(authorizationConsentBuilder)
164+
.registeredClient(registeredClient)
165+
.authorization(authorization)
166+
.authorizationRequest(authorizationRequest)
167+
.build();
168+
// @formatter:on
169+
this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext);
170+
if (this.logger.isTraceEnabled()) {
171+
this.logger.trace("Customized authorization consent");
172+
}
173+
}
174+
175+
Set<GrantedAuthority> authorities = new HashSet<>();
176+
authorizationConsentBuilder.authorities(authorities::addAll);
177+
178+
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class);
179+
OAuth2Authorization.Token<OAuth2UserCode> userCodeToken = authorization.getToken(OAuth2UserCode.class);
180+
181+
if (authorities.isEmpty()) {
182+
// Authorization consent denied (or revoked)
183+
if (currentAuthorizationConsent != null) {
184+
this.authorizationConsentService.remove(currentAuthorizationConsent);
185+
if (this.logger.isTraceEnabled()) {
186+
this.logger.trace("Revoked authorization consent");
187+
}
188+
}
189+
authorization = OAuth2Authorization.from(authorization)
190+
.token(deviceCodeToken.getToken(), metadata ->
191+
metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true))
192+
.token(userCodeToken.getToken(), metadata ->
193+
metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
194+
.build();
195+
this.authorizationService.save(authorization);
196+
if (this.logger.isTraceEnabled()) {
197+
this.logger.trace("Invalidated device code and user code because authorization consent was denied");
198+
}
199+
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED);
200+
}
201+
202+
OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build();
203+
if (!authorizationConsent.equals(currentAuthorizationConsent)) {
204+
this.authorizationConsentService.save(authorizationConsent);
205+
if (this.logger.isTraceEnabled()) {
206+
this.logger.trace("Saved authorization consent");
207+
}
208+
}
209+
210+
OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
211+
.principalName(principal.getName())
212+
.authorizedScopes(authorizedScopes)
213+
.token(deviceCodeToken.getToken(), metadata -> metadata
214+
.put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true))
215+
.token(userCodeToken.getToken(), metadata -> metadata
216+
.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
217+
.attribute(Principal.class.getName(), principal)
218+
.attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE))
219+
.build();
220+
this.authorizationService.save(updatedAuthorization);
221+
222+
if (this.logger.isTraceEnabled()) {
223+
this.logger.trace("Saved authorization with authorized scopes");
224+
// This log is kept separate for consistency with other providers
225+
this.logger.trace("Authenticated authorization consent request");
226+
}
227+
228+
return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal,
229+
deviceAuthorizationConsentAuthentication.getUserCode());
230+
}
231+
232+
@Override
233+
public boolean supports(Class<?> authentication) {
234+
return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication);
235+
}
236+
237+
/**
238+
* Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext}
239+
* containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information.
240+
*
241+
* <p>
242+
* The following context attributes are available:
243+
* <ul>
244+
* <li>The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent
245+
* prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.</li>
246+
* <li>The {@link Authentication} of type
247+
* {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.</li>
248+
* <li>The {@link RegisteredClient} associated with the authorization request.</li>
249+
* <li>The {@link OAuth2Authorization} associated with the state token presented in the
250+
* authorization consent request.</li>
251+
* <li>The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.</li>
252+
* </ul>
253+
*
254+
* @param authorizationConsentCustomizer the {@code Consumer} providing access to the
255+
* {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder}
256+
*/
257+
public void setAuthorizationConsentCustomizer(Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer) {
258+
Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null");
259+
this.authorizationConsentCustomizer = authorizationConsentCustomizer;
260+
}
261+
262+
private static void throwError(String errorCode, String parameterName) {
263+
OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI);
264+
throw new OAuth2AuthorizationException(error);
265+
}
266+
267+
}

0 commit comments

Comments
 (0)