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
15 changes: 13 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ allprojects {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8

tasks.withType(JavaCompile) {
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}

Expand Down Expand Up @@ -118,9 +118,17 @@ subprojects {
}
}
}

// only apply to example sub-projects
if (project.path.startsWith(':examples:')) {
task execute(type:JavaExec) {
main = System.getProperty('mainClass')
classpath = sourceSets.main.runtimeClasspath
}
}
}

tasks.withType(Test) {
tasks.withType(Test).configureEach {
// Enable JUnit 5 (Gradle 4.6+).
useJUnitPlatform()

Expand All @@ -138,5 +146,8 @@ subprojects {
// prevent circular dependency
implementation project(':core')
}

testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.0'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.11.0'
}
}
9 changes: 9 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@

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'
}
3 changes: 0 additions & 3 deletions core/src/main/java/cloud/stackit/sdk/core/CoreDummy.java

This file was deleted.

247 changes: 247 additions & 0 deletions core/src/main/java/cloud/stackit/sdk/core/KeyFlowAuthenticator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
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;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import okhttp3.*;

/** KeyFlowAuthenticator handles the Key Flow Authentication based on the Service Account Key. */
public class KeyFlowAuthenticator {
private final String REFRESH_TOKEN = "refresh_token";
private final String ASSERTION = "assertion";
private final String DEFAULT_TOKEN_ENDPOINT = "https://service-account.api.stackit.cloud/token";
private final long DEFAULT_TOKEN_LEEWAY = 60;
private final int CONNECT_TIMEOUT = 10;
private final int WRITE_TIMEOUT = 10;
private final int READ_TIMEOUT = 10;

private final OkHttpClient httpClient;
private final ServiceAccountKey saKey;
private KeyFlowTokenResponse token;
private final Gson gson;
private final String tokenUrl;
private long tokenLeewayInSeconds = DEFAULT_TOKEN_LEEWAY;

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

@SerializedName("refresh_token")
private String refreshToken;

@SerializedName("expires_in")
private long expiresIn;

@SerializedName("scope")
private String scope;

@SerializedName("token_type")
private String tokenType;

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();
}

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
*/
public KeyFlowAuthenticator(
CoreConfiguration cfg,
ServiceAccountKey saKey,
EnvironmentVariables environmentVariables) {
this.saKey = saKey;
this.gson = new Gson();
this.httpClient =
new OkHttpClient.Builder()
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
.build();

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();
}
}

/**
* 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, InvalidKeySpecException {
if (token == null) {
createAccessToken();
} else if (token.isExpired()) {
createAccessTokenWithRefreshToken();
}
return token.getAccessToken();
}

/**
* Creates the initial accessToken and stores it in `this.token`
*
* @throws InvalidKeySpecException can not parse private key
* @throws IOException request for access token failed
* @throws ApiException response for new access token with bad status code
* @throws JsonSyntaxException parsing of the created access token failed
*/
protected void createAccessToken()
throws InvalidKeySpecException, IOException, JsonSyntaxException, ApiException {
String grant = "urn:ietf:params:oauth:grant-type:jwt-bearer";
String assertion;
try {
assertion = generateSelfSignedJWT();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(
"could not find required algorithm for jwt signing. This should not happen and should be reported on https://github.com/stackitcloud/stackit-sdk-java/issues",
e);
}
Response response = requestToken(grant, assertion).execute();
parseTokenResponse(response);
response.close();
}

/**
* Creates a new access token with the existing refresh token
*
* @throws IOException request for new access token failed
* @throws ApiException response for new access token with bad status code
* @throws JsonSyntaxException can not parse new access token
*/
protected synchronized void createAccessTokenWithRefreshToken()
throws IOException, JsonSyntaxException, ApiException {
String refreshToken = token.refreshToken;
Response response = requestToken(REFRESH_TOKEN, refreshToken).execute();
parseTokenResponse(response);
response.close();
}

private synchronized void parseTokenResponse(Response response)
throws ApiException, JsonSyntaxException, IOException {
if (response.code() != HttpURLConnection.HTTP_OK) {
String body = null;
if (response.body() != null) {
body = response.body().toString();
response.body().close();
}
throw new ApiException(
response.message(), response.code(), response.headers().toMultimap(), body);
}
if (response.body() == null || response.body().contentLength() == 0) {
throw new JsonSyntaxException("body from token creation is null");
}

KeyFlowTokenResponse keyFlowTokenResponse =
gson.fromJson(
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8),
KeyFlowTokenResponse.class);
setToken(keyFlowTokenResponse);
response.body().close();
}

private Call requestToken(String grant, String assertionValue) throws IOException {
FormBody.Builder bodyBuilder = new FormBody.Builder();
bodyBuilder.addEncoded("grant_type", grant);
String assertionKey = grant.equals(REFRESH_TOKEN) ? REFRESH_TOKEN : ASSERTION;
bodyBuilder.addEncoded(assertionKey, assertionValue);
FormBody body = bodyBuilder.build();

Request request =
new Request.Builder()
.url(tokenUrl)
.post(body)
.addHeader("Content-Type", "application/x-www-form-urlencoded")
.build();
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;

prvKey = saKey.getCredentials().getPrivateKeyParsed();
Algorithm algorithm = Algorithm.RSA512(prvKey);

Map<String, Object> jwtHeader = new HashMap<>();
jwtHeader.put("kid", saKey.getCredentials().getKid());

return JWT.create()
.withIssuer(saKey.getCredentials().getIss())
.withSubject(saKey.getCredentials().getSub())
.withJWTId(UUID.randomUUID().toString())
.withAudience(saKey.getCredentials().getAud())
.withIssuedAt(new Date())
.withExpiresAt(new Date().toInstant().plusSeconds(10 * 60))
.withHeader(jwtHeader)
.sign(algorithm);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cloud.stackit.sdk.core;

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;
import org.jetbrains.annotations.NotNull;

public class KeyFlowInterceptor implements Interceptor {
private final KeyFlowAuthenticator authenticator;

public KeyFlowInterceptor(KeyFlowAuthenticator authenticator) {
this.authenticator = authenticator;
}

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

Request originalRequest = chain.request();
String accessToken;
try {
accessToken = authenticator.getAccessToken();
} 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);
}

Request authenticatedRequest =
originalRequest
.newBuilder()
.header("Authorization", "Bearer " + accessToken)
.build();
return chain.proceed(authenticatedRequest);
}
}
Loading