Skip to content

Commit 761e3a9

Browse files
committed
JwtBearerOAuth2AuthorizedClientProvider checks for access token expiry
Fixes gh-9700
1 parent fc6fa79 commit 761e3a9

File tree

2 files changed

+131
-8
lines changed

2 files changed

+131
-8
lines changed

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProvider.java

+50-7
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616

1717
package org.springframework.security.oauth2.client;
1818

19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
1923
import org.springframework.lang.Nullable;
2024
import org.springframework.security.oauth2.client.endpoint.DefaultJwtBearerTokenResponseClient;
2125
import org.springframework.security.oauth2.client.endpoint.JwtBearerGrantRequest;
2226
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
2327
import org.springframework.security.oauth2.client.registration.ClientRegistration;
2428
import org.springframework.security.oauth2.core.AuthorizationGrantType;
2529
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
30+
import org.springframework.security.oauth2.core.OAuth2Token;
2631
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
2732
import org.springframework.security.oauth2.jwt.Jwt;
2833
import org.springframework.util.Assert;
@@ -40,12 +45,18 @@ public final class JwtBearerOAuth2AuthorizedClientProvider implements OAuth2Auth
4045

4146
private OAuth2AccessTokenResponseClient<JwtBearerGrantRequest> accessTokenResponseClient = new DefaultJwtBearerTokenResponseClient();
4247

48+
private Duration clockSkew = Duration.ofSeconds(60);
49+
50+
private Clock clock = Clock.systemUTC();
51+
4352
/**
44-
* Attempt to authorize the {@link OAuth2AuthorizationContext#getClientRegistration()
45-
* client} in the provided {@code context}. Returns {@code null} if authorization is
46-
* not supported, e.g. the client's
47-
* {@link ClientRegistration#getAuthorizationGrantType() authorization grant type} is
48-
* not {@link AuthorizationGrantType#JWT_BEARER jwt-bearer}.
53+
* Attempt to authorize (or re-authorize) the
54+
* {@link OAuth2AuthorizationContext#getClientRegistration() client} in the provided
55+
* {@code context}. Returns {@code null} if authorization (or re-authorization) is not
56+
* supported, e.g. the client's {@link ClientRegistration#getAuthorizationGrantType()
57+
* authorization grant type} is not {@link AuthorizationGrantType#JWT_BEARER
58+
* jwt-bearer} OR the {@link OAuth2AuthorizedClient#getAccessToken() access token} is
59+
* not expired.
4960
* @param context the context that holds authorization-specific state for the client
5061
* @return the {@link OAuth2AuthorizedClient} or {@code null} if authorization is not
5162
* supported
@@ -59,8 +70,9 @@ public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
5970
return null;
6071
}
6172
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
62-
if (authorizedClient != null) {
63-
// Client is already authorized
73+
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
74+
// If client is already authorized but access token is NOT expired than no
75+
// need for re-authorization
6476
return null;
6577
}
6678
if (!(context.getPrincipal().getPrincipal() instanceof Jwt)) {
@@ -95,6 +107,10 @@ private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegi
95107
}
96108
}
97109

110+
private boolean hasTokenExpired(OAuth2Token token) {
111+
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
112+
}
113+
98114
/**
99115
* Sets the client used when requesting an access token credential at the Token
100116
* Endpoint for the {@code jwt-bearer} grant.
@@ -107,4 +123,31 @@ public void setAccessTokenResponseClient(
107123
this.accessTokenResponseClient = accessTokenResponseClient;
108124
}
109125

126+
/**
127+
* Sets the maximum acceptable clock skew, which is used when checking the
128+
* {@link OAuth2AuthorizedClient#getAccessToken() access token} expiry. The default is
129+
* 60 seconds.
130+
*
131+
* <p>
132+
* An access token is considered expired if
133+
* {@code OAuth2AccessToken#getExpiresAt() - clockSkew} is before the current time
134+
* {@code clock#instant()}.
135+
* @param clockSkew the maximum acceptable clock skew
136+
*/
137+
public void setClockSkew(Duration clockSkew) {
138+
Assert.notNull(clockSkew, "clockSkew cannot be null");
139+
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
140+
this.clockSkew = clockSkew;
141+
}
142+
143+
/**
144+
* Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the access
145+
* token expiry.
146+
* @param clock the clock
147+
*/
148+
public void setClock(Clock clock) {
149+
Assert.notNull(clock, "clock cannot be null");
150+
this.clock = clock;
151+
}
152+
110153
}

oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/JwtBearerOAuth2AuthorizedClientProviderTests.java

+81-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
package org.springframework.security.oauth2.client;
1818

19+
import java.time.Duration;
20+
import java.time.Instant;
21+
1922
import org.junit.Before;
2023
import org.junit.Test;
2124

@@ -27,6 +30,7 @@
2730
import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
2831
import org.springframework.security.oauth2.core.AuthorizationGrantType;
2932
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
33+
import org.springframework.security.oauth2.core.OAuth2AccessToken;
3034
import org.springframework.security.oauth2.core.TestOAuth2AccessTokens;
3135
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
3236
import org.springframework.security.oauth2.core.endpoint.TestOAuth2AccessTokenResponses;
@@ -83,6 +87,33 @@ public void setAccessTokenResponseClientWhenClientIsNullThenThrowIllegalArgument
8387
.withMessage("accessTokenResponseClient cannot be null");
8488
}
8589

90+
@Test
91+
public void setClockSkewWhenNullThenThrowIllegalArgumentException() {
92+
// @formatter:off
93+
assertThatIllegalArgumentException()
94+
.isThrownBy(() -> this.authorizedClientProvider.setClockSkew(null))
95+
.withMessage("clockSkew cannot be null");
96+
// @formatter:on
97+
}
98+
99+
@Test
100+
public void setClockSkewWhenNegativeSecondsThenThrowIllegalArgumentException() {
101+
// @formatter:off
102+
assertThatIllegalArgumentException()
103+
.isThrownBy(() -> this.authorizedClientProvider.setClockSkew(Duration.ofSeconds(-1)))
104+
.withMessage("clockSkew must be >= 0");
105+
// @formatter:on
106+
}
107+
108+
@Test
109+
public void setClockWhenNullThenThrowIllegalArgumentException() {
110+
// @formatter:off
111+
assertThatIllegalArgumentException()
112+
.isThrownBy(() -> this.authorizedClientProvider.setClock(null))
113+
.withMessage("clock cannot be null");
114+
// @formatter:on
115+
}
116+
86117
@Test
87118
public void authorizeWhenContextIsNullThenThrowIllegalArgumentException() {
88119
// @formatter:off
@@ -105,7 +136,7 @@ public void authorizeWhenNotJwtBearerThenUnableToAuthorize() {
105136
}
106137

107138
@Test
108-
public void authorizeWhenJwtBearerAndAuthorizedThenNotAuthorized() {
139+
public void authorizeWhenJwtBearerAndTokenNotExpiredThenNotReauthorize() {
109140
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration,
110141
this.principal.getName(), TestOAuth2AccessTokens.scopes("read", "write"));
111142
// @formatter:off
@@ -117,6 +148,55 @@ public void authorizeWhenJwtBearerAndAuthorizedThenNotAuthorized() {
117148
assertThat(this.authorizedClientProvider.authorize(authorizationContext)).isNull();
118149
}
119150

151+
@Test
152+
public void authorizeWhenJwtBearerAndTokenExpiredThenReauthorize() {
153+
Instant now = Instant.now();
154+
Instant issuedAt = now.minus(Duration.ofMinutes(60));
155+
Instant expiresAt = now.minus(Duration.ofMinutes(30));
156+
OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token-1234",
157+
issuedAt, expiresAt);
158+
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration,
159+
this.principal.getName(), accessToken);
160+
OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build();
161+
given(this.accessTokenResponseClient.getTokenResponse(any())).willReturn(accessTokenResponse);
162+
// @formatter:off
163+
OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext
164+
.withAuthorizedClient(authorizedClient)
165+
.principal(this.principal)
166+
.build();
167+
// @formatter:on
168+
authorizedClient = this.authorizedClientProvider.authorize(authorizationContext);
169+
assertThat(authorizedClient.getClientRegistration()).isSameAs(this.clientRegistration);
170+
assertThat(authorizedClient.getPrincipalName()).isEqualTo(this.principal.getName());
171+
assertThat(authorizedClient.getAccessToken()).isEqualTo(accessTokenResponse.getAccessToken());
172+
}
173+
174+
@Test
175+
public void authorizeWhenJwtBearerAndTokenNotExpiredButClockSkewForcesExpiryThenReauthorize() {
176+
Instant now = Instant.now();
177+
Instant issuedAt = now.minus(Duration.ofMinutes(60));
178+
Instant expiresAt = now.plus(Duration.ofMinutes(1));
179+
OAuth2AccessToken expiresInOneMinAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
180+
"access-token-1234", issuedAt, expiresAt);
181+
OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration,
182+
this.principal.getName(), expiresInOneMinAccessToken);
183+
// Shorten the lifespan of the access token by 90 seconds, which will ultimately
184+
// force it to expire on the client
185+
this.authorizedClientProvider.setClockSkew(Duration.ofSeconds(90));
186+
OAuth2AccessTokenResponse accessTokenResponse = TestOAuth2AccessTokenResponses.accessTokenResponse().build();
187+
given(this.accessTokenResponseClient.getTokenResponse(any())).willReturn(accessTokenResponse);
188+
// @formatter:off
189+
OAuth2AuthorizationContext authorizationContext = OAuth2AuthorizationContext
190+
.withAuthorizedClient(authorizedClient)
191+
.principal(this.principal)
192+
.build();
193+
// @formatter:on
194+
OAuth2AuthorizedClient reauthorizedClient = this.authorizedClientProvider.authorize(authorizationContext);
195+
assertThat(reauthorizedClient.getClientRegistration()).isSameAs(this.clientRegistration);
196+
assertThat(reauthorizedClient.getPrincipalName()).isEqualTo(this.principal.getName());
197+
assertThat(reauthorizedClient.getAccessToken()).isEqualTo(accessTokenResponse.getAccessToken());
198+
}
199+
120200
@Test
121201
public void authorizeWhenJwtBearerAndNotAuthorizedAndPrincipalNotJwtThenUnableToAuthorize() {
122202
// @formatter:off

0 commit comments

Comments
 (0)