Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,53 @@ on: [push]

jobs:
build:
name: Build Java ${{ matrix.java-version }}

runs-on: ubuntu-latest

strategy:
matrix:
java-version: ['8', '11', '17', '21', '25']
include:
- java-version: '8'
maven-args: '-Dmaven.test.skip=true -Dspotless.check.skip=true clean package'
- java-version: '11'
maven-args: '-Dspotless.check.skip=true clean package javadoc:javadoc'
- java-version: '17'
maven-args: 'clean package javadoc:javadoc'
- java-version: '21'
maven-args: 'clean package javadoc:javadoc'
- java-version: '25'
maven-args: 'clean package javadoc:javadoc'
steps:
- uses: actions/checkout@v3
- name: Set up JDK
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v3
with:
java-version: '21'
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B -DskipITs clean package javadoc:javadoc --file pom.xml
run: mvn -B -DskipITs ${{ matrix.maven-args }} --file pom.xml

# Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive
- name: Update dependency graph
uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6

e2e-tests:
name: E2E Java ${{ matrix.java-version }}
needs: build
runs-on: ubuntu-latest

strategy:
matrix:
java-version: ['8', '11', '17', '21', '25']
steps:
- uses: actions/checkout@v3
- name: Set up JDK
- name: Set up JDK ${{ matrix.java-version }}
uses: actions/setup-java@v3
with:
java-version: '21'
java-version: ${{ matrix.java-version }}
distribution: 'temurin'
cache: maven
- name: Checkout sinch-sdk-mockserver repository
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ All notable changes to the **Sinch Java SDK** are documented in this file.

---

## v2.1.1 patch - 2026-07-21

### Build & CI

- **[tech]** Build across a Java version matrix (`8`, `11`, `17`, `21`, `25`) in GitHub Actions, replacing the single Java 21 build — unit tests run on `11`, `17`, `21`, and `25`, and artifacts are built on Java 8.
- **[tech]** e2e tests across a Java version matrix (`8`, `11`, `17`, `21`, `25`) in GitHub Actions, replacing the single Java 21 validation.
- **[tech]** Apply Spotless / Google Java Format per JDK via version-activated Maven profiles (`spotless-jdk8`, `spotless-jdk11`, `spotless-jdk17`, `spotless-jdk21-plus`), keeping formatting working across the whole matrix.
- **[tech]** Enable `-Dnet.bytebuddy.experimental=true` for Surefire so Mockito/ByteBuddy run on Java 25.
- **[dependency]** Bump `maven-compiler-plugin` to `3.13.0`, Surefire and Failsafe to `3.5.6`, and Mockito to `5.23.0`.
- **[dependency]** Remove unused dependency on `org.apache.maven:maven-model`.
- **[dependency]** Bump `jackson.version` to `2.21.4` to solve a vulnerability in `jackson-databind` (CVE-2026-54513).


### Documentation
- **[doc]** README: document the supported Java versions (`8`, `11`, `17`, `21`, `25`) with per-version badges and download links, replacing the generic "Java 8+" badge.

### Tests
- **[test]** Remove `DateUtilTest` cases for unsupported timezone abbreviations (`MST`, `EST`): as of Java 25 the JDK no longer resolves legacy COMPAT timezone names ([JDK-8325568](https://bugs.openjdk.org/browse/JDK-8325568)).


## v2.1 – 2026-07-02

### Numbers
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Sinch Java SDK

[![Java](https://img.shields.io/badge/Java-8+-orange.svg)](https://www.java.com)
[![Java 8](https://img.shields.io/badge/Java-8-orange.svg)](https://www.oracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html)
[![Java 11](https://img.shields.io/badge/Java-11-orange.svg)](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html)
[![Java 17](https://img.shields.io/badge/Java-17-orange.svg)](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html)
[![Java 21](https://img.shields.io/badge/Java-21-orange.svg)](https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html)
[![Java 25](https://img.shields.io/badge/Java-25-orange.svg)](https://www.oracle.com/java/technologies/downloads/#java25)
[![Latest Release](https://img.shields.io/maven-central/v/com.sinch.sdk/sinch-sdk-java?label=sinch&labelColor=FFC658)](https://central.sonatype.com/artifact/com.sinch.sdk/sinch-sdk-java)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/sinch/sinch-sdk-java/blob/main/LICENSE)

Expand Down Expand Up @@ -28,7 +32,7 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation

## Prerequisites

- [JDK 8 or later](https://www.java.com)
- Java in one of the supported versions - [8](https://www.oracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html), [11](https://www.oracle.com/java/technologies/javase/jdk11-archive-downloads.html), [17](https://www.oracle.com/java/technologies/javase/jdk17-archive-downloads.html), [21](https://www.oracle.com/java/technologies/javase/jdk21-archive-downloads.html), [25](https://www.oracle.com/java/technologies/downloads/#java25)
- [Maven](https://maven.apache.org/)
- [Sinch account](https://dashboard.sinch.com/)

Expand Down
78 changes: 67 additions & 11 deletions client/src/main/com/sinch/sdk/auth/adapters/OAuthManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.sinch.sdk.core.http.HttpMethod;
import com.sinch.sdk.core.http.HttpRequest;
import com.sinch.sdk.core.http.HttpResponse;
import com.sinch.sdk.core.http.HttpStatus;
import com.sinch.sdk.core.models.ServerConfiguration;
import com.sinch.sdk.core.utils.Pair;
import com.sinch.sdk.models.UnifiedCredentials;
Expand All @@ -17,6 +18,7 @@
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
import java.util.logging.Logger;
import java.util.stream.Collectors;
Expand All @@ -28,12 +30,17 @@ public class OAuthManager implements AuthManager {
public static final String BEARER_AUTHENTICATE_RESPONSE_HEADER_KEYWORD = "www-authenticate";
private static final Logger LOGGER = Logger.getLogger(OAuthManager.class.getName());
private static final String AUTH_KEYWORD = "Bearer";
private static final int maxRefreshAttempt = 5;
// Total refresh attempts for 429 and other failures; sufficient given the backoff.
protected static final int MAX_REFRESH_ATTEMPT = 3;

private static final double BACKOFF_BASE_SECONDS = 1.0;
private static final int BACKOFF_GROWTH = 4;

private final ServerConfiguration oAuthServer;
private final HttpMapper mapper;
private final Supplier<HttpClient> httpClientSupplier;
private final Map<String, AuthManager> authManagers;
private String token;
private volatile String token;

public OAuthManager(
UnifiedCredentials credentials,
Expand Down Expand Up @@ -77,17 +84,25 @@ public void resetToken() {
public Collection<Pair<String, String>> getAuthorizationHeaders(
String timestamp, String method, String httpContentType, String path, String body) {

if (token == null) {
refreshToken();
String currentToken = token;
if (currentToken == null) {
synchronized (this) {
currentToken = token;
if (currentToken == null) {
refreshToken();
currentToken = token;
}
}
}
return Collections.singletonList(new Pair<>("Authorization", AUTH_KEYWORD + " " + token));
return Collections.singletonList(
new Pair<>("Authorization", AUTH_KEYWORD + " " + currentToken));
}

private void refreshToken() {

int attempt = 0;
while (attempt < maxRefreshAttempt) {
Optional<String> newValue = getNewToken();
while (attempt < MAX_REFRESH_ATTEMPT) {
Optional<String> newValue = getNewToken(attempt);
if (newValue.isPresent()) {
token = newValue.get();
return;
Expand All @@ -97,7 +112,7 @@ private void refreshToken() {
throw new ApiAuthException("Unable to get new token");
}

private Optional<String> getNewToken() {
private Optional<String> getNewToken(int attempt) {

LOGGER.fine("Refreshing OAuth token");
HttpRequest request =
Expand All @@ -110,17 +125,58 @@ private Optional<String> getNewToken() {
null,
Collections.singletonList("application/x-www-form-urlencoded"),
Collections.singletonList(SCHEMA_KEYWORD_BASIC));
HttpResponse httpResponse;
try {
httpResponse = httpClientSupplier.get().invokeAPI(oAuthServer, authManagers, request);
} catch (Exception e) {
throw new ApiAuthException(
"Token refresh failed: network or client error: " + e.getMessage());
}
if (httpResponse == null) {
throw new ApiAuthException("Token refresh failed: no response received");
}

if (httpResponse.getCode() == HttpStatus.TOO_MANY_REQUESTS) {
// Only back off if another attempt will follow; on the last attempt we give up immediately.
if (attempt < MAX_REFRESH_ATTEMPT - 1) {
long sleepMillis = computeBackoffMillis(attempt);
LOGGER.fine(
"Rate limited (HTTP 429) during token refresh, attempt "
+ (attempt + 1)
+ "/"
+ MAX_REFRESH_ATTEMPT
+ ", waiting "
+ sleepMillis
+ "ms before next attempt");
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiAuthException("Token refresh interrupted");
}
}
return Optional.empty();
}

if (!HttpStatus.isSuccessfulStatus(httpResponse.getCode())) {
throw new ApiAuthException("Token refresh failed with HTTP " + httpResponse.getCode());
}

try {
HttpResponse httpResponse =
httpClientSupplier.get().invokeAPI(oAuthServer, authManagers, request);
BearerAuthResponse authResponse =
mapper.deserialize(httpResponse, new TypeReference<BearerAuthResponse>() {});
return Optional.ofNullable(authResponse.getAccessToken());
} catch (Exception e) {
return Optional.empty();
throw new ApiAuthException(
"Token refresh failed: could not deserialize response: " + e.getMessage());
}
}

long computeBackoffMillis(int attempt) {
double maxDelay = BACKOFF_BASE_SECONDS * Math.pow(BACKOFF_GROWTH, attempt);
return (long) (ThreadLocalRandom.current().nextDouble(maxDelay) * 1000);
}

public boolean validateAuthenticatedRequest(
String method, String path, Map<String, String> headers, String jsonPayload) {
LOGGER.severe("checkAuthentication not implemented");
Expand Down
24 changes: 11 additions & 13 deletions client/src/main/com/sinch/sdk/http/HttpClientApache.java
Original file line number Diff line number Diff line change
Expand Up @@ -263,21 +263,18 @@ private boolean processUnauthorizedResponse(
}

Collection<String> auths = request.getAuthNames();
Optional<String> requestSupportBearerAuthentication =

boolean requestSupportsBearer =
auths.stream()
.filter(
f ->
null != authManagersByOasSecuritySchemes.get(f)
&& authManagersByOasSecuritySchemes
.get(f)
.getSchema()
.equals(OAuthManager.SCHEMA_KEYWORD_BEARER))
.findFirst();

if (!requestSupportBearerAuthentication.isPresent()) {
.anyMatch(
authName -> {
AuthManager authManager = authManagersByOasSecuritySchemes.get(authName);
return authManager != null
&& authManager.getSchema().equals(OAuthManager.SCHEMA_KEYWORD_BEARER);
});
if (!requestSupportsBearer) {
return false;
}
// looking for "expired" keyword present in "www-authenticate" header
Map<String, List<String>> responseHeaders = response.getHeaders();
Collection<String> header = responseHeaders.get(BEARER_AUTHENTICATE_RESPONSE_HEADER_KEYWORD);

Expand Down Expand Up @@ -320,7 +317,8 @@ private void addFormParams(
}

MultipartEntityBuilder multiPartBuilder = MultipartEntityBuilder.create();
if (contentType.stream().noneMatch(cType -> cType.toLowerCase().contains("charset="))) {
if (null == contentType
|| contentType.stream().noneMatch(cType -> cType.toLowerCase().contains("charset="))) {
multiPartBuilder.setCharset(StandardCharsets.UTF_8);
}
formParams.forEach((key, value) -> addMultiPart(requestBuilder, multiPartBuilder, key, value));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.assertj.core.api.Assertions.assertThat;

import com.adelean.inject.resources.junit.jupiter.GivenTextResource;
import com.adelean.inject.resources.junit.jupiter.TestWithResources;
import com.sinch.sdk.core.exceptions.ApiException;
import com.sinch.sdk.models.Configuration;
Expand All @@ -15,7 +16,11 @@
import org.junit.jupiter.api.Test;

@TestWithResources
class SinchClientTestIT extends BaseTest {
class SinchClientUserAgentTest extends BaseTest {

@GivenTextResource("/client/auth/BearerAuthResponse.json")
String bearerAuthResponse;

static MockWebServer mockBackEnd;
String serverUrl = String.format("http://localhost:%s", mockBackEnd.getPort());

Expand All @@ -24,6 +29,7 @@ class SinchClientTestIT extends BaseTest {
.setKeyId("foo")
.setKeySecret("foo")
.setProjectId("foo")
.setOAuthUrl(serverUrl)
.setNumbersContext(NumbersContext.builder().setNumbersUrl(serverUrl).build())
.build();

Expand All @@ -43,6 +49,10 @@ static void tearDown() throws IOException {
@Test
void sdkUserAgent() throws InterruptedException {

mockBackEnd.enqueue(
new MockResponse()
.setBody(bearerAuthResponse)
.addHeader("Content-Type", "application/json"));
mockBackEnd.enqueue(
new MockResponse().setBody("foo").addHeader("Content-Type", "application/json"));

Expand Down
Loading
Loading