Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
add unit tests for authentication
  • Loading branch information
marceljk committed Aug 5, 2025
commit 24184473ea3fefdefdd7598d5e904886d48e3d29
4 changes: 4 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ dependencies {
implementation 'com.auth0:java-jwt:4.5.0'
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
implementation 'com.google.code.gson:gson:2.9.1'

testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
testImplementation 'org.mockito:mockito-core:5.18.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cloud.stackit.sdk.core;

import cloud.stackit.sdk.core.config.CoreConfiguration;
import cloud.stackit.sdk.core.config.EnvironmentVariables;
import cloud.stackit.sdk.core.exception.ApiException;
import cloud.stackit.sdk.core.model.ServiceAccountKey;
import cloud.stackit.sdk.core.utils.Utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.google.gson.Gson;
Expand Down Expand Up @@ -36,7 +38,7 @@ public class KeyFlowAuthenticator {
private final String tokenUrl;
private long tokenLeewayInSeconds = DEFAULT_TOKEN_LEEWAY;

private static class KeyFlowTokenResponse {
protected static class KeyFlowTokenResponse {
@SerializedName("access_token")
private String accessToken;

Expand All @@ -52,27 +54,42 @@ private static class KeyFlowTokenResponse {
@SerializedName("token_type")
private String tokenType;

public boolean isExpired() {
public KeyFlowTokenResponse(
String accessToken,
String refreshToken,
long expiresIn,
String scope,
String tokenType) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresIn = expiresIn;
this.scope = scope;
this.tokenType = tokenType;
}

protected boolean isExpired() {
return expiresIn < new Date().toInstant().getEpochSecond();
}

public String getAccessToken() {
protected String getAccessToken() {
return accessToken;
}
}

public KeyFlowAuthenticator(CoreConfiguration cfg, ServiceAccountKey saKey) {
this(cfg, saKey, null);
}

/**
* Creates the initial service account and refreshes expired access token.
*
* @param cfg Configuration to set a custom token endpoint and the token expiration leeway.
* @param saKey Service Account Key, which should be used for the authentication
* @throws InvalidKeySpecException thrown when the private key in the service account can not be
* parsed
* @throws IOException thrown on unexpected responses from the key flow
* @throws ApiException thrown on unexpected responses from the key flow
*/
public KeyFlowAuthenticator(CoreConfiguration cfg, ServiceAccountKey saKey)
throws InvalidKeySpecException, IOException, ApiException {
public KeyFlowAuthenticator(
CoreConfiguration cfg,
ServiceAccountKey saKey,
EnvironmentVariables environmentVariables) {
this.saKey = saKey;
this.gson = new Gson();
this.httpClient =
Expand All @@ -81,26 +98,36 @@ public KeyFlowAuthenticator(CoreConfiguration cfg, ServiceAccountKey saKey)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
Comment thread
marceljk marked this conversation as resolved.
Outdated
.build();
if (cfg.getTokenCustomUrl() != null && !cfg.getTokenCustomUrl().trim().isEmpty()) {

if (environmentVariables == null) {
environmentVariables = new EnvironmentVariables();
}

if (Utils.isStringSet(cfg.getTokenCustomUrl())) {
this.tokenUrl = cfg.getTokenCustomUrl();
} else if (Utils.isStringSet(environmentVariables.getStackitTokenBaseurl())) {
this.tokenUrl = environmentVariables.getStackitTokenBaseurl();
} else {
this.tokenUrl = DEFAULT_TOKEN_ENDPOINT;
}
if (cfg.getTokenExpirationLeeway() != null && cfg.getTokenExpirationLeeway() > 0) {
this.tokenLeewayInSeconds = cfg.getTokenExpirationLeeway();
}

createAccessToken();
}

/**
* Returns access token. If the token is expired it creates a new token.
*
* @throws InvalidKeySpecException thrown when the private key in the service account can not be
* parsed
* @throws IOException request for new access token failed
* @throws ApiException response for new access token with bad status code
*/
public synchronized String getAccessToken() throws IOException, ApiException {
if (token == null || token.isExpired()) {
public synchronized String getAccessToken()
throws IOException, ApiException, InvalidKeySpecException {
if (token == null) {
createAccessToken();
} else if (token.isExpired()) {
createAccessTokenWithRefreshToken();
}
return token.getAccessToken();
Expand All @@ -114,7 +141,7 @@ public synchronized String getAccessToken() throws IOException, ApiException {
* @throws ApiException response for new access token with bad status code
* @throws JsonSyntaxException parsing of the created access token failed
*/
private void createAccessToken()
protected void createAccessToken()
throws InvalidKeySpecException, IOException, JsonSyntaxException, ApiException {
String grant = "urn:ietf:params:oauth:grant-type:jwt-bearer";
String assertion;
Expand All @@ -137,7 +164,7 @@ private void createAccessToken()
* @throws ApiException response for new access token with bad status code
* @throws JsonSyntaxException can not parse new access token
*/
private synchronized void createAccessTokenWithRefreshToken()
protected synchronized void createAccessTokenWithRefreshToken()
throws IOException, JsonSyntaxException, ApiException {
String refreshToken = token.refreshToken;
Response response = requestToken(REFRESH_TOKEN, refreshToken).execute();
Expand All @@ -146,7 +173,7 @@ private synchronized void createAccessTokenWithRefreshToken()
}

private synchronized void parseTokenResponse(Response response)
throws ApiException, JsonSyntaxException {
throws ApiException, JsonSyntaxException, IOException {
if (response.code() != HttpURLConnection.HTTP_OK) {
String body = null;
if (response.body() != null) {
Expand All @@ -156,20 +183,15 @@ private synchronized void parseTokenResponse(Response response)
throw new ApiException(
response.message(), response.code(), response.headers().toMultimap(), body);
}
if (response.body() == null) {
if (response.body() == null || response.body().contentLength() == 0) {
throw new JsonSyntaxException("body from token creation is null");
}

token =
KeyFlowTokenResponse keyFlowTokenResponse =
gson.fromJson(
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8),
KeyFlowTokenResponse.class);
token.expiresIn =
JWT.decode(token.accessToken)
.getExpiresAt()
.toInstant()
.minusSeconds(tokenLeewayInSeconds)
.getEpochSecond();
setToken(keyFlowTokenResponse);
response.body().close();
}

Expand All @@ -189,6 +211,16 @@ private Call requestToken(String grant, String assertionValue) throws IOExceptio
return httpClient.newCall(request);
}

protected void setToken(KeyFlowTokenResponse response) {
token = response;
token.expiresIn =
JWT.decode(response.accessToken)
.getExpiresAt()
.toInstant()
.minusSeconds(tokenLeewayInSeconds)
.getEpochSecond();
}

private String generateSelfSignedJWT()
throws InvalidKeySpecException, NoSuchAlgorithmException {
RSAPrivateKey prvKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import cloud.stackit.sdk.core.exception.ApiException;
import java.io.IOException;
import java.security.spec.InvalidKeySpecException;
import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
Expand All @@ -16,11 +17,12 @@ public KeyFlowInterceptor(KeyFlowAuthenticator authenticator) {

@NotNull @Override
public Response intercept(Chain chain) throws IOException {

Request originalRequest = chain.request();
String accessToken;
try {
accessToken = authenticator.getAccessToken();
} catch (ApiException e) {
} catch (InvalidKeySpecException | ApiException e) {
// try-catch required, because ApiException can not be thrown in the implementation
// of Interceptor.intercept(Chain chain)
throw new RuntimeException(e);
Expand Down
Loading