Skip to content

Commit a6a8f0a

Browse files
author
Steve Riesenberg
committed
Add support for OAuth 2.0 Device Authorization Grant
Closes gh-44
1 parent eae6630 commit a6a8f0a

File tree

54 files changed

+4174
-117
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

+4174
-117
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,249 @@
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+
* @author Steve Riesenberg
50+
* @since 1.1
51+
*/
52+
public final class OAuth2DeviceAuthorizationConsentAuthenticationProvider implements AuthenticationProvider {
53+
54+
private static final String DEFAULT_ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1";
55+
private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
56+
57+
private final Log logger = LogFactory.getLog(getClass());
58+
private final RegisteredClientRepository registeredClientRepository;
59+
private final OAuth2AuthorizationService authorizationService;
60+
private final OAuth2AuthorizationConsentService authorizationConsentService;
61+
private Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer;
62+
63+
public OAuth2DeviceAuthorizationConsentAuthenticationProvider(
64+
RegisteredClientRepository registeredClientRepository,
65+
OAuth2AuthorizationService authorizationService,
66+
OAuth2AuthorizationConsentService authorizationConsentService) {
67+
Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
68+
Assert.notNull(authorizationService, "authorizationService cannot be null");
69+
Assert.notNull(authorizationConsentService, "authorizationConsentService cannot be null");
70+
this.registeredClientRepository = registeredClientRepository;
71+
this.authorizationService = authorizationService;
72+
this.authorizationConsentService = authorizationConsentService;
73+
}
74+
75+
@Override
76+
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
77+
OAuth2DeviceAuthorizationConsentAuthenticationToken deviceAuthorizationConsentAuthentication =
78+
(OAuth2DeviceAuthorizationConsentAuthenticationToken) authentication;
79+
80+
OAuth2Authorization authorization = this.authorizationService.findByToken(
81+
deviceAuthorizationConsentAuthentication.getState(), STATE_TOKEN_TYPE);
82+
if (authorization == null) {
83+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
84+
}
85+
86+
if (this.logger.isTraceEnabled()) {
87+
this.logger.trace("Retrieved authorization with device authorization consent state");
88+
}
89+
90+
Authentication principal = (Authentication) deviceAuthorizationConsentAuthentication.getPrincipal();
91+
92+
RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(
93+
deviceAuthorizationConsentAuthentication.getClientId());
94+
if (registeredClient == null || !registeredClient.getId().equals(authorization.getRegisteredClientId())) {
95+
throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
96+
}
97+
98+
if (this.logger.isTraceEnabled()) {
99+
this.logger.trace("Retrieved registered client");
100+
}
101+
102+
OAuth2AuthorizationRequest authorizationRequest = authorization.getAttribute(
103+
OAuth2AuthorizationRequest.class.getName());
104+
Set<String> requestedScopes = authorizationRequest.getScopes();
105+
Set<String> authorizedScopes = deviceAuthorizationConsentAuthentication.getScopes() != null ?
106+
new HashSet<>(deviceAuthorizationConsentAuthentication.getScopes()) :
107+
new HashSet<>();
108+
if (!requestedScopes.containsAll(authorizedScopes)) {
109+
throwError(OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE);
110+
}
111+
112+
if (this.logger.isTraceEnabled()) {
113+
this.logger.trace("Validated device authorization consent request parameters");
114+
}
115+
116+
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService.findById(
117+
authorization.getRegisteredClientId(), principal.getName());
118+
Set<String> currentAuthorizedScopes = currentAuthorizationConsent != null ?
119+
currentAuthorizationConsent.getScopes() : Collections.emptySet();
120+
121+
if (!currentAuthorizedScopes.isEmpty()) {
122+
for (String requestedScope : requestedScopes) {
123+
if (currentAuthorizedScopes.contains(requestedScope)) {
124+
authorizedScopes.add(requestedScope);
125+
}
126+
}
127+
}
128+
129+
OAuth2AuthorizationConsent.Builder authorizationConsentBuilder;
130+
if (currentAuthorizationConsent != null) {
131+
if (this.logger.isTraceEnabled()) {
132+
this.logger.trace("Retrieved existing authorization consent");
133+
}
134+
authorizationConsentBuilder = OAuth2AuthorizationConsent.from(currentAuthorizationConsent);
135+
} else {
136+
authorizationConsentBuilder = OAuth2AuthorizationConsent.withId(
137+
authorization.getRegisteredClientId(), principal.getName());
138+
}
139+
authorizedScopes.forEach(authorizationConsentBuilder::scope);
140+
141+
if (this.authorizationConsentCustomizer != null) {
142+
// @formatter:off
143+
OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext =
144+
OAuth2AuthorizationConsentAuthenticationContext.with(deviceAuthorizationConsentAuthentication)
145+
.authorizationConsent(authorizationConsentBuilder)
146+
.registeredClient(registeredClient)
147+
.authorization(authorization)
148+
.authorizationRequest(authorizationRequest)
149+
.build();
150+
// @formatter:on
151+
this.authorizationConsentCustomizer.accept(authorizationConsentAuthenticationContext);
152+
if (this.logger.isTraceEnabled()) {
153+
this.logger.trace("Customized authorization consent");
154+
}
155+
}
156+
157+
Set<GrantedAuthority> authorities = new HashSet<>();
158+
authorizationConsentBuilder.authorities(authorities::addAll);
159+
160+
OAuth2Authorization.Token<OAuth2DeviceCode> deviceCodeToken = authorization.getToken(OAuth2DeviceCode.class);
161+
OAuth2Authorization.Token<OAuth2UserCode> userCodeToken = authorization.getToken(OAuth2UserCode.class);
162+
163+
if (authorities.isEmpty()) {
164+
// Authorization consent denied (or revoked)
165+
if (currentAuthorizationConsent != null) {
166+
this.authorizationConsentService.remove(currentAuthorizationConsent);
167+
if (this.logger.isTraceEnabled()) {
168+
this.logger.trace("Revoked authorization consent");
169+
}
170+
}
171+
authorization = OAuth2Authorization.from(authorization)
172+
.token(deviceCodeToken.getToken(), metadata ->
173+
metadata.put(OAuth2Authorization.Token.ACCESS_DENIED_METADATA_NAME, true))
174+
.token(userCodeToken.getToken(), metadata ->
175+
metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
176+
.build();
177+
this.authorizationService.save(authorization);
178+
if (this.logger.isTraceEnabled()) {
179+
this.logger.trace("Invalidated device code and user code because authorization consent was denied");
180+
}
181+
throw new OAuth2AuthenticationException(OAuth2ErrorCodes.ACCESS_DENIED);
182+
}
183+
184+
OAuth2AuthorizationConsent authorizationConsent = authorizationConsentBuilder.build();
185+
if (!authorizationConsent.equals(currentAuthorizationConsent)) {
186+
this.authorizationConsentService.save(authorizationConsent);
187+
if (this.logger.isTraceEnabled()) {
188+
this.logger.trace("Saved authorization consent");
189+
}
190+
}
191+
192+
OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
193+
.principalName(principal.getName())
194+
.authorizedScopes(authorizedScopes)
195+
.token(deviceCodeToken.getToken(), metadata -> metadata
196+
.put(OAuth2Authorization.Token.ACCESS_GRANTED_METADATA_NAME, true))
197+
.token(userCodeToken.getToken(), metadata -> metadata
198+
.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
199+
.attribute(Principal.class.getName(), principal)
200+
.attributes(attrs -> attrs.remove(OAuth2ParameterNames.STATE))
201+
.build();
202+
this.authorizationService.save(updatedAuthorization);
203+
204+
if (this.logger.isTraceEnabled()) {
205+
this.logger.trace("Saved authorization with authorized scopes");
206+
// This log is kept separate for consistency with other providers
207+
this.logger.trace("Authenticated authorization consent request");
208+
}
209+
210+
return new OAuth2DeviceVerificationAuthenticationToken(registeredClient.getClientId(), principal,
211+
deviceAuthorizationConsentAuthentication.getUserCode());
212+
}
213+
214+
@Override
215+
public boolean supports(Class<?> authentication) {
216+
return OAuth2DeviceAuthorizationConsentAuthenticationToken.class.isAssignableFrom(authentication);
217+
}
218+
219+
/**
220+
* Sets the {@code Consumer} providing access to the {@link OAuth2AuthorizationConsentAuthenticationContext}
221+
* containing an {@link OAuth2AuthorizationConsent.Builder} and additional context information.
222+
*
223+
* <p>
224+
* The following context attributes are available:
225+
* <ul>
226+
* <li>The {@link OAuth2AuthorizationConsent.Builder} used to build the authorization consent
227+
* prior to {@link OAuth2AuthorizationConsentService#save(OAuth2AuthorizationConsent)}.</li>
228+
* <li>The {@link Authentication} of type
229+
* {@link OAuth2DeviceAuthorizationConsentAuthenticationToken}.</li>
230+
* <li>The {@link RegisteredClient} associated with the authorization request.</li>
231+
* <li>The {@link OAuth2Authorization} associated with the state token presented in the
232+
* authorization consent request.</li>
233+
* <li>The {@link OAuth2AuthorizationRequest} associated with the authorization consent request.</li>
234+
* </ul>
235+
*
236+
* @param authorizationConsentCustomizer the {@code Consumer} providing access to the
237+
* {@link OAuth2AuthorizationConsentAuthenticationContext} containing an {@link OAuth2AuthorizationConsent.Builder}
238+
*/
239+
public void setAuthorizationConsentCustomizer(Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer) {
240+
Assert.notNull(authorizationConsentCustomizer, "authorizationConsentCustomizer cannot be null");
241+
this.authorizationConsentCustomizer = authorizationConsentCustomizer;
242+
}
243+
244+
private static void throwError(String errorCode, String parameterName) {
245+
OAuth2Error error = new OAuth2Error(errorCode, "OAuth 2.0 Parameter: " + parameterName, DEFAULT_ERROR_URI);
246+
throw new OAuth2AuthorizationException(error);
247+
}
248+
249+
}

0 commit comments

Comments
 (0)