Skip to content

Commit 8f4ec21

Browse files
authored
Make ApplicationCertificateIdExtractor configurable via SPI (#1280)
Motivation: The `ApplicationCertificateIdExtractor` was hardcoded to use `CommonNameExtractor`, which extracts the CN from the certificate's subject DN. Users who need to extract certificate IDs differently (e.g., from SANs or other DN fields) had no way to customize this behavior. Modifications: - Modified `ApplicationCertificateAuthorizer` to load `ApplicationCertificateIdExtractor` via `ServiceLoader`. Result: - You can now provide a custom `ApplicationCertificateIdExtractor` via SPI.
1 parent e8ba7ca commit 8f4ec21

File tree

5 files changed

+233
-2
lines changed

5 files changed

+233
-2
lines changed

it/server/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ dependencies {
33
testImplementation project(':server-auth:shiro')
44

55
testImplementation libs.curator.test
6+
7+
testImplementation libs.armeria.junit5
68
}
79

810
// To use @SetEnvironmentVariable
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright 2026 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+
package com.linecorp.centraldogma.it;
17+
18+
import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V1_PATH_PREFIX;
19+
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD;
20+
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME;
21+
import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken;
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
24+
import org.junit.jupiter.api.Order;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.extension.RegisterExtension;
27+
28+
import com.google.common.collect.ImmutableList;
29+
30+
import com.linecorp.armeria.client.ClientFactory;
31+
import com.linecorp.armeria.client.ClientTlsConfig;
32+
import com.linecorp.armeria.client.WebClient;
33+
import com.linecorp.armeria.client.WebClientBuilder;
34+
import com.linecorp.armeria.common.AggregatedHttpResponse;
35+
import com.linecorp.armeria.common.HttpData;
36+
import com.linecorp.armeria.common.HttpRequest;
37+
import com.linecorp.armeria.common.HttpStatus;
38+
import com.linecorp.armeria.common.QueryParams;
39+
import com.linecorp.armeria.common.SessionProtocol;
40+
import com.linecorp.armeria.common.TlsKeyPair;
41+
import com.linecorp.armeria.common.TlsProvider;
42+
import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
43+
import com.linecorp.armeria.testing.junit5.server.SignedCertificateExtension;
44+
import com.linecorp.centraldogma.client.CentralDogma;
45+
import com.linecorp.centraldogma.common.Change;
46+
import com.linecorp.centraldogma.common.ProjectRole;
47+
import com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants;
48+
import com.linecorp.centraldogma.server.CentralDogmaBuilder;
49+
import com.linecorp.centraldogma.server.TlsConfig;
50+
import com.linecorp.centraldogma.server.auth.MtlsConfig;
51+
import com.linecorp.centraldogma.server.internal.api.MetadataApiService.IdAndProjectRole;
52+
import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory;
53+
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;
54+
55+
final class CustomCertificateIdExtractorTest {
56+
57+
private static final String CLIENT_CN = "my-client";
58+
// TestCertificateIdExtractor prepends "test-" to the CN.
59+
private static final String CERT_ID = "test-" + CLIENT_CN;
60+
61+
@Order(1)
62+
@RegisterExtension
63+
static final SelfSignedCertificateExtension serverCert = new SelfSignedCertificateExtension();
64+
65+
@Order(2)
66+
@RegisterExtension
67+
static final SelfSignedCertificateExtension ca = new SelfSignedCertificateExtension();
68+
69+
@Order(3)
70+
@RegisterExtension
71+
static final SignedCertificateExtension clientCert =
72+
new SignedCertificateExtension(CLIENT_CN, ca);
73+
74+
@RegisterExtension
75+
static final CentralDogmaExtension dogma = new CentralDogmaExtension() {
76+
77+
@Override
78+
protected void configure(CentralDogmaBuilder builder) {
79+
builder.authProviderFactory(new TestAuthProviderFactory());
80+
builder.port(0, SessionProtocol.HTTPS);
81+
builder.tls(
82+
new TlsConfig(serverCert.certificateFile(), serverCert.privateKeyFile(), null, null, null));
83+
builder.mtlsConfig(
84+
new MtlsConfig(true, ImmutableList.of(ca.certificateFile())));
85+
builder.systemAdministrators(USERNAME);
86+
}
87+
88+
@Override
89+
protected String accessToken() {
90+
final WebClient client = WebClient.builder("https://127.0.0.1:" + dogma.serverAddress().getPort())
91+
.factory(ClientFactory.insecure())
92+
.build();
93+
return getAccessToken(client, USERNAME, PASSWORD, "testId", true, true, false);
94+
}
95+
96+
@Override
97+
protected void configureHttpClient(WebClientBuilder builder) {
98+
builder.factory(ClientFactory.insecure());
99+
}
100+
101+
@Override
102+
protected void scaffold(CentralDogma client) {
103+
client.createProject("foo").join();
104+
client.createRepository("foo", "bar").join();
105+
client.forRepo("foo", "bar").commit("Add a file", Change.ofTextUpsert("/a.txt", "hello"))
106+
.push().join();
107+
}
108+
};
109+
110+
@Test
111+
void mtlsWithCustomExtractor() {
112+
final TlsKeyPair tlsKeyPair = TlsKeyPair.of(clientCert.privateKey(),
113+
clientCert.certificate());
114+
final ClientTlsConfig tlsConfig =
115+
ClientTlsConfig.builder()
116+
.tlsCustomizer(b -> b.trustManager(serverCert.certificate()))
117+
.build();
118+
try (ClientFactory factory = ClientFactory.builder()
119+
.tlsProvider(TlsProvider.of(tlsKeyPair),
120+
tlsConfig)
121+
.build()) {
122+
final WebClientBuilder builder =
123+
WebClient.builder("https://127.0.0.1:" + dogma.serverAddress().getPort());
124+
builder.factory(factory);
125+
final WebClient mtlsClient = builder.build();
126+
final String contentPath = HttpApiV1Constants.PROJECTS_PREFIX + "/foo/repos/bar/contents/a.txt";
127+
128+
// Not authorized yet — no app identity registered.
129+
AggregatedHttpResponse contentResponse = mtlsClient.get(contentPath).aggregate().join();
130+
assertThat(contentResponse.status()).isEqualTo(HttpStatus.UNAUTHORIZED);
131+
132+
// Register an app identity using the certificate ID produced by TestCertificateIdExtractor
133+
// which prepends "test-" to the CN.
134+
final AggregatedHttpResponse response =
135+
dogma.httpClient().post(API_V1_PATH_PREFIX + "appIdentities",
136+
QueryParams.of("appId", "cert1",
137+
"type", "CERTIFICATE",
138+
"certificateId", CERT_ID,
139+
"isSystemAdmin", false),
140+
HttpData.empty()).aggregate().join();
141+
assertThat(response.status()).isEqualTo(HttpStatus.CREATED);
142+
assertThat(response.contentUtf8()).contains("\"appId\":\"cert1\"");
143+
144+
// Still forbidden — no project role granted yet.
145+
contentResponse = mtlsClient.get(contentPath).aggregate().join();
146+
assertThat(contentResponse.status()).isEqualTo(HttpStatus.FORBIDDEN);
147+
148+
// Grant the cert1 app identity access to the 'foo' project.
149+
final HttpRequest request = HttpRequest.builder()
150+
.post("/api/v1/metadata/foo/appIdentities")
151+
.contentJson(
152+
new IdAndProjectRole("cert1", ProjectRole.MEMBER))
153+
.build();
154+
assertThat(dogma.httpClient().execute(request).aggregate().join().status()).isSameAs(HttpStatus.OK);
155+
156+
// Now the mTLS client can access the content.
157+
contentResponse = mtlsClient.get(contentPath).aggregate().join();
158+
assertThat(contentResponse.status()).isEqualTo(HttpStatus.OK);
159+
}
160+
}
161+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2026 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+
package com.linecorp.centraldogma.it;
17+
18+
import java.security.cert.X509Certificate;
19+
20+
import javax.naming.InvalidNameException;
21+
import javax.naming.ldap.LdapName;
22+
import javax.naming.ldap.Rdn;
23+
24+
import org.jspecify.annotations.Nullable;
25+
26+
import com.linecorp.centraldogma.server.auth.ApplicationCertificateIdExtractor;
27+
28+
/**
29+
* A test {@link ApplicationCertificateIdExtractor} that extracts the CN from the certificate
30+
* and prepends {@code "test-"} to verify that the SPI-loaded extractor is used.
31+
*/
32+
public final class TestCertificateIdExtractor implements ApplicationCertificateIdExtractor {
33+
34+
@Nullable
35+
@Override
36+
public String extractCertificateId(X509Certificate certificate) {
37+
try {
38+
final LdapName ldapName = new LdapName(certificate.getSubjectX500Principal().getName());
39+
for (Rdn rdn : ldapName.getRdns()) {
40+
if ("CN".equalsIgnoreCase(rdn.getType())) {
41+
return "test-" + rdn.getValue().toString();
42+
}
43+
}
44+
} catch (InvalidNameException e) {
45+
// ignore
46+
}
47+
return null;
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
com.linecorp.centraldogma.it.TestCertificateIdExtractor

server/src/main/java/com/linecorp/centraldogma/server/internal/api/auth/ApplicationCertificateAuthorizer.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
import java.security.cert.Certificate;
2323
import java.security.cert.X509Certificate;
24+
import java.util.List;
25+
import java.util.ServiceLoader;
2426
import java.util.concurrent.CompletionStage;
2527
import java.util.function.Function;
2628

@@ -31,6 +33,8 @@
3133
import org.slf4j.Logger;
3234
import org.slf4j.LoggerFactory;
3335

36+
import com.google.common.collect.ImmutableList;
37+
3438
import com.linecorp.armeria.common.HttpRequest;
3539
import com.linecorp.armeria.common.logging.RequestLogProperty;
3640
import com.linecorp.armeria.common.util.Exceptions;
@@ -59,8 +63,22 @@ public final class ApplicationCertificateAuthorizer implements Authorizer<HttpRe
5963
private static final AttributeKey<CertificateId> CERTIFICATE_ID =
6064
AttributeKey.valueOf(ApplicationCertificateAuthorizer.class, "CERTIFICATE_ID");
6165

62-
// TODO(minwoox): Make it configurable via SPI.
63-
private static final ApplicationCertificateIdExtractor ID_EXTRACTOR = CommonNameExtractor.INSTANCE;
66+
private static final ApplicationCertificateIdExtractor ID_EXTRACTOR;
67+
68+
static {
69+
final List<ApplicationCertificateIdExtractor> extractors = ImmutableList.copyOf(
70+
ServiceLoader.load(ApplicationCertificateIdExtractor.class,
71+
ApplicationCertificateAuthorizer.class.getClassLoader()));
72+
if (extractors.isEmpty()) {
73+
ID_EXTRACTOR = CommonNameExtractor.INSTANCE;
74+
} else if (extractors.size() == 1) {
75+
ID_EXTRACTOR = extractors.get(0);
76+
} else {
77+
throw new IllegalStateException(
78+
"Only one ApplicationCertificateIdExtractor implementation must be provided. " +
79+
"found: " + extractors);
80+
}
81+
}
6482

6583
private final Function<String, CertificateAppIdentity> certificateLookupFunc;
6684

0 commit comments

Comments
 (0)