From 8001bcffe5a75117cde5d4d0f2c5dc2bb30f1b1e Mon Sep 17 00:00:00 2001 From: nick lynch-jonely Date: Tue, 24 Jun 2025 10:22:20 -0700 Subject: [PATCH 1/3] feat: adds functionality for github app installations --- README.md | 18 +++++ gentoken.sh | 27 ++++++++ .../GithubOauthAuthenticatingRealm.java | 18 +++-- .../oauth/plugin/api/GithubApiClient.java | 65 ++++++++++++++++--- .../GithubOauthConfiguration.java | 8 +++ .../oauth/plugin/GithubApiClientTest.java | 28 ++++++++ 6 files changed, 149 insertions(+), 15 deletions(-) create mode 100755 gentoken.sh diff --git a/README.md b/README.md index 47b203f..ee66c07 100644 --- a/README.md +++ b/README.md @@ -160,3 +160,21 @@ From the github api: > Lists repositories that the authenticated user has explicit permission (:read, :write, or :admin) to access. This means that any user who has explicit permission, will be granted the role in nexus. You need to be careful with how you give those roles access. It does mean that public repos are ok to have in your org, because repos will only show up for a user when granted explicit permissions. + + +**Using GitHub App Tokens** + +You can now authenticate to Nexus using a GitHub App installation from a valid org in addition to user tokens + +**Testing** +1. **Generate a GitHub App installation token** + - Use a script (see `gentoken.sh` to generate a token for the app installation. + - Needs a valid App ID, Installation ID, and private key. + +2. **Login to Nexus** + - Use any string as the username (e.g., `GITHUB_APP`), and use the installation token as the password. + + Example with curl: + ```sh + curl -u artificialbuild: http://localhost:8081/service/rest/v1/status + ``` diff --git a/gentoken.sh b/gentoken.sh new file mode 100755 index 0000000..d7a1dc5 --- /dev/null +++ b/gentoken.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +APP_ID="app_id_from_github" +INSTALLATION_ID="installation_id_from_url" +PRIVATE_KEY_PATH="/path/to/private-key.pem" + +# 9 min expiration JWT +header='{"alg":"RS256","typ":"JWT"}' +iat=$(date +%s) +exp=$((iat + 540)) +payload="{\"iat\":$iat,\"exp\":$exp,\"iss\":$APP_ID}" + +base64url() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; } +header_b64=$(echo -n "$header" | base64url) +payload_b64=$(echo -n "$payload" | base64url) +unsigned_token="${header_b64}.${payload_b64}" + +signature=$(echo -n "$unsigned_token" | openssl dgst -sha256 -sign "$PRIVATE_KEY_PATH" | base64url) +jwt="${unsigned_token}.${signature}" + +# Exchange JWT for installation token +response=$(curl -s -X POST \ + -H "Authorization: Bearer $jwt" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens") + +echo "$response" | grep token diff --git a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubOauthAuthenticatingRealm.java b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubOauthAuthenticatingRealm.java index 87dce0f..403073c 100755 --- a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubOauthAuthenticatingRealm.java +++ b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubOauthAuthenticatingRealm.java @@ -79,13 +79,17 @@ protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal GithubPrincipal authenticatedPrincipal; try { authenticatedPrincipal = githubClient.authz(user.getUsername(), user.getOauthToken()); - LOGGER.info("Successfully authenticated {}", user.getUsername()); + if (githubClient.isGithubAppInstallationToken(user.getOauthToken())) { + LOGGER.info("Authenticated [GITHUB_APP] using GitHub App installation token. Mapped repositories as roles: {}", + authenticatedPrincipal.getRoles().stream().collect(Collectors.joining(", "))); + } else { + LOGGER.info("Authenticated {} using user token. Mapped roles: {}", user.getUsername(), + authenticatedPrincipal.getRoles().stream().collect(Collectors.joining(", "))); + } } catch (GithubAuthenticationException e) { LOGGER.warn("Failed authentication", e); return null; } - LOGGER.info("doGetAuthorizationInfo for user {} with roles {}", authenticatedPrincipal.getUsername(), - authenticatedPrincipal.getRoles().stream().collect(Collectors.joining(", "))); return new SimpleAuthorizationInfo(authenticatedPrincipal.getRoles()); } @@ -100,7 +104,7 @@ protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { if (!(token instanceof UsernamePasswordToken)) { throw new UnsupportedTokenException(String.format("Token of type %s is not supported. A %s is required.", - token.getClass().getName(), UsernamePasswordToken.class.getName())); + token.getClass().getName(), UsernamePasswordToken.class.getName())); } UsernamePasswordToken t = (UsernamePasswordToken) token; @@ -108,7 +112,11 @@ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) GithubPrincipal authenticatedPrincipal; try { authenticatedPrincipal = githubClient.authz(t.getUsername(), t.getPassword()); - LOGGER.info("Successfully authenticated {}", t.getUsername()); + if (githubClient.isGithubAppInstallationToken(t.getPassword())) { + LOGGER.info("Authenticated [GITHUB_APP] using GitHub App installation token."); + } else { + LOGGER.info("Authenticated {} using user token.", t.getUsername()); + } } catch (GithubAuthenticationException e) { LOGGER.warn("Failed authentication", e); return null; diff --git a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java index 51865da..0fc2276 100644 --- a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java +++ b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java @@ -7,6 +7,9 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; import javax.inject.Inject; import javax.inject.Named; @@ -77,18 +80,60 @@ private void initPrincipalCache() { .build(); } + // Helper to detect if a token is a GitHub App installation token + public boolean isGithubAppInstallationToken(char[] token) { + String tokenStr = new String(token); + return tokenStr.startsWith("ghs_"); + } + + // Fetch installation repositories for a GitHub App installation token + public Set getGithubAppInstallationRepositories(char[] installationToken) throws GithubAuthenticationException { + String uri = configuration.getGithubApiUrl() + "/installation/repositories"; + // The response has a 'repositories' field which is a list + try (InputStreamReader reader = executeGet(uri, installationToken)) { + ObjectMapper mapper = new ObjectMapper(); + Map map = mapper.readValue(reader, Map.class); + List> repos = (List>) map.get("repositories"); + Set repoNames = new HashSet<>(); + for (Map repo : repos) { + String fullName = (String) repo.get("full_name"); + repoNames.add(fullName); + } + return repoNames; + } catch (IOException e) { + throw new GithubAuthenticationException(e); + } + } + public GithubPrincipal authz(String login, char[] token) throws GithubAuthenticationException { - // Combine the login and the token as the cache key since they are both used to generate the principal. If either changes we should obtain a new - // principal. - String cacheKey = login + "|" + new String(token); - GithubPrincipal cached = tokenToPrincipalCache.getIfPresent(cacheKey); - if (cached != null) { - LOGGER.debug("Using cached principal for login: {}", login); - return cached; - } else { - GithubPrincipal principal = doAuthz(login, token); - tokenToPrincipalCache.put(cacheKey, principal); + if (isGithubAppInstallationToken(token)) { + // App installation token: validate and authorize + String allowedOrg = configuration.getGithubOrg(); + Set installationRepos = getGithubAppInstallationRepositories(token); + Set authorizedRepos = installationRepos.stream() + .filter(repo -> allowedOrg.isEmpty() || repo.startsWith(allowedOrg + "/")) + .collect(Collectors.toSet()); + if (authorizedRepos.isEmpty()) { + throw new GithubAuthenticationException("No authorized repositories for this GitHub App installation token"); + } + GithubPrincipal principal = new GithubPrincipal(); + principal.setUsername(login); + principal.setRoles(authorizedRepos); + principal.setOauthToken(token); return principal; + } else { + // Combine the login and the token as the cache key since they are both used to generate the principal. If either changes we should obtain a new + // principal. + String cacheKey = login + "|" + new String(token); + GithubPrincipal cached = tokenToPrincipalCache.getIfPresent(cacheKey); + if (cached != null) { + LOGGER.debug("Using cached principal for login: {}", login); + return cached; + } else { + GithubPrincipal principal = doAuthz(login, token); + tokenToPrincipalCache.put(cacheKey, principal); + return principal; + } } } diff --git a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/configuration/GithubOauthConfiguration.java b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/configuration/GithubOauthConfiguration.java index b303615..e2a2d33 100644 --- a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/configuration/GithubOauthConfiguration.java +++ b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/configuration/GithubOauthConfiguration.java @@ -53,6 +53,9 @@ public class GithubOauthConfiguration { private static final int DEFAULT_REQUEST_SOCKET_TIMEOUT = -1; + private static final String GITHUB_APP_ID = "github.app.id"; + + private static final Logger LOGGER = LoggerFactory.getLogger(GithubOauthConfiguration.class); private Properties configuration; @@ -109,4 +112,9 @@ public Integer getRequestConnectionRequestTimeout() { public Integer getRequestSocketTimeout() { return Integer.parseInt(configuration.getProperty(REQUEST_SOCKET_TIMEOUT, String.valueOf(DEFAULT_REQUEST_SOCKET_TIMEOUT))); } + + public String getGithubAppId() { + return configuration.getProperty(GITHUB_APP_ID, ""); + } + } diff --git a/src/test/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubApiClientTest.java b/src/test/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubApiClientTest.java index e0cf104..1224880 100644 --- a/src/test/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubApiClientTest.java +++ b/src/test/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/GithubApiClientTest.java @@ -349,4 +349,32 @@ public void addBaseRole() throws Exception { MatcherAssert.assertThat(roleIter.next(), Is.is("TEST-ORG/admin")); MatcherAssert.assertThat(roleIter.next(), Is.is("REPO-OWNER/demo-repo")); } + + @Test + public void shouldAuthorizeWithGithubAppToken() throws Exception { + // Mock the /installation/repositories response + HttpClient mockClient = mock(HttpClient.class); + Map responseMap = new HashMap<>(); + List> repos = new ArrayList<>(); + Map repo1 = new HashMap<>(); + repo1.put("full_name", "TEST-ORG/repo1"); + repos.add(repo1); + Map repo2 = new HashMap<>(); + repo2.put("full_name", "OTHER-ORG/repo2"); + repos.add(repo2); + responseMap.put("repositories", repos); + HttpResponse mockResponse = createMockResponse(responseMap); + when(mockClient.execute(Mockito.any())).thenReturn(mockResponse); + + config.setGithubOrg("TEST-ORG"); + GithubApiClient clientToTest = new GithubApiClient(mockClient, config); + // Use a token that will be detected as an app installation token + char[] appToken = "ghs_1234567890abcdef".toCharArray(); + GithubPrincipal principal = clientToTest.authz("ignored", appToken); + // Only TEST-ORG/repo1 should be authorized + MatcherAssert.assertThat(principal.getRoles().size(), Is.is(1)); + MatcherAssert.assertThat(principal.getRoles().iterator().next(), Is.is("TEST-ORG/repo1")); + MatcherAssert.assertThat(principal.getOauthToken(), Is.is(appToken)); + MatcherAssert.assertThat(principal.getRoles().contains("OTHER-ORG/repo2"), Is.is(false)); + } } From f2feefa71044b12987eb6e37666280734b89586f Mon Sep 17 00:00:00 2001 From: nick lynch-jonely Date: Tue, 24 Jun 2025 13:03:43 -0700 Subject: [PATCH 2/3] bump nexus image to match deployed --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1495e7e..227d436 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY . /build WORKDIR /build RUN --mount=type=cache,target=/m2 mvn clean package -Dmaven.repo.local=/m2 -X -FROM sonatype/nexus3:3.52.0 +FROM sonatype/nexus3:3.70.2 USER root COPY --from=builder /build/target/nexus3-github-oauth-plugin-*.kar /opt/sonatype/nexus/deploy COPY githuboauth.properties /opt/sonatype/nexus/etc/githuboauth.properties From 05d26caa7199872bedaf4280dc41eac6ca441ba0 Mon Sep 17 00:00:00 2001 From: nick lynch-jonely Date: Tue, 24 Jun 2025 15:27:32 -0700 Subject: [PATCH 3/3] cache resulting principal for both login methods --- .../oauth/plugin/api/GithubApiClient.java | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java index 0fc2276..42bb124 100644 --- a/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java +++ b/src/main/java/com/larscheidschmitzhermes/nexus3/github/oauth/plugin/api/GithubApiClient.java @@ -106,35 +106,48 @@ public Set getGithubAppInstallationRepositories(char[] installationToken } public GithubPrincipal authz(String login, char[] token) throws GithubAuthenticationException { + String cacheKey; if (isGithubAppInstallationToken(token)) { - // App installation token: validate and authorize - String allowedOrg = configuration.getGithubOrg(); - Set installationRepos = getGithubAppInstallationRepositories(token); - Set authorizedRepos = installationRepos.stream() - .filter(repo -> allowedOrg.isEmpty() || repo.startsWith(allowedOrg + "/")) - .collect(Collectors.toSet()); - if (authorizedRepos.isEmpty()) { - throw new GithubAuthenticationException("No authorized repositories for this GitHub App installation token"); - } - GithubPrincipal principal = new GithubPrincipal(); - principal.setUsername(login); - principal.setRoles(authorizedRepos); - principal.setOauthToken(token); - return principal; + cacheKey = "GITHUB_APP|" + new String(token); } else { - // Combine the login and the token as the cache key since they are both used to generate the principal. If either changes we should obtain a new - // principal. - String cacheKey = login + "|" + new String(token); - GithubPrincipal cached = tokenToPrincipalCache.getIfPresent(cacheKey); - if (cached != null) { - LOGGER.debug("Using cached principal for login: {}", login); - return cached; + cacheKey = login + "|" + new String(token); + } + GithubPrincipal cached = tokenToPrincipalCache.getIfPresent(cacheKey); + if (cached != null) { + if (isGithubAppInstallationToken(token)) { + LOGGER.debug("Using cached principal for GitHub App installation token"); } else { - GithubPrincipal principal = doAuthz(login, token); - tokenToPrincipalCache.put(cacheKey, principal); - return principal; + LOGGER.debug("Using cached principal for login: {}", login); } + return cached; + } + // not in cache so fetch from GitHub + LOGGER.debug("Fetching principal for login: {}", login) + GithubPrincipal principal; + if (isGithubAppInstallationToken(token)) { + principal = doAuthzGithubApp(token); + } else { + principal = doAuthz(login, token); } + tokenToPrincipalCache.put(cacheKey, principal); + return principal; + } + + // Handles GitHub App installation token authorization + private GithubPrincipal doAuthzGithubApp(char[] token) throws GithubAuthenticationException { + String allowedOrg = configuration.getGithubOrg(); + Set installationRepos = getGithubAppInstallationRepositories(token); + Set authorizedRepos = installationRepos.stream() + .filter(repo -> allowedOrg.isEmpty() || repo.startsWith(allowedOrg + "/")) + .collect(Collectors.toSet()); + if (authorizedRepos.isEmpty()) { + throw new GithubAuthenticationException("No authorized repositories for this GitHub App installation token"); + } + GithubPrincipal principal = new GithubPrincipal(); + principal.setUsername("GITHUB_APP"); + principal.setRoles(authorizedRepos); + principal.setOauthToken(token); + return principal; } private GithubPrincipal doAuthz(String loginName, char[] token) throws GithubAuthenticationException {