From 25a785de498be8da0296ef62764ba2b52412b671 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg <5248162+sjohnr@users.noreply.github.com> Date: Mon, 12 Feb 2024 15:30:21 -0600 Subject: [PATCH] Add support for OAuth 2.0 Token Exchange Grant Issue gh-60 --- .../ROOT/pages/protocol-endpoints.adoc | 4 +- ...ionServerBeanRegistrationAotProcessor.java | 8 + .../OAuth2ActorAuthenticationToken.java | 53 +++ .../OAuth2CompositeAuthenticationToken.java | 69 ++++ ...h2TokenExchangeAuthenticationProvider.java | 314 ++++++++++++++++++ ...Auth2TokenExchangeAuthenticationToken.java | 164 +++++++++ .../DelegatingOAuth2TokenCustomizer.java | 46 +++ .../configurers/OAuth2ConfigurerUtils.java | 32 +- .../OAuth2TokenEndpointConfigurer.java | 7 + .../OAuth2TokenExchangeTokenCustomizers.java | 85 +++++ .../OAuth2ActorAuthenticationTokenMixin.java | 44 +++ ...uth2AuthorizationServerJackson2Module.java | 7 +- ...uth2CompositeAuthenticationTokenMixin.java | 48 +++ .../web/OAuth2TokenEndpointFilter.java | 4 +- ...2TokenExchangeAuthenticationConverter.java | 241 ++++++++++++++ .../OAuth2ClientCredentialsGrantTests.java | 15 +- 16 files changed, 1129 insertions(+), 12 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ActorAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2CompositeAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationToken.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DelegatingOAuth2TokenCustomizer.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeTokenCustomizers.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2ActorAuthenticationTokenMixin.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2CompositeAuthenticationTokenMixin.java create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java diff --git a/docs/modules/ROOT/pages/protocol-endpoints.adoc b/docs/modules/ROOT/pages/protocol-endpoints.adoc index 13e3e3f8..f0173e30 100644 --- a/docs/modules/ROOT/pages/protocol-endpoints.adoc +++ b/docs/modules/ROOT/pages/protocol-endpoints.adoc @@ -261,8 +261,8 @@ The supported https://datatracker.ietf.org/doc/html/rfc6749#section-1.3[authoriz `OAuth2TokenEndpointFilter` is configured with the following defaults: -* `*AuthenticationConverter*` -- A `DelegatingAuthenticationConverter` composed of `OAuth2AuthorizationCodeAuthenticationConverter`, `OAuth2RefreshTokenAuthenticationConverter`, `OAuth2ClientCredentialsAuthenticationConverter`, and `OAuth2DeviceCodeAuthenticationConverter`. -* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2AuthorizationCodeAuthenticationProvider`, `OAuth2RefreshTokenAuthenticationProvider`, `OAuth2ClientCredentialsAuthenticationProvider`, and `OAuth2DeviceCodeAuthenticationProvider`. +* `*AuthenticationConverter*` -- A `DelegatingAuthenticationConverter` composed of `OAuth2AuthorizationCodeAuthenticationConverter`, `OAuth2RefreshTokenAuthenticationConverter`, `OAuth2ClientCredentialsAuthenticationConverter`, `OAuth2DeviceCodeAuthenticationConverter`, and `OAuth2TokenExchangeAuthenticationConverter`. +* `*AuthenticationManager*` -- An `AuthenticationManager` composed of `OAuth2AuthorizationCodeAuthenticationProvider`, `OAuth2RefreshTokenAuthenticationProvider`, `OAuth2ClientCredentialsAuthenticationProvider`, `OAuth2DeviceCodeAuthenticationProvider`, and `OAuth2TokenExchangeAuthenticationProvider`. * `*AuthenticationSuccessHandler*` -- An `OAuth2AccessTokenResponseAuthenticationSuccessHandler`. * `*AuthenticationFailureHandler*` -- An `OAuth2ErrorAuthenticationFailureHandler`. diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessor.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessor.java index 6d196844..67e1517f 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessor.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessor.java @@ -43,6 +43,8 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ActorAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2CompositeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; import org.springframework.security.web.authentication.WebAuthenticationDetails; @@ -109,7 +111,9 @@ class OAuth2AuthorizationServerBeanRegistrationAotProcessor implements BeanRegis TypeReference.of(OidcIdToken.class), TypeReference.of(AbstractOAuth2Token.class), TypeReference.of(OidcUserInfo.class), + TypeReference.of(OAuth2ActorAuthenticationToken.class), TypeReference.of(OAuth2AuthorizationRequest.class), + TypeReference.of(OAuth2CompositeAuthenticationToken.class), TypeReference.of(AuthorizationGrantType.class), TypeReference.of(OAuth2AuthorizationResponseType.class), TypeReference.of(OAuth2TokenFormat.class) @@ -150,8 +154,12 @@ class OAuth2AuthorizationServerBeanRegistrationAotProcessor implements BeanRegis loadClass("org.springframework.security.jackson2.UserMixin")); this.reflectionHintsRegistrar.registerReflectionHints(hints.reflection(), loadClass("org.springframework.security.jackson2.SimpleGrantedAuthorityMixin")); + this.reflectionHintsRegistrar.registerReflectionHints(hints.reflection(), + loadClass("org.springframework.security.oauth2.server.authorization.jackson2.OAuth2ActorAuthenticationTokenMixin")); this.reflectionHintsRegistrar.registerReflectionHints(hints.reflection(), loadClass("org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationRequestMixin")); + this.reflectionHintsRegistrar.registerReflectionHints(hints.reflection(), + loadClass("org.springframework.security.oauth2.server.authorization.jackson2.OAuth2CompositeAuthenticationTokenMixin")); this.reflectionHintsRegistrar.registerReflectionHints(hints.reflection(), loadClass("org.springframework.security.oauth2.server.authorization.jackson2.OAuth2TokenFormatMixin")); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ActorAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ActorAuthenticationToken.java new file mode 100644 index 00000000..5ba186eb --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ActorAuthenticationToken.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020-2024 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.io.Serializable; +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for the OAuth 2.0 Token Exchange Grant + * to represent an actor in a composite token (e.g. the "delegation" use case). + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2CompositeAuthenticationToken + */ +public class OAuth2ActorAuthenticationToken extends AbstractAuthenticationToken implements Serializable { + + private final String name; + + public OAuth2ActorAuthenticationToken(String name) { + super(Collections.emptyList()); + Assert.hasText(name, "name cannot be empty"); + this.name = name; + } + + @Override + public Object getPrincipal() { + return this.name; + } + + @Override + public Object getCredentials() { + return null; + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2CompositeAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2CompositeAuthenticationToken.java new file mode 100644 index 00000000..3fb8b572 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2CompositeAuthenticationToken.java @@ -0,0 +1,69 @@ +/* + * Copyright 2020-2024 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.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for the OAuth 2.0 Token Exchange Grant + * to represent the principal in a composite token (e.g. the "delegation" use case). + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2TokenExchangeAuthenticationToken + */ +public class OAuth2CompositeAuthenticationToken extends AbstractAuthenticationToken implements Serializable { + + private final Authentication subject; + + private final List actors; + + public OAuth2CompositeAuthenticationToken(Authentication subject, List actors) { + super(subject != null ? subject.getAuthorities() : null); + Assert.notNull(subject, "subject cannot be null"); + Assert.notNull(actors, "actors cannot be null"); + this.subject = subject; + this.actors = Collections.unmodifiableList(new ArrayList<>(actors)); + setDetails(subject.getDetails()); + setAuthenticated(subject.isAuthenticated()); + } + + @Override + public Object getPrincipal() { + return this.subject.getPrincipal(); + } + + @Override + public Object getCredentials() { + return null; + } + + public Authentication getSubject() { + return this.subject; + } + + public List getActors() { + return this.actors; + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java new file mode 100644 index 00000000..9c07b63c --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProvider.java @@ -0,0 +1,314 @@ +/* + * Copyright 2020-2024 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.security.Principal; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClaimAccessor; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +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.core.OAuth2Token; +import org.springframework.security.oauth2.core.oidc.StandardClaimNames; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.token.DefaultOAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient; + +/** + * An {@link AuthenticationProvider} implementation for the OAuth 2.0 Token Exchange Grant. + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2TokenExchangeAuthenticationToken + * @see OAuth2AccessTokenAuthenticationToken + * @see OAuth2AuthorizationService + * @see OAuth2TokenGenerator + * @see Section 1 Introduction + * @see Section 2.1 Request + */ +public final class OAuth2TokenExchangeAuthenticationProvider implements AuthenticationProvider { + + private static final String ERROR_URI = "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"; + + private static final AuthorizationGrantType TOKEN_EXCHANGE = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:token-exchange"); + + private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt"; + + private static final String MAY_ACT = "may_act"; + + private static final String ISSUED_TOKEN_TYPE = "issued_token_type"; + + private final Log logger = LogFactory.getLog(getClass()); + + private final OAuth2AuthorizationService authorizationService; + + private final OAuth2TokenGenerator tokenGenerator; + + /** + * Constructs an {@code OAuth2TokenExchangeAuthenticationProvider} using the provided parameters. + * + * @param authorizationService the authorization service + * @param tokenGenerator the token generator + */ + public OAuth2TokenExchangeAuthenticationProvider(OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + Assert.notNull(authorizationService, "authorizationService cannot be null"); + Assert.notNull(tokenGenerator, "tokenGenerator cannot be null"); + this.authorizationService = authorizationService; + this.tokenGenerator = tokenGenerator; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = + (OAuth2TokenExchangeAuthenticationToken) authentication; + + OAuth2ClientAuthenticationToken clientPrincipal = + getAuthenticatedClientElseThrowInvalidClient(tokenExchangeAuthentication); + RegisteredClient registeredClient = clientPrincipal.getRegisteredClient(); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved registered client"); + } + + if (!registeredClient.getAuthorizationGrantTypes().contains(TOKEN_EXCHANGE)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT); + } + + if (JWT_TOKEN_TYPE_VALUE.equals(tokenExchangeAuthentication.getRequestedTokenType()) && + !OAuth2TokenFormat.SELF_CONTAINED.equals(registeredClient.getTokenSettings().getAccessTokenFormat())) { + 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); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with subject token"); + } + + OAuth2Authorization.Token subjectToken = subjectAuthorization.getToken( + tokenExchangeAuthentication.getSubjectToken()); + 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 (JWT_TOKEN_TYPE_VALUE.equals(tokenExchangeAuthentication.getSubjectTokenType()) && + !Jwt.class.isAssignableFrom(subjectToken.getToken().getClass())) { + 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. + String authorizedActorSubject = null; + if (subjectToken.getClaims() != null && + subjectToken.getClaims().containsKey(MAY_ACT) && + subjectToken.getClaims().get(MAY_ACT) instanceof Map mayAct) { + authorizedActorSubject = (String) mayAct.get(StandardClaimNames.SUB); + } + + OAuth2Authorization actorAuthorization = null; + if (StringUtils.hasText(tokenExchangeAuthentication.getActorToken())) { + actorAuthorization = this.authorizationService.findByToken( + tokenExchangeAuthentication.getActorToken(), OAuth2TokenType.ACCESS_TOKEN); + if (actorAuthorization == null) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Retrieved authorization with actor token"); + } + + OAuth2Authorization.Token actorToken = actorAuthorization.getToken( + tokenExchangeAuthentication.getActorToken()); + if (!actorToken.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 (JWT_TOKEN_TYPE_VALUE.equals(tokenExchangeAuthentication.getActorTokenType()) && + !Jwt.class.isAssignableFrom(actorToken.getToken().getClass())) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + if (StringUtils.hasText(authorizedActorSubject) && + !authorizedActorSubject.equals(actorAuthorization.getPrincipalName())) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + } else if (StringUtils.hasText(authorizedActorSubject) && + !authorizedActorSubject.equals(clientPrincipal.getName())) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_GRANT); + } + + Set authorizedScopes = Collections.emptySet(); + if (!CollectionUtils.isEmpty(tokenExchangeAuthentication.getScopes())) { + authorizedScopes = validateRequestedScopes(registeredClient, tokenExchangeAuthentication.getScopes()); + } else if (!CollectionUtils.isEmpty(subjectAuthorization.getAuthorizedScopes())) { + authorizedScopes = validateRequestedScopes(registeredClient, subjectAuthorization.getAuthorizedScopes()); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Validated token request parameters"); + } + + Authentication principal = getPrincipal(subjectAuthorization, actorAuthorization); + + // @formatter:off + DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder() + .registeredClient(registeredClient) + .authorization(subjectAuthorization) + .principal(principal) + .authorizationServerContext(AuthorizationServerContextHolder.getContext()) + .authorizedScopes(authorizedScopes) + .tokenType(OAuth2TokenType.ACCESS_TOKEN) + .authorizationGrantType(TOKEN_EXCHANGE) + .authorizationGrant(tokenExchangeAuthentication); + // @formatter:on + + // ----- Access token ----- + OAuth2TokenContext tokenContext = tokenContextBuilder.build(); + OAuth2Token generatedAccessToken = this.tokenGenerator.generate(tokenContext); + if (generatedAccessToken == null) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.SERVER_ERROR, + "The token generator failed to generate the access token.", ERROR_URI); + throw new OAuth2AuthenticationException(error); + } + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Generated access token"); + } + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + generatedAccessToken.getTokenValue(), generatedAccessToken.getIssuedAt(), + generatedAccessToken.getExpiresAt(), tokenContext.getAuthorizedScopes()); + + // @formatter:off + OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.withRegisteredClient(registeredClient) + .principalName(subjectAuthorization.getPrincipalName()) + .authorizationGrantType(TOKEN_EXCHANGE) + .authorizedScopes(authorizedScopes) + .attribute(Principal.class.getName(), principal); + // @formatter:on + + if (generatedAccessToken instanceof ClaimAccessor) { + authorizationBuilder.token(accessToken, (metadata) -> { + metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, ((ClaimAccessor) generatedAccessToken).getClaims()); + metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, false); + }); + } else { + authorizationBuilder.accessToken(accessToken); + } + + OAuth2Authorization authorization = authorizationBuilder.build(); + this.authorizationService.save(authorization); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Saved authorization"); + } + + Map additionalParameters = new HashMap<>(); + additionalParameters.put(ISSUED_TOKEN_TYPE, tokenExchangeAuthentication.getRequestedTokenType()); + + if (this.logger.isTraceEnabled()) { + this.logger.trace("Authenticated token request"); + } + + return new OAuth2AccessTokenAuthenticationToken( + registeredClient, clientPrincipal, accessToken, null, additionalParameters); + } + + private static Set validateRequestedScopes(RegisteredClient registeredClient, Set requestedScopes) { + for (String requestedScope : requestedScopes) { + if (!registeredClient.getScopes().contains(requestedScope)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_SCOPE); + } + } + + return new LinkedHashSet<>(requestedScopes); + } + + private static Authentication getPrincipal(OAuth2Authorization subjectAuthorization, OAuth2Authorization actorAuthorization) { + Authentication subjectPrincipal = subjectAuthorization.getAttribute(Principal.class.getName()); + + List actorPrincipals = new LinkedList<>(); + if (actorAuthorization != null) { + actorPrincipals.add(new OAuth2ActorAuthenticationToken(actorAuthorization.getPrincipalName())); + } + + if (subjectPrincipal instanceof OAuth2CompositeAuthenticationToken compositeAuthenticationToken) { + // As per https://datatracker.ietf.org/doc/html/rfc8693#section-4.1, + // the act claim can be used to represent a chain of delegation, + // so we unwrap the original subject and any previous actor(s). + subjectPrincipal = compositeAuthenticationToken.getSubject(); + actorPrincipals.addAll(compositeAuthenticationToken.getActors()); + // TODO: Should we allow delegation-to-impersonation where previous + // actors exist but no actor_token exists on this request? + } + + return CollectionUtils.isEmpty(actorPrincipals) ? subjectPrincipal : + new OAuth2CompositeAuthenticationToken(subjectPrincipal, actorPrincipals); + } + + @Override + public boolean supports(Class authentication) { + return OAuth2TokenExchangeAuthenticationToken.class.isAssignableFrom(authentication); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationToken.java new file mode 100644 index 00000000..67efb8a2 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationToken.java @@ -0,0 +1,164 @@ +/* + * Copyright 2020-2024 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.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.util.Assert; + +/** + * An {@link Authentication} implementation used for the OAuth 2.0 Token Exchange Grant. + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2AuthorizationGrantAuthenticationToken + * @see OAuth2TokenExchangeAuthenticationProvider + */ +public class OAuth2TokenExchangeAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + + private static final AuthorizationGrantType TOKEN_EXCHANGE = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:token-exchange"); + + private final List resources; + + private final List audiences; + + private final String requestedTokenType; + + private final String subjectToken; + + private final String subjectTokenType; + + private final String actorToken; + + private final String actorTokenType; + + private final Set scopes; + + /** + * Constructs an {@code OAuth2TokenExchangeAuthenticationToken} using the provided parameters. + * + * @param resources a list of resource URIs + * @param audiences a list audience values + * @param scopes the requested scope(s) + * @param requestedTokenType the requested token type + * @param subjectToken the subject token + * @param subjectTokenType the subject token type + * @param actorToken the actor token + * @param actorTokenType the actor token type + * @param clientPrincipal the authenticated client principal + * @param additionalParameters the additional parameters + */ + public OAuth2TokenExchangeAuthenticationToken(List resources, List audiences, + @Nullable Set scopes, @Nullable String requestedTokenType, String subjectToken, + String subjectTokenType, @Nullable String actorToken, @Nullable String actorTokenType, + Authentication clientPrincipal, @Nullable Map additionalParameters) { + super(TOKEN_EXCHANGE, clientPrincipal, additionalParameters); + Assert.notNull(resources, "resources cannot be null"); + Assert.notNull(audiences, "audiences cannot be null"); + Assert.hasText(requestedTokenType, "requestedTokenType cannot be empty"); + Assert.hasText(subjectToken, "subjectToken cannot be empty"); + Assert.hasText(subjectTokenType, "subjectTokenType cannot be empty"); + this.resources = resources; + this.audiences = audiences; + this.requestedTokenType = requestedTokenType; + this.subjectToken = subjectToken; + this.subjectTokenType = subjectTokenType; + this.actorToken = actorToken; + this.actorTokenType = actorTokenType; + this.scopes = Collections.unmodifiableSet( + scopes != null ? new HashSet<>(scopes) : Collections.emptySet()); + } + + /** + * Returns the list of resource URIs. + * + * @return the list of resource URIs + */ + public List getResources() { + return this.resources; + } + + /** + * Returns the list of audience values. + * + * @return the list of audience values + */ + public List getAudiences() { + return this.audiences; + } + + /** + * Returns the requested scope(s). + * + * @return the requested scope(s), or an empty {@code Set} if not available + */ + public Set getScopes() { + return this.scopes; + } + + /** + * Returns the requested token type. + * + * @return the requested token type + */ + public String getRequestedTokenType() { + return this.requestedTokenType; + } + + /** + * Returns the subject token. + * + * @return the subject token + */ + public String getSubjectToken() { + return this.subjectToken; + } + + /** + * Returns the subject token type. + * + * @return the subject token type + */ + public String getSubjectTokenType() { + return this.subjectTokenType; + } + + /** + * Returns the actor token. + * + * @return the actor token + */ + public String getActorToken() { + return this.actorToken; + } + + /** + * Returns the actor token type. + * + * @return the actor token type + */ + public String getActorTokenType() { + return this.actorTokenType; + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DelegatingOAuth2TokenCustomizer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DelegatingOAuth2TokenCustomizer.java new file mode 100644 index 00000000..aba4a2fd --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DelegatingOAuth2TokenCustomizer.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020-2024 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.config.annotation.web.configurers; + +import java.util.Collections; +import java.util.List; + +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +import org.springframework.util.Assert; + +/** + * @author Steve Riesenberg + * @since 1.3 + */ +final class DelegatingOAuth2TokenCustomizer implements OAuth2TokenCustomizer { + + private final List> tokenCustomizers; + + DelegatingOAuth2TokenCustomizer(List> tokenCustomizers) { + Assert.notEmpty(tokenCustomizers, "tokenCustomizers cannot be empty"); + this.tokenCustomizers = Collections.unmodifiableList(tokenCustomizers); + } + + @Override + public void customize(T context) { + for (OAuth2TokenCustomizer tokenCustomizer : this.tokenCustomizers) { + tokenCustomizer.customize(context); + } + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java index 2ff97378..3fd9c202 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java @@ -15,6 +15,8 @@ */ package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import com.nimbusds.jose.jwk.source.JWKSource; @@ -170,13 +172,39 @@ final class OAuth2ConfigurerUtils { } private static OAuth2TokenCustomizer getJwtCustomizer(HttpSecurity httpSecurity) { + OAuth2TokenCustomizer defaultTokenCustomizer = OAuth2TokenExchangeTokenCustomizers.jwt(); ResolvableType type = ResolvableType.forClassWithGenerics(OAuth2TokenCustomizer.class, JwtEncodingContext.class); - return getOptionalBean(httpSecurity, type); + OAuth2TokenCustomizer userTokenCustomizer = getOptionalBean(httpSecurity, type); + + OAuth2TokenCustomizer tokenCustomizer; + if (userTokenCustomizer != null) { + List> tokenCustomizers = new ArrayList<>(); + tokenCustomizers.add(defaultTokenCustomizer); + tokenCustomizers.add(userTokenCustomizer); + tokenCustomizer = new DelegatingOAuth2TokenCustomizer<>(tokenCustomizers); + } else { + tokenCustomizer = defaultTokenCustomizer; + } + + return tokenCustomizer; } private static OAuth2TokenCustomizer getAccessTokenCustomizer(HttpSecurity httpSecurity) { + OAuth2TokenCustomizer defaultTokenCustomizer = OAuth2TokenExchangeTokenCustomizers.accessToken(); ResolvableType type = ResolvableType.forClassWithGenerics(OAuth2TokenCustomizer.class, OAuth2TokenClaimsContext.class); - return getOptionalBean(httpSecurity, type); + OAuth2TokenCustomizer userTokenCustomizer = getOptionalBean(httpSecurity, type); + + OAuth2TokenCustomizer tokenCustomizer; + if (userTokenCustomizer != null) { + List> tokenCustomizers = new ArrayList<>(); + tokenCustomizers.add(defaultTokenCustomizer); + tokenCustomizers.add(userTokenCustomizer); + tokenCustomizer = new DelegatingOAuth2TokenCustomizer<>(tokenCustomizers); + } else { + tokenCustomizer = defaultTokenCustomizer; + } + + return tokenCustomizer; } static AuthorizationServerSettings getAuthorizationServerSettings(HttpSecurity httpSecurity) { diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java index fa23a173..00a7d62b 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java @@ -38,6 +38,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider; 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.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; @@ -46,6 +47,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenExchangeAuthenticationConverter; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; @@ -213,6 +215,7 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure authenticationConverters.add(new OAuth2RefreshTokenAuthenticationConverter()); authenticationConverters.add(new OAuth2ClientCredentialsAuthenticationConverter()); authenticationConverters.add(new OAuth2DeviceCodeAuthenticationConverter()); + authenticationConverters.add(new OAuth2TokenExchangeAuthenticationConverter()); return authenticationConverters; } @@ -243,6 +246,10 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure new OAuth2DeviceCodeAuthenticationProvider(authorizationService, tokenGenerator); authenticationProviders.add(deviceCodeAuthenticationProvider); + OAuth2TokenExchangeAuthenticationProvider tokenExchangeAuthenticationProvider = + new OAuth2TokenExchangeAuthenticationProvider(authorizationService, tokenGenerator); + authenticationProviders.add(tokenExchangeAuthenticationProvider); + return authenticationProviders; } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeTokenCustomizers.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeTokenCustomizers.java new file mode 100644 index 00000000..c8fcf0b7 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeTokenCustomizers.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020-2024 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.config.annotation.web.configurers; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2CompositeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimNames; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +import org.springframework.util.CollectionUtils; + +/** + * @author Steve Riesenberg + * @since 1.3 + */ +final class OAuth2TokenExchangeTokenCustomizers { + + private static final AuthorizationGrantType TOKEN_EXCHANGE = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:token-exchange"); + + private OAuth2TokenExchangeTokenCustomizers() { + } + + static OAuth2TokenCustomizer jwt() { + return (context) -> context.getClaims().claims((claims) -> customize(context, claims)); + } + + static OAuth2TokenCustomizer accessToken() { + return (context) -> context.getClaims().claims((claims) -> customize(context, claims)); + } + + private static void customize(OAuth2TokenContext context, Map claims) { + if (!TOKEN_EXCHANGE.equals(context.getAuthorizationGrantType())) { + return; + } + + if (context.getAuthorizationGrant() instanceof OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication) { + // Customize the token claims when audience is present in the request + List audience = tokenExchangeAuthentication.getAudiences(); + if (!CollectionUtils.isEmpty(audience)) { + claims.put(OAuth2TokenClaimNames.AUD, audience); + } + } + + // As per https://datatracker.ietf.org/doc/html/rfc8693#section-4.1, + // we handle a composite principal with an actor by adding an "act" + // claim with a "sub" claim of the actor. + // + // If more than one actor is present, we create a chain of delegation by + // nesting "act" claims. + if (context.getPrincipal() instanceof OAuth2CompositeAuthenticationToken compositeAuthenticationToken) { + Map currentClaims = claims; + for (Authentication actorPrincipal : compositeAuthenticationToken.getActors()) { + Map actClaim = new HashMap<>(); + actClaim.put("sub", actorPrincipal.getName()); + currentClaims.put("act", Collections.unmodifiableMap(actClaim)); + currentClaims = actClaim; + } + } + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2ActorAuthenticationTokenMixin.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2ActorAuthenticationTokenMixin.java new file mode 100644 index 00000000..12d63f8f --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2ActorAuthenticationTokenMixin.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-2024 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.jackson2; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ActorAuthenticationToken; + +/** + * This mixin class is used to serialize/deserialize {@link OAuth2ActorAuthenticationToken}. + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2ActorAuthenticationToken + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, creatorVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class OAuth2ActorAuthenticationTokenMixin { + + @JsonCreator + OAuth2ActorAuthenticationTokenMixin(@JsonProperty("name") String name) { + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java index e9a58b12..55c039df 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2Module.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2024 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. @@ -27,6 +27,8 @@ import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.oauth2.jose.jws.MacAlgorithm; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ActorAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2CompositeAuthenticationToken; import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; /** @@ -37,6 +39,7 @@ import org.springframework.security.oauth2.server.authorization.settings.OAuth2T *
  • {@link UnmodifiableMapMixin}
  • *
  • {@link HashSetMixin}
  • *
  • {@link OAuth2AuthorizationRequestMixin}
  • + *
  • {@link OAuth2CompositeAuthenticationTokenMixin}
  • *
  • {@link DurationMixin}
  • *
  • {@link JwsAlgorithmMixin}
  • *
  • {@link OAuth2TokenFormatMixin}
  • @@ -77,7 +80,9 @@ public class OAuth2AuthorizationServerJackson2Module extends SimpleModule { UnmodifiableMapMixin.class); context.setMixInAnnotations(HashSet.class, HashSetMixin.class); context.setMixInAnnotations(LinkedHashSet.class, HashSetMixin.class); + context.setMixInAnnotations(OAuth2ActorAuthenticationToken.class, OAuth2ActorAuthenticationTokenMixin.class); context.setMixInAnnotations(OAuth2AuthorizationRequest.class, OAuth2AuthorizationRequestMixin.class); + context.setMixInAnnotations(OAuth2CompositeAuthenticationToken.class, OAuth2CompositeAuthenticationTokenMixin.class); context.setMixInAnnotations(Duration.class, DurationMixin.class); context.setMixInAnnotations(SignatureAlgorithm.class, JwsAlgorithmMixin.class); context.setMixInAnnotations(MacAlgorithm.class, JwsAlgorithmMixin.class); diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2CompositeAuthenticationTokenMixin.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2CompositeAuthenticationTokenMixin.java new file mode 100644 index 00000000..34e3cce3 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2CompositeAuthenticationTokenMixin.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020-2024 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.jackson2; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2CompositeAuthenticationToken; + +/** + * This mixin class is used to serialize/deserialize {@link OAuth2CompositeAuthenticationToken}. + * + * @author Steve Riesenberg + * @since 1.3 + * @see OAuth2CompositeAuthenticationToken + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) +@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE, + isGetterVisibility = JsonAutoDetect.Visibility.NONE, creatorVisibility = JsonAutoDetect.Visibility.NONE) +@JsonIgnoreProperties(ignoreUnknown = true) +abstract class OAuth2CompositeAuthenticationTokenMixin { + + @JsonCreator + OAuth2CompositeAuthenticationTokenMixin(@JsonProperty("subject") Authentication subject, + @JsonProperty("actors") List actors) { + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java index 426071f5..f7ff8db2 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java @@ -48,6 +48,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ErrorAuthenticationFailureHandler; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenExchangeAuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -128,7 +129,8 @@ public final class OAuth2TokenEndpointFilter extends OncePerRequestFilter { new OAuth2AuthorizationCodeAuthenticationConverter(), new OAuth2RefreshTokenAuthenticationConverter(), new OAuth2ClientCredentialsAuthenticationConverter(), - new OAuth2DeviceCodeAuthenticationConverter())); + new OAuth2DeviceCodeAuthenticationConverter(), + new OAuth2TokenExchangeAuthenticationConverter())); } @Override diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java new file mode 100644 index 00000000..68fb1253 --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/authentication/OAuth2TokenExchangeAuthenticationConverter.java @@ -0,0 +1,241 @@ +/* + * Copyright 2020-2024 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.web.authentication; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken; +import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +/** + * Attempts to extract an Access Token Request from {@link HttpServletRequest} for the OAuth 2.0 Token Exchange Grant + * and then converts it to an {@link OAuth2TokenExchangeAuthenticationToken} used for authenticating the authorization grant. + * + * @author Steve Riesenberg + * @since 1.3 + * @see AuthenticationConverter + * @see OAuth2TokenExchangeAuthenticationToken + * @see OAuth2TokenEndpointFilter + */ +public final class OAuth2TokenExchangeAuthenticationConverter implements AuthenticationConverter { + + private static final String TOKEN_TYPE_IDENTIFIERS_URI = "https://datatracker.ietf.org/doc/html/rfc8693#section-3"; + + private static final AuthorizationGrantType TOKEN_EXCHANGE = new AuthorizationGrantType( + "urn:ietf:params:oauth:grant-type:token-exchange"); + + private static final String AUDIENCE = "audience"; + + private static final String RESOURCE = "resource"; + + private static final String REQUESTED_TOKEN_TYPE = "requested_token_type"; + + private static final String SUBJECT_TOKEN = "subject_token"; + + private static final String SUBJECT_TOKEN_TYPE = "subject_token_type"; + + private static final String ACTOR_TOKEN = "actor_token"; + + private static final String ACTOR_TOKEN_TYPE = "actor_token_type"; + + private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token"; + + 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); + + @Nullable + @Override + public Authentication convert(HttpServletRequest request) { + MultiValueMap parameters = OAuth2EndpointUtils.getFormParameters(request); + + // grant_type (REQUIRED) + String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE); + if (!TOKEN_EXCHANGE.getValue().equals(grantType)) { + return null; + } + + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + // resource (OPTIONAL) + List resources = parameters.getOrDefault(RESOURCE, Collections.emptyList()); + if (!CollectionUtils.isEmpty(resources)) { + for (String resource : resources) { + if (!isValidUri(resource)) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + RESOURCE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + } + } + + // audience (OPTIONAL) + List audiences = parameters.getOrDefault(AUDIENCE, Collections.emptyList()); + + // scope (OPTIONAL) + String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE); + if (StringUtils.hasText(scope) && + parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + OAuth2ParameterNames.SCOPE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + Set requestedScopes = null; + if (StringUtils.hasText(scope)) { + requestedScopes = new HashSet<>( + Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + + // requested_token_type (OPTIONAL) + String requestedTokenType = parameters.getFirst(REQUESTED_TOKEN_TYPE); + if (StringUtils.hasText(requestedTokenType)) { + if (parameters.get(REQUESTED_TOKEN_TYPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + REQUESTED_TOKEN_TYPE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + validateTokenType(REQUESTED_TOKEN_TYPE, requestedTokenType); + } else { + requestedTokenType = ACCESS_TOKEN_TYPE_VALUE; + } + + // subject_token (REQUIRED) + String subjectToken = parameters.getFirst(SUBJECT_TOKEN); + if (!StringUtils.hasText(subjectToken) || + parameters.get(SUBJECT_TOKEN).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + SUBJECT_TOKEN, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + // subject_token_type (REQUIRED) + String subjectTokenType = parameters.getFirst(SUBJECT_TOKEN_TYPE); + if (!StringUtils.hasText(subjectTokenType) || + parameters.get(SUBJECT_TOKEN_TYPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + SUBJECT_TOKEN_TYPE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } else { + validateTokenType(SUBJECT_TOKEN_TYPE, subjectTokenType); + } + + // actor_token (OPTIONAL, REQUIRED if actor_token_type is provided) + String actorToken = parameters.getFirst(ACTOR_TOKEN); + if (StringUtils.hasText(actorToken) && + parameters.get(ACTOR_TOKEN).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + ACTOR_TOKEN, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + // actor_token_type (OPTIONAL, REQUIRED if actor_token is provided) + String actorTokenType = parameters.getFirst(ACTOR_TOKEN_TYPE); + if (StringUtils.hasText(actorTokenType)) { + if (parameters.get(ACTOR_TOKEN_TYPE).size() != 1) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + ACTOR_TOKEN_TYPE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + validateTokenType(ACTOR_TOKEN_TYPE, actorTokenType); + } + + if (!StringUtils.hasText(actorToken) && StringUtils.hasText(actorTokenType)) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + ACTOR_TOKEN, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } else if (StringUtils.hasText(actorToken) && !StringUtils.hasText(actorTokenType)) { + OAuth2EndpointUtils.throwError( + OAuth2ErrorCodes.INVALID_REQUEST, + ACTOR_TOKEN_TYPE, + OAuth2EndpointUtils.ACCESS_TOKEN_REQUEST_ERROR_URI); + } + + Map additionalParameters = new HashMap<>(); + parameters.forEach((key, value) -> { + if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) && + !key.equals(RESOURCE) && + !key.equals(AUDIENCE) && + !key.equals(REQUESTED_TOKEN_TYPE) && + !key.equals(SUBJECT_TOKEN) && + !key.equals(SUBJECT_TOKEN_TYPE) && + !key.equals(ACTOR_TOKEN) && + !key.equals(ACTOR_TOKEN_TYPE) && + !key.equals(OAuth2ParameterNames.SCOPE)) { + additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0])); + } + }); + + return new OAuth2TokenExchangeAuthenticationToken(resources, audiences, requestedScopes, requestedTokenType, + subjectToken, subjectTokenType, actorToken, actorTokenType, clientPrincipal, additionalParameters); + } + + private static void validateTokenType(String parameterName, String tokenTypeValue) { + if (!SUPPORTED_TOKEN_TYPES.contains(tokenTypeValue)) { + OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.UNSUPPORTED_TOKEN_TYPE, + String.format("OAuth 2.0 Token Exchange parameter: %s", parameterName), TOKEN_TYPE_IDENTIFIERS_URI); + // @formatter:off + 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); + // @formatter:on + throw new OAuth2AuthenticationException(error, message); + } + } + + private static boolean isValidUri(String uri) { + try { + URI validUri = new URI(uri); + return validUri.isAbsolute() && validUri.getFragment() == null; + } catch (URISyntaxException ex) { + return false; + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java index f76be0cb..de5cb290 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java @@ -24,13 +24,12 @@ import java.util.Base64; import java.util.List; import java.util.function.Consumer; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -74,6 +73,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken; 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.PublicClientAuthenticationProvider; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper; @@ -93,6 +93,7 @@ import org.springframework.security.oauth2.server.authorization.web.authenticati import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter; +import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenExchangeAuthenticationConverter; import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationConverter; @@ -294,7 +295,8 @@ public class OAuth2ClientCredentialsGrantTests { converter instanceof OAuth2AuthorizationCodeAuthenticationConverter || converter instanceof OAuth2RefreshTokenAuthenticationConverter || converter instanceof OAuth2ClientCredentialsAuthenticationConverter || - converter instanceof OAuth2DeviceCodeAuthenticationConverter); + converter instanceof OAuth2DeviceCodeAuthenticationConverter || + converter instanceof OAuth2TokenExchangeAuthenticationConverter); verify(authenticationProvider).authenticate(eq(clientCredentialsAuthentication)); @@ -307,7 +309,8 @@ public class OAuth2ClientCredentialsGrantTests { provider instanceof OAuth2AuthorizationCodeAuthenticationProvider || provider instanceof OAuth2RefreshTokenAuthenticationProvider || provider instanceof OAuth2ClientCredentialsAuthenticationProvider || - provider instanceof OAuth2DeviceCodeAuthenticationProvider); + provider instanceof OAuth2DeviceCodeAuthenticationProvider || + provider instanceof OAuth2TokenExchangeAuthenticationProvider); verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication)); }