diff --git a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/external/TokenAuthenticator.java b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/external/TokenAuthenticator.java index ec9c954b..c2bf39cf 100644 --- a/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/external/TokenAuthenticator.java +++ b/xtraplatform-auth/src/main/java/de/ii/xtraplatform/auth/app/external/TokenAuthenticator.java @@ -19,6 +19,8 @@ import io.dropwizard.auth.AuthenticationException; import io.dropwizard.auth.Authenticator; import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; @@ -49,8 +51,15 @@ public Optional authenticate(String token) throws AuthenticationException if (authConfig.getUserInfo().isPresent()) { UserInfo userInfoProvider = authConfig.getUserInfo().get(); try { + // URL-encode the (client-supplied) token before interpolating it into the endpoint URL, so + // it cannot inject extra query parameters or path segments. The raw token is still sent in + // the Authorization header below. + String encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8); String url = - userInfoProvider.getEndpoint().replace("{{token}}", token).replace("{token}", token); + userInfoProvider + .getEndpoint() + .replace("{{token}}", encodedToken) + .replace("{token}", encodedToken); try (InputStream response = httpClient.getAsInputStream(url, Map.of("Authorization", "Bearer " + token))) { diff --git a/xtraplatform-auth/src/test/groovy/de/ii/xtraplatform/auth/app/external/TokenAuthenticatorSpec.groovy b/xtraplatform-auth/src/test/groovy/de/ii/xtraplatform/auth/app/external/TokenAuthenticatorSpec.groovy new file mode 100644 index 00000000..c43cd030 --- /dev/null +++ b/xtraplatform-auth/src/test/groovy/de/ii/xtraplatform/auth/app/external/TokenAuthenticatorSpec.groovy @@ -0,0 +1,48 @@ +/* + * Copyright 2025 interactive instruments GmbH + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package de.ii.xtraplatform.auth.app.external + +import de.ii.xtraplatform.base.domain.AuthConfiguration +import de.ii.xtraplatform.base.domain.AuthConfiguration.Jwt +import de.ii.xtraplatform.base.domain.AuthConfiguration.UserInfo +import de.ii.xtraplatform.base.domain.ImmutableClaims +import de.ii.xtraplatform.web.domain.HttpClient +import spock.lang.Specification + +class TokenAuthenticatorSpec extends Specification { + + def "the token is URL-encoded when interpolated into the user-info endpoint URL"() { + given: + def claims = new ImmutableClaims.Builder().build() + UserInfo userInfo = Stub() { + getEndpoint() >> "https://idp.example/userinfo?token={token}" + getClaims() >> claims + } + Jwt jwt = Stub() { + getClaims() >> claims + } + AuthConfiguration authConfig = Stub() { + getUserInfo() >> Optional.of(userInfo) + getJwt() >> Optional.of(jwt) + } + String capturedUrl = null + HttpClient httpClient = Mock() { + getAsInputStream(_, _) >> { args -> + capturedUrl = args[0] + new ByteArrayInputStream("{}".getBytes("UTF-8")) + } + } + def authenticator = new TokenAuthenticator(authConfig, httpClient) + + when: + authenticator.authenticate("a b/c?d=e") + + then: "URL metacharacters in the token are percent-encoded, not injected into the URL" + capturedUrl == "https://idp.example/userinfo?token=a+b%2Fc%3Fd%3De" + } +}