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 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..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 @@ -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,19 +80,74 @@ 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); + String cacheKey; + if (isGithubAppInstallationToken(token)) { + cacheKey = "GITHUB_APP|" + new String(token); + } else { + cacheKey = login + "|" + new String(token); + } GithubPrincipal cached = tokenToPrincipalCache.getIfPresent(cacheKey); if (cached != null) { - LOGGER.debug("Using cached principal for login: {}", login); + if (isGithubAppInstallationToken(token)) { + LOGGER.debug("Using cached principal for GitHub App installation token"); + } else { + 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 { - GithubPrincipal principal = doAuthz(login, token); - tokenToPrincipalCache.put(cacheKey, principal); - return principal; + 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 { 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)); + } }