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..26ed86af44
--- /dev/null
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/AgentAuthTokenProvider.java
@@ -0,0 +1,85 @@
+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.common.AuthTokenProvider;
+import com.walmartlabs.concord.common.ExternalAuthToken;
+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(GitHubAppInstallation githubProvider,
+ OauthTokenProvider oauthTokenProvider) {
+
+ this.authTokenProviders = List.of(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 tokenProvider : authTokenProviders) {
+ if (tokenProvider.supports(repo, secret)) {
+ return tokenProvider.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..25ea100e0f 100644
--- a/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java
+++ b/agent/src/main/java/com/walmartlabs/concord/agent/RepositoryManager.java
@@ -34,7 +34,6 @@
import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Path;
-import java.util.Arrays;
import java.util.List;
public class RepositoryManager {
@@ -51,13 +50,13 @@ 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())
.defaultOperationTimeout(gitCfg.getDefaultOperationTimeout())
.fetchTimeout(gitCfg.getFetchTimeout())
.httpLowSpeedLimit(gitCfg.getHttpLowSpeedLimit())
@@ -66,9 +65,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..85abb768e9 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,46 @@ 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 id, String urlPattern, String token) implements AuthConfig {
+
+ static OauthConfig from(Config cfg) {
+ return new OauthConfig(
+ getStringOrDefault(cfg, "id", () -> "system-oauth-token"),
+ cfg.getString("urlPattern"),
+ cfg.getString("token")
+ );
+ }
+
+ @Override
+ public MappingAuthConfig.OauthAuthConfig toGitAuth() {
+ return MappingAuthConfig.OauthAuthConfig.builder()
+ .id(this.id())
+ .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..02d74c64ee
--- /dev/null
+++ b/agent/src/test/java/com/walmartlabs/concord/agent/AgentAuthTokenProviderTest.java
@@ -0,0 +1,113 @@
+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;
+
+ @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(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(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 testNoAuth() {
+ when(ghApp.supports(any(), any())).thenReturn(false);
+ when(oauthTokenProvider.supports(any(), any())).thenReturn(false);
+
+ var provider = new AgentAuthTokenProvider(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..34f0874788
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/AuthTokenProvider.java
@@ -0,0 +1,142 @@
+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) {
+ 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()
+ .id("static-token")
+ .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..952f02f64b
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/ExternalAuthToken.java
@@ -0,0 +1,94 @@
+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 {
+
+ @Nullable
+ @JsonProperty("auth_id")
+ String authId();
+
+ @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..a390d12011
--- /dev/null
+++ b/common/src/main/java/com/walmartlabs/concord/common/cfg/MappingAuthConfig.java
@@ -0,0 +1,91 @@
+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;
+
+/**
+ * Configuration for mapping Git repository URLs to an authentication method.
+ * Mapping is based on regex matching (see {@link #urlPattern()}) against the
+ * repository URL.
+ */
+public interface MappingAuthConfig {
+
+ /**
+ * Identification for the auth config. Should be unique within the
+ * application config. May be used for identifying source configs in metrics */
+ String id();
+
+ /** 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..3eac8516fd
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/AccessTokenProvider.java
@@ -0,0 +1,200 @@
+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.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(GitHubAppAuthConfig 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))
+ .authId(app.id())
+ .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 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(GitHubAppAuthConfig 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 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 the only attribute we **need**, even though there's other
+ attributes. Some may differ between GitHub "cloud" and GitHub Enterprise/private.
+ 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/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
new file mode 100644
index 0000000000..0e20a0f468
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppAuthConfig.java
@@ -0,0 +1,55 @@
+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 GitHubAppAuthConfig(String id,
+ String apiUrl,
+ String clientId,
+ String privateKey,
+ String username,
+ Pattern urlPattern) implements MappingAuthConfig {
+
+ public GitHubAppAuthConfig(String id, 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.id = id;
+ 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..fc5dc8e777
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/GitHubAppInstallation.java
@@ -0,0 +1,205 @@
+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 GitHubAppAuthCacheKey key) {
+ return fetchToken(key.repoUri(), key.secretData());
+ }
+ });
+ }
+
+ @Override
+ public boolean supports(URI repo, @Nullable Secret secret) {
+ return Utils.validateSecret(secret, objectMapper) || systemSupports(repo);
+ }
+
+ private GitHubAppAuthCacheKey createKey(URI repoUri, @Nullable Secret secret) {
+ if (secret == null) {
+ return GitHubAppAuthCacheKey.from(repoUri);
+ }
+
+ if (secret instanceof BinaryDataSecret bds) {
+ return GitHubAppAuthCacheKey.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 ExternalAuthToken.StaticToken.builder()
+ .authId(tokenAuth.id())
+ .token(tokenAuth.token())
+ .username(tokenAuth.username())
+ .build();
+ }
+
+ if (auth instanceof GitHubAppAuthConfig 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, GitHubAppAuthCacheKey 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 ExternalAuthToken.StaticToken.builder()
+ .token(new String(data).trim())
+ .build();
+ }
+
+ private ExternalAuthToken getTokenFromAppInstall(GitHubAppAuthConfig app, URI repo) {
+ try {
+ var ownerAndRepo = Utils.extractOwnerAndRepo(app, repo);
+ return accessTokenProvider().getRepoInstallationToken(app, ownerAndRepo);
+ } catch (RepoExtractionException | GitHubAppException e) {
+ var msg = e.getMessage();
+ log.warn("getTokenFromAppInstall ['{}', '{}'] Error retrieving GitHub access token: {}", app.apiUrl(), repo, 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..014efd1fec
--- /dev/null
+++ b/github-app-installation/src/main/java/com/walmartlabs/concord/github/appinstallation/Utils.java
@@ -0,0 +1,167 @@
+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.typesafe.config.Config;
+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.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Supplier;
+
+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
+ * 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) {
+ var t = mapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class);
+
+ try { // find out if it's at least valid JSON.
+ var base = mapper.