From 266a875904f1081a0527d63df37eb7f43abfe6a9 Mon Sep 17 00:00:00 2001 From: Bapuji Koraganti Date: Tue, 14 Apr 2026 20:17:02 -0400 Subject: [PATCH 1/2] Add ID token support for token exchange Introduce support for exchanging externally-issued OIDC ID tokens for access tokens via the OAuth 2.0 Token Exchange Grant per RFC 8693 Section 3. - Add urn:ietf:params:oauth:token-type:id_token as a supported token type in the converter - Add OAuth2TokenExchangeSubjectTokenResolver strategy interface for resolving external subject tokens - Add OidcIdTokenSubjectTokenResolver as the default implementation using JwtDecoderFactory - Modify OAuth2TokenExchangeAuthenticationProvider to delegate to the resolver before falling back to the authorization service - Auto-wire the resolver bean in the configurer Closes gh-19048 Signed-off-by: Bapuji Koraganti --- .../OAuth2TokenEndpointConfigurer.java | 6 + ...h2TokenExchangeAuthenticationProvider.java | 105 +++++++++---- ...Auth2TokenExchangeSubjectTokenContext.java | 96 ++++++++++++ ...uth2TokenExchangeSubjectTokenResolver.java | 64 ++++++++ .../OidcIdTokenSubjectTokenResolver.java | 129 +++++++++++++++ ...2TokenExchangeAuthenticationConverter.java | 9 +- ...enExchangeAuthenticationProviderTests.java | 120 ++++++++++++++ ...TokenExchangeSubjectTokenContextTests.java | 96 ++++++++++++ .../OidcIdTokenSubjectTokenResolverTests.java | 147 ++++++++++++++++++ ...nExchangeAuthenticationConverterTests.java | 20 +++ 10 files changed, 757 insertions(+), 35 deletions(-) create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContext.java create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenResolver.java create mode 100644 oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java create mode 100644 oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContextTests.java create mode 100644 oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenEndpointConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenEndpointConfigurer.java index a0d02bb83d9..f859f1bc4ac 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenEndpointConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenEndpointConfigurer.java @@ -40,6 +40,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationProvider; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeSubjectTokenResolver; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; @@ -271,6 +272,11 @@ private static List createDefaultAuthenticationProviders OAuth2TokenExchangeAuthenticationProvider tokenExchangeAuthenticationProvider = new OAuth2TokenExchangeAuthenticationProvider( authorizationService, tokenGenerator); + OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver = OAuth2ConfigurerUtils + .getOptionalBean(httpSecurity, OAuth2TokenExchangeSubjectTokenResolver.class); + if (subjectTokenResolver != null) { + tokenExchangeAuthenticationProvider.setSubjectTokenResolver(subjectTokenResolver); + } authenticationProviders.add(tokenExchangeAuthenticationProvider); return authenticationProviders; diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java index b0760ad264f..fc204b2d02f 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java @@ -86,6 +86,8 @@ public final class OAuth2TokenExchangeAuthenticationProvider implements Authenti private final OAuth2TokenGenerator tokenGenerator; + private @Nullable OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver; + /** * Constructs an {@code OAuth2TokenExchangeAuthenticationProvider} using the provided * parameters. @@ -100,6 +102,18 @@ public OAuth2TokenExchangeAuthenticationProvider(OAuth2AuthorizationService auth this.tokenGenerator = tokenGenerator; } + /** + * Sets the {@link OAuth2TokenExchangeSubjectTokenResolver} used for resolving + * externally-issued subject tokens (e.g., OIDC ID tokens from trusted identity + * providers). + * @param subjectTokenResolver the subject token resolver + * @since 7.0 + */ + public void setSubjectTokenResolver(OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver) { + Assert.notNull(subjectTokenResolver, "subjectTokenResolver cannot be null"); + this.subjectTokenResolver = subjectTokenResolver; + } + @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = (OAuth2TokenExchangeAuthenticationToken) authentication; @@ -123,45 +137,72 @@ public Authentication authenticate(Authentication authentication) throws Authent throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); } - OAuth2Authorization subjectAuthorization = this.authorizationService - .findByToken(tokenExchangeAuthentication.getSubjectToken(), OAuth2TokenType.ACCESS_TOKEN); - if (subjectAuthorization == null) { - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + // Try to resolve the subject token using the configured resolver (e.g., for + // externally-issued ID tokens) + OAuth2TokenExchangeSubjectTokenContext subjectTokenContext = null; + if (this.subjectTokenResolver != null) { + subjectTokenContext = this.subjectTokenResolver.resolve(tokenExchangeAuthentication.getSubjectToken(), + tokenExchangeAuthentication.getSubjectTokenType(), registeredClient); } - if (this.logger.isTraceEnabled()) { - this.logger.trace("Retrieved authorization with subject token"); - } + OAuth2Authorization subjectAuthorization; + Map authorizedActorClaims = null; - OAuth2Authorization.Token subjectToken = subjectAuthorization - .getToken(tokenExchangeAuthentication.getSubjectToken()); - Assert.notNull(subjectToken, "subjectToken cannot be null"); - if (!subjectToken.isActive()) { - // As per https://tools.ietf.org/html/rfc6749#section-5.2 - // invalid_grant: The provided authorization grant (e.g., authorization code, - // resource owner credentials) or refresh token is invalid, expired, revoked - // [...]. - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); - } + if (subjectTokenContext != null) { + // Build an OAuth2Authorization from the resolved external subject token + // @formatter:off + subjectAuthorization = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(subjectTokenContext.getPrincipalName()) + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .attribute(Principal.class.getName(), subjectTokenContext.getPrincipal()) + .build(); + // @formatter:on - if (!isValidTokenType(tokenExchangeAuthentication.getSubjectTokenType(), subjectToken)) { - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + if (this.logger.isTraceEnabled()) { + this.logger.trace("Resolved external subject token"); + } } + else { + subjectAuthorization = this.authorizationService.findByToken(tokenExchangeAuthentication.getSubjectToken(), + OAuth2TokenType.ACCESS_TOKEN); + if (subjectAuthorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } - if (subjectAuthorization.getAttribute(Principal.class.getName()) == null) { - // As per https://datatracker.ietf.org/doc/html/rfc8693#section-1.1, - // we require a principal to be available via the subject_token for - // impersonation or delegation use cases. - throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); - } + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with subject token"); + } - // As per https://datatracker.ietf.org/doc/html/rfc8693#section-4.4, - // The may_act claim makes a statement that one party is authorized to - // become the actor and act on behalf of another party. - Map authorizedActorClaims = null; - if (subjectToken.getClaims() != null && subjectToken.getClaims().containsKey(MAY_ACT) - && subjectToken.getClaims().get(MAY_ACT) instanceof Map mayAct) { - authorizedActorClaims = (Map) mayAct; + OAuth2Authorization.Token subjectToken = subjectAuthorization + .getToken(tokenExchangeAuthentication.getSubjectToken()); + Assert.notNull(subjectToken, "subjectToken cannot be null"); + if (!subjectToken.isActive()) { + // As per https://tools.ietf.org/html/rfc6749#section-5.2 + // invalid_grant: The provided authorization grant (e.g., authorization + // code, + // resource owner credentials) or refresh token is invalid, expired, + // revoked [...]. + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (!isValidTokenType(tokenExchangeAuthentication.getSubjectTokenType(), subjectToken)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + if (subjectAuthorization.getAttribute(Principal.class.getName()) == null) { + // As per https://datatracker.ietf.org/doc/html/rfc8693#section-1.1, + // we require a principal to be available via the subject_token for + // impersonation or delegation use cases. + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + // As per https://datatracker.ietf.org/doc/html/rfc8693#section-4.4, + // The may_act claim makes a statement that one party is authorized to + // become the actor and act on behalf of another party. + if (subjectToken.getClaims() != null && subjectToken.getClaims().containsKey(MAY_ACT) + && subjectToken.getClaims().get(MAY_ACT) instanceof Map mayAct) { + authorizedActorClaims = (Map) mayAct; + } } OAuth2Authorization actorAuthorization = null; diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContext.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContext.java new file mode 100644 index 00000000000..eeb0f1e400e --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContext.java @@ -0,0 +1,96 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * The context returned by an {@link OAuth2TokenExchangeSubjectTokenResolver} containing + * the resolved principal and claims from the subject token. + * + * @author Bapuji Koraganti + * @since 7.0 + * @see OAuth2TokenExchangeSubjectTokenResolver + */ +public final class OAuth2TokenExchangeSubjectTokenContext { + + private final Authentication principal; + + private final String principalName; + + private final Map claims; + + private final Set scopes; + + /** + * Constructs an {@code OAuth2TokenExchangeSubjectTokenContext} using the provided + * parameters. + * @param principal the authenticated principal resolved from the subject token + * @param principalName the principal name (e.g., the {@code sub} claim) + * @param claims the claims extracted from the subject token + * @param scopes the scopes associated with the subject token + */ + public OAuth2TokenExchangeSubjectTokenContext(Authentication principal, String principalName, + Map claims, Set scopes) { + Assert.notNull(principal, "principal cannot be null"); + Assert.hasText(principalName, "principalName cannot be empty"); + Assert.notNull(claims, "claims cannot be null"); + Assert.notNull(scopes, "scopes cannot be null"); + this.principal = principal; + this.principalName = principalName; + this.claims = Collections.unmodifiableMap(claims); + this.scopes = Collections.unmodifiableSet(scopes); + } + + /** + * Returns the authenticated principal resolved from the subject token. + * @return the authenticated principal + */ + public Authentication getPrincipal() { + return this.principal; + } + + /** + * Returns the principal name (e.g., the {@code sub} claim from an ID token). + * @return the principal name + */ + public String getPrincipalName() { + return this.principalName; + } + + /** + * Returns the claims extracted from the subject token. + * @return the claims + */ + public Map getClaims() { + return this.claims; + } + + /** + * Returns the scopes associated with the subject token. + * @return the scopes + */ + public Set getScopes() { + return this.scopes; + } + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenResolver.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenResolver.java new file mode 100644 index 00000000000..8c22f16195f --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenResolver.java @@ -0,0 +1,64 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; + +/** + * A strategy for resolving an externally-issued subject token into an + * {@link OAuth2TokenExchangeSubjectTokenContext} during the OAuth 2.0 Token Exchange + * Grant. + * + *

+ * Implementations of this interface are responsible for validating and decoding the + * subject token (e.g., an externally-issued ID token) and constructing the authorization + * and principal context needed for token exchange. + * + *

+ * NOTE: When this resolver returns a non-{@code null} context, the + * {@link OAuth2TokenExchangeAuthenticationProvider} constructs a synthetic + * {@link org.springframework.security.oauth2.server.authorization.OAuth2Authorization} + * that contains only the principal name and principal attribute. This synthetic + * authorization does not contain an access token or other token metadata. Token + * generators or customizers that inspect the authorization's tokens should account for + * this. + * + * @author Bapuji Koraganti + * @since 7.0 + * @see OAuth2TokenExchangeAuthenticationProvider + * @see OAuth2TokenExchangeSubjectTokenContext + * @see Section 2.1 Request + */ +@FunctionalInterface +public interface OAuth2TokenExchangeSubjectTokenResolver { + + /** + * Resolves the subject token into an {@link OAuth2TokenExchangeSubjectTokenContext}. + * Returns {@code null} if this resolver cannot handle the given token type. + * @param subjectToken the subject token value + * @param subjectTokenType the token type identifier (e.g., + * {@code urn:ietf:params:oauth:token-type:id_token}) + * @param registeredClient the registered client performing the token exchange + * @return the resolved subject token context, or {@code null} if not supported + */ + @Nullable OAuth2TokenExchangeSubjectTokenContext resolve(String subjectToken, String subjectTokenType, + RegisteredClient registeredClient); + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java new file mode 100644 index 00000000000..2f148970a7b --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java @@ -0,0 +1,129 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.util.Assert; + +/** + * An {@link OAuth2TokenExchangeSubjectTokenResolver} implementation that resolves + * externally-issued OIDC ID tokens using a configurable {@link JwtDecoderFactory}. + * + *

+ * This resolver activates when the {@code subject_token_type} is + * {@code urn:ietf:params:oauth:token-type:id_token}. It decodes and validates the ID + * token using a {@link JwtDecoder} obtained from the provided factory, then constructs an + * {@link OAuth2TokenExchangeSubjectTokenContext} from the token's claims. + * + * @author Bapuji Koraganti + * @since 7.0 + * @see OAuth2TokenExchangeSubjectTokenResolver + * @see OAuth2TokenExchangeSubjectTokenContext + * @see JwtDecoderFactory + */ +public final class OidcIdTokenSubjectTokenResolver implements OAuth2TokenExchangeSubjectTokenResolver { + + private static final String ID_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:id_token"; + + private final Log logger = LogFactory.getLog(getClass()); + + private final JwtDecoderFactory jwtDecoderFactory; + + /** + * Constructs an {@code OidcIdTokenSubjectTokenResolver} using the provided + * parameters. + * @param jwtDecoderFactory the factory for creating {@link JwtDecoder} instances + * keyed by {@link RegisteredClient} + */ + public OidcIdTokenSubjectTokenResolver(JwtDecoderFactory jwtDecoderFactory) { + Assert.notNull(jwtDecoderFactory, "jwtDecoderFactory cannot be null"); + this.jwtDecoderFactory = jwtDecoderFactory; + } + + @Override + public @Nullable OAuth2TokenExchangeSubjectTokenContext resolve(String subjectToken, String subjectTokenType, + RegisteredClient registeredClient) { + if (!ID_TOKEN_TYPE_VALUE.equals(subjectTokenType)) { + return null; + } + + JwtDecoder jwtDecoder = this.jwtDecoderFactory.createDecoder(registeredClient); + Jwt jwt; + try { + jwt = jwtDecoder.decode(subjectToken); + } + catch (JwtException ex) { + if (this.logger.isDebugEnabled()) { + this.logger.debug("Failed to decode ID token: " + ex.getMessage()); + } + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + String subject = jwt.getSubject(); + if (subject == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + Authentication principal = new IdTokenAuthenticationToken(subject); + + return new OAuth2TokenExchangeSubjectTokenContext(principal, subject, jwt.getClaims(), Collections.emptySet()); + } + + /** + * An {@link Authentication} representing a principal resolved from an + * externally-issued ID token. + */ + private static final class IdTokenAuthenticationToken extends AbstractAuthenticationToken { + + private static final long serialVersionUID = 1L; + + private final String subject; + + IdTokenAuthenticationToken(String subject) { + super(List.of()); + this.subject = subject; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return ""; + } + + @Override + public Object getPrincipal() { + return this.subject; + } + + } + +} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java index 53e49665228..dab8dbb526f 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java @@ -65,7 +65,10 @@ public final class OAuth2TokenExchangeAuthenticationConverter implements Authent private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; - private static final Set SUPPORTED_TOKEN_TYPES = Set.of(ACCESS_TOKEN_TYPE_VALUE, JWT_TOKEN_TYPE_VALUE); + private static final String ID_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:id_token"; + + private static final Set SUPPORTED_TOKEN_TYPES = Set.of(ACCESS_TOKEN_TYPE_VALUE, JWT_TOKEN_TYPE_VALUE, + ID_TOKEN_TYPE_VALUE); @Override public @Nullable Authentication convert(HttpServletRequest request) { @@ -202,8 +205,8 @@ private static void validateTokenType(String parameterName, String tokenTypeValu String message = String.format( "OAuth 2.0 Token Exchange parameter: %s - " + "The provided value is not supported by this authorization server. " + - "Supported values are %s and %s.", - parameterName, ACCESS_TOKEN_TYPE_VALUE, JWT_TOKEN_TYPE_VALUE); + "Supported values are %s, %s and %s.", + parameterName, ACCESS_TOKEN_TYPE_VALUE, JWT_TOKEN_TYPE_VALUE, ID_TOKEN_TYPE_VALUE); // @formatter:on throw new OAuth2AuthenticationException(error, message); } diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java index dfa782c3ccb..61fe2a5cb14 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java @@ -98,6 +98,8 @@ public class OAuth2TokenExchangeAuthenticationProviderTests { private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + private static final String ID_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:id_token"; + private OAuth2AuthorizationService authorizationService; private OAuth2TokenGenerator tokenGenerator; @@ -639,6 +641,124 @@ public void authenticateWhenNoActorTokenAndPreviousActorThenReturnAccessTokenFor assertThat(authorization.getRefreshToken()).isNull(); } + @Test + public void setSubjectTokenResolverWhenNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> this.authenticationProvider.setSubjectTokenResolver(null)) + .withMessage("subjectTokenResolver cannot be null"); + // @formatter:on + } + + @Test + public void authenticateWhenSubjectTokenResolverReturnsContextThenReturnAccessToken() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken( + ACCESS_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ID_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES, + AUDIENCES, registeredClient.getScopes(), null); + + TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user@example.com", null, + "ROLE_USER"); + OAuth2TokenExchangeSubjectTokenContext subjectTokenContext = new OAuth2TokenExchangeSubjectTokenContext( + userPrincipal, "user@example.com", Map.of("iss", "https://gitlab.com", "sub", "user@example.com"), + registeredClient.getScopes()); + + OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver = mock( + OAuth2TokenExchangeSubjectTokenResolver.class); + given(subjectTokenResolver.resolve(SUBJECT_TOKEN, ID_TOKEN_TYPE_VALUE, registeredClient)) + .willReturn(subjectTokenContext); + this.authenticationProvider.setSubjectTokenResolver(subjectTokenResolver); + + OAuth2AccessToken accessToken = createAccessToken("token-value"); + given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken); + + OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + assertThat(authenticationResult.getRefreshToken()).isNull(); + + ArgumentCaptor authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class); + ArgumentCaptor tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class); + verify(this.tokenGenerator).generate(tokenContextCaptor.capture()); + verify(this.authorizationService).save(authorizationCaptor.capture()); + verifyNoMoreInteractions(this.tokenGenerator); + + OAuth2TokenContext tokenContext = tokenContextCaptor.getValue(); + assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient); + assertThat(tokenContext.getPrincipal()).isSameAs(userPrincipal); + assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + + OAuth2Authorization authorization = authorizationCaptor.getValue(); + assertThat(authorization.getPrincipalName()).isEqualTo("user@example.com"); + assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); + assertThat(authorization.getAttribute(Principal.class.getName())).isSameAs(userPrincipal); + assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken); + } + + @Test + public void authenticateWhenSubjectTokenResolverReturnsNullThenFallBackToAuthorizationService() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .build(); + OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient); + + OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver = mock( + OAuth2TokenExchangeSubjectTokenResolver.class); + given(subjectTokenResolver.resolve(anyString(), anyString(), any(RegisteredClient.class))).willReturn(null); + this.authenticationProvider.setSubjectTokenResolver(subjectTokenResolver); + + TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER"); + // @formatter:off + OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient) + .token(createAccessToken(SUBJECT_TOKEN)) + .attribute(Principal.class.getName(), userPrincipal) + .build(); + // @formatter:on + given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))) + .willReturn(subjectAuthorization); + OAuth2AccessToken accessToken = createAccessToken("token-value"); + given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken); + + OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider + .authenticate(authentication); + assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken); + + verify(subjectTokenResolver).resolve(SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, registeredClient); + verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN); + } + + @Test + public void authenticateWhenSubjectTokenResolverThrowsThenPropagateException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .build(); + OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient, + ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null); + OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken( + ACCESS_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ID_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES, + AUDIENCES, registeredClient.getScopes(), null); + + OAuth2TokenExchangeSubjectTokenResolver subjectTokenResolver = mock( + OAuth2TokenExchangeSubjectTokenResolver.class); + given(subjectTokenResolver.resolve(anyString(), anyString(), any(RegisteredClient.class))) + .willThrow(new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT)); + this.authenticationProvider.setSubjectTokenResolver(subjectTokenResolver); + + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.authenticationProvider.authenticate(authentication)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + verifyNoInteractions(this.authorizationService, this.tokenGenerator); + } + @Test public void authenticateWhenActorTokenAndValidTokenExchangeThenReturnAccessTokenForDelegation() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient() diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContextTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContextTests.java new file mode 100644 index 00000000000..1faf72fddd6 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeSubjectTokenContextTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link OAuth2TokenExchangeSubjectTokenContext}. + * + * @author Bapuji Koraganti + */ +public class OAuth2TokenExchangeSubjectTokenContextTests { + + @Test + public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeSubjectTokenContext( + null, "user", Collections.emptyMap(), Collections.emptySet())) + .withMessage("principal cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenPrincipalNameEmptyThenThrowIllegalArgumentException() { + Authentication principal = new TestingAuthenticationToken("user", null); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeSubjectTokenContext( + principal, "", Collections.emptyMap(), Collections.emptySet())) + .withMessage("principalName cannot be empty"); + // @formatter:on + } + + @Test + public void constructorWhenClaimsNullThenThrowIllegalArgumentException() { + Authentication principal = new TestingAuthenticationToken("user", null); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeSubjectTokenContext( + principal, "user", null, Collections.emptySet())) + .withMessage("claims cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenScopesNullThenThrowIllegalArgumentException() { + Authentication principal = new TestingAuthenticationToken("user", null); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OAuth2TokenExchangeSubjectTokenContext( + principal, "user", Collections.emptyMap(), null)) + .withMessage("scopes cannot be null"); + // @formatter:on + } + + @Test + public void constructorWhenValidParametersThenContextCreated() { + Authentication principal = new TestingAuthenticationToken("user", null); + Map claims = Map.of("iss", "https://gitlab.com", "sub", "user"); + Set scopes = Set.of("read", "write"); + + OAuth2TokenExchangeSubjectTokenContext context = new OAuth2TokenExchangeSubjectTokenContext(principal, "user", + claims, scopes); + + assertThat(context.getPrincipal()).isSameAs(principal); + assertThat(context.getPrincipalName()).isEqualTo("user"); + assertThat(context.getClaims()).isEqualTo(claims); + assertThat(context.getScopes()).isEqualTo(scopes); + } + +} diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java new file mode 100644 index 00000000000..65fdeee1050 --- /dev/null +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java @@ -0,0 +1,147 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.authentication; + +import java.time.Instant; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * Tests for {@link OidcIdTokenSubjectTokenResolver}. + * + * @author Bapuji Koraganti + */ +public class OidcIdTokenSubjectTokenResolverTests { + + private static final String ID_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:id_token"; + + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + + private static final String ID_TOKEN_VALUE = "eyJhbGciOiJSUzI1NiJ9.test-id-token"; + + private JwtDecoderFactory jwtDecoderFactory; + + private JwtDecoder jwtDecoder; + + private OidcIdTokenSubjectTokenResolver resolver; + + @BeforeEach + @SuppressWarnings("unchecked") + public void setUp() { + this.jwtDecoderFactory = mock(JwtDecoderFactory.class); + this.jwtDecoder = mock(JwtDecoder.class); + given(this.jwtDecoderFactory.createDecoder(any(RegisteredClient.class))).willReturn(this.jwtDecoder); + this.resolver = new OidcIdTokenSubjectTokenResolver(this.jwtDecoderFactory); + } + + @Test + public void constructorWhenJwtDecoderFactoryNullThenThrowIllegalArgumentException() { + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> new OidcIdTokenSubjectTokenResolver(null)) + .withMessage("jwtDecoderFactory cannot be null"); + // @formatter:on + } + + @Test + public void resolveWhenSubjectTokenTypeNotIdTokenThenReturnNull() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + OAuth2TokenExchangeSubjectTokenContext result = this.resolver.resolve(ID_TOKEN_VALUE, ACCESS_TOKEN_TYPE_VALUE, + registeredClient); + assertThat(result).isNull(); + verifyNoInteractions(this.jwtDecoderFactory); + } + + @Test + public void resolveWhenJwtDecodingFailsThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + given(this.jwtDecoder.decode(anyString())).willThrow(new JwtException("Invalid token")); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.resolver.resolve(ID_TOKEN_VALUE, ID_TOKEN_TYPE_VALUE, registeredClient)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + } + + @Test + public void resolveWhenSubjectClaimMissingThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Jwt jwt = createJwt(null); + given(this.jwtDecoder.decode(anyString())).willReturn(jwt); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> this.resolver.resolve(ID_TOKEN_VALUE, ID_TOKEN_TYPE_VALUE, registeredClient)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_GRANT); + // @formatter:on + } + + @Test + public void resolveWhenValidIdTokenThenReturnContext() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); + Jwt jwt = createJwt("user@example.com"); + given(this.jwtDecoder.decode(anyString())).willReturn(jwt); + + OAuth2TokenExchangeSubjectTokenContext result = this.resolver.resolve(ID_TOKEN_VALUE, ID_TOKEN_TYPE_VALUE, + registeredClient); + + assertThat(result).isNotNull(); + assertThat(result.getPrincipalName()).isEqualTo("user@example.com"); + assertThat(result.getPrincipal()).isNotNull(); + assertThat(result.getPrincipal().isAuthenticated()).isTrue(); + assertThat(result.getPrincipal().getName()).isEqualTo("user@example.com"); + assertThat(result.getClaims()).containsEntry("iss", "https://gitlab.com"); + assertThat(result.getScopes()).isEmpty(); + } + + private static Jwt createJwt(String subject) { + Jwt.Builder builder = Jwt.withTokenValue(ID_TOKEN_VALUE) + .header("alg", "RS256") + .claim("iss", "https://gitlab.com") + .claim("aud", "client-1") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)); + if (subject != null) { + builder.subject(subject); + } + return builder.build(); + } + +} diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java index 754d1bc6284..f72b893fd27 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverterTests.java @@ -55,6 +55,8 @@ public class OAuth2TokenExchangeAuthenticationConverterTests { private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + private static final String ID_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:id_token"; + private OAuth2TokenExchangeAuthenticationConverter converter; @BeforeEach @@ -299,6 +301,24 @@ public void convertWhenInvalidActorTokenTypeThenUnsupportedTokenTypeError() { // @formatter:on } + @Test + public void convertWhenIdTokenSubjectTokenTypeThenTokenExchangeAuthenticationToken() { + MockHttpServletRequest request = createRequest(); + request.addParameter(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue()); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN); + request.addParameter(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, ID_TOKEN_TYPE_VALUE); + + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(new TestingAuthenticationToken(CLIENT_ID, null)); + SecurityContextHolder.setContext(securityContext); + + OAuth2TokenExchangeAuthenticationToken authentication = (OAuth2TokenExchangeAuthenticationToken) this.converter + .convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN); + assertThat(authentication.getSubjectTokenType()).isEqualTo(ID_TOKEN_TYPE_VALUE); + } + @Test public void convertWhenAllParametersThenTokenExchangeAuthenticationToken() { MockHttpServletRequest request = createRequest(); From e5bb86696bb5c024a879ed35686c05517a1c411e Mon Sep 17 00:00:00 2001 From: Bapuji Koraganti Date: Wed, 15 Apr 2026 21:26:51 -0400 Subject: [PATCH 2/2] 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 --- ...h2TokenExchangeAuthenticationProvider.java | 11 ++++ .../OidcIdTokenSubjectTokenResolver.java | 62 ++++++++++++++++++- .../settings/ClientSettings.java | 22 +++++++ .../settings/ConfigurationSettingNames.java | 7 +++ ...enExchangeAuthenticationProviderTests.java | 9 +++ .../OidcIdTokenSubjectTokenResolverTests.java | 16 +++++ .../settings/ClientSettingsTests.java | 14 +++++ 7 files changed, 140 insertions(+), 1 deletion(-) diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java index fc204b2d02f..de837a98c2e 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java @@ -80,6 +80,16 @@ public final class OAuth2TokenExchangeAuthenticationProvider implements Authenti private static final String MAY_ACT = "may_act"; + /** + * The attribute name for the subject token claims stored in the + * {@link OAuth2Authorization}. These claims are available to + * {@link org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer} + * implementations for mapping external ID token claims to the generated access token. + * @since 7.0 + */ + public static final String SUBJECT_TOKEN_CLAIMS_ATTRIBUTE = OAuth2TokenExchangeSubjectTokenContext.class.getName() + + ".CLAIMS"; + private final Log logger = LogFactory.getLog(getClass()); private final OAuth2AuthorizationService authorizationService; @@ -155,6 +165,7 @@ public Authentication authenticate(Authentication authentication) throws Authent .principalName(subjectTokenContext.getPrincipalName()) .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) .attribute(Principal.class.getName(), subjectTokenContext.getPrincipal()) + .attribute(SUBJECT_TOKEN_CLAIMS_ATTRIBUTE, subjectTokenContext.getClaims()) .build(); // @formatter:on diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java index 2f148970a7b..f9db4a87633 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolver.java @@ -18,6 +18,8 @@ import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -26,13 +28,17 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtDecoderFactory; import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** * An {@link OAuth2TokenExchangeSubjectTokenResolver} implementation that resolves @@ -44,11 +50,30 @@ * token using a {@link JwtDecoder} obtained from the provided factory, then constructs an * {@link OAuth2TokenExchangeSubjectTokenContext} from the token's claims. * + *

+ * When constructed with no arguments, the resolver uses the + * {@link ClientSettings#getIdTokenJwkSetUrl()} setting to resolve the external IdP's JWKS + * endpoint per client. Example client registration: + * + *

+ * RegisteredClient.withId(UUID.randomUUID().toString())
+ *     .clientId("cicd-client")
+ *     .clientSettings(ClientSettings.builder()
+ *         .idTokenJwkSetUrl("https://gitlab.com/oauth/discovery/keys")
+ *         .build())
+ *     .build();
+ * 
+ * + *

+ * For advanced use cases (e.g., multi-issuer routing, custom validation), a custom + * {@link JwtDecoderFactory} can be provided via the constructor. + * * @author Bapuji Koraganti * @since 7.0 * @see OAuth2TokenExchangeSubjectTokenResolver * @see OAuth2TokenExchangeSubjectTokenContext * @see JwtDecoderFactory + * @see ClientSettings#getIdTokenJwkSetUrl() */ public final class OidcIdTokenSubjectTokenResolver implements OAuth2TokenExchangeSubjectTokenResolver { @@ -58,9 +83,19 @@ public final class OidcIdTokenSubjectTokenResolver implements OAuth2TokenExchang private final JwtDecoderFactory jwtDecoderFactory; + /** + * Constructs an {@code OidcIdTokenSubjectTokenResolver} that uses the + * {@link ClientSettings#getIdTokenJwkSetUrl()} setting to resolve the external IdP's + * JWKS endpoint for each client. Decoders are cached per client. + * @since 7.0 + */ + public OidcIdTokenSubjectTokenResolver() { + this(new DefaultIdTokenJwtDecoderFactory()); + } + /** * Constructs an {@code OidcIdTokenSubjectTokenResolver} using the provided - * parameters. + * {@link JwtDecoderFactory}. * @param jwtDecoderFactory the factory for creating {@link JwtDecoder} instances * keyed by {@link RegisteredClient} */ @@ -98,6 +133,31 @@ public OidcIdTokenSubjectTokenResolver(JwtDecoderFactory jwtDe return new OAuth2TokenExchangeSubjectTokenContext(principal, subject, jwt.getClaims(), Collections.emptySet()); } + /** + * Default {@link JwtDecoderFactory} that reads the JWKS endpoint from + * {@link ClientSettings#getIdTokenJwkSetUrl()} and caches decoders per client. + */ + private static final class DefaultIdTokenJwtDecoderFactory implements JwtDecoderFactory { + + private final Map jwtDecoders = new ConcurrentHashMap<>(); + + @Override + public JwtDecoder createDecoder(RegisteredClient registeredClient) { + return this.jwtDecoders.computeIfAbsent(registeredClient.getId(), (key) -> { + String idTokenJwkSetUrl = registeredClient.getClientSettings().getIdTokenJwkSetUrl(); + if (!StringUtils.hasText(idTokenJwkSetUrl)) { + OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT, + "Failed to find an ID Token Verifier for Client: '" + registeredClient.getId() + + "'. Check to ensure you have configured the ID Token JWK Set URL.", + null); + throw new OAuth2AuthenticationException(oauth2Error); + } + return NimbusJwtDecoder.withJwkSetUri(idTokenJwkSetUrl).build(); + }); + } + + } + /** * An {@link Authentication} representing a principal resolved from an * externally-issued ID token. diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java index 665300fc049..2a8e23edb04 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettings.java @@ -99,6 +99,17 @@ public boolean isRequireAuthorizationConsent() { return getSetting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN); } + /** + * Returns the {@code URL} for the external Identity Provider's JSON Web Key Set used + * to validate ID tokens during token exchange. + * @return the {@code URL} for the external IdP's JSON Web Key Set, or {@code null} if + * not set + * @since 7.0 + */ + public @Nullable String getIdTokenJwkSetUrl() { + return getSetting(ConfigurationSettingNames.Client.ID_TOKEN_JWK_SET_URL); + } + /** * Constructs a new {@link Builder} with the default settings. * @return the {@link Builder} @@ -185,6 +196,17 @@ public Builder x509CertificateSubjectDN(String x509CertificateSubjectDN) { return setting(ConfigurationSettingNames.Client.X509_CERTIFICATE_SUBJECT_DN, x509CertificateSubjectDN); } + /** + * Sets the {@code URL} for the external Identity Provider's JSON Web Key Set used + * to validate ID tokens during token exchange. + * @param idTokenJwkSetUrl the {@code URL} for the external IdP's JSON Web Key Set + * @return the {@link Builder} for further configuration + * @since 7.0 + */ + public Builder idTokenJwkSetUrl(String idTokenJwkSetUrl) { + return setting(ConfigurationSettingNames.Client.ID_TOKEN_JWK_SET_URL, idTokenJwkSetUrl); + } + /** * Builds the {@link ClientSettings}. * @return the {@link ClientSettings} diff --git a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java index ff598bc7e0a..46de51f062e 100644 --- a/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java +++ b/oauth2/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java @@ -78,6 +78,13 @@ public static final class Client { public static final String X509_CERTIFICATE_SUBJECT_DN = CLIENT_SETTINGS_NAMESPACE .concat("x509-certificate-subject-dn"); + /** + * Set the {@code URL} for the external Identity Provider's JSON Web Key Set used + * to validate ID tokens during token exchange. + * @since 7.0 + */ + public static final String ID_TOKEN_JWK_SET_URL = CLIENT_SETTINGS_NAMESPACE.concat("id-token-jwk-set-url"); + private Client() { } diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java index 61fe2a5cb14..e55da0cfb87 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java @@ -698,6 +698,15 @@ public void authenticateWhenSubjectTokenResolverReturnsContextThenReturnAccessTo assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE); assertThat(authorization.getAttribute(Principal.class.getName())).isSameAs(userPrincipal); assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken); + + // Verify subject token claims are available via the token context authorization + OAuth2Authorization subjectAuth = tokenContext.getAuthorization(); + assertThat(subjectAuth).isNotNull(); + Map subjectTokenClaims = subjectAuth + .getAttribute(OAuth2TokenExchangeAuthenticationProvider.SUBJECT_TOKEN_CLAIMS_ATTRIBUTE); + assertThat(subjectTokenClaims).isNotNull(); + assertThat(subjectTokenClaims).containsEntry("iss", "https://gitlab.com"); + assertThat(subjectTokenClaims).containsEntry("sub", "user@example.com"); } @Test diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java index 65fdeee1050..769e812b1ba 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OidcIdTokenSubjectTokenResolverTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2ErrorCodes; @@ -113,6 +114,21 @@ public void resolveWhenSubjectClaimMissingThenThrowOAuth2AuthenticationException // @formatter:on } + @Test + public void resolveWhenDefaultConstructorAndNoIdTokenJwkSetUrlThenThrowOAuth2AuthenticationException() { + RegisteredClient registeredClient = TestRegisteredClients.registeredClient() + .authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE) + .build(); + OidcIdTokenSubjectTokenResolver defaultResolver = new OidcIdTokenSubjectTokenResolver(); + // @formatter:off + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> defaultResolver.resolve(ID_TOKEN_VALUE, ID_TOKEN_TYPE_VALUE, registeredClient)) + .extracting(OAuth2AuthenticationException::getError) + .extracting(OAuth2Error::getErrorCode) + .isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT); + // @formatter:on + } + @Test public void resolveWhenValidIdTokenThenReturnContext() { RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build(); diff --git a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java index fe0caa87755..67f6f394b39 100644 --- a/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java +++ b/oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java @@ -72,6 +72,20 @@ public void x509CertificateSubjectDNWhenProvidedThenSet() { .isEqualTo("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US"); } + @Test + public void idTokenJwkSetUrlWhenProvidedThenSet() { + ClientSettings clientSettings = ClientSettings.builder() + .idTokenJwkSetUrl("https://gitlab.com/oauth/discovery/keys") + .build(); + assertThat(clientSettings.getIdTokenJwkSetUrl()).isEqualTo("https://gitlab.com/oauth/discovery/keys"); + } + + @Test + public void idTokenJwkSetUrlWhenNotSetThenNull() { + ClientSettings clientSettings = ClientSettings.builder().build(); + assertThat(clientSettings.getIdTokenJwkSetUrl()).isNull(); + } + @Test public void settingWhenCustomThenSet() { ClientSettings clientSettings = ClientSettings.builder()