Replace commons-codec Base64 with JDK Base64 for JWT decoding
JWT tokens encode their header and payload using Base64URL as defined by
RFC 7515/7519. The current implementation decodes JWT payloads using
either org.apache.commons.codec.binary.Base64 or the standard Base64
decoder, neither of which implements the URL-safe Base64 variant required
by the JWT specification. This may silently accept malformed input or
fail for URL-safe payloads.
Replace existing decoding logic with java.util.Base64.getUrlDecoder(),
which correctly implements Base64URL, and apply the same decoding to
SAP IAS JWT parsing, which previously used the standard Base64 decoder.
As part of this change:
* Decode JWT payloads using Base64.getUrlDecoder()
* Remove reliance on UnsupportedEncodingException by using
StandardCharsets.UTF_8 directly
* Maintain existing failure semantics for malformed JWT structure,
while treating invalid Base64URL payloads as IO failures.
Malformed tokens that were previously accepted due to permissive
decoding will now be rejected.
Remove the commons-codec dependency from the oauth plugin entirely.
Change-Id: I033936936bdf88713e9eab604923215b0b57d4a7
diff --git a/BUILD b/BUILD
index 089ec90..424d36c 100644
--- a/BUILD
+++ b/BUILD
@@ -20,7 +20,6 @@
],
resources = glob(["src/main/resources/**/*"]),
deps = [
- "@commons-codec//jar:neverlink",
"@jackson-core//jar",
"@jackson-databind//jar",
"@json//jar",
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/JsonUtil.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/JsonUtil.java
index db7aaff..0e20ec4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/JsonUtil.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/JsonUtil.java
@@ -14,8 +14,12 @@
package com.googlesource.gerrit.plugins.oauth;
+import com.google.common.base.Preconditions;
import com.google.gerrit.common.Nullable;
import com.google.gson.JsonElement;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
public class JsonUtil {
@@ -27,4 +31,16 @@
public static String asString(JsonElement e) {
return isNull(e) ? null : e.getAsString();
}
+
+ /** Returns the decoded JSON payload (2nd segment) of a JWT (base64url encoded). */
+ public static String jwtPayloadJson(String jwt) throws IOException {
+ try {
+ String[] parts = jwt.split("\\.", -1);
+ Preconditions.checkState(
+ parts.length == 3 && !parts[0].isEmpty() && !parts[1].isEmpty() && !parts[2].isEmpty());
+ return new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ throw new IOException("Invalid JWT payload encoding", e);
+ }
+ }
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/azure/AzureActiveDirectoryService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/azure/AzureActiveDirectoryService.java
index 6f89662..41e8107 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/azure/AzureActiveDirectoryService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/azure/AzureActiveDirectoryService.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.json.OutputFormat.JSON;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.asString;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.isNull;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.jwtPayloadJson;
import com.github.scribejava.apis.MicrosoftAzureActiveDirectory20Api;
import com.github.scribejava.core.builder.ServiceBuilder;
@@ -45,8 +46,6 @@
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
import java.util.concurrent.ExecutionException;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
@@ -226,15 +225,10 @@
/** Get the {@link JsonObject} of a given token. */
private JsonObject getTokenJson(String tokenBase64) {
- String[] tokenParts = tokenBase64.split("\\.");
- if (tokenParts.length != 3) {
- throw new OAuthException("Token does not contain expected number of parts");
+ try {
+ return gson.fromJson(jwtPayloadJson(tokenBase64), JsonObject.class);
+ } catch (IOException e) {
+ throw new OAuthException("Invalid token payload encoding", e);
}
-
- // Extract the payload part from the JWT token (header.payload.signature) by retrieving
- // tokenParts[1].
- return gson.fromJson(
- new String(Base64.getUrlDecoder().decode(tokenParts[1]), StandardCharsets.UTF_8),
- JsonObject.class);
}
}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/dex/DexOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/dex/DexOAuthService.java
index 4f8e7c8..2248d17 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/dex/DexOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/dex/DexOAuthService.java
@@ -16,12 +16,12 @@
import static com.google.gerrit.json.OutputFormat.JSON;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.isNull;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.jwtPayloadJson;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.base.CharMatcher;
-import com.google.common.base.Preconditions;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -39,11 +39,8 @@
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.net.URI;
-import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
-import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -81,28 +78,12 @@
extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME);
}
- private String parseJwt(String input) throws UnsupportedEncodingException {
- String[] parts = input.split("\\.");
- Preconditions.checkState(parts.length == 3);
- Preconditions.checkNotNull(parts[1]);
- return new String(Base64.decodeBase64(parts[1]), StandardCharsets.UTF_8.name());
- }
-
@Override
public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException {
JsonElement tokenJson = JSON.newGson().fromJson(token.getRaw(), JsonElement.class);
JsonObject tokenObject = tokenJson.getAsJsonObject();
JsonElement id_token = tokenObject.get("id_token");
-
- String jwt;
- try {
- jwt = parseJwt(id_token.getAsString());
- } catch (UnsupportedEncodingException e) {
- throw new IOException(
- String.format(
- "%s support is required to interact with JWTs", StandardCharsets.UTF_8.name()),
- e);
- }
+ String jwt = jwtPayloadJson(id_token.getAsString());
JsonElement claimJson = JSON.newGson().fromJson(jwt, JsonElement.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/google/GoogleOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/google/GoogleOAuthService.java
index 2daee21..49a78d6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/google/GoogleOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/google/GoogleOAuthService.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.json.OutputFormat.JSON;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.asString;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.isNull;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.jwtPayloadJson;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.model.OAuth2AccessToken;
@@ -25,7 +26,6 @@
import com.github.scribejava.core.model.Verb;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.base.CharMatcher;
-import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
@@ -50,7 +50,6 @@
import java.util.List;
import java.util.concurrent.ExecutionException;
import javax.servlet.http.HttpServletResponse;
-import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -164,15 +163,7 @@
JsonObject idTokenObj = idToken.getAsJsonObject();
JsonElement idTokenElement = idTokenObj.get("id_token");
if (idTokenElement != null && !idTokenElement.isJsonNull()) {
- String payload;
- try {
- payload = decodePayload(idTokenElement.getAsString());
- } catch (UnsupportedEncodingException e) {
- throw new IOException(
- String.format(
- "%s support is required to interact with JWTs", StandardCharsets.UTF_8.name()),
- e);
- }
+ String payload = jwtPayloadJson(idTokenElement.getAsString());
if (!Strings.isNullOrEmpty(payload)) {
JsonElement tokenJsonElement = JSON.newGson().fromJson(payload, JsonElement.class);
if (tokenJsonElement.isJsonObject()) {
@@ -185,6 +176,10 @@
}
private static String retrieveHostedDomain(JsonObject jwtToken) {
+ if (jwtToken == null) {
+ log.debug("OAuth2: JWT token is null");
+ return null;
+ }
JsonElement hdClaim = jwtToken.get("hd");
if (!isNull(hdClaim)) {
String hd = hdClaim.getAsString();
@@ -195,21 +190,6 @@
return null;
}
- /**
- * Decode payload from JWT according to spec: "header.payload.signature"
- *
- * @param idToken Base64 encoded tripple, separated with dot
- * @return openid_id part of payload, when contained, null otherwise
- */
- private static String decodePayload(String idToken) throws UnsupportedEncodingException {
- Preconditions.checkNotNull(idToken);
- String[] jwtParts = idToken.split("\\.");
- Preconditions.checkState(jwtParts.length == 3);
- String payloadStr = jwtParts[1];
- Preconditions.checkNotNull(payloadStr);
- return new String(Base64.decodeBase64(payloadStr), StandardCharsets.UTF_8.name());
- }
-
@Override
public OAuthToken getAccessToken(OAuthVerifier rv) {
try {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/keycloak/KeycloakOAuthService.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/keycloak/KeycloakOAuthService.java
index 2a06f06..4bdf501 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/keycloak/KeycloakOAuthService.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/keycloak/KeycloakOAuthService.java
@@ -16,12 +16,12 @@
import static com.google.gerrit.json.OutputFormat.JSON;
import static com.googlesource.gerrit.plugins.oauth.JsonUtil.isNull;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.jwtPayloadJson;
import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.base.CharMatcher;
-import com.google.common.base.Preconditions;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -39,11 +39,8 @@
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
import java.io.IOException;
-import java.io.UnsupportedEncodingException;
import java.net.URI;
-import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
-import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -82,27 +79,12 @@
extIdScheme = OAuthServiceProviderExternalIdScheme.create(PROVIDER_NAME);
}
- private String parseJwt(String input) throws UnsupportedEncodingException {
- String[] parts = input.split("\\.");
- Preconditions.checkState(parts.length == 3);
- Preconditions.checkNotNull(parts[1]);
- return new String(Base64.decodeBase64(parts[1]), StandardCharsets.UTF_8.name());
- }
-
@Override
public OAuthUserInfo getUserInfo(OAuthToken token) throws IOException {
JsonElement tokenJson = JSON.newGson().fromJson(token.getRaw(), JsonElement.class);
JsonObject tokenObject = tokenJson.getAsJsonObject();
JsonElement id_token = tokenObject.get("id_token");
- String jwt;
- try {
- jwt = parseJwt(id_token.getAsString());
- } catch (UnsupportedEncodingException e) {
- throw new IOException(
- String.format(
- "%s support is required to interact with JWTs", StandardCharsets.UTF_8.name()),
- e);
- }
+ String jwt = jwtPayloadJson(id_token.getAsString());
JsonElement claimJson = JSON.newGson().fromJson(jwt, JsonElement.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
index 138e1e0..6d2d719 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/oauth/sap/SAPIasOAuthLoginProvider.java
@@ -15,10 +15,9 @@
package com.googlesource.gerrit.plugins.oauth.sap;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.googlesource.gerrit.plugins.oauth.JsonUtil.jwtPayloadJson;
import com.github.scribejava.core.model.OAuth2AccessToken;
-import com.google.common.base.Splitter;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -36,8 +35,6 @@
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderConfig;
import com.googlesource.gerrit.plugins.oauth.OAuthServiceProviderExternalIdScheme;
import java.io.IOException;
-import java.util.Base64;
-import java.util.List;
import java.util.Optional;
@Singleton
@@ -137,19 +134,10 @@
}
private JsonObject toJsonWebToken(String accessToken) throws IOException {
- List<String> segments = getSegments(accessToken);
- return getAsJsonObject(decodeBase64(segments.get(1)));
- }
-
- private String decodeBase64(String s) {
- return new String(Base64.getDecoder().decode(s), UTF_8);
- }
-
- private List<String> getSegments(String accessToken) throws IOException {
- List<String> segments = Splitter.on('.').splitToList(accessToken);
- if (segments.size() != 3) {
- throw new IOException("Invalid token: must be of the form 'header.token.signature'");
+ try {
+ return getAsJsonObject(jwtPayloadJson(accessToken));
+ } catch (IllegalStateException e) {
+ throw new IOException("Invalid token: must be of the form 'header.token.signature'", e);
}
- return segments;
}
}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/oauth/JsonUtilTest.java b/src/test/java/com/googlesource/gerrit/plugins/oauth/JsonUtilTest.java
new file mode 100644
index 0000000..9797ef7
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/oauth/JsonUtilTest.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// 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
+//
+// http://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 com.googlesource.gerrit.plugins.oauth;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class JsonUtilTest {
+
+ @Rule public ExpectedException thrown = ExpectedException.none();
+
+ @Test
+ public void jwtPayloadJson_decodesBase64UrlWithoutPadding() throws Exception {
+ String payloadJson = "{\"email\":\"alice@example.com\",\"name\":\"Alice\"}";
+ String payload =
+ Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
+
+ String jwt = "header." + payload + ".signature";
+
+ assertThat(JsonUtil.jwtPayloadJson(jwt)).isEqualTo(payloadJson);
+ }
+
+ @Test
+ public void jwtPayloadJson_invalidBase64Url_throwsIOException() throws Exception {
+ thrown.expect(IOException.class);
+ thrown.expectMessage("Invalid JWT payload encoding");
+
+ JsonUtil.jwtPayloadJson("header.in!valid.signature");
+ }
+
+ @Test
+ public void jwtPayloadJson_malformedJwtStructure_propagatesRuntimeException() throws Exception {
+ thrown.expect(IllegalStateException.class);
+
+ JsonUtil.jwtPayloadJson("not-a-jwt");
+ }
+
+ @Test
+ public void jwtPayloadJson_emptyPayload_propagatesRuntimeException() throws Exception {
+ thrown.expect(IllegalStateException.class);
+
+ // header..signature - payload is empty, but still 3 parts
+ JsonUtil.jwtPayloadJson("header..signature");
+ }
+}