Skip to content

Commit 62a2da2

Browse files
authored
Add Athenz integration module (#6321)
Motivation: This PR aims to provide an integration layer for Athenz so that users can easily obtain Athenz tokens and validate them by decorating clients or services and annotating required Athenz roles declaratively. Modifications: - Client side - `ZtsBaseClient` provides common functionality such as `TlsKeyPair` management and Athenz client configurations. - Users should create `ZtsBaseClient` first to create `AthenzClient` and `AthenzService`. - `ZtsBaseClient` is designed as a resource, and it needs to be closed since Armeria decorators are not closable. - The lifecycle of Athenz `ClientFactory` is delegated to `ZtsBaseClient` - `AccessTokenClient` acquires OAuth 2.0 token from the`/oauth2/token` endpoint. - The cached tokens are automatically refreshed before expiration. - Athenz uses mTLS as the authorization layer, and the client credentials of OAuth 2.0 are unnecessary. - This method does not conform to the official OAuth 2.0 specification. - `RoleTokenClient` obtains Athenz role tokens from the `/domain/{domainName}/token?role=<roleName>` endpoint. - It has a similar refreshing logic to `AccessTokenClient`. - `AthenzClient` is a public decorator that delegates to `AccessTokenClient` or `RoleTokenClient` depending on the configuration. - Server side - `AthenzPolicyLoader` loads Athenz domain policies from ZTS servers, just like the `zpu` CLI does. - The external `zpu` cronjob to fetch policies is no longer necessary. - Both JWS policy data and signed policy data are supported. - `AthenzPolicyHandler` parses the policy data and verifies it with public keys. - `MinifiedAuthZpeClient` is forked from `AuthZpeClient` and modified to seamlessly integrate with Armeria. - `MinifiedAuthZpeClient` is responsible for token validation. - Reviewers may skip a detailed review of this class. - `AthenzService` is a public decorator to check access permission for projected resources. - `RequiresAthenzRole` allows users to specify an Athenz role using annotations. - `AthenzServiceDecoratorFactory` should be injected via `DependencyInjector` to use `RequiresAthenzRole` Result: - You can now use the Athenz module to easily obtain Athenz tokens and validate them. - Closes #6050 - Server example: ```java class MyService { // 1. Decorate the method with `RequiresAthenzRole` to check Athenz role. @RequiresAthenzRole(resource = "user", action = "get") @ProducesJson @get("/user") public CompletableFuture<User> getUser() { ... } } // 2. Create a `ZtsBaseClient` and `AthenzServiceDecoratorFactory` to use Athenz. ZtsBaseClient ztsBaseClient = ZtsBaseClient .builder("https://athenz.example.com:4443/zts/v1") .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem") .build(); final AthenzServiceDecoratorFactory athenzDecoratorFactory = AthenzServiceDecoratorFactory .builder(ztsBaseClient) .policyConfig(new AthenzPolicyConfig("my-domain")) .build(); // 3. Create a `DependencyInjector` with the `AthenzServiceDecoratorFactory` // and set it to the server. `AthenzServiceDecoratorFactory` is required to // create the `RequiresAthenzRole` decorator. final DependencyInjector di = DependencyInjector.ofSingletons(athenzDecoratorFactory) .orElse(DependencyInjector.ofReflective()); serverBuilder.dependencyInjector(di, true); ``` - Client example: ```java ZtsBaseClient ztsBaseClient = ZtsBaseClient .builder("https://athenz.example.com:4443/zts/v1") .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem") .build(); WebClient .builder() .decorator(AthenzClient.newDecorator(ztsBaseClient, "my-domain", TokenType.ROLE_TOKEN) ... .build(); ```
1 parent d562343 commit 62a2da2

File tree

86 files changed

+7115
-14
lines changed

Some content is hidden

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

86 files changed

+7115
-14
lines changed

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: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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 com.linecorp.armeria.internal.common.athenz.AthenzHeaderNames.YAHOO_ROLE_AUTH;
20+
import static java.util.Objects.requireNonNull;
21+
22+
import java.time.Duration;
23+
import java.util.List;
24+
import java.util.concurrent.CompletableFuture;
25+
import java.util.function.Function;
26+
27+
import com.google.common.collect.ImmutableList;
28+
29+
import com.linecorp.armeria.client.ClientRequestContext;
30+
import com.linecorp.armeria.client.HttpClient;
31+
import com.linecorp.armeria.client.SimpleDecoratingHttpClient;
32+
import com.linecorp.armeria.common.HttpHeaderNames;
33+
import com.linecorp.armeria.common.HttpRequest;
34+
import com.linecorp.armeria.common.HttpResponse;
35+
import com.linecorp.armeria.common.RequestHeadersBuilder;
36+
import com.linecorp.armeria.common.annotation.UnstableApi;
37+
import com.linecorp.armeria.common.athenz.TokenType;
38+
import com.linecorp.armeria.common.util.Exceptions;
39+
40+
/**
41+
* An {@link HttpClient} that adds an Athenz token to the request headers.
42+
* {@link TokenType#ACCESS_TOKEN} and {@link TokenType#ROLE_TOKEN} are supported.
43+
*
44+
* <p>The acquired token is cached and automatically refreshed before it expires based on the specified
45+
* duration. If not specified, the default refresh duration is 10 minutes before the token expires.
46+
*
47+
* <p>Example:
48+
* <pre>{@code
49+
* import com.linecorp.armeria.client.athenz.ZtsBaseClient;
50+
* import com.linecorp.armeria.client.athenz.AthenzClient;
51+
*
52+
* ZtsBaseClient ztsBaseClient =
53+
* ZtsBaseClient
54+
* .builder("https://athenz.example.com:8443/zts/v1")
55+
* .keyPair("/var/lib/athenz/service.key.pem", "/var/lib/athenz/service.cert.pem")
56+
* .build();
57+
*
58+
* WebClient
59+
* .builder()
60+
* .decorator(AthenzClient.newDecorator(ztsBaseClient, "my-domain",
61+
* TokenType.ROLE_TOKEN)
62+
* ...
63+
* .build();
64+
* }</pre>
65+
*/
66+
@UnstableApi
67+
public final class AthenzClient extends SimpleDecoratingHttpClient {
68+
69+
private static final Duration DEFAULT_REFRESH_BEFORE = Duration.ofMinutes(10);
70+
71+
/**
72+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
73+
* adds it to the request headers.
74+
*
75+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
76+
* @param domainName the Athenz domain name
77+
* @param tokenType the type of Athenz token to obtain
78+
*/
79+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
80+
String domainName, TokenType tokenType) {
81+
return newDecorator(ztsBaseClient, domainName, ImmutableList.of(), tokenType);
82+
}
83+
84+
/**
85+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
86+
* role name, and adds it to the request headers.
87+
*
88+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
89+
* @param domainName the Athenz domain name
90+
* @param roleName the Athenz role name
91+
* @param tokenType the type of Athenz token to obtain
92+
*/
93+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
94+
String domainName, String roleName,
95+
TokenType tokenType) {
96+
return newDecorator(ztsBaseClient, domainName, ImmutableList.of(roleName), tokenType);
97+
}
98+
99+
/**
100+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
101+
* role names, and adds it to the request headers.
102+
*
103+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
104+
* @param domainName the Athenz domain name
105+
* @param roleNames the list of Athenz role names
106+
* @param tokenType the type of Athenz token to obtain
107+
*/
108+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
109+
String domainName, List<String> roleNames,
110+
TokenType tokenType) {
111+
return newDecorator(ztsBaseClient, domainName, roleNames, tokenType, DEFAULT_REFRESH_BEFORE);
112+
}
113+
114+
/**
115+
* Returns a new {@link HttpClient} decorator that obtains an Athenz token for the specified domain and
116+
* role names, and adds it to the request headers.
117+
*
118+
* @param ztsBaseClient the ZTS base client to use to communicate with the ZTS server
119+
* @param domainName the Athenz domain name
120+
* @param roleNames the list of Athenz role names
121+
* @param tokenType the type of Athenz token to obtain
122+
* @param refreshBefore the duration before the token expires to refresh it
123+
*/
124+
public static Function<HttpClient, AthenzClient> newDecorator(ZtsBaseClient ztsBaseClient,
125+
String domainName, List<String> roleNames,
126+
TokenType tokenType, Duration refreshBefore) {
127+
requireNonNull(ztsBaseClient, "ztsBaseClient");
128+
requireNonNull(domainName, "domainName");
129+
requireNonNull(roleNames, "roleNames");
130+
final ImmutableList<String> roleNames0 = ImmutableList.copyOf(roleNames);
131+
requireNonNull(tokenType, "tokenType");
132+
requireNonNull(refreshBefore, "refreshBefore");
133+
return delegate -> new AthenzClient(delegate, ztsBaseClient, domainName, roleNames0,
134+
tokenType, refreshBefore);
135+
}
136+
137+
private final TokenType tokenType;
138+
private final TokenClient tokenClient;
139+
140+
private AthenzClient(HttpClient delegate, ZtsBaseClient ztsBaseClient, String domainName,
141+
List<String> roleNames, TokenType tokenType, Duration refreshBefore) {
142+
super(delegate);
143+
this.tokenType = tokenType;
144+
switch (tokenType) {
145+
case ROLE_TOKEN:
146+
tokenClient = new RoleTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore);
147+
break;
148+
case ACCESS_TOKEN:
149+
tokenClient = new AccessTokenClient(ztsBaseClient, domainName, roleNames, refreshBefore);
150+
break;
151+
default:
152+
throw new Error("unknown auth type: " + tokenType);
153+
}
154+
}
155+
156+
@Override
157+
public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception {
158+
final CompletableFuture<HttpResponse> future = tokenClient.getToken().thenApply(token -> {
159+
final HttpRequest newReq = req.mapHeaders(headers -> {
160+
final RequestHeadersBuilder builder = headers.toBuilder();
161+
if (tokenType == TokenType.ROLE_TOKEN) {
162+
builder.set(YAHOO_ROLE_AUTH, token);
163+
} else {
164+
builder.set(HttpHeaderNames.AUTHORIZATION, "Bearer " + token);
165+
}
166+
return builder.build();
167+
});
168+
ctx.updateRequest(newReq);
169+
try {
170+
return unwrap().execute(ctx, newReq);
171+
} catch (Exception e) {
172+
return Exceptions.throwUnsafely(e);
173+
}
174+
});
175+
176+
return HttpResponse.of(future);
177+
}
178+
}

0 commit comments

Comments
 (0)