diff --git a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverter.java b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverter.java index d30cd8e05af..6918985523c 100644 --- a/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverter.java +++ b/oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-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. @@ -16,14 +16,20 @@ package org.springframework.security.oauth2.server.resource.web.server.authentication; +import static org.springframework.security.oauth2.server.resource.BearerTokenErrors.invalidRequest; + import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -47,16 +53,20 @@ */ public class ServerBearerTokenAuthenticationConverter implements ServerAuthenticationConverter { + public static final String ACCESS_TOKEN_NAME = "access_token"; + public static final String MULTIPLE_BEARER_TOKENS_ERROR_MSG = "Found multiple bearer tokens in the request"; private static final Pattern authorizationPattern = Pattern.compile("^Bearer (?[a-zA-Z0-9-._~+/]+=*)$", Pattern.CASE_INSENSITIVE); private boolean allowUriQueryParameter = false; + private boolean allowFormEncodedBodyParameter = false; + private String bearerTokenHeaderName = HttpHeaders.AUTHORIZATION; @Override public Mono convert(ServerWebExchange exchange) { - return Mono.fromCallable(() -> token(exchange.getRequest())).map((token) -> { + return Mono.defer(() -> token(exchange)).map(token -> { if (token.isEmpty()) { BearerTokenError error = invalidTokenError(); throw new OAuth2AuthenticationException(error); @@ -65,38 +75,53 @@ public Mono convert(ServerWebExchange exchange) { }); } - private String token(ServerHttpRequest request) { - String authorizationHeaderToken = resolveFromAuthorizationHeader(request.getHeaders()); - String parameterToken = resolveAccessTokenFromRequest(request); - - if (authorizationHeaderToken != null) { - if (parameterToken != null) { - BearerTokenError error = BearerTokenErrors - .invalidRequest("Found multiple bearer tokens in the request"); - throw new OAuth2AuthenticationException(error); - } - return authorizationHeaderToken; - } - if (parameterToken != null && isParameterTokenSupportedForRequest(request)) { - return parameterToken; - } - return null; + private Mono token(ServerWebExchange exchange) { + final ServerHttpRequest request = exchange.getRequest(); + + return Flux.merge(resolveFromAuthorizationHeader(request.getHeaders()).map(s -> Tuples.of(s, TokenSource.HEADER)), + resolveAccessTokenFromRequest(request).map(s -> Tuples.of(s, TokenSource.QUERY_PARAMETER)), + resolveAccessTokenFromBody(exchange).map(s -> Tuples.of(s, TokenSource.BODY_PARAMETER))) + .collectList() + .mapNotNull(tokenTuples -> { + switch (tokenTuples.size()) { + case 0: + return null; + case 1: + return getTokenIfSupported(tokenTuples.get(0), request); + default: + BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG); + throw new OAuth2AuthenticationException(error); + } + }); } - private static String resolveAccessTokenFromRequest(ServerHttpRequest request) { - List parameterTokens = request.getQueryParams().get("access_token"); + private static Mono resolveAccessTokenFromRequest(ServerHttpRequest request) { + List parameterTokens = request.getQueryParams().get(ACCESS_TOKEN_NAME); if (CollectionUtils.isEmpty(parameterTokens)) { - return null; + return Mono.empty(); } if (parameterTokens.size() == 1) { - return parameterTokens.get(0); + return Mono.just(parameterTokens.get(0)); } - BearerTokenError error = BearerTokenErrors.invalidRequest("Found multiple bearer tokens in the request"); + BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG); throw new OAuth2AuthenticationException(error); } + private String getTokenIfSupported(Tuple2 tokenTuple, ServerHttpRequest request) { + switch (tokenTuple.getT2()) { + case HEADER: + return tokenTuple.getT1(); + case QUERY_PARAMETER: + return isParameterTokenSupportedForRequest(request) ? tokenTuple.getT1() : null; + case BODY_PARAMETER: + return isBodyParameterTokenSupportedForRequest(request) ? tokenTuple.getT1() : null; + default: + throw new IllegalArgumentException(); + } + } + /** * Set if transport of access token using URI query parameter is supported. Defaults * to {@code false}. @@ -122,25 +147,70 @@ public void setBearerTokenHeaderName(String bearerTokenHeaderName) { this.bearerTokenHeaderName = bearerTokenHeaderName; } - private String resolveFromAuthorizationHeader(HttpHeaders headers) { + /** + * Set if transport of access token using form-encoded body parameter is supported. + * Defaults to {@code false}. + * @param allowFormEncodedBodyParameter if the form-encoded body parameter is + * supported + * @since 6.5 + */ + public void setAllowFormEncodedBodyParameter(boolean allowFormEncodedBodyParameter) { + this.allowFormEncodedBodyParameter = allowFormEncodedBodyParameter; + } + + private Mono resolveFromAuthorizationHeader(HttpHeaders headers) { String authorization = headers.getFirst(this.bearerTokenHeaderName); if (!StringUtils.startsWithIgnoreCase(authorization, "bearer")) { - return null; + return Mono.empty(); } Matcher matcher = authorizationPattern.matcher(authorization); if (!matcher.matches()) { BearerTokenError error = invalidTokenError(); throw new OAuth2AuthenticationException(error); } - return matcher.group("token"); + return Mono.just(matcher.group("token")); } private static BearerTokenError invalidTokenError() { return BearerTokenErrors.invalidToken("Bearer token is malformed"); } + private Mono resolveAccessTokenFromBody(ServerWebExchange exchange) { + if (!allowFormEncodedBodyParameter) { + return Mono.empty(); + } + + final ServerHttpRequest request = exchange.getRequest(); + + if (request.getMethod() == HttpMethod.POST && + MediaType.APPLICATION_FORM_URLENCODED.equalsTypeAndSubtype(request.getHeaders().getContentType())) { + + return exchange.getFormData().mapNotNull(formData -> { + if (formData.isEmpty()) { + return null; + } + final List tokens = formData.get(ACCESS_TOKEN_NAME); + if (tokens == null) { + return null; + } + if (tokens.size() > 1) { + BearerTokenError error = invalidRequest(MULTIPLE_BEARER_TOKENS_ERROR_MSG); + throw new OAuth2AuthenticationException(error); + } + return formData.getFirst(ACCESS_TOKEN_NAME); + }); + } + return Mono.empty(); + } + + private boolean isBodyParameterTokenSupportedForRequest(ServerHttpRequest request) { + return this.allowFormEncodedBodyParameter && HttpMethod.POST == request.getMethod(); + } + private boolean isParameterTokenSupportedForRequest(ServerHttpRequest request) { return this.allowUriQueryParameter && HttpMethod.GET.equals(request.getMethod()); } + private enum TokenSource {HEADER, QUERY_PARAMETER, BODY_PARAMETER} + } diff --git a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverterTests.java b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverterTests.java index 6d9c7a5b98f..33e06470b6a 100644 --- a/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverterTests.java +++ b/oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/web/server/authentication/ServerBearerTokenAuthenticationConverterTests.java @@ -32,6 +32,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.post; /** * @author Rob Winch @@ -217,6 +220,107 @@ void resolveWhenQueryParameterHasMultipleAccessTokensThenOAuth2AuthenticationExc } + @Test + void resolveWhenBodyParameterIsPresentThenTokenIsResolved() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN); + + assertThat(convertToToken(request).getToken()).isEqualTo(TEST_TOKEN); + } + + + @Test + void resolveWhenBodyParameterIsPresentButNotAllowedThenTokenIsNotResolved() { + this.converter.setAllowFormEncodedBodyParameter(false); + MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN); + + assertThat(convertToToken(request)).isNull(); + } + + @Test + void resolveWhenBodyParameterHasMultipleAccessTokensThenOAuth2AuthenticationException() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN + "&access_token=" + TEST_TOKEN); + + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> convertToToken(request)) + .satisfies(ex -> { + BearerTokenError error = (BearerTokenError) ex.getError(); + assertThat(error.getDescription()).isEqualTo("Found multiple bearer tokens in the request"); + assertThat(error.getErrorCode()).isEqualTo(BearerTokenErrorCodes.INVALID_REQUEST); + assertThat(error.getUri()).isEqualTo("https://tools.ietf.org/html/rfc6750#section-3.1"); + assertThat(error.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + }); + } + + @Test + void resolveBodyContainsOtherParameterAsWellThenTokenIsResolved() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN + "&other_param=value"); + + assertThat(convertToToken(request).getToken()).isEqualTo(TEST_TOKEN); + } + + @Test + void resolveWhenNoBodyParameterThenTokenIsNotResolved() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest.BaseBuilder request = post("/").contentType(APPLICATION_FORM_URLENCODED); + + assertThat(convertToToken(request)).isNull(); + } + + @Test + void resolveWhenWrongBodyParameterThenTokenIsNotResolved() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").contentType(APPLICATION_FORM_URLENCODED) + .body("other_param=value"); + + assertThat(convertToToken(request)).isNull(); + } + + @Test + void resolveWhenValidHeaderIsPresentTogetherWithBodyParameterThenAuthenticationExceptionIsThrown() { + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").header(AUTHORIZATION, "Bearer " + TEST_TOKEN) + .contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN); + + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> convertToToken(request)) + .withMessageContaining("Found multiple bearer tokens in the request"); + } + + @Test + void resolveWhenValidQueryParameterIsPresentTogetherWithBodyParameterThenAuthenticationExceptionIsThrown() { + this.converter.setAllowUriQueryParameter(true); + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").queryParam("access_token", TEST_TOKEN) + .contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN); + + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> convertToToken(request)) + .withMessageContaining("Found multiple bearer tokens in the request"); + } + + @Test + void resolveWhenValidQueryParameterIsPresentTogetherWithBodyParameterAndValidHeaderThenAuthenticationExceptionIsThrown() { + this.converter.setAllowUriQueryParameter(true); + this.converter.setAllowFormEncodedBodyParameter(true); + MockServerHttpRequest request = post("/").header(AUTHORIZATION, "Bearer " + TEST_TOKEN) + .queryParam("access_token", TEST_TOKEN) + .contentType(APPLICATION_FORM_URLENCODED) + .body("access_token=" + TEST_TOKEN); + + assertThatExceptionOfType(OAuth2AuthenticationException.class) + .isThrownBy(() -> convertToToken(request)) + .withMessageContaining("Found multiple bearer tokens in the request"); + } + private BearerTokenAuthenticationToken convertToToken(MockServerHttpRequest.BaseBuilder request) { return convertToToken(request.build()); }