Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<INSTALLATION_TOKEN> http://localhost:8081/service/rest/v1/status
```
27 changes: 27 additions & 0 deletions gentoken.sh
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand All @@ -100,15 +104,19 @@ 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;
LOGGER.info("doGetAuthenticationInfo for {}", ((UsernamePasswordToken) token).getUsername());
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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<String, Object> map = mapper.readValue(reader, Map.class);
List<Map<String, Object>> repos = (List<Map<String, Object>>) map.get("repositories");
Set<String> repoNames = new HashSet<>();
for (Map<String, Object> 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<String> installationRepos = getGithubAppInstallationRepositories(token);
Set<String> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, "");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> responseMap = new HashMap<>();
List<Map<String, Object>> repos = new ArrayList<>();
Map<String, Object> repo1 = new HashMap<>();
repo1.put("full_name", "TEST-ORG/repo1");
repos.add(repo1);
Map<String, Object> 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));
}
}