Skip to content

Commit 1bfbd37

Browse files
Dinesh GuptaDinesh Gupta
authored andcommitted
Add device verification authentication context support
Previously, device consent handling did not provide a dedicated context for device verification authentication flows. This commit introduces OAuth2DeviceVerificationAuthenticationContext and updates related providers and tests to enhance device authorization and consent flows. Fixes gh-1965 Signed-off-by: Dinesh Gupta <dineshgupta630@outlook.com> Add Predicate for authorizationConsentRequired for device code grant Introduce a customizable Predicate to determine whether user authorization consent is required in the Device Code grant flow. This enhancement allows applications to define custom logic for skipping or displaying the consent page, enabling greater flexibility to handle cases where user code confirmation and scope approval may be decoupled. The default behavior is preserved, but can be overridden by calling OAuth2DeviceVerificationAuthenticationProvider#setAuthorizationConsentRequired(Predicate). Closes: gh-1965 Signed-off-by: Dinesh Gupta <dineshgupta630@outlook.com> Add Predicate for authorizationConsentRequired for device code grant This commit introduces a Predicate extension point for determining if user consent is required during the OAuth 2.0 Device Authorization Grant (device code flow). - Adds OAuth2DeviceVerificationAuthenticationContext to provide context to the Predicate - Updates OAuth2DeviceVerificationAuthenticationProvider to support a custom Predicate via setAuthorizationConsentRequired - Refactors default consent logic to use the Predicate - Updates and adds tests for custom Predicate behavior Closes gh-1965 Signed-off-by: Dinesh Gupta <dineshgupta630@outlook.com> Refactor DeviceVerification context to align with code grant context Refactored OAuth2DeviceVerificationAuthenticationContext to use a map-based structure consistent with OAuth2AuthorizationCodeRequestAuthenticationContext. Aligned method signatures, builder pattern, and attribute handling for consistency and extensibility. Updated OAuth2DeviceVerificationAuthenticationProvider to use the revised context and normalize requested scopes. Closes gh-1965-device-consent Authored-by: Dinesh Gupta <dineshgupta630@outlook.com> Align device verification consent logic with code grant context Refactored OAuth2DeviceVerificationAuthenticationProvider and its tests to ensure the device verification consent logic and structure are consistent with the authorization code flow. Improved test consistency, predicate usage, and aligned context handling for maintainability. Closes gh-1965-device-consent Authored-by: Dinesh Gupta <dineshgupta630@outlook.com> Clarify Javadoc for device consent predicate Closes gh-1965-device-consent Authored-by: Dinesh Gupta <dineshgupta630@outlook.com>
1 parent 76ae518 commit 1bfbd37

File tree

3 files changed

+317
-5
lines changed

3 files changed

+317
-5
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright 2025 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.util.Collections;
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
import java.util.Set;
22+
23+
import org.springframework.lang.Nullable;
24+
import org.springframework.security.core.Authentication;
25+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
26+
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
27+
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
28+
import org.springframework.util.Assert;
29+
30+
/**
31+
* An {@link OAuth2AuthenticationContext} that holds an
32+
* {@link OAuth2DeviceVerificationAuthenticationToken} and additional information and is
33+
* used when validating the OAuth 2.0 Device Verification Request parameters, as well as
34+
* determining if authorization consent is required.
35+
*
36+
* @author Dinesh Gupta
37+
* @since 2.0.0
38+
* @see OAuth2AuthenticationContext
39+
* @see OAuth2DeviceVerificationAuthenticationToken
40+
* @see OAuth2DeviceVerificationAuthenticationProvider#setAuthorizationConsentRequired(java.util.function.Predicate)
41+
*/
42+
public final class OAuth2DeviceVerificationAuthenticationContext implements OAuth2AuthenticationContext {
43+
44+
private final Map<Object, Object> context;
45+
46+
private OAuth2DeviceVerificationAuthenticationContext(Map<Object, Object> context) {
47+
this.context = Collections.unmodifiableMap(new HashMap<>(context));
48+
}
49+
50+
@SuppressWarnings("unchecked")
51+
@Nullable
52+
@Override
53+
public <T extends Authentication> T getAuthentication() {
54+
return (T) get(OAuth2DeviceVerificationAuthenticationToken.class);
55+
}
56+
57+
@Override
58+
public boolean hasKey(Object key) {
59+
Assert.notNull(key, "key cannot be null");
60+
return this.context.containsKey(key);
61+
}
62+
63+
@SuppressWarnings("unchecked")
64+
@Nullable
65+
@Override
66+
public <V> V get(Object key) {
67+
return hasKey(key) ? (V) this.context.get(key) : null;
68+
}
69+
70+
/**
71+
* Returns the {@link RegisteredClient registered client}.
72+
* @return the {@link RegisteredClient}
73+
*/
74+
public RegisteredClient getRegisteredClient() {
75+
return get(RegisteredClient.class);
76+
}
77+
78+
/**
79+
* Returns the {@link OAuth2Authorization authorization}.
80+
* @return the {@link OAuth2Authorization}, or {@code null} if not available
81+
*/
82+
@Nullable
83+
public OAuth2Authorization getAuthorization() {
84+
return get(OAuth2Authorization.class);
85+
}
86+
87+
/**
88+
* Returns the {@link OAuth2AuthorizationConsent authorization consent}.
89+
* @return the {@link OAuth2AuthorizationConsent}, or {@code null} if not available
90+
*/
91+
@Nullable
92+
public OAuth2AuthorizationConsent getAuthorizationConsent() {
93+
return get(OAuth2AuthorizationConsent.class);
94+
}
95+
96+
/**
97+
* Returns the requested scopes. Never {@code null}; always a {@link Set} (possibly
98+
* empty).
99+
* @return the requested scopes
100+
*/
101+
@SuppressWarnings("unchecked")
102+
public Set<String> getRequestedScopes() {
103+
Set<String> scopes = get(Set.class);
104+
return scopes != null ? scopes : Collections.emptySet();
105+
}
106+
107+
/**
108+
* Constructs a new {@link Builder} with the provided
109+
* {@link OAuth2DeviceVerificationAuthenticationToken}.
110+
* @param authentication the {@link OAuth2DeviceVerificationAuthenticationToken}
111+
* @return the {@link Builder}
112+
*/
113+
public static Builder with(OAuth2DeviceVerificationAuthenticationToken authentication) {
114+
return new Builder(authentication);
115+
}
116+
117+
/**
118+
* A builder for {@link OAuth2DeviceVerificationAuthenticationContext}.
119+
*/
120+
public static final class Builder {
121+
122+
private final Map<Object, Object> context = new HashMap<>();
123+
124+
private Builder(OAuth2DeviceVerificationAuthenticationToken authentication) {
125+
Assert.notNull(authentication, "authentication cannot be null");
126+
context.put(OAuth2DeviceVerificationAuthenticationToken.class, authentication);
127+
}
128+
129+
/**
130+
* Sets the {@link RegisteredClient registered client}.
131+
* @param registeredClient the {@link RegisteredClient}
132+
* @return the {@link Builder} for further configuration
133+
*/
134+
public Builder registeredClient(RegisteredClient registeredClient) {
135+
context.put(RegisteredClient.class, registeredClient);
136+
return this;
137+
}
138+
139+
/**
140+
* Sets the {@link OAuth2Authorization authorization}.
141+
* @param authorization the {@link OAuth2Authorization}
142+
* @return the {@link Builder} for further configuration
143+
*/
144+
public Builder authorization(@Nullable OAuth2Authorization authorization) {
145+
if (authorization != null) {
146+
context.put(OAuth2Authorization.class, authorization);
147+
}
148+
return this;
149+
}
150+
151+
/**
152+
* Sets the {@link OAuth2AuthorizationConsent authorization consent}.
153+
* @param authorizationConsent the {@link OAuth2AuthorizationConsent}
154+
* @return the {@link Builder} for further configuration
155+
*/
156+
public Builder authorizationConsent(@Nullable OAuth2AuthorizationConsent authorizationConsent) {
157+
if (authorizationConsent != null) {
158+
context.put(OAuth2AuthorizationConsent.class, authorizationConsent);
159+
}
160+
return this;
161+
}
162+
163+
/**
164+
* Sets the requested scopes. Never {@code null}; always a {@link Set} (possibly
165+
* empty).
166+
* @param requestedScopes the requested scopes
167+
* @return the {@link Builder} for further configuration
168+
*/
169+
public Builder requestedScopes(@Nullable Set<String> requestedScopes) {
170+
context.put(Set.class, requestedScopes != null ? requestedScopes : Collections.emptySet());
171+
return this;
172+
}
173+
174+
/**
175+
* Builds a new {@link OAuth2DeviceVerificationAuthenticationContext}.
176+
* @return the {@link OAuth2DeviceVerificationAuthenticationContext}
177+
*/
178+
public OAuth2DeviceVerificationAuthenticationContext build() {
179+
Assert.notNull(context.get(RegisteredClient.class), "registeredClient cannot be null");
180+
return new OAuth2DeviceVerificationAuthenticationContext(context);
181+
}
182+
183+
}
184+
185+
}

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.security.Principal;
1919
import java.util.Base64;
2020
import java.util.Set;
21+
import java.util.function.Predicate;
2122

2223
import org.apache.commons.logging.Log;
2324
import org.apache.commons.logging.LogFactory;
@@ -78,6 +79,8 @@ public final class OAuth2DeviceVerificationAuthenticationProvider implements Aut
7879

7980
private final OAuth2AuthorizationConsentService authorizationConsentService;
8081

82+
private Predicate<OAuth2DeviceVerificationAuthenticationContext> authorizationConsentRequired = OAuth2DeviceVerificationAuthenticationProvider::isAuthorizationConsentRequired;
83+
8184
/**
8285
* Constructs an {@code OAuth2DeviceVerificationAuthenticationProvider} using the
8386
* provided parameters.
@@ -140,12 +143,19 @@ public Authentication authenticate(Authentication authentication) throws Authent
140143
this.logger.trace("Retrieved registered client");
141144
}
142145

146+
OAuth2DeviceVerificationAuthenticationContext.Builder authenticationContextBuilder = OAuth2DeviceVerificationAuthenticationContext
147+
.with(deviceVerificationAuthentication)
148+
.registeredClient(registeredClient)
149+
.authorization(authorization);
150+
143151
Set<String> requestedScopes = authorization.getAttribute(OAuth2ParameterNames.SCOPE);
152+
authenticationContextBuilder.requestedScopes(requestedScopes);
144153

145154
OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService
146155
.findById(registeredClient.getId(), principal.getName());
156+
authenticationContextBuilder.authorizationConsent(currentAuthorizationConsent);
147157

148-
if (requiresAuthorizationConsent(requestedScopes, currentAuthorizationConsent)) {
158+
if (this.authorizationConsentRequired.test(authenticationContextBuilder.build())) {
149159
String state = DEFAULT_STATE_GENERATOR.generateKey();
150160
authorization = OAuth2Authorization.from(authorization)
151161
.principalName(principal.getName())
@@ -201,13 +211,38 @@ public boolean supports(Class<?> authentication) {
201211
return OAuth2DeviceVerificationAuthenticationToken.class.isAssignableFrom(authentication);
202212
}
203213

204-
private static boolean requiresAuthorizationConsent(Set<String> requestedScopes,
205-
OAuth2AuthorizationConsent authorizationConsent) {
214+
/**
215+
* Sets the {@code Predicate} used to determine if authorization consent is required
216+
* during the OAuth 2.0 Device Verification flow.
217+
*
218+
* <p>
219+
* The {@link OAuth2DeviceVerificationAuthenticationContext} provides the predicate
220+
* access to the following context attributes:
221+
* <ul>
222+
* <li>The {@link RegisteredClient} associated with the authorization request.</li>
223+
* <li>The {@link OAuth2Authorization} associated with the device verification.</li>
224+
* <li>The {@link OAuth2AuthorizationConsent} previously granted to the
225+
* {@link RegisteredClient}, or {@code null} if not available.</li>
226+
* </ul>
227+
* </p>
228+
* @param authorizationConsentRequired the {@code Predicate} used to determine if
229+
* authorization consent is required for device verification
230+
* @since 2.0.0
231+
*/
232+
public void setAuthorizationConsentRequired(
233+
Predicate<OAuth2DeviceVerificationAuthenticationContext> authorizationConsentRequired) {
234+
Assert.notNull(authorizationConsentRequired, "authorizationConsentRequired cannot be null");
235+
this.authorizationConsentRequired = authorizationConsentRequired;
236+
}
237+
238+
private static boolean isAuthorizationConsentRequired(
239+
OAuth2DeviceVerificationAuthenticationContext authenticationContext) {
206240

207-
if (authorizationConsent != null && authorizationConsent.getScopes().containsAll(requestedScopes)) {
241+
if (authenticationContext.getAuthorizationConsent() != null && authenticationContext.getAuthorizationConsent()
242+
.getScopes()
243+
.containsAll(authenticationContext.getRequestedScopes())) {
208244
return false;
209245
}
210-
211246
return true;
212247
}
213248

oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Map;
2323
import java.util.function.Consumer;
2424
import java.util.function.Function;
25+
import java.util.function.Predicate;
2526

2627
import org.junit.jupiter.api.BeforeEach;
2728
import org.junit.jupiter.api.Test;
@@ -50,10 +51,12 @@
5051
import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
5152
import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
5253
import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
54+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
5355

5456
import static org.assertj.core.api.Assertions.assertThat;
5557
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
5658
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
59+
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
5760
import static org.mockito.ArgumentMatchers.any;
5861
import static org.mockito.ArgumentMatchers.anyString;
5962
import static org.mockito.ArgumentMatchers.eq;
@@ -124,6 +127,13 @@ public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgu
124127
// @formatter:on
125128
}
126129

130+
@Test
131+
public void setAuthorizationConsentRequiredWhenNullThenThrowIllegalArgumentException() {
132+
assertThatThrownBy(() -> this.authenticationProvider.setAuthorizationConsentRequired(null))
133+
.isInstanceOf(IllegalArgumentException.class)
134+
.hasMessage("authorizationConsentRequired cannot be null");
135+
}
136+
127137
@Test
128138
public void supportsWhenTypeOAuth2DeviceVerificationAuthenticationTokenThenReturnTrue() {
129139
assertThat(this.authenticationProvider.supports(OAuth2DeviceVerificationAuthenticationToken.class)).isTrue();
@@ -381,6 +391,88 @@ public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesDoNotMat
381391
.isEqualTo(authenticationResult.getState());
382392
}
383393

394+
@Test
395+
public void authenticateWhenCustomAuthorizationConsentRequiredThenUsed() {
396+
@SuppressWarnings("unchecked")
397+
Predicate<OAuth2DeviceVerificationAuthenticationContext> authorizationConsentRequired = mock(Predicate.class);
398+
given(authorizationConsentRequired.test(any())).willReturn(true);
399+
this.authenticationProvider.setAuthorizationConsentRequired(authorizationConsentRequired);
400+
401+
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
402+
given(this.registeredClientRepository.findById(eq(registeredClient.getId()))).willReturn(registeredClient);
403+
404+
OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
405+
.token(createDeviceCode())
406+
.token(createUserCode())
407+
.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
408+
.build();
409+
410+
TestingAuthenticationToken principal = new TestingAuthenticationToken("user", "password");
411+
principal.setAuthenticated(true);
412+
413+
OAuth2DeviceVerificationAuthenticationToken authentication = new OAuth2DeviceVerificationAuthenticationToken(
414+
principal, USER_CODE, Collections.emptyMap());
415+
416+
given(this.authorizationService.findByToken(eq(USER_CODE),
417+
eq(OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE)))
418+
.willReturn(authorization);
419+
given(this.authorizationConsentService.findById(eq(registeredClient.getId()), eq(principal.getName())))
420+
.willReturn(null);
421+
422+
OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider
423+
.authenticate(authentication);
424+
425+
assertDeviceVerificationRequestWithAuthorizationConsentResult(registeredClient, authentication,
426+
authenticationResult);
427+
428+
verify(authorizationConsentRequired).test(any());
429+
}
430+
431+
private void assertDeviceVerificationRequestWithAuthorizationConsentResult(RegisteredClient registeredClient,
432+
OAuth2DeviceVerificationAuthenticationToken authentication,
433+
OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult) {
434+
435+
assertThat(authenticationResult.isAuthenticated()).isTrue();
436+
assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
437+
assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
438+
assertThat(authenticationResult.getUserCode()).isEqualTo(authentication.getUserCode());
439+
assertThat(authenticationResult.getRequestedScopes())
440+
.containsExactlyInAnyOrderElementsOf(registeredClient.getScopes());
441+
assertThat(authenticationResult.getState()).isNotNull();
442+
}
443+
444+
@Test
445+
public void authenticateSkipsConsentPageWhenConsentNotRequired() {
446+
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
447+
.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
448+
.build();
449+
450+
this.authenticationProvider.setAuthorizationConsentRequired(
451+
ctx -> ctx.getRegisteredClient().getClientSettings().isRequireAuthorizationConsent());
452+
453+
OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
454+
.token(createDeviceCode())
455+
.token(createUserCode())
456+
.attributes(Map::clear)
457+
.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
458+
.build();
459+
460+
TestingAuthenticationToken principal = new TestingAuthenticationToken("user", "password");
461+
principal.setAuthenticated(true);
462+
Authentication authentication = new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE,
463+
Collections.emptyMap());
464+
465+
given(this.registeredClientRepository.findById(anyString())).willReturn(registeredClient);
466+
given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
467+
given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(null);
468+
469+
Authentication result = this.authenticationProvider.authenticate(authentication);
470+
471+
assertThat(result).isInstanceOf(OAuth2DeviceVerificationAuthenticationToken.class)
472+
.extracting(Authentication::isAuthenticated)
473+
.isEqualTo(true);
474+
}
475+
384476
private static void mockAuthorizationServerContext() {
385477
AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
386478
TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(

0 commit comments

Comments
 (0)