From 505d38f93baa7ba724310ee078b66a14dd435756 Mon Sep 17 00:00:00 2001
From: Benjamin Broadaway <4554569+benbroadaway@users.noreply.github.com>
Date: Fri, 14 Nov 2025 16:38:47 -0600
Subject: [PATCH 1/9] server, agent: github app installation clone support
---
agent/pom.xml | 9 +
.../concord/agent/AgentAuthTokenProvider.java | 92 +++++++
.../concord/agent/AgentModule.java | 6 +
.../concord/agent/RepositoryManager.java | 12 +-
.../concord/agent/cfg/GitConfiguration.java | 67 ++++-
.../agent/cfg/GitHubConfiguration.java | 63 +++++
agent/src/main/resources/concord-agent.conf | 45 +++-
.../agent/AgentAuthTokenProviderTest.java | 138 ++++++++++
.../cli/runner/CliRepositoryExporter.java | 22 +-
common/pom.xml | 15 ++
.../concord/common/AuthTokenProvider.java | 143 ++++++++++
.../concord/common/ExternalAuthToken.java | 90 +++++++
.../concord/common/cfg/MappingAuthConfig.java | 81 ++++++
.../concord/common/cfg/OauthTokenConfig.java | 36 +++
.../concord/common/AuthTokenProviderTest.java | 155 +++++++++++
.../concord/common/ExternalAuthTokenTest.java | 123 +++++++++
github-app-installation/pom.xml | 156 +++++++++++
.../appinstallation/AccessTokenProvider.java | 244 ++++++++++++++++++
.../github/appinstallation/CacheKey.java | 66 +++++
.../appinstallation/GitHubAppAuthConfig.java | 56 ++++
.../GitHubAppAuthConfigNew.java | 53 ++++
.../GitHubAppInstallation.java | 206 +++++++++++++++
.../GitHubInstallationToken.java | 33 +++
.../concord/github/appinstallation/Utils.java | 149 +++++++++++
.../cfg/GitHubAppInstallationConfig.java | 175 +++++++++++++
.../exception/GitHubAppException.java | 41 +++
.../exception/RepoExtractionException.java | 28 ++
.../AccessTokenProviderTest.java | 150 +++++++++++
.../GitHubAppAuthConfigTest.java | 43 +++
.../GitHubAppInstallationTest.java | 236 +++++++++++++++++
.../appinstallation/RepoExtractionTest.java | 119 +++++++++
.../github/appinstallation/TestConstants.java | 66 +++++
.../github/appinstallation/UtilsTest.java | 105 ++++++++
.../appinstallation/cfg/ConfigTest.java | 98 +++++++
.../src/test/resources/logback-test.xml | 13 +
pom.xml | 1 +
repository/pom.xml | 10 +
.../concord/repository/FetchRequest.java | 6 +-
.../repository/GitCliRepositoryProvider.java | 5 +-
.../concord/repository/GitClient.java | 65 ++---
.../repository/GitClientConfiguration.java | 10 +-
.../repository/RepositoryException.java | 3 +
.../repository/GitClientFetch2Test.java | 10 +-
.../repository/GitClientFetchTest.java | 10 +-
.../concord/repository/GitClientRealTest.java | 10 +-
.../repository/GitClientSpeedTest.java | 10 +-
.../concord/repository/GitUriTest.java | 32 ++-
.../src/main/resources/concord-server.conf | 60 ++++-
server/impl/pom.xml | 4 +
.../concord/server/ConcordServerModule.java | 4 +
.../server/cfg/ConfigurationModule.java | 4 +
.../concord/server/cfg/GitConfiguration.java | 72 +++++-
.../server/cfg/GithubConfiguration.java | 39 ++-
.../concord/server/process/ProcessModule.java | 1 -
.../server/repository/RepositoryManager.java | 18 +-
.../repository/ServerAuthTokenProvider.java | 70 +++++
.../server/ConcordObjectMapperTest.java | 20 ++
.../ServerAuthTokenProviderTest.java | 109 ++++++++
targetplatform/pom.xml | 5 +
59 files changed, 3637 insertions(+), 75 deletions(-)
create mode 100644 agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
create mode 100644 agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java
create mode 100644 agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java
create mode 100644 common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java
create mode 100644 common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java
create mode 100644 common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java
create mode 100644 common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java
create mode 100644 common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java
create mode 100644 common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java
create mode 100644 github-app-installation/pom.xml
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java
create mode 100644 github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
create mode 100644 github-app-installation/src/test/resources/logback-test.xml
create mode 100644 server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java
create mode 100644 server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java
diff --git a/agent/pom.xml b/agent/pom.xml
index 1ae72c37b8..6fd9e2b018 100644
--- a/agent/pom.xml
+++ b/agent/pom.xml
@@ -29,6 +29,10 @@
com.walmartlabs.concord
concord-common
+
+ com.walmartlabs.concord
+ concord-github-app-installation
+
com.walmartlabs.concord
concord-client2
@@ -143,6 +147,11 @@
mockito-core
test
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
new file mode 100644
index 0000000000..45de4ae8ce
--- /dev/null
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
@@ -0,0 +1,92 @@
+package com.walmartlabs.concord.agent;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.typesafe.config.Config;
+import com.walmartlabs.concord.agent.remote.ApiClientFactory;
+import com.walmartlabs.concord.client2.ApiException;
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation;
+import com.walmartlabs.concord.sdk.Secret;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+public class AgentAuthTokenProvider implements AuthTokenProvider {
+ private final List authTokenProviders;
+
+ @Inject
+ public AgentAuthTokenProvider(ConcordServerTokenProvider concordProvider,
+ GitHubAppInstallation githubProvider,
+ AuthTokenProvider.OauthTokenProvider oauthTokenProvider) {
+
+ this.authTokenProviders = List.of(
+ concordProvider,
+ githubProvider,
+ oauthTokenProvider
+ );
+ }
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return authTokenProviders.stream()
+ .anyMatch(p -> p.supports(repo, secret));
+ }
+
+ public Optional getToken(URI repo, @Nullable Secret secret) {
+ for (var k : authTokenProviders) {
+ if (k.supports(repo, secret)) {
+ return k.getToken(repo, secret);
+ }
+ }
+
+ return Optional.empty();
+ }
+
+ public static class ConcordServerTokenProvider implements AuthTokenProvider {
+ private final ApiClientFactory apiClientFactory;
+ private final Config config;
+
+ @Inject
+ public ConcordServerTokenProvider(ApiClientFactory apiClientFactory, Config config) {
+ this.apiClientFactory = apiClientFactory;
+ this.config = config;
+ }
+
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ // TODO implement
+ return false;
+ }
+
+ @Override
+ public Optional getToken(URI repo, @Nullable Secret secret) {
+ // TODO implement
+ return Optional.empty();
+ }
+ }
+}
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java
index 81ad08b741..cc6e06f1e1 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentModule.java
@@ -30,7 +30,9 @@
import com.walmartlabs.concord.agent.remote.ApiClientFactory;
import com.walmartlabs.concord.agent.remote.QueueClientProvider;
import com.walmartlabs.concord.common.ObjectMapperProvider;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
import com.walmartlabs.concord.config.ConfigModule;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
import com.walmartlabs.concord.server.queueclient.QueueClient;
import javax.inject.Named;
@@ -59,6 +61,10 @@ public void configure(Binder binder) {
binder.bind(DockerConfiguration.class).in(SINGLETON);
binder.bind(RuntimeConfiguration.class).asEagerSingleton();
binder.bind(GitConfiguration.class).in(SINGLETON);
+ binder.bind(OauthTokenConfig.class).to(GitConfiguration.class).in(SINGLETON);
+ binder.bind(GitHubConfiguration.class).in(SINGLETON);
+ binder.bind(GitHubAppInstallationConfig.class).to(GitHubConfiguration.class).in(SINGLETON);
+ binder.bind(AgentAuthTokenProvider.ConcordServerTokenProvider.class).in(SINGLETON);
binder.bind(ImportConfiguration.class).in(SINGLETON);
binder.bind(PreForkConfiguration.class).in(SINGLETON);
binder.bind(RepositoryCacheConfiguration.class).in(SINGLETON);
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java b/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java
index e743d4203d..e9828fc8a3 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java
@@ -51,13 +51,14 @@ public RepositoryManager(SecretClient secretClient,
GitConfiguration gitCfg,
RepositoryCacheConfiguration cacheCfg,
ObjectMapper objectMapper,
- DependencyManager dependencyManager) throws IOException {
+ DependencyManager dependencyManager,
+ AgentAuthTokenProvider agentAuthTokenProvider) throws IOException {
this.secretClient = secretClient;
this.gitCfg = gitCfg;
GitClientConfiguration clientCfg = GitClientConfiguration.builder()
- .oauthToken(gitCfg.getToken())
+ .oauthToken(gitCfg.getOauthToken())
.defaultOperationTimeout(gitCfg.getDefaultOperationTimeout())
.fetchTimeout(gitCfg.getFetchTimeout())
.httpLowSpeedLimit(gitCfg.getHttpLowSpeedLimit())
@@ -66,9 +67,10 @@ public RepositoryManager(SecretClient secretClient,
.sshTimeoutRetryCount(gitCfg.getSshTimeoutRetryCount())
.build();
- List providers = Arrays.asList(new MavenRepositoryProvider(dependencyManager), new GitCliRepositoryProvider(clientCfg));
- this.providers = new RepositoryProviders(providers);
-
+ this.providers = new RepositoryProviders(List.of(
+ new MavenRepositoryProvider(dependencyManager),
+ new GitCliRepositoryProvider(clientCfg, agentAuthTokenProvider)
+ ));
this.repositoryCache = new RepositoryCache(cacheCfg.getCacheDir(),
cacheCfg.getInfoDir(),
cacheCfg.getLockTimeout(),
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java
index 19d63aa9a7..80c6bd8856 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitConfiguration.java
@@ -21,15 +21,21 @@
*/
import com.typesafe.config.Config;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
import javax.inject.Inject;
import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
import static com.walmartlabs.concord.agent.cfg.Utils.getStringOrDefault;
-public class GitConfiguration {
+public class GitConfiguration implements OauthTokenConfig {
private final String token;
+ private final String oauthUsername;
+ private final String oauthUrlPattern;
private final boolean shallowClone;
private final boolean checkAlreadyFetched;
private final Duration defaultOperationTimeout;
@@ -39,10 +45,13 @@ public class GitConfiguration {
private final Duration sshTimeout;
private final int sshTimeoutRetryCount;
private final boolean skip;
+ private final List extends Config> authConfigs;
@Inject
public GitConfiguration(Config cfg) {
this.token = getStringOrDefault(cfg, "git.oauth", () -> null);
+ this.oauthUsername = getStringOrDefault(cfg, "git.oauthUsername", () -> null);
+ this.oauthUrlPattern = getStringOrDefault(cfg, "git.oauthUrlPattern", () -> null);
this.shallowClone = cfg.getBoolean("git.shallowClone");
this.checkAlreadyFetched = cfg.getBoolean("git.checkAlreadyFetched");
this.defaultOperationTimeout = cfg.getDuration("git.defaultOperationTimeout");
@@ -52,10 +61,22 @@ public GitConfiguration(Config cfg) {
this.sshTimeout = cfg.getDuration("git.sshTimeout");
this.sshTimeoutRetryCount = cfg.getInt("git.sshTimeoutRetryCount");
this.skip = cfg.getBoolean("git.skip");
+ this.authConfigs = cfg.getConfigList("git.systemAuth");
}
- public String getToken() {
- return token;
+ @Override
+ public Optional getOauthToken() {
+ return Optional.ofNullable(token);
+ }
+
+ @Override
+ public Optional getOauthUsername() {
+ return Optional.ofNullable(oauthUsername);
+ }
+
+ @Override
+ public Optional getOauthUrlPattern() {
+ return Optional.ofNullable(oauthUrlPattern);
}
public boolean isShallowClone() {
@@ -93,4 +114,44 @@ public int getSshTimeoutRetryCount() {
public boolean isSkip() {
return skip;
}
+
+ public List getSystemAuth() {
+ return authConfigs.stream()
+ .map(o -> {
+ AuthSource type = AuthSource.valueOf(o.getString("type").toUpperCase());
+
+ return (AuthConfig) switch (type) {
+ case OAUTH_TOKEN -> OauthConfig.from(o);
+ };
+ })
+ .map(AuthConfig::toGitAuth)
+ .toList();
+
+ }
+
+ enum AuthSource {
+ OAUTH_TOKEN
+ }
+
+ public interface AuthConfig {
+ MappingAuthConfig toGitAuth();
+ }
+
+ public record OauthConfig(String urlPattern, String token) implements AuthConfig {
+
+ static OauthConfig from(Config cfg) {
+ return new OauthConfig(
+ cfg.getString("urlPattern"),
+ cfg.getString("token")
+ );
+ }
+
+ @Override
+ public MappingAuthConfig.OauthAuthConfig toGitAuth() {
+ return MappingAuthConfig.OauthAuthConfig.builder()
+ .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern()))
+ .token(this.token())
+ .build();
+ }
+ }
}
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java
new file mode 100644
index 0000000000..9913ed4be4
--- /dev/null
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/cfg/GitHubConfiguration.java
@@ -0,0 +1,63 @@
+package com.walmartlabs.concord.agent.cfg;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
+
+import javax.inject.Inject;
+import java.time.Duration;
+import java.util.List;
+
+public class GitHubConfiguration implements GitHubAppInstallationConfig {
+
+ private static final String CFG_APP_INSTALLATION = "github.appInstallation";
+
+ private final GitHubAppInstallationConfig appInstallation;
+
+ @Inject
+ public GitHubConfiguration(com.typesafe.config.Config config) {
+ if (config.hasPath(CFG_APP_INSTALLATION)) {
+ var raw = config.getConfig(CFG_APP_INSTALLATION);
+ this.appInstallation = GitHubAppInstallationConfig.fromConfig(raw);
+ } else {
+ this.appInstallation = GitHubAppInstallationConfig.builder()
+ .authConfigs(List.of())
+ .build();
+ }
+ }
+
+ @Override
+ public List getAuthConfigs() {
+ return appInstallation.getAuthConfigs();
+ }
+
+ @Override
+ public Duration getSystemAuthCacheDuration() {
+ return appInstallation.getSystemAuthCacheDuration();
+ }
+
+ @Override
+ public long getSystemAuthCacheMaxWeight() {
+ return appInstallation.getSystemAuthCacheMaxWeight();
+ }
+
+}
diff --git a/agent/src/main/resources/concord-agent.conf b/agent/src/main/resources/concord-agent.conf
index 47846066f7..ebdab716c2 100644
--- a/agent/src/main/resources/concord-agent.conf
+++ b/agent/src/main/resources/concord-agent.conf
@@ -188,9 +188,26 @@ concord-agent {
# if true, skip Git fetch, use workspace state only
skip = false
- # GitHub auth token to use when cloning repositories without explicitly configured authentication
+ # GitHub auth token to use when cloning repositories without explicitly
+ # configured authentication. Deprecated in favor of systemAuth list of
+ # tokens or service-specific app config (e.g. github)
# oauth = "..."
+ # specific username to use for auth
+ # oauthUsername = ""
+
+ # regex to match against git server's hostname + port + path so oauth
+ # token isn't used for and unexpected host
+ # oauthUrlPattern = ""
+
+ # List of system-provided auth token configs
+ # {
+ # "token" = "...",
+ # "username" = "...", # optional, username to send with auth token
+ # "urlPattern" = "..." # required, regex to match against target git host + port + path
+ # }
+ systemAuth = []
+
# use GIT's shallow clone
shallowClone = true
@@ -212,6 +229,32 @@ concord-agent {
sshTimeout = "10 minutes"
}
+ # github app settings. While this works on the agent, it's preferable to
+ # get auth token from concord-server via externalTokenProvider
+ github {
+ # App installation settings. Multiple auth (private key) definitions are supported,
+ # as each is matched to a particular url pattern.
+ appInstallation {
+ # {
+ # type = "GITHUB_APP_INSTALLATION",
+ # urlPattern = "github.com", # regex
+ # username = "...", # optional, defaults to "x-access-token"
+ # apiUrl = "https://api.github.com", # github api url, usually *not* the same as the repo url host/path
+ # clientId = "...",
+ # privateKey = "/path/to/pk.pem"
+ # }
+ # or static oauth config. Not exactly a "GitHub App", but can do some
+ # API interactions and cloning. Less preferred to actual app.
+ # {
+ # type = "OAUTH_TOKEN",
+ # token = "...",
+ # username = "...", # optional, usually not necessary
+ # urlPattern = "..." # regex to match against git server's hostname + port + path
+ # }
+ auth = []
+ }
+ }
+
imports {
# base git url for imports
src = ""
diff --git a/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java b/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java
new file mode 100644
index 0000000000..bd7fc22f75
--- /dev/null
+++ b/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java
@@ -0,0 +1,138 @@
+package com.walmartlabs.concord.agent;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AgentAuthTokenProviderTest {
+
+ @Mock
+ GitHubAppInstallation ghApp;
+
+ @Mock
+ AuthTokenProvider.OauthTokenProvider oauthTokenProvider;
+
+ @Mock
+ AgentAuthTokenProvider.ConcordServerTokenProvider concordServerTokenProvider;
+
+ @Test
+ void testGitHubApp() {
+ when(ghApp.getToken(any(), any())).
+ thenReturn(Optional.of(ExternalAuthToken.SimpleToken.builder()
+ .token("gh-installation-token")
+ .expiresAt(OffsetDateTime.now().plusMinutes(60))
+ .build()));
+ when(ghApp.supports(any(), any())).thenReturn(true);
+
+ var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider);
+
+ // --
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ // --
+
+ assertTrue(o.isPresent());
+ var result = assertInstanceOf(ExternalAuthToken.class, o.get());
+ assertEquals("gh-installation-token", result.token());
+ }
+
+ @Test
+ void testOauth() {
+ when(oauthTokenProvider.supports(any(), any())).thenReturn(true);
+ when(oauthTokenProvider.getToken(any(), any()))
+ .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder()
+ .token("oauth-token")
+ .build()));
+
+ var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider);
+
+ // --
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ // --
+
+ assertTrue(o.isPresent());
+ var result = assertInstanceOf(ExternalAuthToken.class, o.get());
+ assertEquals("oauth-token", result.token());
+ }
+
+ @Test
+ void testConcord() {
+ when(concordServerTokenProvider.supports(any(), any())).thenReturn(true);
+ when(concordServerTokenProvider.getToken(any(), any()))
+ .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder()
+ .token("token-from-concord")
+ .build()));
+
+ var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider);
+
+ // --
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ // --
+
+ assertTrue(o.isPresent());
+ var result = assertInstanceOf(ExternalAuthToken.class, o.get());
+ assertEquals("token-from-concord", result.token());
+ }
+
+ @Test
+ void testNoAuth() {
+ when(ghApp.supports(any(), any())).thenReturn(false);
+ when(oauthTokenProvider.supports(any(), any())).thenReturn(false);
+
+ var provider = new AgentAuthTokenProvider(concordServerTokenProvider, ghApp, oauthTokenProvider);
+
+ // --
+
+ assertFalse(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ // --
+
+ assertFalse(o.isPresent());
+ }
+
+}
diff --git a/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java b/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java
index 7237f1e904..5b851a9049 100644
--- a/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java
+++ b/cli/src/main/java/com/walmartlabs/concord/cli/runner/CliRepositoryExporter.java
@@ -20,17 +20,22 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
import com.walmartlabs.concord.imports.Import;
import com.walmartlabs.concord.imports.RepositoryExporter;
import com.walmartlabs.concord.repository.*;
import com.walmartlabs.concord.sdk.Secret;
+import javax.annotation.Nullable;
import java.io.UnsupportedEncodingException;
+import java.net.URI;
import java.net.URLEncoder;
import java.nio.file.Path;
import java.time.Duration;
-import java.util.Collections;
+import java.util.List;
import java.util.Objects;
+import java.util.Optional;
public class CliRepositoryExporter implements RepositoryExporter {
@@ -50,7 +55,6 @@ public CliRepositoryExporter(Path repoCacheDir) {
this.repoCacheDir = repoCacheDir;
GitClientConfiguration clientCfg = GitClientConfiguration.builder()
- .oauthToken(null)
.defaultOperationTimeout(DEFAULT_OPERATION_TIMEOUT)
.fetchTimeout(FETCH_TIMEOUT)
.httpLowSpeedLimit(HTTP_LOW_SPEED_LIMIT)
@@ -59,7 +63,19 @@ public CliRepositoryExporter(Path repoCacheDir) {
.sshTimeoutRetryCount(SSH_TIMEOUT_RETRY_COUNT)
.build();
- this.providers = new RepositoryProviders(Collections.singletonList(new GitCliRepositoryProvider(clientCfg)));
+ AuthTokenProvider authProvider = new AuthTokenProvider() {
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return false;
+ }
+
+ @Override
+ public Optional getToken(URI repo, @Nullable Secret secret) throws RepositoryException {
+ throw new UnsupportedOperationException("Not supported");
+ }
+ };
+
+ this.providers = new RepositoryProviders(List.of(new GitCliRepositoryProvider(clientCfg, authProvider)));
}
@Override
diff --git a/common/pom.xml b/common/pom.xml
index 46e3352516..0fb9d05ee0 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -49,6 +49,11 @@
commons-validator
provided
+
+ org.immutables
+ value
+ provided
+
com.fasterxml.jackson.core
jackson-databind
@@ -92,6 +97,16 @@
junit-jupiter-api
test
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
diff --git a/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java b/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java
new file mode 100644
index 0000000000..957a246abc
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java
@@ -0,0 +1,143 @@
+package com.walmartlabs.concord.common;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.sdk.Secret;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.List;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+public interface AuthTokenProvider {
+
+ /**
+ * @return {@code true} if this the given repo URI and secret are compatible
+ * with this provider's {@link #getToken(URI, Secret)} method,
+ * {@code false} otherwise.
+ */
+ boolean supports(URI repo, @Nullable Secret secret);
+
+ Optional getToken(URI repo, @Nullable Secret secret);
+
+ default URI addUserInfoToUri(URI repo, @Nullable Secret secret) {
+ if (!supports(repo, secret)) {
+ // not compatible with auth provider(s)
+ return repo;
+ }
+
+ return getToken(repo, secret)
+ .map(expiringToken -> {
+ var token = expiringToken.token();
+ var userInfo = expiringToken.username() != null
+ ? expiringToken.username() + ":" + token
+ : token;
+
+ try {
+ return new URI(repo.getScheme(), userInfo, repo.getHost(),
+ repo.getPort(), repo.getPath(), repo.getQuery(), repo.getFragment());
+ } catch (URISyntaxException e) {
+ // TODO add log?
+ }
+
+ return null;
+ })
+ .orElse(repo);
+ }
+
+ @SuppressWarnings("ClassCanBeRecord")
+ class OauthTokenProvider implements AuthTokenProvider {
+ // >0 length, printable ascii (no newlines, etc)
+ private static final Pattern BASIC_STRING_PTN = Pattern.compile("[ -~]+");
+ private final List authConfigs;
+
+ @Inject
+ public OauthTokenProvider(OauthTokenConfig config) {
+ this.authConfigs = toConfigList(config);
+ }
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return validateSecret(secret) || systemSupports(repo);
+ }
+
+ @Override
+ public Optional getToken(URI repo, @Nullable Secret secret) {
+ if (secret != null) {
+ if (secret instanceof BinaryDataSecret bds) {
+ return Optional.of(ExternalAuthToken.StaticToken.builder()
+ .token(new String(bds.getData()))
+ .build());
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ return authConfigs.stream()
+ .filter(auth -> auth.canHandle(repo))
+ .filter(MappingAuthConfig.OauthAuthConfig.class::isInstance)
+ .map(MappingAuthConfig.OauthAuthConfig.class::cast)
+ .findFirst()
+ .map(auth -> ExternalAuthToken.StaticToken.builder()
+ .token(auth.token())
+ .username(auth.username())
+ .build());
+ }
+
+ private boolean validateSecret(Secret secret) {
+ if (secret == null) {
+ return false;
+ }
+
+ if (!(secret instanceof BinaryDataSecret bds)) {
+ // this class is not the place for handling key pairs or username/password
+ return false;
+ } else {
+ var data = new String(bds.getData());
+ return BASIC_STRING_PTN.matcher(data).matches();
+ }
+ }
+
+ private boolean systemSupports(URI repoUri) {
+ return authConfigs.stream().anyMatch(auth -> auth.canHandle(repoUri));
+ }
+
+ private static List toConfigList(OauthTokenConfig config) {
+ var token = config.getOauthToken().orElse(null);
+
+ if (token == null || token.isBlank() && config.getSystemAuth().isEmpty()) {
+ return config.getSystemAuth();
+ }
+
+ return List.of(MappingAuthConfig.OauthAuthConfig.builder()
+ .token(token)
+ .username(config.getOauthUsername().orElse(null))
+ .urlPattern(MappingAuthConfig.assertBaseUrlPattern(config.getOauthUrlPattern().orElse(".*")))// for backwards compat with git.oauth
+ .build());
+ }
+ }
+}
diff --git a/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java b/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java
new file mode 100644
index 0000000000..6087f861d6
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java
@@ -0,0 +1,90 @@
+package com.walmartlabs.concord.common;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import org.immutables.value.Value;
+
+import javax.annotation.Nullable;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+
+@JsonDeserialize(as = ImmutableSimpleToken.class)
+public interface ExternalAuthToken {
+
+ @JsonProperty("token")
+ String token();
+
+ @Nullable
+ @JsonProperty("username")
+ String username();
+
+ @Nullable
+ @JsonProperty("expires_at")
+ // GitHub gives time in seconds, but most parsers (e.g. jackson) expect milliseconds
+ @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss[.SSS]X")
+ OffsetDateTime expiresAt();
+
+ @Value.Default
+ @JsonIgnore
+ default long secondsUntilExpiration() {
+ if (expiresAt() == null) {
+ return Long.MAX_VALUE;
+ }
+
+ var d = Duration.between(OffsetDateTime.now(), expiresAt());
+ return d.getSeconds();
+ }
+
+ /**
+ * Basic implementation of an expiring token.
+ */
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ interface SimpleToken extends ExternalAuthToken {
+ static ImmutableSimpleToken.Builder builder() {
+ return ImmutableSimpleToken.builder();
+ }
+ }
+
+ /**
+ * A token that effectively never expires.
+ */
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ interface StaticToken extends ExternalAuthToken {
+
+ @Value.Default
+ @Nullable
+ @Override
+ default OffsetDateTime expiresAt() {
+ return null;
+ }
+
+ static ImmutableStaticToken.Builder builder() {
+ return ImmutableStaticToken.builder();
+ }
+ }
+
+}
diff --git a/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java b/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java
new file mode 100644
index 0000000000..dbb1a96a70
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java
@@ -0,0 +1,81 @@
+package com.walmartlabs.concord.common.cfg;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import org.immutables.value.Value;
+
+import javax.annotation.Nullable;
+import java.net.URI;
+import java.util.regex.Pattern;
+
+public interface MappingAuthConfig {
+
+ /** Regex matching the host, optional port and path of a Git repository URL. */
+ Pattern urlPattern();
+
+ /**
+ * Username to use for authentication with a provided token. Some services
+ * (e.g. GitHub API for app installation) require a specific username. Others
+ * (e.g. GitHub API for personal access tokens) accept just the token and no username
+ */
+ @Nullable
+ String username();
+
+ /**
+ * For compatibility with a {@link MappingAuthConfig} instance, a URI must match the
+ * {@link #urlPattern()} regex. The regex may match against the path to support
+ * either a Git host behind a reverse proxy or restricting the auth to specific
+ * org/repo patterns.
+ * @return {@code true} if this provider can handle the given repo URI, {@code false} otherwise.
+ */
+ default boolean canHandle(URI repo) {
+ var port = (repo.getPort() == -1 ? "" : (":" + repo.getPort()));
+ var path = (repo.getPath() == null ? "" : repo.getPath());
+ var repoHostPortAndPath = repo.getHost() + port + path;
+
+ return repoHostPortAndPath.matches(urlPattern() + ".*");
+ }
+
+ static Pattern assertBaseUrlPattern(String pattern) {
+ return pattern.endsWith(".*")
+ ? Pattern.compile(pattern)
+ : Pattern.compile(pattern + ".*");
+ }
+
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ interface OauthAuthConfig extends MappingAuthConfig {
+ String token();
+
+ static ImmutableOauthAuthConfig.Builder builder() {
+ return ImmutableOauthAuthConfig.builder();
+ }
+ }
+
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ interface ConcordServerAuthConfig extends MappingAuthConfig {
+ static ImmutableConcordServerAuthConfig.Builder builder() {
+ return ImmutableConcordServerAuthConfig.builder();
+ }
+ }
+
+}
diff --git a/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java b/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java
new file mode 100644
index 0000000000..5fb708b272
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/cfg/OauthTokenConfig.java
@@ -0,0 +1,36 @@
+package com.walmartlabs.concord.common.cfg;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import java.util.List;
+import java.util.Optional;
+
+public interface OauthTokenConfig {
+
+ Optional getOauthToken();
+
+ Optional getOauthUsername();
+
+ Optional getOauthUrlPattern();
+
+ List getSystemAuth();
+
+}
diff --git a/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java b/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java
new file mode 100644
index 0000000000..b1a07b0f2b
--- /dev/null
+++ b/common/src/test/java/com/walmartlabs/concord/common/AuthTokenProviderTest.java
@@ -0,0 +1,155 @@
+package com.walmartlabs.concord.common;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.common.secret.UsernamePassword;
+import org.immutables.value.Value;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AuthTokenProviderTest {
+
+ private static final byte[] SECRET_BYTES = "abc123".getBytes(StandardCharsets.UTF_8);
+ private static final String MOCK_TOKEN = "mock-token";
+ private static final String MOCK_USERNAME = "mock-username";
+ private static final String VALID_REPO = "https://github.local/owner/repo.git";
+
+ @Mock
+ BinaryDataSecret binaryDataSecret;
+
+ @Mock
+ UsernamePassword usernamePassword;
+
+ @Mock
+ MappingAuthConfig.OauthAuthConfig oauth;
+
+ @Mock
+ TestOauthTokenConfig oauthTokenConfig;
+
+ @Test
+ void testSingleOauth() {
+ // the "old" config approach
+ when(oauthTokenConfig.getOauthToken()).thenReturn(Optional.of(MOCK_TOKEN));
+ when(oauthTokenConfig.getOauthUrlPattern()).thenReturn(Optional.of("github\\.local"));
+ when(oauthTokenConfig.getOauthUsername()).thenReturn(Optional.of(MOCK_USERNAME));
+
+ executeWithoutSecret(oauthTokenConfig);
+
+ verify(oauthTokenConfig, times(1)).getOauthUrlPattern(); // retrieved once and stored
+ }
+
+ @Test
+ void testSystemAuth() {
+ when(oauth.canHandle(any())).thenCallRealMethod();
+ when(oauth.urlPattern()).thenReturn(Pattern.compile("github\\.local"));
+ when(oauth.token()).thenReturn(MOCK_TOKEN);
+ when(oauth.username()).thenReturn(MOCK_USERNAME);
+
+ var cfg = TestOauthTokenConfig.builder()
+ .addSystemAuth(oauth)
+ .build();
+
+ executeWithoutSecret(cfg);
+
+ verify(oauth, times(12)).canHandle(any());
+ }
+
+ void executeWithoutSecret(OauthTokenConfig cfg) {
+ var provider = new AuthTokenProvider.OauthTokenProvider(cfg);
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo"), null));
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo/"), null));
+ assertFalse(provider.supports(URI.create("https://elsewhere.local/owner/repo.git"), null));
+ assertFalse(provider.supports(URI.create("https://elsewhere.local/owner/repo"), null));
+
+ assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo.git"), null).map(ExternalAuthToken::token).orElse(null));
+ assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo"), null).map(ExternalAuthToken::token).orElse(null));
+ assertEquals(MOCK_TOKEN, provider.getToken(URI.create("https://github.local/owner/repo/"), null).map(ExternalAuthToken::token).orElse(null));
+ assertFalse(provider.getToken(URI.create("https://elsewhere.local/owner/repo.git"), null).isPresent());
+ assertFalse(provider.getToken(URI.create("https://elsewhere.local/owner/repo"), null).isPresent());
+
+ var enriched = provider.addUserInfoToUri(URI.create("https://github.local/owner/repo.git"), null);
+ assertEquals(MOCK_USERNAME + ":" + MOCK_TOKEN, enriched.getUserInfo());
+ assertEquals("https://" + MOCK_USERNAME + ":" + MOCK_TOKEN + "@github.local/owner/repo.git", enriched.toString());
+ }
+
+ @Test
+ void testUsernamePassword() {
+ var cfg = TestOauthTokenConfig.builder().build();
+ var provider = new AuthTokenProvider.OauthTokenProvider(cfg);
+
+ assertFalse(provider.supports(URI.create(VALID_REPO), usernamePassword));
+ }
+
+ @Test
+ void testWithSecret() {
+ var cfg = TestOauthTokenConfig.builder()
+ .addSystemAuth(oauth) // won't be used
+ .build();
+
+ executeWithSecret(cfg);
+ }
+
+ @Test
+ void testWithSecretNoDefault() {
+ var cfg = TestOauthTokenConfig.builder().build();
+
+ executeWithSecret(cfg);
+ }
+
+ private void executeWithSecret(TestOauthTokenConfig cfg) {
+ var provider = new AuthTokenProvider.OauthTokenProvider(cfg);
+
+ when(binaryDataSecret.getData()).thenReturn(SECRET_BYTES);
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), binaryDataSecret));
+
+ verify(oauth, never()).token(); // prove it wasn't used
+ verify(binaryDataSecret, times(1)).getData();
+ }
+
+ @Value.Immutable
+ interface TestOauthTokenConfig extends OauthTokenConfig {
+ static ImmutableTestOauthTokenConfig.Builder builder() {
+ return ImmutableTestOauthTokenConfig.builder();
+ }
+ }
+}
diff --git a/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java b/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java
new file mode 100644
index 0000000000..c365f2f125
--- /dev/null
+++ b/common/src/test/java/com/walmartlabs/concord/common/ExternalAuthTokenTest.java
@@ -0,0 +1,123 @@
+package com.walmartlabs.concord.common;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ExternalAuthTokenTest {
+
+ static final String MOCK_TOKEN = "mock-token";
+ static final ObjectMapper MAPPER = new ObjectMapperProvider().get();
+
+ @Test
+ void testExpiration() {
+ var externalToken = ExternalAuthToken.SimpleToken.builder()
+ .token(MOCK_TOKEN)
+ .expiresAt(OffsetDateTime.now().minusSeconds((100)))
+ .build();
+
+ assertTrue(externalToken.secondsUntilExpiration() < 0);
+ }
+
+ @Test
+ void testStaticExpiration() {
+ var externalToken = ExternalAuthToken.StaticToken.builder()
+ .token(MOCK_TOKEN)
+ .build();
+
+ assertEquals(MOCK_TOKEN, externalToken.token());
+ assertEquals(Long.MAX_VALUE, externalToken.secondsUntilExpiration());
+ }
+
+ @Test
+ void testMinimalDeserialization() throws JsonProcessingException {
+ var minimalFromJson = MAPPER.readValue("""
+ {
+ "token": "mock-token"
+ }
+ """, ExternalAuthToken.class);
+
+ assertEquals(MOCK_TOKEN, minimalFromJson.token());
+ assertEquals(Long.MAX_VALUE, minimalFromJson.secondsUntilExpiration());
+ }
+
+ @Test
+ void testFullDeserialization() throws JsonProcessingException {
+ var fullFromJson = MAPPER.readValue("""
+ {
+ "token": "mock-token",
+ "expires_at": "2099-12-31T23:59:59Z",
+ "username": "mock-username"
+ }
+ """, ExternalAuthToken.class);
+
+ assertEquals(MOCK_TOKEN, fullFromJson.token());
+ assertEquals("mock-username", fullFromJson.username());
+ var dt = fullFromJson.expiresAt();
+ assertNotNull(dt);
+ assertEquals(2099, dt.getYear());
+ }
+
+ @Test
+ void testFullDeserializationMillis() throws JsonProcessingException {
+ var fullFromJson = MAPPER.readValue("""
+ {
+ "token": "mock-token",
+ "expires_at": "2099-12-31T23:59:59.123Z",
+ "username": "mock-username"
+ }
+ """, ExternalAuthToken.class);
+
+ assertEquals(MOCK_TOKEN, fullFromJson.token());
+ var dt = fullFromJson.expiresAt();
+ assertNotNull(dt);
+ assertEquals(2099, dt.getYear());
+ assertEquals(123, dt.getNano() / 1_000_000);
+ }
+
+ @Test
+ void testDateSerializationSecondsToMillis() throws JsonProcessingException {
+ var json = MAPPER.writeValueAsString(ExternalAuthToken.SimpleToken.builder()
+ .token(MOCK_TOKEN)
+ .expiresAt(OffsetDateTime.parse("2099-12-31T23:59:59Z"))
+ .build());
+
+ assertTrue(json.contains("23:59:59.000Z"));
+ }
+
+ @Test
+ void testDateSerializationMillis() throws JsonProcessingException {
+ var json = MAPPER.writeValueAsString(ExternalAuthToken.SimpleToken.builder()
+ .token(MOCK_TOKEN)
+ .expiresAt(OffsetDateTime.parse("2099-12-31T23:59:59.123Z"))
+ .build());
+
+ assertTrue(json.contains("23:59:59.123Z"));
+ }
+}
diff --git a/github-app-installation/pom.xml b/github-app-installation/pom.xml
new file mode 100644
index 0000000000..d8d5e5c2fa
--- /dev/null
+++ b/github-app-installation/pom.xml
@@ -0,0 +1,156 @@
+
+
+
+ 4.0.0
+
+
+ com.walmartlabs.concord
+ parent
+ 2.34.1-SNAPSHOT
+ ../pom.xml
+
+
+ concord-github-app-installation
+ jar
+
+ ${project.groupId}:${project.artifactId}
+
+
+
+ com.walmartlabs.concord
+ concord-sdk
+
+
+ com.walmartlabs.concord
+ concord-common
+
+
+ io.takari.bpm
+ bpm-engine-impl
+
+
+ javax.validation
+ validation-api
+ provided
+
+
+ org.slf4j
+ slf4j-api
+ provided
+
+
+ com.typesafe
+ config
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+
+
+ org.bouncycastle
+ bcprov-ext-jdk15on
+ 1.70
+
+
+ org.bouncycastle
+ bcpkix-jdk15on
+ 1.70
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+ 1.70
+
+
+ org.immutables
+ value
+ provided
+
+
+ org.immutables
+ builder
+ provided
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ provided
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jdk8
+ provided
+
+
+ javax.inject
+ javax.inject
+ provided
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-guava
+ provided
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ provided
+
+
+
+
+ javax.xml.bind
+ jaxb-api
+ provided
+
+
+ com.sun.xml.bind
+ jaxb-impl
+ provided
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
+
+ ch.qos.logback
+ logback-classic
+ test
+
+
+
+
+
+
+ org.eclipse.sisu
+ sisu-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+
+ ${java.io.tmpdir}
+
+
+
+
+
+
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
new file mode 100644
index 0000000000..2c1b9c1cc5
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
@@ -0,0 +1,244 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import org.immutables.value.Value;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpResponse.BodyHandlers;
+import java.time.Duration;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+public class AccessTokenProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(AccessTokenProvider.class);
+
+ private final HttpClient httpClient;
+ private final Duration httpTimeout;
+ private final ObjectMapper objectMapper;
+
+ public AccessTokenProvider(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper) {
+ this.httpTimeout = cfg.getHttpClientTimeout();
+ this.objectMapper = objectMapper;
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(httpTimeout)
+ .build();
+ }
+
+ public AccessTokenProvider(GitHubAppInstallationConfig cfg,
+ ObjectMapper objectMapper,
+ HttpClient httpClient) {
+ this.httpTimeout = cfg.getHttpClientTimeout();
+ this.objectMapper = objectMapper;
+ this.httpClient = httpClient;
+ }
+
+ ExternalAuthToken getRepoInstallationToken(GitHubAppAuthConfigNew app, String orgRepo) throws GitHubAppException {
+ try {
+ var jwt = generateJWT(app);
+ var accessTokenUrl = getAccessTokenUrl(app.apiUrl(), orgRepo, jwt);
+ return ExternalAuthToken.StaticToken.builder()
+ .from(createAccessToken(accessTokenUrl, jwt))
+ .username(app.username())
+ .build();
+ } catch (JOSEException e) {
+ throw new GitHubAppException("Error generating JWT for app: " + app.clientId());
+ }
+ }
+
+ private String getAccessTokenUrl(String apiBaseUrl, String installationRepo, String jwt) throws GitHubAppException {
+ var req = HttpRequest.newBuilder().GET()
+ .uri(URI.create(apiBaseUrl + "/repos/" + installationRepo + "/installation"))
+ .header("Authorization", "Bearer " + jwt)
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .timeout(httpTimeout)
+ .build();
+
+ var appInstallation = sendRequest(req, 200, AccessTokenProvider.GitHubAppInstallationResp.class, (code, body) -> {
+ if (code == 404) {
+ // not possible to discern between repo not found and app not installed for existing (private) repo
+ log.warn("getAccessTokenUrl ['{}'] -> not found", installationRepo);
+ return new GitHubAppException.NotFoundException("Repo not found or App installation not found for repo");
+ }
+
+ log.warn("getAccessTokenUrl ['{}'] -> error: {} : {}", installationRepo, code, body);
+ return new GitHubAppException("Unexpected error locating repo installation: " + code);
+ });
+
+ return appInstallation.accessTokensUrl();
+ }
+
+ private String getAccessTokenUrlOooorg(String apiBaseUrl, String jwt, String org) throws GitHubAppException {
+ var req = HttpRequest.newBuilder().GET()
+ .uri(URI.create(apiBaseUrl + "/app/installations"))
+ .header("Authorization", "Bearer " + jwt)
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .timeout(httpTimeout)
+ .build();
+
+ var appInstallation = sendRequestForList(req, 200, Map.class, (code, body) -> {
+ if (code == 404) {
+ // not possible to discern between repo not found and app not installed for existing (private) repo
+ log.warn("getAccessTokenUrl -> not found");
+ return new GitHubAppException.NotFoundException("Repo not found or App installation not found for repo");
+ }
+
+ log.warn("getAccessTokenUrl -> error: {} : {}", code, body);
+ return new GitHubAppException("Unexpected error locating repo installation: " + code);
+ });
+
+ var installation = objectMapper.convertValue(appInstallation.get(0), AccessTokenProvider.GitHubAppInstallationResp.class);
+
+ return installation.accessTokensUrl();
+ }
+
+ private ExternalAuthToken createAccessToken(String accessTokenUrl, String jwt) {
+ var req = HttpRequest.newBuilder()
+ .POST(HttpRequest.BodyPublishers.noBody())
+ .uri(URI.create(accessTokenUrl))
+ .header("Authorization", "Bearer " + jwt)
+ .header("Accept", "application/vnd.github+json")
+ .header("X-GitHub-Api-Version", "2022-11-28")
+ .timeout(httpTimeout)
+ .build();
+
+ return sendRequest(req, 201, ExternalAuthToken.class, (code, body) -> {
+ log.warn("createAccessToken ['{}'] -> error: {} : {}", accessTokenUrl, code, body);
+
+ if (code == 404) {
+ // this would be pretty odd to hit, this means the url returned from the installation lookup is invalid
+ return new GitHubAppException.NotFoundException("App access token url not found");
+ }
+
+ return new GitHubAppException("Unexpected error creating app access token: " + code);
+ });
+ }
+
+ private static String generateJWT(GitHubAppAuthConfigNew auth) throws JOSEException {
+ var pk = auth.privateKey();
+ var rsaJWK = JWK.parseFromPEMEncodedObjects(pk).toRSAKey();
+
+ // Create RSA-signer with the private key
+ var signer = new RSASSASigner(rsaJWK);
+
+ // Prepare JWT with claims set
+ var claimsSet = new JWTClaimsSet.Builder()
+ .issueTime(new Date())
+ .issuer(auth.clientId())
+ // JWT expiration. GH requires less than 10 minutes
+ .expirationTime(new Date(new Date().getTime() + Duration.ofMinutes(10).toMillis()))
+ .build();
+
+ var signedJWT = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256)
+ .keyID(rsaJWK.getKeyID())
+ .build(),
+ claimsSet);
+
+ // Compute the RSA signature
+ signedJWT.sign(signer);
+
+ // To serialize to compact form, produces something like
+ return signedJWT.serialize();
+ }
+
+ private T sendRequest(HttpRequest httpRequest, int expectedCode, Class clazz, BiFunction exFun) throws GitHubAppException {
+ try {
+ var resp = httpClient.send(httpRequest, BodyHandlers.ofInputStream());
+ if (resp.statusCode() != expectedCode) {
+ throw exFun.apply(resp.statusCode(), readBody(resp));
+ }
+ return objectMapper.readValue(resp.body(), clazz);
+ } catch (IOException e) {
+ throw new GitHubAppException("Error sending request", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ throw new IllegalStateException("Unexpected error sending HTTP request");
+ }
+
+ private List sendRequestForList(HttpRequest httpRequest, int expectedCode, Class clazz, BiFunction exFun) throws GitHubAppException {
+ try {
+ var resp = httpClient.send(httpRequest, BodyHandlers.ofInputStream());
+ if (resp.statusCode() != expectedCode) {
+ throw exFun.apply(resp.statusCode(), readBody(resp));
+ }
+
+ var t = objectMapper.getTypeFactory().constructCollectionType(List.class, clazz);
+
+ return objectMapper.readValue(resp.body(), t);
+ } catch (IOException e) {
+ throw new GitHubAppException("Error sending request", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+
+ throw new IllegalStateException("Unexpected error sending HTTP request");
+ }
+
+ private static String readBody(HttpResponse resp) throws IOException {
+ try (var is = resp.body()) {
+ return new String(is.readAllBytes());
+ }
+ }
+
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ @JsonDeserialize(as = ImmutableGitHubAppInstallationResp.class)
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public interface GitHubAppInstallationResp {
+
+ /*
+ This is all we **need**, even though there's other attributes. Some may differ
+ between GitHub "cloud" and GitHub Enterprise/private. So, be care if/when adding more.
+ */
+ @JsonProperty("access_tokens_url")
+ String accessTokensUrl();
+
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
new file mode 100644
index 0000000000..ea5af01f1c
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
@@ -0,0 +1,66 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import org.immutables.value.Value;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.net.URI;
+import java.util.Objects;
+
+@Value.Immutable
+@Value.Style(jdkOnly = true, redactedMask = "**redacted**")
+interface CacheKey {
+
+ URI repoUri();
+
+ @Nullable
+ @Value.Redacted
+ byte[] binaryDataSecret();
+
+ @Value.Default
+ default int weight() {
+ var weight = 1;
+
+ if (binaryDataSecret() != null) {
+ var data = Objects.requireNonNull(binaryDataSecret());
+ weight += 1;
+ weight += data.length / 1024;
+ }
+
+ return weight;
+ }
+
+ static CacheKey from(URI repoUri) {
+ return ImmutableCacheKey.builder()
+ .repoUri(repoUri)
+ .build();
+ }
+
+ static CacheKey from(URI repoUri, @Nonnull byte[] secret) {
+ return ImmutableCacheKey.builder()
+ .repoUri(repoUri)
+ .binaryDataSecret(secret)
+ .build();
+ }
+
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
new file mode 100644
index 0000000000..06d479148c
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
@@ -0,0 +1,56 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import org.immutables.value.Value;
+
+@Value.Immutable
+@Value.Style(jdkOnly = true)
+@JsonDeserialize(as = ImmutableGitHubAppAuthConfig.class)
+public interface GitHubAppAuthConfig extends MappingAuthConfig {
+
+ //TODO convert to record, sanity check in constructor
+
+ @Value.Default
+ default String apiUrl() {
+ return "https://api.github.com";
+ }
+
+ String clientId();
+
+ String privateKey();
+
+ @Value.Check
+ default void checkUrlPattern() {
+ // sanity check url pattern before this object gets too far out there
+ if (!urlPattern().toString().contains("?")) {
+ throw new IllegalArgumentException("The url pattern must contain the ? named group");
+ }
+ }
+
+ static ImmutableGitHubAppAuthConfig.Builder builder() {
+ return ImmutableGitHubAppAuthConfig.builder();
+ }
+
+}
+
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
new file mode 100644
index 0000000000..3fb65419af
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
@@ -0,0 +1,53 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+
+import java.util.regex.Pattern;
+
+public record GitHubAppAuthConfigNew(String apiUrl,
+ String clientId,
+ String privateKey,
+ String username,
+ Pattern urlPattern) implements MappingAuthConfig {
+
+ public GitHubAppAuthConfigNew(String apiUrl, String clientId, String privateKey, String username, Pattern urlPattern) {
+ if (clientId == null || clientId.isBlank()) {
+ throw new IllegalArgumentException("clientId must be provided");
+ }
+
+ if (privateKey == null || privateKey.isBlank()) {
+ throw new IllegalArgumentException("privateKey must be provided");
+ }
+
+ // sanity check url pattern before this object gets too far out there
+ if (!urlPattern.toString().contains("?")) {
+ throw new IllegalArgumentException("The url pattern must contain the ? named group");
+ }
+
+ this.apiUrl = (apiUrl == null) ? "https://api.github.com" : apiUrl;
+ this.clientId = clientId;
+ this.privateKey = privateKey;
+ this.username = username;
+ this.urlPattern = urlPattern;
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
new file mode 100644
index 0000000000..25a3cdc135
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
@@ -0,0 +1,206 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import com.walmartlabs.concord.github.appinstallation.exception.RepoExtractionException;
+import com.walmartlabs.concord.sdk.Secret;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import java.net.URI;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+
+public class GitHubAppInstallation implements AuthTokenProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation.class);
+
+ private final GitHubAppInstallationConfig cfg;
+ private final AccessTokenProvider tokenProvider;
+ private final ObjectMapper objectMapper;
+
+ private final LoadingCache> cache;
+
+ @Inject
+ public GitHubAppInstallation(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper) {
+ this.cfg = cfg;
+ this.objectMapper = objectMapper;
+ this.tokenProvider = new AccessTokenProvider(cfg, objectMapper);
+
+ this.cache = CacheBuilder.newBuilder()
+ .expireAfterWrite(cfg.getSystemAuthCacheDuration())
+ .maximumWeight(cfg.getSystemAuthCacheMaxWeight())
+ .weigher((Weigher>) (key, value) -> key.weight())
+ .build(new CacheLoader<>() {
+ @Override
+ public @Nonnull Optional load(@Nonnull CacheKey key) {
+ return fetchToken(key.repoUri(), key.binaryDataSecret());
+ }
+ });
+ }
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return Utils.validateSecret(secret, objectMapper) || systemSupports(repo);
+ }
+
+ private CacheKey createKey(URI repoUri, @Nullable Secret secret) {
+ if (secret == null) {
+ return CacheKey.from(repoUri);
+ }
+
+ if (secret instanceof BinaryDataSecret bds) {
+ return CacheKey.from(repoUri, bds.getData());
+ }
+
+ return null;
+ }
+
+ @Override
+ public Optional getToken(URI repo, @Nullable Secret secret) {
+ var cacheKey = createKey(repo, secret);
+
+ if (cacheKey == null) {
+ return Optional.empty();
+ }
+
+ try {
+ var activeToken = cache.get(cacheKey);
+
+ return activeToken.map(t -> refreshBeforeExpire(t, cacheKey));
+ } catch (ExecutionException e) {
+ throw new GitHubAppException("Error retrieving access token for repo: " + repo, e);
+ } catch (UncheckedExecutionException e) {
+ // unwrap from guava
+ if (e.getCause() instanceof GitHubAppException repoEx) {
+ throw repoEx;
+ }
+
+ log.warn("getAccessToken ['{}'] -> error: {}", repo, e.getMessage());
+
+ throw new GitHubAppException("Unexpected error retrieving access token for repo: " + repo);
+ }
+ }
+
+ public long cacheSize() {
+ return cache.size();
+ }
+
+ private boolean systemSupports(URI repoUri) {
+ return cfg.getAuthConfigs().stream().anyMatch(auth -> auth.canHandle(repoUri));
+ }
+
+ private Optional fetchToken(URI repo, @Nullable byte[] secret) {
+ if (secret != null) {
+ return Optional.ofNullable(fromBinaryData(repo, secret));
+ }
+
+ // no secret, see if system config has something for this repo
+ return cfg.getAuthConfigs().stream()
+ .filter(auth -> auth.canHandle(repo))
+ .findFirst()
+ .map(auth -> {
+ if (auth instanceof MappingAuthConfig.OauthAuthConfig tokenAuth) {
+ return GitHubInstallationToken.builder()
+ .token(tokenAuth.token())
+ .username(tokenAuth.username())
+ .build();
+ }
+
+ if (auth instanceof GitHubAppAuthConfigNew app) {
+ return getTokenFromAppInstall(app, repo);
+ }
+
+ throw new IllegalArgumentException("Unsupported GitAuth type for repo: " + repo);
+ });
+ }
+
+ /**
+ * Cache may return a token that's close to expiring. If it's too close,
+ * invalidate and get a new one. If it's just a little close, refresh the
+ * cache in the background and return the still-active token.
+ */
+ private ExternalAuthToken refreshBeforeExpire(@Nonnull ExternalAuthToken token, CacheKey cacheKey) {
+ if (token.secondsUntilExpiration() < 10) {
+ // not enough time to be useful. get a new token right now
+ cache.invalidate(cacheKey);
+ try {
+ return cache.get(cacheKey).orElse(null);
+ } catch (ExecutionException e) {
+ throw new GitHubAppException("Error retrieving access token for repo: " + cacheKey.repoUri(), e);
+ }
+ }
+
+ // refresh cache if the token is expiring soon, doesn't affect current token
+ if (token.secondsUntilExpiration() < 300) {
+ cache.refresh(cacheKey);
+ }
+
+ return token;
+ }
+
+ private ExternalAuthToken fromBinaryData(URI repo, byte[] data) {
+ var appInfo = Utils.parseAppInstallation(data, objectMapper);
+ if (appInfo.isPresent()) {
+ // great, it's apparently a valid app installation config
+ return getTokenFromAppInstall(appInfo.get(), repo);
+ }
+
+ // hopefully it's just a token a plaintext token
+ return GitHubInstallationToken.builder()
+ .token(new String(data).trim())
+ .build();
+ }
+
+ private ExternalAuthToken getTokenFromAppInstall(GitHubAppAuthConfigNew app, URI repo) {
+ log.info("getTokenFromAppInstall ['{}', '{}']", app.apiUrl(), repo);
+
+ try {
+ var ownerAndRepo = Utils.extractOwnerAndRepo(app, repo);
+ return accessTokenProvider().getRepoInstallationToken(app, ownerAndRepo);
+ } catch (RepoExtractionException | GitHubAppException e) {
+ var msg = e.getMessage();
+ log.warn("Error retrieving GitHub access token: {}", msg);
+ }
+
+ return null;
+ }
+
+ AccessTokenProvider accessTokenProvider() {
+ return tokenProvider;
+ }
+
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java
new file mode 100644
index 0000000000..0833bd7fc5
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubInstallationToken.java
@@ -0,0 +1,33 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import org.immutables.value.Value;
+
+@Value.Immutable
+@Value.Style(jdkOnly = true)
+public interface GitHubInstallationToken extends ExternalAuthToken {
+
+ static ImmutableGitHubInstallationToken.Builder builder() {
+ return ImmutableGitHubInstallationToken.builder();
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
new file mode 100644
index 0000000000..f97a27bfbd
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
@@ -0,0 +1,149 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import com.walmartlabs.concord.github.appinstallation.exception.RepoExtractionException;
+import com.walmartlabs.concord.sdk.Secret;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Optional;
+
+public class Utils {
+
+ /**
+ * Validates given secret is usable enough to attempt a remote lookup. Not
+ * guaranteed to actually work, just a sanity check useful to avoid attempting
+ * API calls with something that will definitely not work.
+ */
+ static boolean validateSecret(Secret secret, ObjectMapper mapper) {
+ // secret must be one of:
+ // * JSON-formatted GitHub app installation details: clientId, privateKey, urlPattern
+ // * single-line, plaintext access token
+
+ if (secret == null) {
+ return false;
+ }
+
+ if (!(secret instanceof BinaryDataSecret bds)) {
+ // this class is not the place for handling key pairs or username/password
+ return false;
+ }
+
+ var base = parseRawAppInstallation(bds.getData(), mapper);
+ if (base == null) {
+ // It's not JSON, may be an oauth token
+ return isPrintableAscii(bds.getData());
+ } else if (base.isEmpty()) {
+ // Doesn't match something we can parse
+ return false;
+ }
+
+ // App installation config format is either valid or not
+ return parseAppInstallation(bds.getData(), mapper).isPresent();
+ }
+
+ private static boolean isPrintableAscii(byte[] bytes) {
+ if (bytes == null || bytes.length == 0) {
+ return false;
+ }
+
+ for (byte b : bytes) {
+ // Cast byte to int to avoid issues with negative byte values
+ int asciiValue = b & 0xFF; // Use bitwise AND to get unsigned value
+
+ if (asciiValue < 32 || asciiValue > 126) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ static Map, ?> parseRawAppInstallation(byte[] bds, ObjectMapper mapper) {
+ try { // find out if it's at least valid JSON.
+ var base = mapper.readValue(bds, Map.class);
+ if (base.containsKey("githubAppInstallation")) {
+ return base;
+ } else {
+ // it's JSON, but not in our format
+ return Map.of();
+ }
+ } catch (Exception e) {
+ // invalid JSON, may be a plaintext token
+ return null;
+ }
+ }
+
+ static Optional parseAppInstallation(byte[] bds, ObjectMapper mapper) {
+ Map, ?> base = parseRawAppInstallation(bds, mapper);
+
+ if (base == null || !base.containsKey("githubAppInstallation")) {
+ // it's either not JSON or not in our format
+ return Optional.empty();
+ }
+
+ try { // great, now convert it to the expected structure
+ return Optional.of(mapper.convertValue(base.get("githubAppInstallation"), GitHubAppAuthConfigNew.class));
+ } catch (IllegalArgumentException e) {
+ // doesn't match the expected structure
+ throw new GitHubAppException("Invalid app installation definition.", e);
+ }
+ }
+
+ static String extractOwnerAndRepo(GitHubAppAuthConfigNew auth, URI repo) throws RepoExtractionException {
+ var port = (repo.getPort() == -1 ? "" : (":" + repo.getPort()));
+ var path = (repo.getPath() == null ? "" : repo.getPath());
+ var repoHostPortAndPath = repo.getHost() + port + path;
+
+ var match = auth.urlPattern().matcher(repoHostPortAndPath);
+
+ if (!match.matches()) {
+ // at this point, this should only fail if the urlPattern is not
+ // constructed correctly. We wouldn't get there if the pattern didn't
+ // match the repo in the first place.
+ throw new RepoExtractionException("Failed to parse owner and repository from path: " + repo.getPath());
+ }
+
+ var baseUrl = match.group("baseUrl");
+ var relevantPath = repo.toString().replaceAll("^.*" + baseUrl + "/?", "")
+ .replaceAll(repo.getQuery() != null ? "\\?" + repo.getQuery() : "", "")
+ .replaceFirst("\\.git$", "");
+
+ // parse out the owner/repo from the path
+ var pathParts = Arrays.stream(relevantPath.split("/"))
+ .filter(e -> !e.isBlank())
+ .limit(2)
+ .toList();
+
+ if (pathParts.size() != 2) {
+ throw new RepoExtractionException("Failed to parse owner and repository from path: " + repo.getPath());
+ }
+
+ return pathParts.get(0) + "/" + pathParts.get(1);
+ }
+
+ private Utils() {}
+
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
new file mode 100644
index 0000000000..1d3730a29f
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
@@ -0,0 +1,175 @@
+package com.walmartlabs.concord.github.appinstallation.cfg;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.typesafe.config.Config;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfigNew;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import org.immutables.value.Value;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.List;
+import java.util.Optional;
+
+@Value.Immutable
+@Value.Style(jdkOnly = true)
+public interface GitHubAppInstallationConfig {
+
+ List getAuthConfigs();
+
+ @Value.Default
+ default Duration getSystemAuthCacheDuration() {
+ return Duration.ofMinutes(50);
+ }
+
+ @Value.Default
+ default Duration getHttpClientTimeout() {
+ return Duration.ofSeconds(30);
+ }
+
+ /**
+ * Weight is roughly calculated in kilobytes. Any cached item will have a
+ * minimum weight of 1. While further weight calculations are based on size
+ * of the given secret, if any.
+ *
+ * The default of 10,240 (~10MB) can hold approximately:
+ *
+ * - 10,000 tokens with no secret
+ * - 5,000 tokens with string or credentials secret
+ * - 3,500 tokens with app private key secret
+ *
+ */
+ @Value.Default
+ default long getSystemAuthCacheMaxWeight() {
+ return 1024 * 10L;
+ }
+
+ static ImmutableGitHubAppInstallationConfig.Builder builder() {
+ return ImmutableGitHubAppInstallationConfig.builder();
+ }
+
+ static GitHubAppInstallationConfig fromConfig(Config config) {
+ var auths = config.getConfigList("auth").stream()
+ .map(GitHubAppInstallationConfig::toGitAuth)
+ .toList();
+
+ var builder = builder();
+
+ if (config.hasPath("httpClientTimeout")) {
+ builder.httpClientTimeout(config.getDuration("httpClientTimeout"));
+ }
+
+ if (config.hasPath("systemAuthCacheDuration")) {
+ builder.systemAuthCacheDuration(config.getDuration("systemAuthCacheDuration"));
+ }
+
+ if (config.hasPath("systemAuthCacheMaxWeight")) {
+ builder.systemAuthCacheMaxWeight(config.getInt("systemAuthCacheMaxWeight"));
+ }
+
+ return builder
+ .authConfigs(auths)
+ .build();
+ }
+
+ enum AuthSource {
+ OAUTH_TOKEN,
+ GITHUB_APP_INSTALLATION,
+ }
+
+ interface AuthConfig {
+ MappingAuthConfig toGitAuth();
+ }
+
+ static MappingAuthConfig toGitAuth(com.typesafe.config.Config auth) {
+ var a = switch (AuthSource.valueOf(auth.getString("type").toUpperCase())) {
+ case OAUTH_TOKEN -> OauthConfig.from(auth);
+ case GITHUB_APP_INSTALLATION -> AppInstallationConfig.from(auth);
+ };
+ return a.toGitAuth();
+ }
+
+ record OauthConfig(String urlPattern, String username, String token) implements AuthConfig {
+
+ static OauthConfig from(com.typesafe.config.Config cfg) {
+ var username = Optional.ofNullable(cfg.hasPath("username") ? cfg.getString("username") : null)
+ .filter(s -> !s.isBlank())
+ .orElse(null);
+
+ return new OauthConfig(
+ cfg.getString("urlPattern"),
+ username,
+ cfg.getString("token")
+ );
+ }
+
+ @Override
+ public MappingAuthConfig toGitAuth() {
+ return MappingAuthConfig.OauthAuthConfig.builder()
+ .token(this.token())
+ .username(this.username())
+ .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern()))
+ .build();
+ }
+ }
+
+ record AppInstallationConfig(String urlPattern, String username, String apiUrl, String clientId, String privateKey) implements AuthConfig {
+
+ static AppInstallationConfig from(com.typesafe.config.Config cfg) {
+ var username = Optional.ofNullable(cfg.hasPath("username") ? cfg.getString("username") : null)
+ .filter(s -> !s.isBlank())
+ .orElse("x-access-token");
+
+ var apiUrl = Optional.ofNullable(cfg.hasPath("apiUrl") ? cfg.getString("apiUrl") : null)
+ .filter(s -> !s.isBlank())
+ .orElse("https://api.github.com");
+
+ return new AppInstallationConfig(
+ cfg.getString("urlPattern"),
+ username,
+ apiUrl,
+ cfg.getString("clientId"),
+ cfg.getString("privateKey")
+ );
+ }
+
+ @Override
+ public MappingAuthConfig toGitAuth() {
+ try {
+ var pkData = Files.readString(Paths.get(this.privateKey()));
+
+ return new GitHubAppAuthConfigNew(
+ this.apiUrl(),
+ this.clientId(),
+ pkData,
+ this.username(),
+ MappingAuthConfig.assertBaseUrlPattern(this.urlPattern())
+ );
+ } catch (IOException e) {
+ throw new GitHubAppException("Error initializing Git App Installation auth", e);
+ }
+ }
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java
new file mode 100644
index 0000000000..f883cfd06e
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/GitHubAppException.java
@@ -0,0 +1,41 @@
+package com.walmartlabs.concord.github.appinstallation.exception;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+public class GitHubAppException extends RuntimeException {
+ public GitHubAppException(String message) {
+ super(message);
+ }
+
+ public GitHubAppException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static class NotFoundException extends GitHubAppException {
+ public NotFoundException(String message) {
+ super(message);
+ }
+
+ public NotFoundException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java
new file mode 100644
index 0000000000..b004a6af94
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/exception/RepoExtractionException.java
@@ -0,0 +1,28 @@
+package com.walmartlabs.concord.github.appinstallation.exception;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+public class RepoExtractionException extends IllegalArgumentException {
+
+ public RepoExtractionException(String s) {
+ super(s);
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
new file mode 100644
index 0000000000..9679f34d6a
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
@@ -0,0 +1,150 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.net.http.HttpClient;
+import java.net.http.HttpResponse;
+import java.util.List;
+
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.PRIVATE_KEY_TEXT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AccessTokenProviderTest {
+
+ @Mock
+ HttpClient httpClient;
+
+ @Mock
+ HttpResponse tokenUrlResponse;
+
+ @Mock
+ HttpResponse accessTokenResponse;
+
+ private static final GitHubAppAuthConfigNew auth = new GitHubAppAuthConfigNew(null, "123", PRIVATE_KEY_TEXT, null, MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"));
+
+// private static final GitHubAppAuthConfig auth = GitHubAppAuthConfig.builder()
+// .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"))
+// .privateKey(PRIVATE_KEY_TEXT)
+// .clientId("123")
+// .build();
+
+ private static final GitHubAppInstallationConfig CFG = GitHubAppInstallationConfig.builder()
+ .authConfigs(List.of(auth))
+ .build();
+ @Test
+ void test() throws Exception {
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(tokenUrlResponse, accessTokenResponse);
+
+ when(tokenUrlResponse.statusCode()).thenReturn(200);
+ when(tokenUrlResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_URL_RESPONSE));
+
+ when(accessTokenResponse.statusCode()).thenReturn(201);
+ when(accessTokenResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_RESPONSE));
+
+ var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient);
+
+ // --
+
+ var result = provider.getRepoInstallationToken(auth, "owner/repo");
+
+ // --
+
+ assertNotNull(result);
+ assertEquals("mock-token", result.token());
+ assertTrue(result.secondsUntilExpiration() > 300);
+ }
+
+ @Test
+ void testAppNotInstalled() throws Exception {
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(tokenUrlResponse);
+
+ when(tokenUrlResponse.statusCode()).thenReturn(404);
+ when(tokenUrlResponse.body()).thenReturn(asInputStream("App is not installed on repo"));
+
+ var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient);
+
+ // --
+
+ var ex = assertThrows(GitHubAppException.NotFoundException.class,
+ () -> provider.getRepoInstallationToken(auth, "owenr/repo"));
+
+ // --
+
+ assertTrue(ex.getMessage().contains("Repo not found or App installation not found for repo"));
+ }
+
+ @Test
+ void testErrorCreatingToken() throws Exception {
+ when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
+ .thenReturn(tokenUrlResponse, accessTokenResponse);
+
+ when(tokenUrlResponse.statusCode()).thenReturn(200);
+ when(tokenUrlResponse.body()).thenReturn(asInputStream(ACCESS_TOKEN_URL_RESPONSE));
+
+ when(accessTokenResponse.statusCode()).thenReturn(500);
+ when(accessTokenResponse.body()).thenReturn(asInputStream("server error"));
+
+ var provider = new AccessTokenProvider(CFG, TestConstants.MAPPPER, httpClient);
+
+ // --
+
+ var ex = assertThrows(GitHubAppException.class,
+ () -> provider.getRepoInstallationToken(auth, "owenr/repo"));
+
+ // --
+
+ assertTrue(ex.getMessage().contains("Unexpected error creating app access token: 500"));
+ }
+
+ private static final String ACCESS_TOKEN_URL_RESPONSE = """
+ {
+ "access_tokens_url": "https://github.local/access_tokens"
+ }""";
+
+ private static final String ACCESS_TOKEN_RESPONSE = """
+ {
+ "token": "mock-token",
+ "expires_at": "2099-12-31T23:59:59Z"
+ }""";
+
+ private static InputStream asInputStream(String s) {
+ return new ByteArrayInputStream(s.getBytes());
+ }
+
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
new file mode 100644
index 0000000000..7a1866d475
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
@@ -0,0 +1,43 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GitHubAppAuthConfigTest {
+
+ @Test
+ void testUrlPatternMissingNamedGroup() {
+ var ex = assertThrows(IllegalArgumentException.class, () -> new GitHubAppAuthConfigNew(
+ "https://api.github.com",
+ "mock-client-id",
+ "/not/used/in/test",
+ null,
+ MappingAuthConfig.assertBaseUrlPattern(".*")
+ ));
+
+ assertTrue(ex.getMessage().contains("The url pattern must contain the ? named group"));
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
new file mode 100644
index 0000000000..40c09d4adf
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
@@ -0,0 +1,236 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.common.ObjectMapperProvider;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
+import com.walmartlabs.concord.sdk.Secret;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.List;
+
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.APP_INSTALL_CONTENT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class GitHubAppInstallationTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapperProvider().get();
+
+ @Mock
+ AccessTokenProvider accessTokenProvider;
+
+ private static final URI VALID_APP_AUTH_URI_01 = URI.create("https://github.local/owner/repo-1.git");
+ private static final URI VALID_APP_AUTH_URI_02 = URI.create("https://github.local/owner/repo-2.git");
+ private static final URI VALID_STATIC_AUTH_URI_01 = URI.create("https://staticgithub.local/owner/repo-1.git");
+
+ private static final GitHubAppAuthConfigNew auth = new GitHubAppAuthConfigNew(
+ null,
+ "123",
+ "/does/not/exist",
+ null,
+ MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"));
+ private static final MappingAuthConfig.OauthAuthConfig staticKeyAuth = MappingAuthConfig.OauthAuthConfig.builder()
+ .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?staticgithub.local)/"))
+ .token("static-token")
+ .build();
+ private final GitHubAppInstallationConfig CFG = GitHubAppInstallationConfig.builder()
+ .authConfigs(List.of(staticKeyAuth, auth))
+ .build();
+ private static final ExternalAuthToken TOKEN_1HR = ExternalAuthToken.SimpleToken.builder()
+ .token("mock-installation-token")
+ .expiresAt(OffsetDateTime.now().plusHours(1))
+ .build();
+ private static final ExternalAuthToken TOKEN_200S = ExternalAuthToken.SimpleToken.builder()
+ .token("mock-200-seconds-expiration-token")
+ .expiresAt(OffsetDateTime.now().plusSeconds(200))
+ .build();
+ private static final ExternalAuthToken TOKEN_9S = ExternalAuthToken.SimpleToken.builder()
+ .token("mock-9-seconds-expiration-token")
+ .expiresAt(OffsetDateTime.now().plusSeconds(9))
+ .build();
+ private static final Secret MOCK_STATIC_SECRET = new BinaryDataSecret(staticKeyAuth.token().getBytes(StandardCharsets.UTF_8));
+
+ private static final Secret MOCK_APP_INSTALL_SECRET = new BinaryDataSecret(APP_INSTALL_CONTENT.getBytes(StandardCharsets.UTF_8));
+
+ @Test
+ void testCache() {
+ // -- prepare 0
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- validate 0
+ // empty when nothing was ever looked up
+ assertEquals(0, app.cacheSize());
+
+ // -- prepare 1
+ when(accessTokenProvider.getRepoInstallationToken(any(), any()))
+ .thenReturn(TOKEN_1HR);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null);
+
+ // -- verify 1
+ assertTrue(tokenResp.isPresent());
+ assertEquals(TOKEN_1HR, tokenResp.get());
+ assertEquals(1, app.cacheSize());
+ verify(accessTokenProvider, times(1))
+ .getRepoInstallationToken(any(), any());
+
+ // -- execute 2
+ // should hit cache
+ app.getToken(VALID_APP_AUTH_URI_01, null);
+ app.getToken(VALID_APP_AUTH_URI_01, null);
+
+ // -- verify 2
+ verify(accessTokenProvider, times(1))
+ .getRepoInstallationToken(any(), any());
+
+ // -- execute 3
+ // Different repo, will retrieve first and hit cache second
+ app.getToken(VALID_APP_AUTH_URI_02, null);
+ app.getToken(VALID_APP_AUTH_URI_02, null);
+
+ // -- verify 3
+ // cache now has 2 entries
+ verify(accessTokenProvider, times(2))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ @Test
+ void testRefreshBeforeExpire() {
+ // -- prepare 0
+ when(accessTokenProvider.getRepoInstallationToken(any(), any()))
+ .thenReturn(TOKEN_200S, TOKEN_1HR);
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null);
+
+ // -- verify 1
+ // first is usable so we receive that token, new token is refreshed in background
+ assertTrue(tokenResp.isPresent());
+ assertEquals(TOKEN_200S, tokenResp.get());
+ assertEquals(1, app.cacheSize());
+ //
+ verify(accessTokenProvider, times(2))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ @Test
+ void testForceRefreshBeforeExpire() {
+ // -- prepare 0
+ when(accessTokenProvider.getRepoInstallationToken(any(), any()))
+ .thenReturn(TOKEN_9S, TOKEN_1HR);
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, null);
+
+ // -- verify 1
+ assertTrue(tokenResp.isPresent());
+ assertEquals(TOKEN_1HR, tokenResp.get());
+ assertEquals(1, app.cacheSize());
+ // first is usable refresh is forced we get the new token
+ verify(accessTokenProvider, times(2))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ @Test
+ void testFromStaticTokenTokenConfig() {
+ // -- prepare 0
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_STATIC_AUTH_URI_01, null);
+
+ // -- verify 1
+ assertTrue(tokenResp.isPresent());
+ assertEquals(staticKeyAuth.token(), tokenResp.get().token());
+ assertEquals(1, app.cacheSize());
+ // not an app installation, no lookup expected
+ verify(accessTokenProvider, times(0))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ @Test
+ void testFromStaticTokenSecret() {
+ // -- prepare 0
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, MOCK_STATIC_SECRET);
+
+ // -- verify 1
+ assertTrue(tokenResp.isPresent());
+ assertEquals(staticKeyAuth.token(), tokenResp.get().token());
+ assertEquals(1, app.cacheSize());
+ // not an app installation, no lookup expected
+ verify(accessTokenProvider, times(0))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ @Test
+ void testFromAppInstallSecret() {
+ // -- prepare 0
+ when(accessTokenProvider.getRepoInstallationToken(any(), any()))
+ .thenReturn(TOKEN_1HR);
+ var app = new TestApp(CFG, MAPPER, accessTokenProvider);
+
+ // -- execute 1
+ var tokenResp = app.getToken(VALID_APP_AUTH_URI_01, MOCK_APP_INSTALL_SECRET);
+
+ // -- verify 1
+ assertTrue(tokenResp.isPresent());
+ assertEquals(TOKEN_1HR, tokenResp.get());
+ assertEquals(1, app.cacheSize());
+ verify(accessTokenProvider, times(1))
+ .getRepoInstallationToken(any(), any());
+ }
+
+ private static class TestApp extends GitHubAppInstallation {
+ private final AccessTokenProvider tokenProvider;
+
+ public TestApp(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper, AccessTokenProvider tokenProvider) {
+ super(cfg, objectMapper);
+ this.tokenProvider = tokenProvider;
+ }
+
+ @Override
+ AccessTokenProvider accessTokenProvider() {
+ return tokenProvider;
+ }
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
new file mode 100644
index 0000000000..8f792bdec3
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
@@ -0,0 +1,119 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.net.URI;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RepoExtractionTest {
+
+ static Stream validPublicUris() {
+ return Stream.of(
+ "https://github.com/owner/repo.git", // typicalRepo
+ "https://github.com/owner/repo", // no trailing '.git', should still work
+ "https://github.com/owner/repo/", // ...same with trailing slash
+ // with query params. not very typical, but does work in this pattern matching
+ "https://github.com/owner/repo.git?hello=world",
+ "https://github.com/owner/repo?hello=world",
+ "https://github.com/owner/repo/?hello=world"
+ ).map(URI::create);
+ }
+
+ static Stream invalidPublicUris() {
+ return Stream.of(
+ "https://github.com/owner", // no repo
+ "https://github.com/owner/" // no repo with slash
+ ).map(URI::create);
+ }
+
+ static Stream validProxiedUris() {
+ return Stream.of(
+ "https://git.company.local/proxypath/owner/repo.git", // typicalRepo
+ "https://git.company.local/proxypath/owner/repo", // no trailing '.git', should still work
+ "https://git.company.local/proxypath/owner/repo/", // ...same with trailing slash
+ // with query params. not very typical, but does work in this pattern matching
+ "https://git.company.local/proxypath/owner/repo.git?hello=world",
+ "https://git.company.local/proxypath/owner/repo?hello=world",
+ "https://git.company.local/proxypath/owner/repo/?hello=world"
+ ).map(URI::create);
+ }
+
+ static Stream invalidProxiedUris() {
+ return Stream.of(
+ "https://git.company.local/proxypath/owner", // no repo
+ "https://git.company.local/proxypath/owner/" // no repo with slash
+ ).map(URI::create);
+ }
+
+ @ParameterizedTest
+ @MethodSource("validPublicUris")
+ void testValidPublicUris(URI repo) {
+ var ownerAndRepo = assertDoesNotThrow(() -> runExtract("(?github.com).*", repo));
+ assertEquals("owner/repo", ownerAndRepo);
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidPublicUris")
+ void testInvalidPublicUris(URI repo) {
+ var ex = assertThrows(IllegalArgumentException.class,
+ () -> runExtract("(?github.com).*", repo));
+ assertTrue(ex.getMessage().contains("Failed to parse owner and repository from path"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("validProxiedUris")
+ void testValidProxiedUris(URI repo) {
+ var ownerAndRepo = assertDoesNotThrow(() -> runExtract("(?git.company.local/proxypath).*", repo));
+ assertEquals("owner/repo", ownerAndRepo);
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidProxiedUris")
+ void testInvalidProxiedUris(URI repo) {
+ var ex = assertThrows(IllegalArgumentException.class,
+ () -> runExtract("(?git.company.local/proxypath).*", repo));
+ assertTrue(ex.getMessage().contains("Failed to parse owner and repository from path"));
+ }
+
+ private static String runExtract(String pattern, URI repo) {
+ var auth = getAuth(pattern);
+ return Utils.extractOwnerAndRepo(auth, repo);
+ }
+
+ private static GitHubAppAuthConfigNew getAuth(String urlPattern) {
+ return new GitHubAppAuthConfigNew(
+ "https://api.github.com",
+ "1234",
+ "/not/used",
+ null,
+ MappingAuthConfig.assertBaseUrlPattern(urlPattern)
+ );
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java
new file mode 100644
index 0000000000..9d54dea56f
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/TestConstants.java
@@ -0,0 +1,66 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.walmartlabs.concord.common.ObjectMapperProvider;
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.sdk.Secret;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
+
+public class TestConstants {
+ static final ObjectMapper MAPPPER = new ObjectMapperProvider().get();
+ static final String APP_INSTALL_CONTENT = """
+ {
+ "githubAppInstallation": {
+ "urlPattern": "(?github.local)/.*",
+ "clientId": "123",
+ "privateKey": "mock-key-data"
+ }
+ }""";
+ static final Secret MOCK_APP_INSTALL_SECRET = new BinaryDataSecret(APP_INSTALL_CONTENT.getBytes(StandardCharsets.UTF_8));
+ static final Secret MOCK_STATIC_TOKEN_SECRET = new BinaryDataSecret("mock-static-token".getBytes(StandardCharsets.UTF_8));
+
+ public static final String PRIVATE_KEY_TEXT = generatePrivateKey();
+
+ private static String generatePrivateKey() {
+ KeyPairGenerator kpg;
+ try {
+ kpg = KeyPairGenerator.getInstance("RSA");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("Algorithm not found", e);
+ }
+
+ kpg.initialize(2048);
+ KeyPair kp = kpg.generateKeyPair();
+
+ Base64.Encoder encoder = Base64.getEncoder();
+
+ return "-----BEGIN PRIVATE KEY-----\n" +
+ encoder.encodeToString(kp.getPrivate().getEncoded()) +
+ "\n-----END PRIVATE KEY-----\n";
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java
new file mode 100644
index 0000000000..62355c8c0c
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/UtilsTest.java
@@ -0,0 +1,105 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.walmartlabs.concord.common.secret.BinaryDataSecret;
+import com.walmartlabs.concord.common.secret.UsernamePassword;
+import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.APP_INSTALL_CONTENT;
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.MAPPPER;
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.MOCK_APP_INSTALL_SECRET;
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.MOCK_STATIC_TOKEN_SECRET;
+import static com.walmartlabs.concord.github.appinstallation.Utils.parseAppInstallation;
+import static com.walmartlabs.concord.github.appinstallation.Utils.parseRawAppInstallation;
+import static com.walmartlabs.concord.github.appinstallation.Utils.validateSecret;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class UtilsTest {
+
+ @Test
+ void testValidateSecret() {
+ assertTrue(validateSecret(MOCK_APP_INSTALL_SECRET, MAPPPER));
+ assertTrue(validateSecret(MOCK_STATIC_TOKEN_SECRET, MAPPPER));
+ assertFalse(validateSecret(null, MAPPPER));
+ assertFalse(validateSecret(Mockito.mock(UsernamePassword.class), MAPPPER));
+ assertFalse(validateSecret(new BinaryDataSecret(null), MAPPPER));
+ assertFalse(validateSecret(new BinaryDataSecret(new byte[]{}), MAPPPER));
+ assertFalse(validateSecret(new BinaryDataSecret("\nmytoken".getBytes()), MAPPPER));
+ assertFalse(validateSecret(new BinaryDataSecret("{\"hello\":\"world\"}".getBytes()), MAPPPER));
+ }
+
+ @Test
+ void parseAppInstallation_ValidJson() {
+ var o = parseAppInstallation(APP_INSTALL_CONTENT.getBytes(), MAPPPER);
+
+ assertTrue(o.isPresent());
+ var result = o.get();
+ assertEquals("123", result.clientId());
+ }
+
+ @Test
+ void parseAppInstallation_MissingElement() {
+ var missingClientId = """
+ {
+ "githubAppInstallation": {
+ "urlPattern": "(?github.local)/.*",
+ "privateKey": "mock-key-data"
+ }
+ }""";
+ var data = missingClientId.getBytes();
+ var ex = assertThrows(GitHubAppException.class, () -> parseAppInstallation(data, MAPPPER));
+
+ assertTrue(ex.getMessage().contains("Invalid app installation definition"));
+ }
+
+ @Test
+ void parseAppInstallation_OtherJson() {
+ var unexpectedJson = "{ \"valid\": \"but not usable here\"}";
+ var result = parseAppInstallation(unexpectedJson.getBytes(), MAPPPER);
+
+ assertFalse(result.isPresent());
+ }
+
+ @Test
+ void parseRawAppInstallation_NotJson() {
+ var unexpectedJson = "justText";
+ var result = parseRawAppInstallation(unexpectedJson.getBytes(), MAPPPER);
+
+ assertNull(result);
+ }
+
+ @Test
+ void parseRawAppInstallation_OtherJson() {
+ var unexpectedJson = "{ \"valid\": \"but not usable here\"}";
+ var result = parseRawAppInstallation(unexpectedJson.getBytes(), MAPPPER);
+
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+}
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
new file mode 100644
index 0000000000..2184acb8ac
--- /dev/null
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
@@ -0,0 +1,98 @@
+package com.walmartlabs.concord.github.appinstallation.cfg;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.typesafe.config.ConfigFactory;
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfigNew;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+import static com.walmartlabs.concord.github.appinstallation.TestConstants.PRIVATE_KEY_TEXT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class ConfigTest {
+
+ @TempDir
+ private static Path workDir;
+
+ @Test
+ void simpleConfig() throws Exception {
+ var pk = Files.writeString(workDir.resolve("pk.pem"), PRIVATE_KEY_TEXT);
+ var typesafeConfig = ConfigFactory.parseString("""
+ {
+ "auth" = [
+ { type = "GITHUB_APP_INSTALLATION", urlPattern = "(?github.local)", clientId = "123", privateKey = "{{PK_PATH}}" },
+ { type = "OAUTH_TOKEN", urlPattern = "(?github.local)", token = "mock-token" }
+ ]
+ }""".replace("{{PK_PATH}}", pk.toString()));
+ var cfg = GitHubAppInstallationConfig.fromConfig(typesafeConfig);
+ assertNotNull(cfg);
+ assertEquals(10240, cfg.getSystemAuthCacheMaxWeight());
+ assertEquals(Duration.ofSeconds(30), cfg.getHttpClientTimeout());
+ assertEquals(Duration.ofMinutes(50), cfg.getSystemAuthCacheDuration());
+ assertEquals(2, cfg.getAuthConfigs().size());
+
+ var appInstall = assertInstanceOf(GitHubAppAuthConfigNew.class, cfg.getAuthConfigs().get(0));
+ assertEquals("x-access-token", appInstall.username());
+ assertEquals("https://api.github.com", appInstall.apiUrl());
+
+ var oauth = assertInstanceOf(MappingAuthConfig.OauthAuthConfig.class, cfg.getAuthConfigs().get(1));
+ assertNull(oauth.username());
+ assertEquals("mock-token", oauth.token());
+ }
+
+ @Test
+ void overrideConfig() throws Exception {
+ var pk = Files.writeString(workDir.resolve("pk.pem"), PRIVATE_KEY_TEXT);
+ var typesafeConfig = ConfigFactory.parseString("""
+ {
+ httpClientTimeout = "1 minute",
+ systemAuthCacheDuration = "1 minute",
+ systemAuthCacheMaxWeight = "10"
+ "auth" = [
+ { type = "GITHUB_APP_INSTALLATION", urlPattern = "(?github.local)", username = "custom", apiUrl = "https://api.github.local", clientId = "123", privateKey = "{{PK_PATH}}" },
+ { type = "OAUTH_TOKEN", urlPattern = "(?github.local)", token = "mock-token", username = "custom" }
+ ]
+ }""".replace("{{PK_PATH}}", pk.toString()));
+ var cfg = GitHubAppInstallationConfig.fromConfig(typesafeConfig);
+ assertNotNull(cfg);
+ assertEquals(10, cfg.getSystemAuthCacheMaxWeight());
+ assertEquals(Duration.ofMinutes(1), cfg.getHttpClientTimeout());
+ assertEquals(Duration.ofMinutes(1), cfg.getSystemAuthCacheDuration());
+ assertEquals(2, cfg.getAuthConfigs().size());
+
+ var appInstall = assertInstanceOf(GitHubAppAuthConfigNew.class, cfg.getAuthConfigs().get(0));
+ assertEquals("custom", appInstall.username());
+ assertEquals("https://api.github.local", appInstall.apiUrl());
+
+ var oauth = assertInstanceOf(MappingAuthConfig.OauthAuthConfig.class, cfg.getAuthConfigs().get(1));
+ assertEquals("custom", oauth.username());
+ assertEquals("mock-token", oauth.token());
+ }
+}
diff --git a/github-app-installation/src/test/resources/logback-test.xml b/github-app-installation/src/test/resources/logback-test.xml
new file mode 100644
index 0000000000..00003ef25f
--- /dev/null
+++ b/github-app-installation/src/test/resources/logback-test.xml
@@ -0,0 +1,13 @@
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] [%-5level] %logger{36} - %msg%n
+
+
+
+
+
+
+
+
+
diff --git a/pom.xml b/pom.xml
index 3abb373f2a..9aaf161bcc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -37,6 +37,7 @@
common
policy-engine
dependency-manager
+ github-app-installation
repository
imports
forms
diff --git a/repository/pom.xml b/repository/pom.xml
index 812f380ed1..5bc9e982b4 100644
--- a/repository/pom.xml
+++ b/repository/pom.xml
@@ -85,5 +85,15 @@
org.eclipse.jgit
test
+
+ org.mockito
+ mockito-core
+ test
+
+
+ org.mockito
+ mockito-junit-jupiter
+ test
+
diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java b/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java
index 7ab3812867..9e2534a4ea 100644
--- a/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java
+++ b/repository/src/main/java/com/walmartlabs/concord/repository/FetchRequest.java
@@ -41,8 +41,10 @@ public interface FetchRequest {
Path destination();
/**
- * Concord secret for authentication.
- * If not provided {@link GitClientConfiguration#oauthToken()} is used for {@code https://} URLs.
+ * Concord secret for authentication. If not provided, an available
+ * {@link com.walmartlabs.concord.common.AuthTokenProvider} matching
+ * {@link #url()} will be used for {@code https://} URLs. Otherwise,
+ * anonymous auth is attempted.
*/
@Nullable
Secret secret();
diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java
index 2ab61cd447..99b9de7947 100644
--- a/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java
+++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitCliRepositoryProvider.java
@@ -20,6 +20,7 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,8 +38,8 @@ public class GitCliRepositoryProvider implements RepositoryProvider {
private final GitClient client;
- public GitCliRepositoryProvider(GitClientConfiguration cfg) {
- this.client = new GitClient(cfg);
+ public GitCliRepositoryProvider(GitClientConfiguration cfg, AuthTokenProvider authProvider) {
+ this.client = new GitClient(cfg, authProvider);
}
@Override
diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java
index f9642b9c3d..a142bd7091 100644
--- a/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java
+++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitClient.java
@@ -20,7 +20,7 @@
* =====
*/
-import com.google.common.collect.ImmutableSet;
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.common.secret.BinaryDataSecret;
import com.walmartlabs.concord.common.secret.KeyPair;
@@ -54,14 +54,22 @@ public class GitClient {
private static final int SUCCESS_EXIT_CODE = 0;
private final GitClientConfiguration cfg;
-
- private final List sensitiveData;
+ private final AuthTokenProvider authProvider;
+ private final Set sensitiveData;
private final ExecutorService executor;
- public GitClient(GitClientConfiguration cfg) {
+ public GitClient(GitClientConfiguration cfg, AuthTokenProvider authProvider) {
this.cfg = cfg;
- this.sensitiveData = cfg.oauthToken() != null ? Collections.singletonList(cfg.oauthToken()) : Collections.emptyList();
+ this.authProvider = authProvider;
this.executor = Executors.newCachedThreadPool();
+ this.sensitiveData = new LinkedHashSet<>();
+
+ // urls with user info.
+ sensitiveData.add(new Obfuscation("https://([^@]*)@", "https://***@"));
+
+ cfg.oauthToken().ifPresent(oauth ->
+ // definitely don't print a hard-code oauth token
+ sensitiveData.add(new Obfuscation(oauth, "***")));
}
public FetchResult fetch(FetchRequest req) {
@@ -310,23 +318,22 @@ String updateUrl(String url, Secret secret) {
URI uri = assertUriAllowed(url);
- List allowedDefaultHosts = cfg.authorizedGitHosts();
-
- if(secret == null && allowedDefaultHosts!= null && !allowedDefaultHosts.contains(uri.getHost())) {
- // in this case the user has not provided authentication AND the host is not in the whitelist of hosts
- // which may use the default git credentials. return the url un-modified to attempt anonymous auth;
- // if it fails
+ if (url.contains("@") || !url.startsWith("https://")) {
+ // provided url already has credentials OR it's a non-https url
return url;
}
- if (secret != null || cfg.oauthToken() == null || url.contains("@") || !url.startsWith("https://")) {
- // provided url already has credentials OR there are no default credentials to use.
- // anonymous auth is the only viable option.
- return url;
+ if (secret instanceof UsernamePassword up) {
+ // Secret contains static auth (token or username). No lookup needed.
+ return "https://" +
+ (up.getUsername() == null ? "" : up.getUsername() + ":") +
+ String.valueOf(up.getPassword()) +
+ "@" +
+ url.substring("https://".length());
}
- // using default credentials
- return "https://" + cfg.oauthToken() + "@" + url.substring("https://".length());
+ // This will either add auth from a matching provider, or none for anonymous access
+ return authProvider.addUserInfoToUri(uri, secret).toString();
}
private URI assertUriAllowed(String rawUri) {
@@ -554,9 +561,7 @@ private String execWithCredentials(Command cmd, Secret secret) {
env.put("GIT_TERMINAL_PROMPT", "0");
try {
- if (secret instanceof KeyPair) {
- KeyPair keyPair = (KeyPair) secret;
-
+ if (secret instanceof KeyPair keyPair) {
key = createSshKeyFile(keyPair);
ssh = createUnixGitSSH(key);
@@ -569,18 +574,14 @@ private String execWithCredentials(Command cmd, Secret secret) {
}
log.info("using GIT_SSH to set credentials");
- } else if (secret instanceof UsernamePassword) {
- UsernamePassword userPass = (UsernamePassword) secret;
-
+ } else if (secret instanceof UsernamePassword userPass) {
askpass = createUnixStandardAskpass(userPass);
env.put("GIT_ASKPASS", askpass.toAbsolutePath().toString());
env.put("SSH_ASKPASS", askpass.toAbsolutePath().toString());
log.info("using GIT_ASKPASS to set credentials ");
- } else if (secret instanceof BinaryDataSecret) {
- BinaryDataSecret token = (BinaryDataSecret) secret;
-
+ } else if (secret instanceof BinaryDataSecret token) {
askpass = createUnixStandardAskpass(new UsernamePassword(new String(token.getData()), "".toCharArray()));
env.put("GIT_ASKPASS", askpass.toAbsolutePath().toString());
@@ -608,8 +609,8 @@ private String hideSensitiveData(String s) {
return null;
}
- for (String p : sensitiveData) {
- s = s.replaceAll(p, "***");
+ for (Obfuscation o : sensitiveData) {
+ s = s.replaceAll(o.pattern(), o.replacement());
}
return s;
}
@@ -628,7 +629,7 @@ private Path createUnixGitSSH(Path key) throws IOException {
" -o ServerAliveInterval=" + cfg.sshTimeout().getSeconds() +
" -o StrictHostKeyChecking=no \"$@\"");
}
- Files.setPosixFilePermissions(ssh, ImmutableSet.of(OWNER_READ, OWNER_EXECUTE));
+ Files.setPosixFilePermissions(ssh, Set.of(OWNER_READ, OWNER_EXECUTE));
return ssh;
}
@@ -647,7 +648,7 @@ private static Path createUnixStandardAskpass(UsernamePassword creds) throws IOE
w.println("Password*) echo '" + quoteUnixCredentials(new String(creds.getPassword())) + "' ;;");
w.println("esac");
}
- Files.setPosixFilePermissions(askpass, ImmutableSet.of(OWNER_READ, OWNER_EXECUTE));
+ Files.setPosixFilePermissions(askpass, Set.of(OWNER_READ, OWNER_EXECUTE));
return askpass;
}
@@ -783,4 +784,8 @@ static ImmutableCommitInfo.Builder builder() {
return ImmutableCommitInfo.builder();
}
}
+
+ private record Obfuscation(String pattern,
+ String replacement) {
+ }
}
diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java b/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java
index 122dd3467e..3dc7a3ccc6 100644
--- a/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java
+++ b/repository/src/main/java/com/walmartlabs/concord/repository/GitClientConfiguration.java
@@ -25,15 +25,19 @@
import javax.annotation.Nullable;
import java.time.Duration;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
@Value.Immutable
@Value.Style(jdkOnly = true)
public interface GitClientConfiguration {
- @Nullable
- String oauthToken();
-
+ Optional oauthToken();
+
+ Optional oauthUsername();
+
+ Optional oauthUrlPattern();
+
@Nullable
List authorizedGitHosts();
diff --git a/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java b/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java
index fecc45e3e0..9b277a4631 100644
--- a/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java
+++ b/repository/src/main/java/com/walmartlabs/concord/repository/RepositoryException.java
@@ -21,8 +21,11 @@
*/
+import java.io.Serial;
+
public class RepositoryException extends RuntimeException {
+ @Serial
private static final long serialVersionUID = 1L;
public RepositoryException(String message) {
diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java
index fbd22cd0f2..36eda84716 100644
--- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java
+++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetch2Test.java
@@ -20,11 +20,15 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.common.TemporaryPath;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
@@ -38,10 +42,14 @@
/**
* test for checkout prev commitId on master branch + checkAlreadyFetched=true
*/
+@ExtendWith(MockitoExtension.class)
public class GitClientFetch2Test {
private GitClient client;
+ @Mock
+ AuthTokenProvider authProvider;
+
@BeforeEach
public void init() {
client = new GitClient(GitClientConfiguration.builder()
@@ -49,7 +57,7 @@ public void init() {
.sshTimeoutRetryCount(1)
.httpLowSpeedLimit(1)
.httpLowSpeedTime(Duration.ofMinutes(10))
- .build());
+ .build(), authProvider);
}
@Test
diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java
index 7c608fed05..f7ef97e666 100644
--- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java
+++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientFetchTest.java
@@ -20,6 +20,7 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.common.TemporaryPath;
import com.walmartlabs.concord.sdk.Secret;
@@ -28,6 +29,9 @@
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
@@ -40,10 +44,14 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
+@ExtendWith(MockitoExtension.class)
public class GitClientFetchTest {
private GitClient client;
+ @Mock
+ AuthTokenProvider authProvider;
+
@BeforeEach
public void init() {
client = new GitClient(GitClientConfiguration.builder()
@@ -52,7 +60,7 @@ public void init() {
.sshTimeoutRetryCount(1)
.httpLowSpeedLimit(1)
.httpLowSpeedTime(Duration.ofMinutes(10))
- .build());
+ .build(), authProvider);
}
@Test
diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java
index 5f717fc010..72c440a669 100644
--- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java
+++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientRealTest.java
@@ -20,6 +20,7 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.common.TemporaryPath;
import com.walmartlabs.concord.common.secret.KeyPair;
@@ -28,6 +29,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
@@ -38,6 +42,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
@Disabled
+@ExtendWith(MockitoExtension.class)
public class GitClientRealTest {
private static final String HTTPS_REPO_URL = System.getenv("HTTPS_REPO_URL");
@@ -61,6 +66,9 @@ private static Secret createKeypair() {
private GitClient client;
+ @Mock
+ AuthTokenProvider authProvider;
+
@BeforeEach
public void init() {
client = new GitClient(GitClientConfiguration.builder()
@@ -69,7 +77,7 @@ public void init() {
.sshTimeoutRetryCount(1)
.httpLowSpeedLimit(1)
.httpLowSpeedTime(Duration.ofMinutes(10))
- .build());
+ .build(), authProvider);
}
@Test
diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java
index dc906b98ec..7b100bd575 100644
--- a/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java
+++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitClientSpeedTest.java
@@ -20,6 +20,7 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.common.TemporaryPath;
import com.walmartlabs.concord.sdk.Secret;
@@ -27,6 +28,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.nio.file.Files;
@@ -54,10 +58,14 @@
* Require internet connection
*/
@Disabled
+@ExtendWith(MockitoExtension.class)
public class GitClientSpeedTest {
private GitClient client;
+ @Mock
+ AuthTokenProvider authProvider;
+
@BeforeEach
public void init() {
client = new GitClient(GitClientConfiguration.builder()
@@ -65,7 +73,7 @@ public void init() {
.sshTimeoutRetryCount(1)
.httpLowSpeedLimit(1)
.httpLowSpeedTime(Duration.ofMinutes(10))
- .build());
+ .build(), authProvider);
}
@Test
diff --git a/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java b/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java
index 933560689a..86847144e4 100644
--- a/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java
+++ b/repository/src/test/java/com/walmartlabs/concord/repository/GitUriTest.java
@@ -20,19 +20,24 @@
* =====
*/
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
import com.walmartlabs.concord.common.secret.BinaryDataSecret;
import com.walmartlabs.concord.sdk.Secret;
+import org.immutables.value.Value;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class GitUriTest {
+ private static final AuthTokenProvider AUTH_PROVIDER = authProvider(null);
+ private static final AuthTokenProvider RESTRICTED_AUTH_PROVIDER = authProvider("gitserver.local");
private static final GitClientConfiguration cfg = GitClientConfiguration.builder()
.oauthToken("mock-token")
.build();
- private static final GitClient client = new GitClient(cfg);
- private static Secret secret = new BinaryDataSecret(new byte[0]);
+ private static final GitClient client = new GitClient(cfg, AUTH_PROVIDER);
+ private static Secret secret = new BinaryDataSecret("secret-mock-token".getBytes());
@Test
void testSsh() {
@@ -53,7 +58,7 @@ void testHttps() {
@Test
void testHttpWithSecret() {
var httpsSecret = client.updateUrl("https://gitserver.local/my-org/my-repo.git", secret);
- assertEquals("https://gitserver.local/my-org/my-repo.git", httpsSecret);
+ assertEquals("https://secret-mock-token@gitserver.local/my-org/my-repo.git", httpsSecret);
}
@Test
@@ -72,8 +77,7 @@ void testUnrestrictedHost() {
void testGitHostRestriction() {
var restrictedClient = new GitClient(GitClientConfiguration.builder()
.from(cfg)
- .addAuthorizedGitHosts("gitserver.local")
- .build());
+ .build(), RESTRICTED_AUTH_PROVIDER);
var anonAuth = restrictedClient.updateUrl("https://elsewhere.local/my-org/my-repo.git", null);
// unchanged
@@ -84,4 +88,22 @@ void testGitHostRestriction() {
assertEquals("https://mock-token@gitserver.local/my-org/my-repo.git", url2);
}
+ private static AuthTokenProvider authProvider(String urlPattern) {
+ var builder = TestOauthTokenConfig.builder()
+ .oauthToken("mock-token");
+
+ if (urlPattern != null) {
+ builder.oauthUrlPattern(urlPattern);
+ }
+
+ return new AuthTokenProvider.OauthTokenProvider(builder.build());
+ }
+
+ @Value.Immutable
+ @Value.Style(jdkOnly = true)
+ interface TestOauthTokenConfig extends OauthTokenConfig {
+ static ImmutableTestOauthTokenConfig.Builder builder() {
+ return ImmutableTestOauthTokenConfig.builder();
+ }
+ }
}
diff --git a/server/dist/src/main/resources/concord-server.conf b/server/dist/src/main/resources/concord-server.conf
index f4fba570db..911d3090ac 100644
--- a/server/dist/src/main/resources/concord-server.conf
+++ b/server/dist/src/main/resources/concord-server.conf
@@ -463,9 +463,29 @@ concord-server {
# git clone config
git {
- # GitHub auth token to use when cloning repositories without explicitly configured authentication
+ allowedSchemes = [ "https", "ssh", "classpath" ]
+
+ # GitHub auth token to use when cloning repositories without explicitly
+ # configured authentication. Deprecated in favor of systemAuth list of
+ # tokens or service-specific app config (e.g. github)
# oauth = "..."
+ # specific username to use for auth
+ # oauthUsername = ""
+
+ # regex to match against git server's hostname + port + path so oauth
+ # token isn't used for and unexpected host
+ # oauthUrlPattern = ""
+
+ # List of system-provided auth token configs
+ # {
+ # type = "OAUTH_TOKEN",
+ # token = "...",
+ # username = "...", # optional, username to send with auth token
+ # urlPattern = "..." # required, regex to match against target git host + port + path
+ # }
+ systemAuth = []
+
# use GIT's shallow clone
shallowClone = true
@@ -487,7 +507,7 @@ concord-server {
sshTimeout = "10 minutes"
}
- # GitHub webhook integration
+ # GitHub app and webhook integration
github {
# default value, for testing only
secret = "12345"
@@ -500,6 +520,42 @@ concord-server {
# disable concord repos on push event with deleted ref (branch, tag)
disableReposOnDeletedRef = false
+
+ # App installation settings. Multiple auth (private key) definitions are supported,
+ # as each is matched to a particular url pattern.
+ appInstallation {
+ # Importantly, urlPattern must include a regex capture group named 'baseUrl'.
+ # This is necessary to detect where the owner/repo values begin in the path.
+ # {
+ # type = "GITHUB_APP_INSTALLATION",
+ # urlPattern = "(?github.com)", # regex
+ # username = "...", # optional, defaults to "x-access-token"
+ # apiUrl = "https://api.github.com", # github API url, usually *not* the same as the repo url host/path
+ # clientId = "...",
+ # privateKey = "/path/to/pk.pem"
+ # }
+ # or static oauth config. Not exactly a "GitHub App", but can do some
+ # API interactions and cloning. Actual app installation is preferred.
+ # {
+ # type = "OAUTH_TOKEN",
+ # token = "...",
+ # username = "...", # optional, usually not necessary
+ # urlPattern = "..." # regex to match against git server's hostname + port + path
+ # }
+ auth = []
+
+ # Timeout duration for http calls from the app
+ httpClientTimeout = "30 seconds"
+
+ # Duration of time to keep tokens cached after creation. May be purged
+ # earlier depending on overall cache weight and usage. GitHub installation
+ # tokens expire after 1 hour
+ systemAuthCacheDuration = "50 minutes"
+
+ # Max "weight" of the token cache. Defaults to ~10MB which should hold
+ # between 3,500 and 10,000 tokens depending on associated secret size.
+ systemAuthCacheMaxWeight = 10240
+ }
}
# Ansible event processor configuration
diff --git a/server/impl/pom.xml b/server/impl/pom.xml
index 0576fb3afb..b0ca0fc98d 100644
--- a/server/impl/pom.xml
+++ b/server/impl/pom.xml
@@ -102,6 +102,10 @@
com.walmartlabs.concord
concord-common
+
+ com.walmartlabs.concord
+ concord-github-app-installation
+
io.takari.bpm
bpm-engine-api
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java
index b542e17542..8179ae6346 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/ConcordServerModule.java
@@ -24,6 +24,7 @@
import com.google.inject.Binder;
import com.google.inject.Module;
import com.typesafe.config.Config;
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.ObjectMapperProvider;
import com.walmartlabs.concord.config.ConfigModule;
import com.walmartlabs.concord.db.DatabaseModule;
@@ -42,6 +43,7 @@
import com.walmartlabs.concord.server.policy.PolicyModule;
import com.walmartlabs.concord.server.process.ProcessModule;
import com.walmartlabs.concord.server.repository.RepositoryModule;
+import com.walmartlabs.concord.server.repository.ServerAuthTokenProvider;
import com.walmartlabs.concord.server.role.RoleModule;
import com.walmartlabs.concord.server.security.SecurityModule;
import com.walmartlabs.concord.server.security.apikey.ApiKeyModule;
@@ -89,6 +91,8 @@ public void configure(Binder binder) {
binder.bind(Listeners.class).in(SINGLETON);
binder.bind(SecureRandom.class).toProvider(SecureRandomProvider.class);
+ binder.bind(AuthTokenProvider.class).to(ServerAuthTokenProvider.class).in(SINGLETON);
+
binder.bind(MessageChannelManager.class).in(SINGLETON);
binder.bind(DependencyManagerConfiguration.class).toProvider(DependencyManagerConfigurationProvider.class);
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java
index 14894dd55a..a4e9fa58f5 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/ConfigurationModule.java
@@ -23,7 +23,9 @@
import com.google.inject.Binder;
import com.google.inject.Module;
import com.typesafe.config.Config;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
import com.walmartlabs.concord.config.ConfigModule;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
import static com.google.inject.Scopes.SINGLETON;
@@ -52,7 +54,9 @@ public void configure(Binder binder) {
binder.bind(EnqueueWorkersConfiguration.class).in(SINGLETON);
binder.bind(ExternalEventsConfiguration.class).in(SINGLETON);
binder.bind(GitConfiguration.class).in(SINGLETON);
+ binder.bind(OauthTokenConfig.class).to(GitConfiguration.class).in(SINGLETON);
binder.bind(GithubConfiguration.class).in(SINGLETON);
+ binder.bind(GitHubAppInstallationConfig.class).to(GithubConfiguration.class).in(SINGLETON);
binder.bind(ImportConfiguration.class).in(SINGLETON);
binder.bind(LdapConfiguration.class).in(SINGLETON);
binder.bind(LdapGroupSyncConfiguration.class).in(SINGLETON);
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java
index 07bfd08971..f0f75e00c3 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GitConfiguration.java
@@ -20,6 +20,8 @@
* =====
*/
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
+import com.walmartlabs.concord.common.cfg.OauthTokenConfig;
import com.walmartlabs.concord.config.Config;
import org.eclipse.sisu.Nullable;
@@ -27,8 +29,9 @@
import java.io.Serializable;
import java.time.Duration;
import java.util.List;
+import java.util.Optional;
-public class GitConfiguration implements Serializable {
+public class GitConfiguration implements OauthTokenConfig, Serializable {
private static final long serialVersionUID = 1L;
@@ -38,9 +41,14 @@ public class GitConfiguration implements Serializable {
private String oauthToken;
@Inject
- @Config("git.authorizedGitHosts")
+ @Config("git.oauthUsername")
@Nullable
- private List authorizedGitHosts;
+ private String oauthUsername;
+
+ @Inject
+ @Config("git.oauthUrlPattern")
+ @Nullable
+ private String oauthUrlPattern;
@Inject
@Config("git.shallowClone")
@@ -74,6 +82,10 @@ public class GitConfiguration implements Serializable {
@Config("git.sshTimeoutRetryCount")
private int sshTimeoutRetryCount;
+ @Inject
+ @Config("git.systemAuth")
+ List authConfigs;
+
public boolean isShallowClone() {
return shallowClone;
}
@@ -90,12 +102,19 @@ public Duration getFetchTimeout() {
return fetchTimeout;
}
- public String getOauthToken() {
- return oauthToken;
+ @Override
+ public Optional getOauthToken() {
+ return Optional.ofNullable(oauthToken);
+ }
+
+ @Override
+ public Optional getOauthUsername() {
+ return Optional.ofNullable(oauthUsername);
}
- public List getAuthorizedGitHosts() {
- return authorizedGitHosts;
+ @Override
+ public Optional getOauthUrlPattern() {
+ return Optional.ofNullable(oauthUrlPattern);
}
public int getHttpLowSpeedLimit() {
@@ -113,4 +132,43 @@ public Duration getSshTimeout() {
public int getSshTimeoutRetryCount() {
return sshTimeoutRetryCount;
}
+
+ public List getSystemAuth() {
+ return authConfigs.stream()
+ .map(o -> {
+ AuthSource type = AuthSource.valueOf(o.getString("type").toUpperCase());
+
+ return (AuthConfig) switch (type) {
+ case OAUTH_TOKEN -> OauthConfig.from(o);
+ };
+ })
+ .map(AuthConfig::toGitAuth)
+ .toList();
+ }
+
+ enum AuthSource {
+ OAUTH_TOKEN
+ }
+
+ public interface AuthConfig {
+ MappingAuthConfig toGitAuth();
+ }
+
+ public record OauthConfig(String urlPattern, String token) implements AuthConfig {
+
+ static OauthConfig from(com.typesafe.config.Config cfg) {
+ return new OauthConfig(
+ cfg.getString("urlPattern"),
+ cfg.getString("token")
+ );
+ }
+
+ @Override
+ public MappingAuthConfig.OauthAuthConfig toGitAuth() {
+ return MappingAuthConfig.OauthAuthConfig.builder()
+ .urlPattern(MappingAuthConfig.assertBaseUrlPattern(this.urlPattern()))
+ .token(this.token())
+ .build();
+ }
+ }
}
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java
index d606e3699b..3b948e2c76 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/cfg/GithubConfiguration.java
@@ -20,12 +20,16 @@
* =====
*/
+import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
import com.walmartlabs.concord.config.Config;
+import com.walmartlabs.concord.github.appinstallation.cfg.GitHubAppInstallationConfig;
import org.eclipse.sisu.Nullable;
import javax.inject.Inject;
+import java.time.Duration;
+import java.util.List;
-public class GithubConfiguration {
+public class GithubConfiguration implements GitHubAppInstallationConfig {
@Inject
@Config("github.secret")
@@ -44,6 +48,18 @@ public class GithubConfiguration {
@Config("github.disableReposOnDeletedRef")
private boolean disableReposOnDeletedRef;
+ private final GitHubAppInstallationConfig appInstallation;
+
+ @Inject
+ public GithubConfiguration(com.typesafe.config.Config config) {
+ if (config.hasPath("github.appInstallation")) {
+ var raw = config.getConfig("github.appInstallation");
+ this.appInstallation = GitHubAppInstallationConfig.fromConfig(raw);
+ } else {
+ this.appInstallation = GitHubAppInstallationConfig.builder().authConfigs(List.of()).build();
+ }
+ }
+
public String getSecret() {
return secret;
}
@@ -59,4 +75,25 @@ public boolean isLogEvents() {
public boolean isDisableReposOnDeletedRef() {
return disableReposOnDeletedRef;
}
+
+ @Override
+ public List getAuthConfigs() {
+ return appInstallation.getAuthConfigs();
+ }
+
+ @Override
+ public Duration getHttpClientTimeout() {
+ return appInstallation.getHttpClientTimeout();
+ }
+
+ @Override
+ public Duration getSystemAuthCacheDuration() {
+ return appInstallation.getSystemAuthCacheDuration();
+ }
+
+ @Override
+ public long getSystemAuthCacheMaxWeight() {
+ return appInstallation.getSystemAuthCacheMaxWeight();
+ }
+
}
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java b/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java
index b3247d744d..62615395d9 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/process/ProcessModule.java
@@ -23,7 +23,6 @@
import com.google.inject.Binder;
import com.google.inject.Module;
import com.walmartlabs.concord.imports.ImportManager;
-import com.walmartlabs.concord.process.loader.DelegatingProjectLoader;
import com.walmartlabs.concord.process.loader.ProjectLoader;
import com.walmartlabs.concord.runtime.v1.ProjectLoaderV1;
import com.walmartlabs.concord.runtime.v2.ProjectLoaderV2;
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java
index c50f8c5145..6f4034cfe6 100644
--- a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/RepositoryManager.java
@@ -21,6 +21,7 @@
*/
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.PathUtils;
import com.walmartlabs.concord.dependencymanager.DependencyManager;
import com.walmartlabs.concord.repository.*;
@@ -63,11 +64,13 @@ public RepositoryManager(ObjectMapper objectMapper,
RepositoryConfiguration repoCfg,
ProjectDao projectDao,
SecretManager secretManager,
- DependencyManager dependencyManager) throws IOException {
+ DependencyManager dependencyManager,
+ AuthTokenProvider authProvider) throws IOException {
GitClientConfiguration gitCliCfg = GitClientConfiguration.builder()
- .oauthToken(gitCfg.getOauthToken())
- .authorizedGitHosts(gitCfg.getAuthorizedGitHosts())
+ .oauthToken(gitCfg.getOauthToken()) // TODO remove? authProvider should have the same info now
+ .oauthUsername(gitCfg.getOauthUsername())
+ .oauthUrlPattern(gitCfg.getOauthUrlPattern())
.defaultOperationTimeout(gitCfg.getDefaultOperationTimeout())
.fetchTimeout(gitCfg.getFetchTimeout())
.httpLowSpeedLimit(gitCfg.getHttpLowSpeedLimit())
@@ -76,10 +79,13 @@ public RepositoryManager(ObjectMapper objectMapper,
.sshTimeoutRetryCount(gitCfg.getSshTimeoutRetryCount())
.build();
- List providers = Arrays.asList(new ClasspathRepositoryProvider(), new MavenRepositoryProvider(dependencyManager), new GitCliRepositoryProvider(gitCliCfg));
-
this.gitCfg = gitCfg;
- this.providers = new RepositoryProviders(providers);
+ this.providers =
+ new RepositoryProviders(List.of(
+ new ClasspathRepositoryProvider(),
+ new MavenRepositoryProvider(dependencyManager),
+ new GitCliRepositoryProvider(gitCliCfg, authProvider)
+ ));
this.secretManager = secretManager;
this.projectDao = projectDao;
this.repoCfg = repoCfg;
diff --git a/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java
new file mode 100644
index 0000000000..99c34ee625
--- /dev/null
+++ b/server/impl/src/main/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProvider.java
@@ -0,0 +1,70 @@
+package com.walmartlabs.concord.server.repository;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.codahale.metrics.MetricRegistry;
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation;
+import com.walmartlabs.concord.sdk.Secret;
+import com.walmartlabs.concord.server.sdk.metrics.WithTimer;
+
+import javax.annotation.Nullable;
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Singleton;
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+@Named
+@Singleton
+public class ServerAuthTokenProvider implements AuthTokenProvider {
+
+ private final List authTokenProviders;
+
+ @Inject
+ public ServerAuthTokenProvider(GitHubAppInstallation githubProvider,
+ AuthTokenProvider.OauthTokenProvider oauthTokenProvider,
+ MetricRegistry metricRegistry) {
+ this.authTokenProviders = List.of(githubProvider, oauthTokenProvider);
+
+ metricRegistry.gauge("github-token-cache-size", () -> githubProvider::cacheSize);
+ }
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return authTokenProviders.stream()
+ .anyMatch(p -> p.supports(repo, secret));
+ }
+
+ @WithTimer
+ public Optional getToken(URI repo, @Nullable Secret secret) {
+ for (var k : authTokenProviders) {
+ if (k.supports(repo, secret)) {
+ return k.getToken(repo, secret);
+ }
+ }
+
+ return Optional.empty();
+ }
+
+}
diff --git a/server/impl/src/test/java/com/walmartlabs/concord/server/ConcordObjectMapperTest.java b/server/impl/src/test/java/com/walmartlabs/concord/server/ConcordObjectMapperTest.java
index e792b036f5..1406d7c04f 100644
--- a/server/impl/src/test/java/com/walmartlabs/concord/server/ConcordObjectMapperTest.java
+++ b/server/impl/src/test/java/com/walmartlabs/concord/server/ConcordObjectMapperTest.java
@@ -1,5 +1,25 @@
package com.walmartlabs.concord.server;
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
import com.fasterxml.jackson.databind.ObjectMapper;
import com.walmartlabs.concord.common.ObjectMapperProvider;
import org.junit.jupiter.api.Test;
diff --git a/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java b/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java
new file mode 100644
index 0000000000..e0f46ca2f1
--- /dev/null
+++ b/server/impl/src/test/java/com/walmartlabs/concord/server/repository/ServerAuthTokenProviderTest.java
@@ -0,0 +1,109 @@
+package com.walmartlabs.concord.server.repository;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import com.codahale.metrics.MetricRegistry;
+import com.walmartlabs.concord.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URI;
+import java.time.OffsetDateTime;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ServerAuthTokenProviderTest {
+
+ @Mock
+ private MetricRegistry metricRegistry;
+
+ @Mock
+ GitHubAppInstallation ghApp;
+
+ @Mock
+ AuthTokenProvider.OauthTokenProvider oauthTokenProvider;
+
+ @Test
+ void testGitHubApp() {
+ when(ghApp.getToken(any(), any())).
+ thenReturn(Optional.of(ExternalAuthToken.SimpleToken.builder()
+ .token("gh-installation-token")
+ .expiresAt(OffsetDateTime.now().plusMinutes(60))
+ .build()));
+ when(ghApp.supports(any(), any())).thenReturn(true);
+
+ var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry);
+
+ // --
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ // --
+
+ assertTrue(o.isPresent());
+ var result = assertInstanceOf(ExternalAuthToken.class, o.get());
+ assertEquals("gh-installation-token", result.token());
+ }
+
+ @Test
+ void testOauth() {
+ when(oauthTokenProvider.supports(any(), any())).thenReturn(true);
+ when(oauthTokenProvider.getToken(any(), any()))
+ .thenReturn(Optional.of(ExternalAuthToken.StaticToken.builder()
+ .token("oauth-token")
+ .build()));
+
+ var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry);
+
+ assertTrue(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ assertTrue(o.isPresent());
+ var result = assertInstanceOf(ExternalAuthToken.class, o.get());
+ assertEquals("oauth-token", result.token());
+ }
+
+ @Test
+ void testNoAuth() {
+ when(ghApp.supports(any(), any())).thenReturn(false);
+ when(oauthTokenProvider.supports(any(), any())).thenReturn(false);
+
+ var provider = new ServerAuthTokenProvider(ghApp, oauthTokenProvider, metricRegistry);
+
+ assertFalse(provider.supports(URI.create("https://github.local/owner/repo.git"), null));
+ var o = provider.getToken(URI.create("https://github.local/owner/repo.git"), null);
+
+ assertFalse(o.isPresent());
+ }
+
+}
diff --git a/targetplatform/pom.xml b/targetplatform/pom.xml
index db427168b0..9435ea9360 100644
--- a/targetplatform/pom.xml
+++ b/targetplatform/pom.xml
@@ -147,6 +147,11 @@
concord-common
${project.version}
+
+ com.walmartlabs.concord
+ concord-github-app-installation
+ ${project.version}
+
com.walmartlabs.concord
concord-server
From 339fa4802d7ef673f9e5862f44dc7ad51fc6f50c Mon Sep 17 00:00:00 2001
From: Benjamin Broadaway <4554569+benbroadaway@users.noreply.github.com>
Date: Fri, 14 Nov 2025 17:39:06 -0600
Subject: [PATCH 2/9] refactor CacheKey to record
---
.../concord/agent/AgentAuthTokenProvider.java | 2 -
.../github/appinstallation/CacheKey.java | 41 ++++---------------
.../GitHubAppInstallation.java | 2 +-
3 files changed, 10 insertions(+), 35 deletions(-)
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
index 45de4ae8ce..9d01ee6b29 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
@@ -22,10 +22,8 @@
import com.typesafe.config.Config;
import com.walmartlabs.concord.agent.remote.ApiClientFactory;
-import com.walmartlabs.concord.client2.ApiException;
import com.walmartlabs.concord.common.AuthTokenProvider;
import com.walmartlabs.concord.common.ExternalAuthToken;
-import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
import com.walmartlabs.concord.github.appinstallation.GitHubAppInstallation;
import com.walmartlabs.concord.sdk.Secret;
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
index ea5af01f1c..2e1c50bbbe 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
@@ -20,47 +20,24 @@
* =====
*/
-import org.immutables.value.Value;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
import java.net.URI;
-import java.util.Objects;
-@Value.Immutable
-@Value.Style(jdkOnly = true, redactedMask = "**redacted**")
-interface CacheKey {
+public record CacheKey(URI repoUri, byte[] secretData, int weight) {
- URI repoUri();
- @Nullable
- @Value.Redacted
- byte[] binaryDataSecret();
+ static CacheKey from(URI repoUri) {
+ return from(repoUri, null);
+ }
- @Value.Default
- default int weight() {
+ static CacheKey from(URI repoUri, byte[] secretData) {
var weight = 1;
- if (binaryDataSecret() != null) {
- var data = Objects.requireNonNull(binaryDataSecret());
+ if (secretData != null) {
weight += 1;
- weight += data.length / 1024;
+ weight += secretData.length / 1024;
}
- return weight;
- }
-
- static CacheKey from(URI repoUri) {
- return ImmutableCacheKey.builder()
- .repoUri(repoUri)
- .build();
- }
-
- static CacheKey from(URI repoUri, @Nonnull byte[] secret) {
- return ImmutableCacheKey.builder()
- .repoUri(repoUri)
- .binaryDataSecret(secret)
- .build();
+ return new CacheKey(repoUri, secretData, weight);
}
-
}
+
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
index 25a3cdc135..5a7e1a1f47 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
@@ -67,7 +67,7 @@ public GitHubAppInstallation(GitHubAppInstallationConfig cfg, ObjectMapper objec
.build(new CacheLoader<>() {
@Override
public @Nonnull Optional load(@Nonnull CacheKey key) {
- return fetchToken(key.repoUri(), key.binaryDataSecret());
+ return fetchToken(key.repoUri(), key.secretData());
}
});
}
From febbfdfd9abbfe2baafad4973cadfff59b7aad0c Mon Sep 17 00:00:00 2001
From: Benjamin Broadaway <4554569+benbroadaway@users.noreply.github.com>
Date: Mon, 17 Nov 2025 07:55:39 -0600
Subject: [PATCH 3/9] removed unused code, rename CacheKey
---
.../appinstallation/AccessTokenProvider.java | 4 +-
.../github/appinstallation/CacheKey.java | 43 ------------
.../GitHubAppAuthCacheKey.java | 66 +++++++++++++++++++
.../appinstallation/GitHubAppAuthConfig.java | 41 ++++++------
.../GitHubAppAuthConfigNew.java | 53 ---------------
.../GitHubAppInstallation.java | 18 ++---
.../concord/github/appinstallation/Utils.java | 6 +-
.../cfg/GitHubAppInstallationConfig.java | 4 +-
.../AccessTokenProviderTest.java | 2 +-
.../GitHubAppAuthConfigTest.java | 2 +-
.../GitHubAppInstallationTest.java | 2 +-
.../appinstallation/RepoExtractionTest.java | 4 +-
.../appinstallation/cfg/ConfigTest.java | 6 +-
13 files changed, 109 insertions(+), 142 deletions(-)
delete mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
create mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthCacheKey.java
delete mode 100644 github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
index 2c1b9c1cc5..86b6cdb884 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
@@ -75,7 +75,7 @@ public AccessTokenProvider(GitHubAppInstallationConfig cfg,
this.httpClient = httpClient;
}
- ExternalAuthToken getRepoInstallationToken(GitHubAppAuthConfigNew app, String orgRepo) throws GitHubAppException {
+ ExternalAuthToken getRepoInstallationToken(GitHubAppAuthConfig app, String orgRepo) throws GitHubAppException {
try {
var jwt = generateJWT(app);
var accessTokenUrl = getAccessTokenUrl(app.apiUrl(), orgRepo, jwt);
@@ -158,7 +158,7 @@ private ExternalAuthToken createAccessToken(String accessTokenUrl, String jwt) {
});
}
- private static String generateJWT(GitHubAppAuthConfigNew auth) throws JOSEException {
+ private static String generateJWT(GitHubAppAuthConfig auth) throws JOSEException {
var pk = auth.privateKey();
var rsaJWK = JWK.parseFromPEMEncodedObjects(pk).toRSAKey();
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
deleted file mode 100644
index 2e1c50bbbe..0000000000
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/CacheKey.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package com.walmartlabs.concord.github.appinstallation;
-
-/*-
- * *****
- * Concord
- * -----
- * Copyright (C) 2017 - 2025 Walmart Inc.
- * -----
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * =====
- */
-
-import java.net.URI;
-
-public record CacheKey(URI repoUri, byte[] secretData, int weight) {
-
-
- static CacheKey from(URI repoUri) {
- return from(repoUri, null);
- }
-
- static CacheKey from(URI repoUri, byte[] secretData) {
- var weight = 1;
-
- if (secretData != null) {
- weight += 1;
- weight += secretData.length / 1024;
- }
-
- return new CacheKey(repoUri, secretData, weight);
- }
-}
-
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthCacheKey.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthCacheKey.java
new file mode 100644
index 0000000000..d27bd9256e
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthCacheKey.java
@@ -0,0 +1,66 @@
+package com.walmartlabs.concord.github.appinstallation;
+
+/*-
+ * *****
+ * Concord
+ * -----
+ * Copyright (C) 2017 - 2025 Walmart Inc.
+ * -----
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ * =====
+ */
+
+import java.net.URI;
+import java.util.Arrays;
+
+public record GitHubAppAuthCacheKey(URI repoUri, byte[] secretData, int weight) {
+
+ static GitHubAppAuthCacheKey from(URI repoUri) {
+ return from(repoUri, null);
+ }
+
+ static GitHubAppAuthCacheKey from(URI repoUri, byte[] secretData) {
+ var weight = 1;
+
+ if (secretData != null) {
+ weight += 1;
+ weight += secretData.length / 1024;
+ }
+
+ return new GitHubAppAuthCacheKey(repoUri, secretData, weight);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) return false;
+
+ var key = (GitHubAppAuthCacheKey) o;
+ return weight() == key.weight() && repoUri().equals(key.repoUri()) && Arrays.equals(secretData(), key.secretData());
+ }
+
+ @Override
+ public int hashCode() {
+ int result = repoUri().hashCode();
+ result = 31 * result + Arrays.hashCode(secretData());
+ result = 31 * result + weight();
+ return result;
+ }
+
+ @Override
+ public String toString() {
+ return "GitHubAppAuthCacheKey{" +
+ "repoUri=" + repoUri +
+ ", weight=" + weight +
+ '}';
+ }
+}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
index 06d479148c..15eaed7425 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
@@ -20,37 +20,34 @@
* =====
*/
-import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
-import org.immutables.value.Value;
-@Value.Immutable
-@Value.Style(jdkOnly = true)
-@JsonDeserialize(as = ImmutableGitHubAppAuthConfig.class)
-public interface GitHubAppAuthConfig extends MappingAuthConfig {
+import java.util.regex.Pattern;
- //TODO convert to record, sanity check in constructor
+public record GitHubAppAuthConfig(String apiUrl,
+ String clientId,
+ String privateKey,
+ String username,
+ Pattern urlPattern) implements MappingAuthConfig {
- @Value.Default
- default String apiUrl() {
- return "https://api.github.com";
- }
-
- String clientId();
+ public GitHubAppAuthConfig(String apiUrl, String clientId, String privateKey, String username, Pattern urlPattern) {
+ if (clientId == null || clientId.isBlank()) {
+ throw new IllegalArgumentException("clientId must be provided");
+ }
- String privateKey();
+ if (privateKey == null || privateKey.isBlank()) {
+ throw new IllegalArgumentException("privateKey must be provided");
+ }
- @Value.Check
- default void checkUrlPattern() {
// sanity check url pattern before this object gets too far out there
- if (!urlPattern().toString().contains("?")) {
+ if (!urlPattern.toString().contains("?")) {
throw new IllegalArgumentException("The url pattern must contain the ? named group");
}
- }
- static ImmutableGitHubAppAuthConfig.Builder builder() {
- return ImmutableGitHubAppAuthConfig.builder();
+ this.apiUrl = (apiUrl == null) ? "https://api.github.com" : apiUrl;
+ this.clientId = clientId;
+ this.privateKey = privateKey;
+ this.username = username;
+ this.urlPattern = urlPattern;
}
-
}
-
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
deleted file mode 100644
index 3fb65419af..0000000000
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigNew.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package com.walmartlabs.concord.github.appinstallation;
-
-/*-
- * *****
- * Concord
- * -----
- * Copyright (C) 2017 - 2025 Walmart Inc.
- * -----
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- * =====
- */
-
-import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
-
-import java.util.regex.Pattern;
-
-public record GitHubAppAuthConfigNew(String apiUrl,
- String clientId,
- String privateKey,
- String username,
- Pattern urlPattern) implements MappingAuthConfig {
-
- public GitHubAppAuthConfigNew(String apiUrl, String clientId, String privateKey, String username, Pattern urlPattern) {
- if (clientId == null || clientId.isBlank()) {
- throw new IllegalArgumentException("clientId must be provided");
- }
-
- if (privateKey == null || privateKey.isBlank()) {
- throw new IllegalArgumentException("privateKey must be provided");
- }
-
- // sanity check url pattern before this object gets too far out there
- if (!urlPattern.toString().contains("?")) {
- throw new IllegalArgumentException("The url pattern must contain the ? named group");
- }
-
- this.apiUrl = (apiUrl == null) ? "https://api.github.com" : apiUrl;
- this.clientId = clientId;
- this.privateKey = privateKey;
- this.username = username;
- this.urlPattern = urlPattern;
- }
-}
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
index 5a7e1a1f47..0d81e1bf36 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
@@ -52,7 +52,7 @@ public class GitHubAppInstallation implements AuthTokenProvider {
private final AccessTokenProvider tokenProvider;
private final ObjectMapper objectMapper;
- private final LoadingCache> cache;
+ private final LoadingCache> cache;
@Inject
public GitHubAppInstallation(GitHubAppInstallationConfig cfg, ObjectMapper objectMapper) {
@@ -63,10 +63,10 @@ public GitHubAppInstallation(GitHubAppInstallationConfig cfg, ObjectMapper objec
this.cache = CacheBuilder.newBuilder()
.expireAfterWrite(cfg.getSystemAuthCacheDuration())
.maximumWeight(cfg.getSystemAuthCacheMaxWeight())
- .weigher((Weigher>) (key, value) -> key.weight())
+ .weigher((Weigher>) (key, value) -> key.weight())
.build(new CacheLoader<>() {
@Override
- public @Nonnull Optional load(@Nonnull CacheKey key) {
+ public @Nonnull Optional load(@Nonnull GitHubAppAuthCacheKey key) {
return fetchToken(key.repoUri(), key.secretData());
}
});
@@ -77,13 +77,13 @@ public boolean supports(URI repo, @Nullable Secret secret) {
return Utils.validateSecret(secret, objectMapper) || systemSupports(repo);
}
- private CacheKey createKey(URI repoUri, @Nullable Secret secret) {
+ private GitHubAppAuthCacheKey createKey(URI repoUri, @Nullable Secret secret) {
if (secret == null) {
- return CacheKey.from(repoUri);
+ return GitHubAppAuthCacheKey.from(repoUri);
}
if (secret instanceof BinaryDataSecret bds) {
- return CacheKey.from(repoUri, bds.getData());
+ return GitHubAppAuthCacheKey.from(repoUri, bds.getData());
}
return null;
@@ -140,7 +140,7 @@ private Optional fetchToken(URI repo, @Nullable byte[] secret
.build();
}
- if (auth instanceof GitHubAppAuthConfigNew app) {
+ if (auth instanceof GitHubAppAuthConfig app) {
return getTokenFromAppInstall(app, repo);
}
@@ -153,7 +153,7 @@ private Optional fetchToken(URI repo, @Nullable byte[] secret
* invalidate and get a new one. If it's just a little close, refresh the
* cache in the background and return the still-active token.
*/
- private ExternalAuthToken refreshBeforeExpire(@Nonnull ExternalAuthToken token, CacheKey cacheKey) {
+ private ExternalAuthToken refreshBeforeExpire(@Nonnull ExternalAuthToken token, GitHubAppAuthCacheKey cacheKey) {
if (token.secondsUntilExpiration() < 10) {
// not enough time to be useful. get a new token right now
cache.invalidate(cacheKey);
@@ -185,7 +185,7 @@ private ExternalAuthToken fromBinaryData(URI repo, byte[] data) {
.build();
}
- private ExternalAuthToken getTokenFromAppInstall(GitHubAppAuthConfigNew app, URI repo) {
+ private ExternalAuthToken getTokenFromAppInstall(GitHubAppAuthConfig app, URI repo) {
log.info("getTokenFromAppInstall ['{}', '{}']", app.apiUrl(), repo);
try {
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
index f97a27bfbd..7274d1f447 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
@@ -96,7 +96,7 @@ private static boolean isPrintableAscii(byte[] bytes) {
}
}
- static Optional parseAppInstallation(byte[] bds, ObjectMapper mapper) {
+ static Optional parseAppInstallation(byte[] bds, ObjectMapper mapper) {
Map, ?> base = parseRawAppInstallation(bds, mapper);
if (base == null || !base.containsKey("githubAppInstallation")) {
@@ -105,14 +105,14 @@ static Optional parseAppInstallation(byte[] bds, ObjectM
}
try { // great, now convert it to the expected structure
- return Optional.of(mapper.convertValue(base.get("githubAppInstallation"), GitHubAppAuthConfigNew.class));
+ return Optional.of(mapper.convertValue(base.get("githubAppInstallation"), GitHubAppAuthConfig.class));
} catch (IllegalArgumentException e) {
// doesn't match the expected structure
throw new GitHubAppException("Invalid app installation definition.", e);
}
}
- static String extractOwnerAndRepo(GitHubAppAuthConfigNew auth, URI repo) throws RepoExtractionException {
+ static String extractOwnerAndRepo(GitHubAppAuthConfig auth, URI repo) throws RepoExtractionException {
var port = (repo.getPort() == -1 ? "" : (":" + repo.getPort()));
var path = (repo.getPath() == null ? "" : repo.getPath());
var repoHostPortAndPath = repo.getHost() + port + path;
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
index 1d3730a29f..58badeee74 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/cfg/GitHubAppInstallationConfig.java
@@ -22,7 +22,7 @@
import com.typesafe.config.Config;
import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
-import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfigNew;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfig;
import com.walmartlabs.concord.github.appinstallation.exception.GitHubAppException;
import org.immutables.value.Value;
@@ -160,7 +160,7 @@ public MappingAuthConfig toGitAuth() {
try {
var pkData = Files.readString(Paths.get(this.privateKey()));
- return new GitHubAppAuthConfigNew(
+ return new GitHubAppAuthConfig(
this.apiUrl(),
this.clientId(),
pkData,
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
index 9679f34d6a..a9d708a9b2 100644
--- a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProviderTest.java
@@ -54,7 +54,7 @@ class AccessTokenProviderTest {
@Mock
HttpResponse accessTokenResponse;
- private static final GitHubAppAuthConfigNew auth = new GitHubAppAuthConfigNew(null, "123", PRIVATE_KEY_TEXT, null, MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"));
+ private static final GitHubAppAuthConfig auth = new GitHubAppAuthConfig(null, "123", PRIVATE_KEY_TEXT, null, MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"));
// private static final GitHubAppAuthConfig auth = GitHubAppAuthConfig.builder()
// .urlPattern(MappingAuthConfig.assertBaseUrlPattern("(?github.local)/"))
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
index 7a1866d475..3e58bc7a7f 100644
--- a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfigTest.java
@@ -30,7 +30,7 @@ class GitHubAppAuthConfigTest {
@Test
void testUrlPatternMissingNamedGroup() {
- var ex = assertThrows(IllegalArgumentException.class, () -> new GitHubAppAuthConfigNew(
+ var ex = assertThrows(IllegalArgumentException.class, () -> new GitHubAppAuthConfig(
"https://api.github.com",
"mock-client-id",
"/not/used/in/test",
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
index 40c09d4adf..6b18b88827 100644
--- a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallationTest.java
@@ -57,7 +57,7 @@ class GitHubAppInstallationTest {
private static final URI VALID_APP_AUTH_URI_02 = URI.create("https://github.local/owner/repo-2.git");
private static final URI VALID_STATIC_AUTH_URI_01 = URI.create("https://staticgithub.local/owner/repo-1.git");
- private static final GitHubAppAuthConfigNew auth = new GitHubAppAuthConfigNew(
+ private static final GitHubAppAuthConfig auth = new GitHubAppAuthConfig(
null,
"123",
"/does/not/exist",
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
index 8f792bdec3..a361be1504 100644
--- a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/RepoExtractionTest.java
@@ -107,8 +107,8 @@ private static String runExtract(String pattern, URI repo) {
return Utils.extractOwnerAndRepo(auth, repo);
}
- private static GitHubAppAuthConfigNew getAuth(String urlPattern) {
- return new GitHubAppAuthConfigNew(
+ private static GitHubAppAuthConfig getAuth(String urlPattern) {
+ return new GitHubAppAuthConfig(
"https://api.github.com",
"1234",
"/not/used",
diff --git a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
index 2184acb8ac..e6029ca2b8 100644
--- a/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
+++ b/github-app-installation/src/test/java/com/walmartlabs/concord/github/appinstallation/cfg/ConfigTest.java
@@ -22,7 +22,7 @@
import com.typesafe.config.ConfigFactory;
import com.walmartlabs.concord.common.cfg.MappingAuthConfig;
-import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfigNew;
+import com.walmartlabs.concord.github.appinstallation.GitHubAppAuthConfig;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -58,7 +58,7 @@ void simpleConfig() throws Exception {
assertEquals(Duration.ofMinutes(50), cfg.getSystemAuthCacheDuration());
assertEquals(2, cfg.getAuthConfigs().size());
- var appInstall = assertInstanceOf(GitHubAppAuthConfigNew.class, cfg.getAuthConfigs().get(0));
+ var appInstall = assertInstanceOf(GitHubAppAuthConfig.class, cfg.getAuthConfigs().get(0));
assertEquals("x-access-token", appInstall.username());
assertEquals("https://api.github.com", appInstall.apiUrl());
@@ -87,7 +87,7 @@ void overrideConfig() throws Exception {
assertEquals(Duration.ofMinutes(1), cfg.getSystemAuthCacheDuration());
assertEquals(2, cfg.getAuthConfigs().size());
- var appInstall = assertInstanceOf(GitHubAppAuthConfigNew.class, cfg.getAuthConfigs().get(0));
+ var appInstall = assertInstanceOf(GitHubAppAuthConfig.class, cfg.getAuthConfigs().get(0));
assertEquals("custom", appInstall.username());
assertEquals("https://api.github.local", appInstall.apiUrl());
From dfe9bc82f1053ce8af0b318d231d927dd66213a9 Mon Sep 17 00:00:00 2001
From: Benjamin Broadaway <4554569+benbroadaway@users.noreply.github.com>
Date: Mon, 17 Nov 2025 08:31:20 -0600
Subject: [PATCH 4/9] tidy
---
.../concord/agent/AgentAuthTokenProvider.java | 2 +-
.../concord/github/appinstallation/Utils.java | 49 +++++++++++--------
2 files changed, 30 insertions(+), 21 deletions(-)
diff --git a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
index 9d01ee6b29..8e45bc27cd 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
@@ -39,7 +39,7 @@ public class AgentAuthTokenProvider implements AuthTokenProvider {
@Inject
public AgentAuthTokenProvider(ConcordServerTokenProvider concordProvider,
GitHubAppInstallation githubProvider,
- AuthTokenProvider.OauthTokenProvider oauthTokenProvider) {
+ OauthTokenProvider oauthTokenProvider) {
this.authTokenProviders = List.of(
concordProvider,
diff --git a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
index 7274d1f447..88edd3931d 100644
--- a/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
@@ -28,11 +28,14 @@
import java.net.URI;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class Utils {
+ private static final String KEY_GITHUB_APP_INSTALLATION = "githubAppInstallation";
+
/**
* Validates given secret is usable enough to attempt a remote lookup. Not
* guaranteed to actually work, just a sanity check useful to avoid attempting
@@ -81,10 +84,12 @@ private static boolean isPrintableAscii(byte[] bytes) {
return true;
}
- static Map, ?> parseRawAppInstallation(byte[] bds, ObjectMapper mapper) {
+ static Map parseRawAppInstallation(byte[] bds, ObjectMapper mapper) {
+ var t = mapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class);
+
try { // find out if it's at least valid JSON.
- var base = mapper.readValue(bds, Map.class);
- if (base.containsKey("githubAppInstallation")) {
+ var base = mapper.