Skip to content

Commit 85c3d0a

Browse files
committed
Add reactive support for OAuth 2.0 Token Exchange Grant
Issue gh-5199
1 parent d2fe909 commit 85c3d0a

File tree

4 files changed

+1292
-0
lines changed

4 files changed

+1292
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.security.oauth2.client;
18+
19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.util.function.Function;
23+
24+
import reactor.core.publisher.Mono;
25+
26+
import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient;
27+
import org.springframework.security.oauth2.client.endpoint.TokenExchangeGrantRequest;
28+
import org.springframework.security.oauth2.client.endpoint.WebClientReactiveTokenExchangeTokenResponseClient;
29+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
30+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
31+
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
32+
import org.springframework.security.oauth2.core.OAuth2Token;
33+
import org.springframework.util.Assert;
34+
35+
/**
36+
* An implementation of an {@link ReactiveOAuth2AuthorizedClientProvider} for the
37+
* {@link AuthorizationGrantType#TOKEN_EXCHANGE token-exchange} grant.
38+
*
39+
* @author Steve Riesenberg
40+
* @since 6.3
41+
* @see ReactiveOAuth2AuthorizedClientProvider
42+
* @see WebClientReactiveTokenExchangeTokenResponseClient
43+
*/
44+
public final class TokenExchangeReactiveOAuth2AuthorizedClientProvider
45+
implements ReactiveOAuth2AuthorizedClientProvider {
46+
47+
private ReactiveOAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> accessTokenResponseClient = new WebClientReactiveTokenExchangeTokenResponseClient();
48+
49+
private Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> subjectTokenResolver = this::resolveSubjectToken;
50+
51+
private Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> actorTokenResolver = (context) -> Mono.empty();
52+
53+
private Duration clockSkew = Duration.ofSeconds(60);
54+
55+
private Clock clock = Clock.systemUTC();
56+
57+
/**
58+
* Attempt to authorize (or re-authorize) the
59+
* {@link OAuth2AuthorizationContext#getClientRegistration() client} in the provided
60+
* {@code context}. Returns an empty {@code Mono} if authorization (or
61+
* re-authorization) is not supported, e.g. the client's
62+
* {@link ClientRegistration#getAuthorizationGrantType() authorization grant type} is
63+
* not {@link AuthorizationGrantType#TOKEN_EXCHANGE token-exchange} OR the
64+
* {@link OAuth2AuthorizedClient#getAccessToken() access token} is not expired.
65+
* @param context the context that holds authorization-specific state for the client
66+
* @return the {@link OAuth2AuthorizedClient} or an empty {@code Mono} if
67+
* authorization is not supported
68+
*/
69+
@Override
70+
public Mono<OAuth2AuthorizedClient> authorize(OAuth2AuthorizationContext context) {
71+
Assert.notNull(context, "context cannot be null");
72+
ClientRegistration clientRegistration = context.getClientRegistration();
73+
if (!AuthorizationGrantType.TOKEN_EXCHANGE.equals(clientRegistration.getAuthorizationGrantType())) {
74+
return Mono.empty();
75+
}
76+
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
77+
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
78+
// If client is already authorized but access token is NOT expired than no
79+
// need for re-authorization
80+
return Mono.empty();
81+
}
82+
83+
return this.subjectTokenResolver.apply(context)
84+
.flatMap((subjectToken) -> this.actorTokenResolver.apply(context)
85+
.map((actorToken) -> new TokenExchangeGrantRequest(clientRegistration, subjectToken, actorToken))
86+
.defaultIfEmpty(new TokenExchangeGrantRequest(clientRegistration, subjectToken, null)))
87+
.flatMap(this.accessTokenResponseClient::getTokenResponse)
88+
.onErrorMap(OAuth2AuthorizationException.class,
89+
(ex) -> new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex))
90+
.map((tokenResponse) -> new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
91+
tokenResponse.getAccessToken()));
92+
}
93+
94+
private Mono<OAuth2Token> resolveSubjectToken(OAuth2AuthorizationContext context) {
95+
// @formatter:off
96+
return Mono.just(context)
97+
.map((ctx) -> ctx.getPrincipal().getPrincipal())
98+
.filter((principal) -> principal instanceof OAuth2Token)
99+
.cast(OAuth2Token.class);
100+
// @formatter:on
101+
}
102+
103+
private boolean hasTokenExpired(OAuth2Token token) {
104+
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
105+
}
106+
107+
/**
108+
* Sets the client used when requesting an access token credential at the Token
109+
* Endpoint for the {@code token-exchange} grant.
110+
* @param accessTokenResponseClient the client used when requesting an access token
111+
* credential at the Token Endpoint for the {@code token-exchange} grant
112+
*/
113+
public void setAccessTokenResponseClient(
114+
ReactiveOAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> accessTokenResponseClient) {
115+
Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null");
116+
this.accessTokenResponseClient = accessTokenResponseClient;
117+
}
118+
119+
/**
120+
* Sets the resolver used for resolving the {@link OAuth2Token subject token}.
121+
* @param subjectTokenResolver the resolver used for resolving the {@link OAuth2Token
122+
* subject token}
123+
*/
124+
public void setSubjectTokenResolver(Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> subjectTokenResolver) {
125+
Assert.notNull(subjectTokenResolver, "subjectTokenResolver cannot be null");
126+
this.subjectTokenResolver = subjectTokenResolver;
127+
}
128+
129+
/**
130+
* Sets the resolver used for resolving the {@link OAuth2Token actor token}.
131+
* @param actorTokenResolver the resolver used for resolving the {@link OAuth2Token
132+
* actor token}
133+
*/
134+
public void setActorTokenResolver(Function<OAuth2AuthorizationContext, Mono<OAuth2Token>> actorTokenResolver) {
135+
Assert.notNull(actorTokenResolver, "actorTokenResolver cannot be null");
136+
this.actorTokenResolver = actorTokenResolver;
137+
}
138+
139+
/**
140+
* Sets the maximum acceptable clock skew, which is used when checking the
141+
* {@link OAuth2AuthorizedClient#getAccessToken() access token} expiry. The default is
142+
* 60 seconds.
143+
*
144+
* <p>
145+
* An access token is considered expired if
146+
* {@code OAuth2AccessToken#getExpiresAt() - clockSkew} is before the current time
147+
* {@code clock#instant()}.
148+
* @param clockSkew the maximum acceptable clock skew
149+
*/
150+
public void setClockSkew(Duration clockSkew) {
151+
Assert.notNull(clockSkew, "clockSkew cannot be null");
152+
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
153+
this.clockSkew = clockSkew;
154+
}
155+
156+
/**
157+
* Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the access
158+
* token expiry.
159+
* @param clock the clock
160+
*/
161+
public void setClock(Clock clock) {
162+
Assert.notNull(clock, "clock cannot be null");
163+
this.clock = clock;
164+
}
165+
166+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.security.oauth2.client.endpoint;
18+
19+
import java.util.Set;
20+
21+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
22+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
23+
import org.springframework.security.oauth2.core.OAuth2AccessToken;
24+
import org.springframework.security.oauth2.core.OAuth2Token;
25+
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
26+
import org.springframework.security.oauth2.jwt.Jwt;
27+
import org.springframework.web.reactive.function.BodyInserters;
28+
import org.springframework.web.reactive.function.client.WebClient;
29+
30+
/**
31+
* The default implementation of an {@link ReactiveOAuth2AccessTokenResponseClient} for
32+
* the {@link AuthorizationGrantType#TOKEN_EXCHANGE token-exchange} grant. This
33+
* implementation uses {@link WebClient} when requesting an access token credential at the
34+
* Authorization Server's Token Endpoint.
35+
*
36+
* @author Steve Riesenberg
37+
* @since 6.3
38+
* @see ReactiveOAuth2AccessTokenResponseClient
39+
* @see TokenExchangeGrantRequest
40+
* @see OAuth2AccessToken
41+
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc8693#section-2.1">Section
42+
* 2.1 Request</a>
43+
* @see <a target="_blank" href="https://tools.ietf.org/html/rfc8693#section-2.2">Section
44+
* 2.2 Response</a>
45+
*/
46+
public final class WebClientReactiveTokenExchangeTokenResponseClient
47+
extends AbstractWebClientReactiveOAuth2AccessTokenResponseClient<TokenExchangeGrantRequest> {
48+
49+
private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
50+
51+
private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";
52+
53+
@Override
54+
ClientRegistration clientRegistration(TokenExchangeGrantRequest grantRequest) {
55+
return grantRequest.getClientRegistration();
56+
}
57+
58+
@Override
59+
Set<String> scopes(TokenExchangeGrantRequest grantRequest) {
60+
return grantRequest.getClientRegistration().getScopes();
61+
}
62+
63+
@Override
64+
BodyInserters.FormInserter<String> populateTokenRequestBody(TokenExchangeGrantRequest grantRequest,
65+
BodyInserters.FormInserter<String> body) {
66+
super.populateTokenRequestBody(grantRequest, body);
67+
body.with(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
68+
OAuth2Token subjectToken = grantRequest.getSubjectToken();
69+
body.with(OAuth2ParameterNames.SUBJECT_TOKEN, subjectToken.getTokenValue());
70+
body.with(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, tokenType(subjectToken));
71+
OAuth2Token actorToken = grantRequest.getActorToken();
72+
if (actorToken != null) {
73+
body.with(OAuth2ParameterNames.ACTOR_TOKEN, actorToken.getTokenValue());
74+
body.with(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, tokenType(actorToken));
75+
}
76+
return body;
77+
}
78+
79+
private static String tokenType(OAuth2Token token) {
80+
return (token instanceof Jwt) ? JWT_TOKEN_TYPE_VALUE : ACCESS_TOKEN_TYPE_VALUE;
81+
}
82+
83+
}

0 commit comments

Comments
 (0)