Skip to content

Commit 3e7d0c8

Browse files
authored
Merge branch 'main' into feature/allow-custom-doc-service
2 parents 4ad7a53 + ad4f22f commit 3e7d0c8

File tree

415 files changed

+23055
-3780
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

415 files changed

+23055
-3780
lines changed

.editorconfig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
[*.{kt,kts}]
22
max_line_length = 112
3+
ktlint_standard_import-ordering = disabled

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,6 @@ typings/
141141

142142
# macOS folder metadata
143143
.DS_Store
144+
145+
# Claude
146+
CLAUDE.md

athenz/build.gradle

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
dependencies {
18+
implementation project(":oauth2")
19+
implementation libs.athenz.zts.client
20+
implementation libs.athenz.zpe.client
21+
implementation libs.caffeine
22+
23+
testImplementation libs.athenz.zms.client
24+
testImplementation libs.jwt
25+
testImplementation libs.testcontainers.junit.jupiter
26+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2025 LINE Corporation
3+
*
4+
* LINE Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.client.athenz;
18+
19+
import static com.linecorp.armeria.client.athenz.RoleTokenClient.ROLE_JOINER;
20+
21+
import java.time.Duration;
22+
import java.time.Instant;
23+
import java.util.List;
24+
import java.util.concurrent.CompletableFuture;
25+
import java.util.concurrent.atomic.AtomicBoolean;
26+
27+
import com.google.common.collect.ImmutableList;
28+
import com.yahoo.athenz.auth.AuthorityConsts;
29+
30+
import com.linecorp.armeria.client.auth.oauth2.AccessTokenRequest;
31+
import com.linecorp.armeria.client.auth.oauth2.OAuth2AuthorizationGrant;
32+
import com.linecorp.armeria.common.HttpHeadersBuilder;
33+
import com.linecorp.armeria.common.QueryParamsBuilder;
34+
import com.linecorp.armeria.common.athenz.AccessDeniedException;
35+
import com.linecorp.armeria.common.auth.oauth2.ClientAuthentication;
36+
import com.linecorp.armeria.common.auth.oauth2.GrantedOAuth2AccessToken;
37+
import com.linecorp.armeria.common.util.Exceptions;
38+
39+
final class AccessTokenClient implements TokenClient {
40+
41+
private final AtomicBoolean tlsKeyPairUpdated = new AtomicBoolean();
42+
43+
private final long refreshBeforeMillis;
44+
private final String domainName;
45+
private final List<String> roleNames;
46+
private final OAuth2AuthorizationGrant authorizationGrant;
47+
48+
AccessTokenClient(ZtsBaseClient ztsBaseClient, String domainName, List<String> roleNames,
49+
Duration refreshBefore) {
50+
refreshBeforeMillis = refreshBefore.toMillis();
51+
this.domainName = domainName;
52+
this.roleNames = roleNames;
53+
54+
ztsBaseClient.addTlsKeyPairListener(tlsKeyPair -> tlsKeyPairUpdated.set(true));
55+
56+
// Scope syntax:
57+
// - <domain-name>:domain
58+
// - <domain-name>:role.<role-name>
59+
// https://github.com/AthenZ/athenz/blob/5e064414224eca025c7a4ae1df5b5eb381e71a16/clients/java/zts/src/main/java/com/yahoo/athenz/zts/ZTSClient.java#L1446
60+
final ImmutableList.Builder<String> scopeBuilder = ImmutableList.builder();
61+
if (roleNames.isEmpty()) {
62+
scopeBuilder.add(domainName + ":domain");
63+
} else {
64+
for (String role : roleNames) {
65+
scopeBuilder.add(domainName + AuthorityConsts.ROLE_SEP + role);
66+
}
67+
}
68+
final AccessTokenRequest tokenRequest = AccessTokenRequest.ofClientCredentials(
69+
NoopClientAuthentication.INSTANCE, scopeBuilder.build());
70+
authorizationGrant = OAuth2AuthorizationGrant.builder(ztsBaseClient.webClient(),
71+
"/oauth2/token")
72+
.accessTokenRequest(tokenRequest)
73+
.refreshIf(this::shouldRefreshToken)
74+
.build();
75+
}
76+
77+
private boolean shouldRefreshToken(GrantedOAuth2AccessToken token) {
78+
if (tlsKeyPairUpdated.compareAndSet(true, false)) {
79+
// If the TLS key pair is updated, we need to refresh the token.
80+
return true;
81+
}
82+
final Duration expiresIn = token.expiresIn();
83+
if (expiresIn == null) {
84+
return false;
85+
}
86+
87+
return !token.isValid(Instant.now().plusMillis(refreshBeforeMillis));
88+
}
89+
90+
@Override
91+
public CompletableFuture<String> getToken() {
92+
return authorizationGrant.getAccessToken().handle((token, cause) -> {
93+
if (cause != null) {
94+
cause = Exceptions.peel(cause);
95+
throw new AccessDeniedException("Failed to obtain an Athenz access token. (domain: " +
96+
domainName + ", roles: " + ROLE_JOINER.join(roleNames) + ')',
97+
cause);
98+
}
99+
return token.accessToken();
100+
}).toCompletableFuture();
101+
}
102+
103+
private enum NoopClientAuthentication implements ClientAuthentication {
104+
105+
INSTANCE;
106+
107+
@Override
108+
public void addAsHeaders(HttpHeadersBuilder headersBuilder) {}
109+
110+
@Override
111+
public void addAsBodyParams(QueryParamsBuilder formBuilder) {}
112+
}
113+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright 2025 LY Corporation
3+
*
4+
* LY Corporation licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
17+
package com.linecorp.armeria.client.athenz;
18+
19+
import static java.util.Objects.requireNonNull;
20+
21+
import java.time.Duration;
22+
import java.util.List;
23+
import java.util.concurrent.CompletableFuture;
24+
import java.util.function.Function;
25+
26+
import com.google.common.collect.ImmutableList;
27+
28+
import com.linecorp.armeria.client.ClientRequestContext;
29+
import com.linecorp.armeria.client.HttpClient;
30+
import com.linecorp.armeria.client.SimpleDecoratingHttpClient;
31+
import com.linecorp.armeria.common.HttpRequest;
32+
import com.linecorp.armeria.common.HttpResponse;
33+
import com.linecorp.armeria.common.RequestHeadersBuilder;
34+
import com.linecorp.armeria.common.annotation.UnstableApi;
35+
import com.linecorp.armeria.common.athenz.TokenType;
36+
import com.linecorp.armeria.common.util.Exceptions;
37+
38+
/**
39+
* An {@link HttpClient} that adds an Athenz token to the request headers.
40+
* {@link TokenType#ACCESS_TOKEN} and {@link TokenType#YAHOO_ROLE_TOKEN} are supported.
41+
*
42+
* <p>The acquired token is cached and automatically refreshed before it expires based on the specified
43+
* duration. If not specified, the default refresh duration is 10 minutes before the token expires.
44+
*
45+
* <p>Example:
46+
* <pre>{@code
47+
* import com.linecorp.armeria.client.athenz.ZtsBaseClient;
48+
* import com.linecorp.armeria.client.athenz.AthenzClient;
49+
*
50+
* ZtsBaseClient ztsBaseClient =
51+
* ZtsBaseClient
52+
* .builder("https://athenz.example.com:8443/zts/v1")
53+
* .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
54+
* .build();
55+
*
56+
* WebClient
57+
* .builder()
58+
* .decorator(AthenzClient.newDecorator(ztsBaseClient, "my-domain",
59+
* TokenType.ROLE_TOKEN)
60+
* ...
61+
* .build();
62+
* }</pre>
63+
*/
64+
@UnstableApi
65+
public final class AthenzClient extends SimpleDecoratingHttpClient {
66+
67+
private static final Duration DEFAULT_REFRESH_BEFORE = Duration.ofMinutes(10);
68+
69+
/**
70+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
71+
* adds it to the request headers.
72+
*
73+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
74+
* @param domainName the Athenz domain name
75+
* @param tokenType the type of Athenz token to obtain
76+
*/
77+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
78+
String domainName, TokenType tokenType) {
79+
return newDecorator(ztsBaseClient, domainName, ImmutableList.of(), tokenType);
80+
}
81+
82+
/**
83+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
84+
* role name, and adds it to the request headers.
85+
*
86+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
87+
* @param domainName the Athenz domain name
88+
* @param roleName the Athenz role name
89+
* @param tokenType the type of Athenz token to obtain
90+
*/
91+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
92+
String domainName, String roleName,
93+
TokenType tokenType) {
94+
return newDecorator(ztsBaseClient, domainName, ImmutableList.of(roleName), tokenType);
95+
}
96+
97+
/**
98+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
99+
* role names, and adds it to the request headers.
100+
*
101+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
102+
* @param domainName the Athenz domain name
103+
* @param roleNames the list of Athenz role names
104+
* @param tokenType the type of Athenz token to obtain
105+
*/
106+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
107+
String domainName, List<String> roleNames,
108+
TokenType tokenType) {
109+
return newDecorator(ztsBaseClient, domainName, roleNames, tokenType, DEFAULT_REFRESH_BEFORE);
110+
}
111+
112+
/**
113+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
114+
* role names, and adds it to the request headers.
115+
*
116+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
117+
* @param domainName the Athenz domain name
118+
* @param roleNames the list of Athenz role names
119+
* @param tokenType the type of Athenz token to obtain
120+
* @param refreshBefore the duration before the token expires to refresh it
121+
*/
122+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
123+
String domainName, List<String> roleNames,
124+
TokenType tokenType, Duration refreshBefore) {
125+
requireNonNull(ztsBaseClient, "ztsBaseClient");
126+
requireNonNull(domainName, "domainName");
127+
requireNonNull(roleNames, "roleNames");
128+
final ImmutableList<String> roleNames0 = ImmutableList.copyOf(roleNames);
129+
requireNonNull(tokenType, "tokenType");
130+
requireNonNull(refreshBefore, "refreshBefore");
131+
return delegate -> new AthenzClient(delegate, ztsBaseClient, domainName, roleNames0,
132+
tokenType, refreshBefore);
133+
}
134+
135+
private final TokenType tokenType;
136+
private final TokenClient tokenClient;
137+
138+
private AthenzClient(HttpClient delegate, ZtsBaseClient ztsBaseClient, String domainName,
139+
List<String> roleNames, TokenType tokenType, Duration refreshBefore) {
140+
super(delegate);
141+
this.tokenType = tokenType;
142+
if (tokenType.isRoleToken()) {
143+
tokenClient = new RoleTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore);
144+
} else {
145+
tokenClient = new AccessTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore);
146+
}
147+
}
148+
149+
@Override
150+
public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception {
151+
final CompletableFuture<HttpResponse> future = tokenClient.getToken().thenApply(token -> {
152+
final HttpRequest newReq = req.mapHeaders(headers -> {
153+
final RequestHeadersBuilder builder = headers.toBuilder();
154+
String token0 = token;
155+
if (tokenType.authScheme() != null) {
156+
token0 = tokenType.authScheme() + ' ' + token0;
157+
}
158+
builder.set(tokenType.headerName(), token0);
159+
return builder.build();
160+
});
161+
ctx.updateRequest(newReq);
162+
try {
163+
return unwrap().execute(ctx, newReq);
164+
} catch (Exception e) {
165+
return Exceptions.throwUnsafely(e);
166+
}
167+
});
168+
169+
return HttpResponse.of(future);
170+
}
171+
}

0 commit comments

Comments
 (0)