Skip to content

Commit e5bb866

Browse files
committed
Add id-token-jwk-set-url ClientSettings and claim mapping
- Add ID_TOKEN_JWK_SET_URL to ConfigurationSettingNames and typed getter/builder to ClientSettings - Add default no-arg constructor to resolver that reads idTokenJwkSetUrl from ClientSettings automatically - Store subject token claims as authorization attribute so OAuth2TokenCustomizer can map ID token claims to the generated access token Closes gh-19048 Signed-off-by: Bapuji Koraganti <bapuk.2008@gmail.com>
1 parent 266a875 commit e5bb866

File tree

7 files changed

+140
-1
lines changed

7 files changed

+140
-1
lines changed

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ public final class OAuth2TokenExchangeAuthenticationProvider implements Authenti
8080

8181
private static final String MAY_ACT = "may_act";
8282

83+
/**
84+
* The attribute name for the subject token claims stored in the
85+
* {@link OAuth2Authorization}. These claims are available to
86+
* {@link org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer}
87+
* implementations for mapping external ID token claims to the generated access token.
88+
* @since 7.0
89+
*/
90+
public static final String SUBJECT_TOKEN_CLAIMS_ATTRIBUTE = OAuth2TokenExchangeSubjectTokenContext.class.getName()
91+
+ ".CLAIMS";
92+
8393
private final Log logger = LogFactory.getLog(getClass());
8494

8595
private final OAuth2AuthorizationService authorizationService;
@@ -155,6 +165,7 @@ public Authentication authenticate(Authentication authentication) throws Authent
155165
.principalName(subjectTokenContext.getPrincipalName())
156166
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
157167
.attribute(Principal.class.getName(), subjectTokenContext.getPrincipal())
168+
.attribute(SUBJECT_TOKEN_CLAIMS_ATTRIBUTE, subjectTokenContext.getClaims())
158169
.build();
159170
// @formatter:on
160171

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
import java.util.Collections;
2020
import java.util.List;
21+
import java.util.Map;
22+
import java.util.concurrent.ConcurrentHashMap;
2123

2224
import org.apache.commons.logging.Log;
2325
import org.apache.commons.logging.LogFactory;
@@ -26,13 +28,17 @@
2628
import org.springframework.security.authentication.AbstractAuthenticationToken;
2729
import org.springframework.security.core.Authentication;
2830
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
31+
import org.springframework.security.oauth2.core.OAuth2Error;
2932
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
3033
import org.springframework.security.oauth2.jwt.Jwt;
3134
import org.springframework.security.oauth2.jwt.JwtDecoder;
3235
import org.springframework.security.oauth2.jwt.JwtDecoderFactory;
3336
import org.springframework.security.oauth2.jwt.JwtException;
37+
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
3438
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
39+
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
3540
import org.springframework.util.Assert;
41+
import org.springframework.util.StringUtils;
3642

3743
/**
3844
* An {@link OAuth2TokenExchangeSubjectTokenResolver} implementation that resolves
@@ -44,11 +50,30 @@
4450
* token using a {@link JwtDecoder} obtained from the provided factory, then constructs an
4551
* {@link OAuth2TokenExchangeSubjectTokenContext} from the token's claims.
4652
*
53+
* <p>
54+
* When constructed with no arguments, the resolver uses the
55+
* {@link ClientSettings#getIdTokenJwkSetUrl()} setting to resolve the external IdP's JWKS
56+
* endpoint per client. Example client registration:
57+
*
58+
* <pre>
59+
* RegisteredClient.withId(UUID.randomUUID().toString())
60+
* .clientId("cicd-client")
61+
* .clientSettings(ClientSettings.builder()
62+
* .idTokenJwkSetUrl("https://gitlab.com/oauth/discovery/keys")
63+
* .build())
64+
* .build();
65+
* </pre>
66+
*
67+
* <p>
68+
* For advanced use cases (e.g., multi-issuer routing, custom validation), a custom
69+
* {@link JwtDecoderFactory} can be provided via the constructor.
70+
*
4771
* @author Bapuji Koraganti
4872
* @since 7.0
4973
* @see OAuth2TokenExchangeSubjectTokenResolver
5074
* @see OAuth2TokenExchangeSubjectTokenContext
5175
* @see JwtDecoderFactory
76+
* @see ClientSettings#getIdTokenJwkSetUrl()
5277
*/
5378
public final class OidcIdTokenSubjectTokenResolver implements OAuth2TokenExchangeSubjectTokenResolver {
5479

@@ -58,9 +83,19 @@ public final class OidcIdTokenSubjectTokenResolver implements OAuth2TokenExchang
5883

5984
private final JwtDecoderFactory<RegisteredClient> jwtDecoderFactory;
6085

86+
/**
87+
* Constructs an {@code OidcIdTokenSubjectTokenResolver} that uses the
88+
* {@link ClientSettings#getIdTokenJwkSetUrl()} setting to resolve the external IdP's
89+
* JWKS endpoint for each client. Decoders are cached per client.
90+
* @since 7.0
91+
*/
92+
public OidcIdTokenSubjectTokenResolver() {
93+
this(new DefaultIdTokenJwtDecoderFactory());
94+
}
95+
6196
/**
6297
* Constructs an {@code OidcIdTokenSubjectTokenResolver} using the provided
63-
* parameters.
98+
* {@link JwtDecoderFactory}.
6499
* @param jwtDecoderFactory the factory for creating {@link JwtDecoder} instances
65100
* keyed by {@link RegisteredClient}
66101
*/
@@ -98,6 +133,31 @@ public OidcIdTokenSubjectTokenResolver(JwtDecoderFactory<RegisteredClient> jwtDe
98133
return new OAuth2TokenExchangeSubjectTokenContext(principal, subject, jwt.getClaims(), Collections.emptySet());
99134
}
100135

136+
/**
137+
* Default {@link JwtDecoderFactory} that reads the JWKS endpoint from
138+
* {@link ClientSettings#getIdTokenJwkSetUrl()} and caches decoders per client.
139+
*/
140+
private static final class DefaultIdTokenJwtDecoderFactory implements JwtDecoderFactory<RegisteredClient> {
141+
142+
private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
143+
144+
@Override
145+
public JwtDecoder createDecoder(RegisteredClient registeredClient) {
146+
return this.jwtDecoders.computeIfAbsent(registeredClient.getId(), (key) -> {
147+
String idTokenJwkSetUrl = registeredClient.getClientSettings().getIdTokenJwkSetUrl();
148+
if (!StringUtils.hasText(idTokenJwkSetUrl)) {
149+
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT,
150+
"Failed to find an ID Token Verifier for Client: '" + registeredClient.getId()
151+
+ "'. Check to ensure you have configured the ID Token JWK Set URL.",
152+
null);
153+
throw new OAuth2AuthenticationException(oauth2Error);
154+
}
155+
return NimbusJwtDecoder.withJwkSetUri(idTokenJwkSetUrl).build();
156+
});
157+
}
158+
159+
}
160+
101161
/**
102162
* An {@link Authentication} representing a principal resolved from an
103163
* externally-issued ID token.

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ public boolean isRequireAuthorizationConsent() {
9999
return getSetting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN);
100100
}
101101

102+
/**
103+
* Returns the {@code URL} for the external Identity Provider's JSON Web Key Set used
104+
* to validate ID tokens during token exchange.
105+
* @return the {@code URL} for the external IdP's JSON Web Key Set, or {@code null} if
106+
* not set
107+
* @since 7.0
108+
*/
109+
public @Nullable String getIdTokenJwkSetUrl() {
110+
return getSetting(ConfigurationSettingNames.Client.ID_TOKEN_JWK_SET_URL);
111+
}
112+
102113
/**
103114
* Constructs a new {@link Builder} with the default settings.
104115
* @return the {@link Builder}
@@ -185,6 +196,17 @@ public Builder x509CertificateSubjectDN(String x509CertificateSubjectDN) {
185196
return setting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN, x509CertificateSubjectDN);
186197
}
187198

199+
/**
200+
* Sets the {@code URL} for the external Identity Provider's JSON Web Key Set used
201+
* to validate ID tokens during token exchange.
202+
* @param idTokenJwkSetUrl the {@code URL} for the external IdP's JSON Web Key Set
203+
* @return the {@link Builder} for further configuration
204+
* @since 7.0
205+
*/
206+
public Builder idTokenJwkSetUrl(String idTokenJwkSetUrl) {
207+
return setting(ConfigurationSettingNames.Client.ID_TOKEN_JWK_SET_URL, idTokenJwkSetUrl);
208+
}
209+
188210
/**
189211
* Builds the {@link ClientSettings}.
190212
* @return the {@link ClientSettings}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ public static final class Client {
7878
public static final String X509_CERTIFICATE_SUBJECT_DN = CLIENT_SETTINGS_NAMESPACE
7979
.concat("x509-certificate-subject-dn");
8080

81+
/**
82+
* Set the {@code URL} for the external Identity Provider's JSON Web Key Set used
83+
* to validate ID tokens during token exchange.
84+
* @since 7.0
85+
*/
86+
public static final String ID_TOKEN_JWK_SET_URL = CLIENT_SETTINGS_NAMESPACE.concat("id-token-jwk-set-url");
87+
8188
private Client() {
8289
}
8390

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,15 @@ public void authenticateWhenSubjectTokenResolverReturnsContextThenReturnAccessTo
698698
assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
699699
assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isSameAs(userPrincipal);
700700
assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken);
701+
702+
// Verify subject token claims are available via the token context authorization
703+
OAuth2Authorization subjectAuth = tokenContext.getAuthorization();
704+
assertThat(subjectAuth).isNotNull();
705+
Map<String, Object> subjectTokenClaims = subjectAuth
706+
.getAttribute(OAuth2TokenExchangeAuthenticationProvider.SUBJECT_TOKEN_CLAIMS_ATTRIBUTE);
707+
assertThat(subjectTokenClaims).isNotNull();
708+
assertThat(subjectTokenClaims).containsEntry("iss", "https://gitlab.com");
709+
assertThat(subjectTokenClaims).containsEntry("sub", "user@example.com");
701710
}
702711

703712
@Test

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.junit.jupiter.api.BeforeEach;
2222
import org.junit.jupiter.api.Test;
2323

24+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
2425
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
2526
import org.springframework.security.oauth2.core.OAuth2Error;
2627
import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
@@ -113,6 +114,21 @@ public void resolveWhenSubjectClaimMissingThenThrowOAuth2AuthenticationException
113114
// @formatter:on
114115
}
115116

117+
@Test
118+
public void resolveWhenDefaultConstructorAndNoIdTokenJwkSetUrlThenThrowOAuth2AuthenticationException() {
119+
RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
120+
.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
121+
.build();
122+
OidcIdTokenSubjectTokenResolver defaultResolver = new OidcIdTokenSubjectTokenResolver();
123+
// @formatter:off
124+
assertThatExceptionOfType(OAuth2AuthenticationException.class)
125+
.isThrownBy(() -> defaultResolver.resolve(ID_TOKEN_VALUE, ID_TOKEN_TYPE_VALUE, registeredClient))
126+
.extracting(OAuth2AuthenticationException::getError)
127+
.extracting(OAuth2Error::getErrorCode)
128+
.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
129+
// @formatter:on
130+
}
131+
116132
@Test
117133
public void resolveWhenValidIdTokenThenReturnContext() {
118134
RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();

oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ public void x509CertificateSubjectDNWhenProvidedThenSet() {
7272
.isEqualTo("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US");
7373
}
7474

75+
@Test
76+
public void idTokenJwkSetUrlWhenProvidedThenSet() {
77+
ClientSettings clientSettings = ClientSettings.builder()
78+
.idTokenJwkSetUrl("https://gitlab.com/oauth/discovery/keys")
79+
.build();
80+
assertThat(clientSettings.getIdTokenJwkSetUrl()).isEqualTo("https://gitlab.com/oauth/discovery/keys");
81+
}
82+
83+
@Test
84+
public void idTokenJwkSetUrlWhenNotSetThenNull() {
85+
ClientSettings clientSettings = ClientSettings.builder().build();
86+
assertThat(clientSettings.getIdTokenJwkSetUrl()).isNull();
87+
}
88+
7589
@Test
7690
public void settingWhenCustomThenSet() {
7791
ClientSettings clientSettings = ClientSettings.builder()

0 commit comments

Comments
 (0)