Skip to content

Commit 2f10254

Browse files
feat: Onboard key flow auth (#5)
* add keyflow authentication implementation * add configuration for ApiClient --------- Co-authored-by: Ruben Hoenle <Ruben.Hoenle@stackit.cloud>
1 parent 4d16f1c commit 2f10254

File tree

26 files changed

+2485
-5
lines changed

26 files changed

+2485
-5
lines changed

build.gradle

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ allprojects {
2424
sourceCompatibility = JavaVersion.VERSION_1_8
2525
targetCompatibility = JavaVersion.VERSION_1_8
2626

27-
tasks.withType(JavaCompile) {
27+
tasks.withType(JavaCompile).configureEach {
2828
options.encoding = 'UTF-8'
2929
}
3030

@@ -118,9 +118,17 @@ subprojects {
118118
}
119119
}
120120
}
121+
122+
// only apply to example sub-projects
123+
if (project.path.startsWith(':examples:')) {
124+
task execute(type:JavaExec) {
125+
main = System.getProperty('mainClass')
126+
classpath = sourceSets.main.runtimeClasspath
127+
}
128+
}
121129
}
122130

123-
tasks.withType(Test) {
131+
tasks.withType(Test).configureEach {
124132
// Enable JUnit 5 (Gradle 4.6+).
125133
useJUnitPlatform()
126134

@@ -138,5 +146,8 @@ subprojects {
138146
// prevent circular dependency
139147
implementation project(':core')
140148
}
149+
150+
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.0'
151+
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.11.0'
141152
}
142153
}

core/build.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
11

2+
dependencies {
3+
implementation 'com.auth0:java-jwt:4.5.0'
4+
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
5+
implementation 'com.google.code.gson:gson:2.9.1'
6+
7+
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
8+
testImplementation 'org.mockito:mockito-core:5.18.0'
9+
testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0'
10+
}

core/src/main/java/cloud/stackit/sdk/core/CoreDummy.java

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package cloud.stackit.sdk.core;
2+
3+
import cloud.stackit.sdk.core.config.CoreConfiguration;
4+
import cloud.stackit.sdk.core.config.EnvironmentVariables;
5+
import cloud.stackit.sdk.core.exception.ApiException;
6+
import cloud.stackit.sdk.core.model.ServiceAccountKey;
7+
import cloud.stackit.sdk.core.utils.Utils;
8+
import com.auth0.jwt.JWT;
9+
import com.auth0.jwt.algorithms.Algorithm;
10+
import com.google.gson.Gson;
11+
import com.google.gson.JsonSyntaxException;
12+
import com.google.gson.annotations.SerializedName;
13+
import java.io.IOException;
14+
import java.io.InputStreamReader;
15+
import java.net.HttpURLConnection;
16+
import java.nio.charset.StandardCharsets;
17+
import java.security.NoSuchAlgorithmException;
18+
import java.security.interfaces.RSAPrivateKey;
19+
import java.security.spec.InvalidKeySpecException;
20+
import java.util.Date;
21+
import java.util.HashMap;
22+
import java.util.Map;
23+
import java.util.UUID;
24+
import java.util.concurrent.TimeUnit;
25+
import okhttp3.*;
26+
27+
/** KeyFlowAuthenticator handles the Key Flow Authentication based on the Service Account Key. */
28+
public class KeyFlowAuthenticator {
29+
private final String REFRESH_TOKEN = "refresh_token";
30+
private final String ASSERTION = "assertion";
31+
private final String DEFAULT_TOKEN_ENDPOINT = "https://service-account.api.stackit.cloud/token";
32+
private final long DEFAULT_TOKEN_LEEWAY = 60;
33+
private final int CONNECT_TIMEOUT = 10;
34+
private final int WRITE_TIMEOUT = 10;
35+
private final int READ_TIMEOUT = 10;
36+
37+
private final OkHttpClient httpClient;
38+
private final ServiceAccountKey saKey;
39+
private KeyFlowTokenResponse token;
40+
private final Gson gson;
41+
private final String tokenUrl;
42+
private long tokenLeewayInSeconds = DEFAULT_TOKEN_LEEWAY;
43+
44+
protected static class KeyFlowTokenResponse {
45+
@SerializedName("access_token")
46+
private String accessToken;
47+
48+
@SerializedName("refresh_token")
49+
private String refreshToken;
50+
51+
@SerializedName("expires_in")
52+
private long expiresIn;
53+
54+
@SerializedName("scope")
55+
private String scope;
56+
57+
@SerializedName("token_type")
58+
private String tokenType;
59+
60+
public KeyFlowTokenResponse(
61+
String accessToken,
62+
String refreshToken,
63+
long expiresIn,
64+
String scope,
65+
String tokenType) {
66+
this.accessToken = accessToken;
67+
this.refreshToken = refreshToken;
68+
this.expiresIn = expiresIn;
69+
this.scope = scope;
70+
this.tokenType = tokenType;
71+
}
72+
73+
protected boolean isExpired() {
74+
return expiresIn < new Date().toInstant().getEpochSecond();
75+
}
76+
77+
protected String getAccessToken() {
78+
return accessToken;
79+
}
80+
}
81+
82+
public KeyFlowAuthenticator(CoreConfiguration cfg, ServiceAccountKey saKey) {
83+
this(cfg, saKey, null);
84+
}
85+
86+
/**
87+
* Creates the initial service account and refreshes expired access token.
88+
*
89+
* @param cfg Configuration to set a custom token endpoint and the token expiration leeway.
90+
* @param saKey Service Account Key, which should be used for the authentication
91+
*/
92+
public KeyFlowAuthenticator(
93+
CoreConfiguration cfg,
94+
ServiceAccountKey saKey,
95+
EnvironmentVariables environmentVariables) {
96+
this.saKey = saKey;
97+
this.gson = new Gson();
98+
this.httpClient =
99+
new OkHttpClient.Builder()
100+
.connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS)
101+
.writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS)
102+
.readTimeout(READ_TIMEOUT, TimeUnit.SECONDS)
103+
.build();
104+
105+
if (environmentVariables == null) {
106+
environmentVariables = new EnvironmentVariables();
107+
}
108+
109+
if (Utils.isStringSet(cfg.getTokenCustomUrl())) {
110+
this.tokenUrl = cfg.getTokenCustomUrl();
111+
} else if (Utils.isStringSet(environmentVariables.getStackitTokenBaseurl())) {
112+
this.tokenUrl = environmentVariables.getStackitTokenBaseurl();
113+
} else {
114+
this.tokenUrl = DEFAULT_TOKEN_ENDPOINT;
115+
}
116+
if (cfg.getTokenExpirationLeeway() != null && cfg.getTokenExpirationLeeway() > 0) {
117+
this.tokenLeewayInSeconds = cfg.getTokenExpirationLeeway();
118+
}
119+
}
120+
121+
/**
122+
* Returns access token. If the token is expired it creates a new token.
123+
*
124+
* @throws InvalidKeySpecException thrown when the private key in the service account can not be
125+
* parsed
126+
* @throws IOException request for new access token failed
127+
* @throws ApiException response for new access token with bad status code
128+
*/
129+
public synchronized String getAccessToken()
130+
throws IOException, ApiException, InvalidKeySpecException {
131+
if (token == null) {
132+
createAccessToken();
133+
} else if (token.isExpired()) {
134+
createAccessTokenWithRefreshToken();
135+
}
136+
return token.getAccessToken();
137+
}
138+
139+
/**
140+
* Creates the initial accessToken and stores it in `this.token`
141+
*
142+
* @throws InvalidKeySpecException can not parse private key
143+
* @throws IOException request for access token failed
144+
* @throws ApiException response for new access token with bad status code
145+
* @throws JsonSyntaxException parsing of the created access token failed
146+
*/
147+
protected void createAccessToken()
148+
throws InvalidKeySpecException, IOException, JsonSyntaxException, ApiException {
149+
String grant = "urn:ietf:params:oauth:grant-type:jwt-bearer";
150+
String assertion;
151+
try {
152+
assertion = generateSelfSignedJWT();
153+
} catch (NoSuchAlgorithmException e) {
154+
throw new RuntimeException(
155+
"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",
156+
e);
157+
}
158+
Response response = requestToken(grant, assertion).execute();
159+
parseTokenResponse(response);
160+
response.close();
161+
}
162+
163+
/**
164+
* Creates a new access token with the existing refresh token
165+
*
166+
* @throws IOException request for new access token failed
167+
* @throws ApiException response for new access token with bad status code
168+
* @throws JsonSyntaxException can not parse new access token
169+
*/
170+
protected synchronized void createAccessTokenWithRefreshToken()
171+
throws IOException, JsonSyntaxException, ApiException {
172+
String refreshToken = token.refreshToken;
173+
Response response = requestToken(REFRESH_TOKEN, refreshToken).execute();
174+
parseTokenResponse(response);
175+
response.close();
176+
}
177+
178+
private synchronized void parseTokenResponse(Response response)
179+
throws ApiException, JsonSyntaxException, IOException {
180+
if (response.code() != HttpURLConnection.HTTP_OK) {
181+
String body = null;
182+
if (response.body() != null) {
183+
body = response.body().toString();
184+
response.body().close();
185+
}
186+
throw new ApiException(
187+
response.message(), response.code(), response.headers().toMultimap(), body);
188+
}
189+
if (response.body() == null || response.body().contentLength() == 0) {
190+
throw new JsonSyntaxException("body from token creation is null");
191+
}
192+
193+
KeyFlowTokenResponse keyFlowTokenResponse =
194+
gson.fromJson(
195+
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8),
196+
KeyFlowTokenResponse.class);
197+
setToken(keyFlowTokenResponse);
198+
response.body().close();
199+
}
200+
201+
private Call requestToken(String grant, String assertionValue) throws IOException {
202+
FormBody.Builder bodyBuilder = new FormBody.Builder();
203+
bodyBuilder.addEncoded("grant_type", grant);
204+
String assertionKey = grant.equals(REFRESH_TOKEN) ? REFRESH_TOKEN : ASSERTION;
205+
bodyBuilder.addEncoded(assertionKey, assertionValue);
206+
FormBody body = bodyBuilder.build();
207+
208+
Request request =
209+
new Request.Builder()
210+
.url(tokenUrl)
211+
.post(body)
212+
.addHeader("Content-Type", "application/x-www-form-urlencoded")
213+
.build();
214+
return httpClient.newCall(request);
215+
}
216+
217+
protected void setToken(KeyFlowTokenResponse response) {
218+
token = response;
219+
token.expiresIn =
220+
JWT.decode(response.accessToken)
221+
.getExpiresAt()
222+
.toInstant()
223+
.minusSeconds(tokenLeewayInSeconds)
224+
.getEpochSecond();
225+
}
226+
227+
private String generateSelfSignedJWT()
228+
throws InvalidKeySpecException, NoSuchAlgorithmException {
229+
RSAPrivateKey prvKey;
230+
231+
prvKey = saKey.getCredentials().getPrivateKeyParsed();
232+
Algorithm algorithm = Algorithm.RSA512(prvKey);
233+
234+
Map<String, Object> jwtHeader = new HashMap<>();
235+
jwtHeader.put("kid", saKey.getCredentials().getKid());
236+
237+
return JWT.create()
238+
.withIssuer(saKey.getCredentials().getIss())
239+
.withSubject(saKey.getCredentials().getSub())
240+
.withJWTId(UUID.randomUUID().toString())
241+
.withAudience(saKey.getCredentials().getAud())
242+
.withIssuedAt(new Date())
243+
.withExpiresAt(new Date().toInstant().plusSeconds(10 * 60))
244+
.withHeader(jwtHeader)
245+
.sign(algorithm);
246+
}
247+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package cloud.stackit.sdk.core;
2+
3+
import cloud.stackit.sdk.core.exception.ApiException;
4+
import java.io.IOException;
5+
import java.security.spec.InvalidKeySpecException;
6+
import okhttp3.Interceptor;
7+
import okhttp3.Request;
8+
import okhttp3.Response;
9+
import org.jetbrains.annotations.NotNull;
10+
11+
public class KeyFlowInterceptor implements Interceptor {
12+
private final KeyFlowAuthenticator authenticator;
13+
14+
public KeyFlowInterceptor(KeyFlowAuthenticator authenticator) {
15+
this.authenticator = authenticator;
16+
}
17+
18+
@NotNull @Override
19+
public Response intercept(Chain chain) throws IOException {
20+
21+
Request originalRequest = chain.request();
22+
String accessToken;
23+
try {
24+
accessToken = authenticator.getAccessToken();
25+
} catch (InvalidKeySpecException | ApiException e) {
26+
// try-catch required, because ApiException can not be thrown in the implementation
27+
// of Interceptor.intercept(Chain chain)
28+
throw new RuntimeException(e);
29+
}
30+
31+
Request authenticatedRequest =
32+
originalRequest
33+
.newBuilder()
34+
.header("Authorization", "Bearer " + accessToken)
35+
.build();
36+
return chain.proceed(authenticatedRequest);
37+
}
38+
}

0 commit comments

Comments
 (0)