diff --git a/CHANGELOG.md b/CHANGELOG.md index 58814f2..abf0559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +## [0.2.0] - 2024-11-22 + +### Fixed + +- Fixed Base64 decoding on Android by replacing the Apache Commons Codec dependency. + +### Changed + +- Internal dependency version bumps. + ## [0.1.0] - 2024-02-02 ### Fixed @@ -58,7 +68,9 @@ ## [0.0.1] - 2021-02-17 -[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...HEAD +[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.2.0...HEAD + +[0.2.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...0.2.0 [0.1.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.0.5...0.1.0 diff --git a/README.md b/README.md index b9988e9..b990d96 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Add the dependency: com.github.saasquatch saasquatch-java-sdk - 0.1.0 + 0.2.0 ``` @@ -50,13 +50,13 @@ Add the dependency: ```gradle dependencies { - implementation 'com.github.saasquatch:saasquatch-java-sdk:0.1.0' + implementation 'com.github.saasquatch:saasquatch-java-sdk:0.2.0' } ``` For more information and other built tools, [please refer to the JitPack page](https://jitpack.io/#saasquatch/saasquatch-java-sdk). -This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future. +This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them directly**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future. ### Android diff --git a/build.gradle b/build.gradle index d376026..373b51f 100644 --- a/build.gradle +++ b/build.gradle @@ -11,20 +11,27 @@ tasks.javadoc.options.encoding = 'UTF-8' repositories { mavenCentral() - maven { url = uri('https://jitpack.io') } } dependencies { api 'org.reactivestreams:reactive-streams:1.0.4' implementation 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'commons-codec:commons-codec:1.16.0' - implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1' - implementation 'io.reactivex.rxjava3:rxjava:3.1.8' + /* + * This is a lightweight Base64 implementation that works on both Android and the regular JRE. + * java.util.Base64 is not available on earlier versions of Android. + * android.util.Base64 does not work on regular JRE. + * Commons codec should be avoided because Android comes bundled with an early version. + * Apache client5 comes with a Base64 class but it's considered internal API. + */ + implementation 'net.iharder:base64:2.3.9' + implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.1' + implementation 'io.reactivex.rxjava3:rxjava:3.1.9' + // Version 2.11.0 requires Android API level 21 implementation 'com.google.code.gson:gson:2.10.1' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1' - testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1' - testImplementation 'com.google.guava:guava:33.0.0-jre' - testImplementation 'io.projectreactor:reactor-core:3.6.2' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3' + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3' + testImplementation 'com.google.guava:guava:33.3.1-jre' + testImplementation 'io.projectreactor:reactor-core:3.7.0' } tasks.test { diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd49..a4b76b9 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e09..94113f2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a4..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 6689b85..9b42019 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail diff --git a/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java b/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java index 8e15498..eff3752 100644 --- a/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java +++ b/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java @@ -30,7 +30,6 @@ static AuthMethod noAuth() { /** * Basic authentication with username and password */ - @Beta static AuthMethod ofBasic(@Nonnull String username, @Nonnull String password) { return new BasicAuth(Objects.requireNonNull(username, "username"), Objects.requireNonNull(password, "password")); diff --git a/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java b/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java index d8c017f..39df44f 100644 --- a/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java +++ b/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java @@ -1,7 +1,8 @@ package com.saasquatch.sdk.auth; import static java.nio.charset.StandardCharsets.UTF_8; -import org.apache.commons.codec.binary.Base64; + +import net.iharder.Base64; import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; import org.apache.hc.core5.http.HttpHeaders; @@ -18,7 +19,7 @@ final class BasicAuth implements AuthMethod { @Override public void mutateRequest(SimpleRequestBuilder requestBuilder) { requestBuilder.setHeader(HttpHeaders.AUTHORIZATION, - "Basic " + Base64.encodeBase64String((username + ':' + password).getBytes(UTF_8))); + "Basic " + Base64.encodeBytes((username + ':' + password).getBytes(UTF_8))); } } diff --git a/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java b/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java index 0c5d9c9..22ca122 100644 --- a/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java +++ b/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java @@ -3,9 +3,7 @@ import static com.saasquatch.sdk.internal.json.GsonUtils.gson; import static java.nio.charset.StandardCharsets.UTF_8; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; import com.saasquatch.sdk.exceptions.SaaSquatchApiException; import com.saasquatch.sdk.exceptions.SaaSquatchUnhandledApiException; import com.saasquatch.sdk.input.UserIdInput; @@ -20,6 +18,7 @@ import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; +import java.net.URLEncoder; import java.nio.CharBuffer; import java.nio.charset.Charset; import java.util.AbstractMap.SimpleImmutableEntry; @@ -42,8 +41,7 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.WillNotClose; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.codec.net.URLCodec; +import net.iharder.Base64; import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; @@ -244,7 +242,12 @@ public static Map unmodifiableMap(@Nonnull Map map) { * RFC3986 URL encode */ public static String urlEncode(@Nonnull String s) { - return new String(URLCodec.encodeUrl(RFC_3986_SAFE_CHARS, s.getBytes(UTF_8)), UTF_8); + try { + return URLEncoder.encode(s, UTF_8.name()) + .replace("+", "%20").replace("*", "%2A").replace("%7E", "~"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); // Won't happen + } } /** @@ -362,13 +365,23 @@ public static Object getNestedMapValue(Map map, String... keys) if (result == null) { return null; } - @SuppressWarnings("unchecked") final Map resultAsMap = - (Map) result; - result = resultAsMap.get(key); + //noinspection unchecked + result = ((Map) result).get(key); } return result; } + public static String addBase64Padding(String base64) { + if (base64.length() % 4 == 0) { + return base64; // Avoid unnecessary copying with StringBuilder + } + final StringBuilder base64Builder = new StringBuilder(base64); + do { + base64Builder.append('='); + } while (base64Builder.length() % 4 != 0); + return base64Builder.toString(); + } + /** * Extract the payload as a JSON object. This method does NOT do a full JWT validation. */ @@ -378,15 +391,19 @@ public static Map getJwtPayload(String jwt) { throw new IllegalArgumentException("Invalid JWT"); } final String payloadPart = jwtParts[1]; - // Do not use the overload that takes a String. It does not work on Android. - final byte[] payloadBytes = Base64.decodeBase64(payloadPart.getBytes(UTF_8)); - final JsonElement jsonElement = JsonParser.parseString(new String(payloadBytes, UTF_8)); - if (!(jsonElement instanceof JsonObject)) { - throw new IllegalArgumentException("JWT payload is not a JSON object"); + final byte[] payloadBytes; + // This Base64 library expects the base64 string to have proper padding + try { + payloadBytes = Base64.decode(addBase64Padding(payloadPart), Base64.URL_SAFE); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid JWT payload", e); + } + try { + //noinspection unchecked + return gson.fromJson(new String(payloadBytes, UTF_8), Map.class); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Invalid JWT payload", e); } - @SuppressWarnings("unchecked") final Map payloadMap = - gson.fromJson(jsonElement, Map.class); - return payloadMap; } @Nonnull diff --git a/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java b/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java index aa98e6d..95ecb06 100644 --- a/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java +++ b/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java @@ -1,5 +1,6 @@ package com.saasquatch.sdk; +import static com.saasquatch.sdk.internal.InternalUtils.addBase64Padding; import static com.saasquatch.sdk.internal.InternalUtils.defaultIfNull; import static com.saasquatch.sdk.internal.InternalUtils.entryOf; import static com.saasquatch.sdk.internal.InternalUtils.getJwtPayload; @@ -8,6 +9,7 @@ import static com.saasquatch.sdk.internal.InternalUtils.isBlank; import static com.saasquatch.sdk.internal.InternalUtils.requireNotBlank; import static com.saasquatch.sdk.internal.InternalUtils.unmodifiableList; +import static com.saasquatch.sdk.internal.InternalUtils.urlEncode; import static com.saasquatch.sdk.internal.json.GsonUtils.gson; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -111,6 +113,11 @@ public void testUnmodifiableList() { assertEquals("SingletonList", unmodifiableList(Arrays.asList(1)).getClass().getSimpleName()); } + @Test + public void testUrlEncode() throws Exception { + assertEquals("foo%20%2B%2A~bar", urlEncode("foo +*~bar")); + } + @Test public void testRequireNotBlank() { assertThrows(NullPointerException.class, () -> requireNotBlank(null, "")); @@ -162,6 +169,16 @@ public void testGetNestedMapValue() { } } + @Test + public void testBase64Padding() { + assertEquals("", addBase64Padding("")); + assertEquals("a===", addBase64Padding("a")); + assertEquals("aa==", addBase64Padding("aa")); + assertEquals("aaa=", addBase64Padding("aaa")); + assertEquals("aaaa", addBase64Padding("aaaa")); + assertEquals("aaaaa===", addBase64Padding("aaaaa")); + } + @Test public void testGetJwtPayload() { //noinspection ConstantConditions @@ -173,6 +190,13 @@ public void testGetJwtPayload() { assertEquals(ImmutableMap.of(), getJwtPayload("a.e30.c")); } + @Test + public void testGetJwtPayloadWithPadding() { + final Map map = assertDoesNotThrow(() -> getJwtPayload( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSWThGZHI2aXVpVzU2NTI1NTVyclk2TG9hUFVKQzg0WjEifQ.eyJ1c2VyIjp7ImVtYWlsIjoiZm9vQGV4YW1wbGUuY29tIiwiZmlyc3ROYW1lIjoiRklSU1ROQU1FIiwiaWQiOiJhYWEiLCJhY2NvdW50SWQiOiJhYWEiLCJsYXN0TmFtZSI6ImFhYSJ9fQ.6XoB_nYJy2Vged-Dm9yuf2X9t8XqfqZDQziby0jIDeY")); + assertEquals("FIRSTNAME", getNestedMapValue(map, "user", "firstName")); + } + @Test public void testJwtToUserIdInput() { final UserIdInput userIdInput = getUserIdInputFromUserJwt(