Skip to content

Add GitHub authentication #853

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
7 changes: 7 additions & 0 deletions spring-vault-core/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,13 @@
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>${okhttp3.version}</version>
<scope>test</scope>
</dependency>

<!-- Logging -->

<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2017-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.vault.authentication;

import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.util.Assert;
import org.springframework.vault.VaultException;
import org.springframework.vault.support.VaultResponse;
import org.springframework.vault.support.VaultToken;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;

/**
* GitHub's authentication method can be used to authenticate with Vault using a GitHub
* personal access token.
*
* @author Nanne Baars
* @since 3.2
* @see GitHubAuthentication
* @see RestOperations
* @see <a href="https://www.vaultproject.io/api-docs/auth/github">GitHub Auth Backend</a>
*/
public class GitHubAuthentication implements ClientAuthentication, AuthenticationStepsFactory {

private static final Log logger = LogFactory.getLog(GitHubAuthentication.class);

private final GitHubAuthenticationOptions options;

private final RestOperations restOperations;

/**
* Create a {@link GitHubAuthentication} using {@link GitHubAuthenticationOptions} and
* {@link RestOperations}.
* @param options must not be {@literal null}.
* @param restOperations must not be {@literal null}.
*/
public GitHubAuthentication(GitHubAuthenticationOptions options, RestOperations restOperations) {

Assert.notNull(options, "GithubAuthenticationOptions must not be null");
Assert.notNull(restOperations, "RestOperations must not be null");

this.options = options;
this.restOperations = restOperations;
}

@Override
public AuthenticationSteps getAuthenticationSteps() {
return AuthenticationSteps.fromSupplier(options.getTokenSupplier())
.map(token -> getGitHubLogin(token))
.login(AuthenticationUtil.getLoginPath(this.options.getPath()));
}

@Override
public VaultToken login() throws VaultException {

Map<String, String> login = getGitHubLogin(this.options.getTokenSupplier().get());

try {

VaultResponse response = this.restOperations
.postForObject(AuthenticationUtil.getLoginPath(this.options.getPath()), login, VaultResponse.class);
Assert.state(response != null && response.getAuth() != null, "Auth field must not be null");

logger.debug("Login successful using GitHub authentication");

return LoginTokenUtil.from(response.getAuth());
}
catch (RestClientException e) {
throw VaultLoginException.create("GitHub", e);
}
}

private static Map<String, String> getGitHubLogin(String token) {
return Map.of("token", token);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Copyright 2017-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.vault.authentication;

import java.util.function.Supplier;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

/**
* Authentication options for {@link GitHubAuthentication}.
* <p>
* Authentication options provide the role and the token.
* {@link GitHubAuthenticationOptions} can be constructed using {@link #builder()}.
* Instances of this class are immutable once constructed.
*
* @author Nanne Baars
* @author Mark Paluch
* @since 3.2
* @see GitHubAuthentication
* @see #builder()
*/
public class GitHubAuthenticationOptions {

public static final String DEFAULT_GITHUB_AUTHENTICATION_PATH = "github";

/**
* Path of the GitHub authentication backend mount. Optional and defaults to
* {@literal github}.
*/
private final String path;

/**
* Supplier instance to obtain the GitHub personal access token.
*/
private final Supplier<String> tokenSupplier;

private GitHubAuthenticationOptions(Supplier<String> tokenSupplier, String path) {

this.tokenSupplier = tokenSupplier;
this.path = path;
}

/**
* @return a new {@link GitHubAuthenticationOptions}.
*/
public static GithubAuthenticationOptionsBuilder builder() {
return new GithubAuthenticationOptionsBuilder();
}

/**
* @return access token to use.
*/
public Supplier<String> getTokenSupplier() {
return this.tokenSupplier;
}

/**
* @return the path of the GitHub authentication backend mount.
*/
public String getPath() {
return this.path;
}

/**
* Builder for {@link GitHubAuthenticationOptions}.
*/
public static class GithubAuthenticationOptionsBuilder {

private String path = DEFAULT_GITHUB_AUTHENTICATION_PATH;

@Nullable
private Supplier<String> tokenSupplier;

/**
* Configure the mount path.
* @param path must not be {@literal null} or empty.
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
*/
public GithubAuthenticationOptionsBuilder path(String path) {

Assert.hasText(path, "Path must not be empty");

this.path = path;
return this;
}

/**
* Configure the GitHub token. Vault authentication will use this token as
* singleton. If you want to provide a dynamic token that can change over time,
* see {@link #tokenSupplier(Supplier)}.
* @param token must not be {@literal null}.
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
*/
public GithubAuthenticationOptionsBuilder token(String token) {

Assert.hasText(token, "Token must not be empty");

return tokenSupplier(() -> token);
}

/**
* Configure the {@link Supplier} to obtain a token.
* @param tokenSupplier must not be {@literal null}.
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
*/
public GithubAuthenticationOptionsBuilder tokenSupplier(Supplier<String> tokenSupplier) {

Assert.notNull(tokenSupplier, "Token supplier must not be null");

this.tokenSupplier = tokenSupplier;
return this;
}

/**
* Build a new {@link GitHubAuthenticationOptions} instance.
* @return a new {@link GitHubAuthenticationOptions}.
*/
public GitHubAuthenticationOptions build() {

Assert.notNull(this.tokenSupplier, "Token must not be null");

return new GitHubAuthenticationOptions(this.tokenSupplier, this.path);
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright 2017-2024 the original author or authors.
*
* 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
*
* https://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.
*/
package org.springframework.vault.authentication;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.IOException;
import java.util.Map;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.vault.support.VaultToken;
import org.springframework.vault.util.IntegrationTestSupport;
import org.springframework.vault.util.Settings;
import org.springframework.vault.util.TestRestTemplateFactory;
import org.springframework.web.client.RestTemplate;

/**
* Integration tests for {@link GitHubAuthentication} using
* {@link AuthenticationStepsExecutor}.
*
* @author Nanne Baars
* @author Mark Paluch
*/
class GitHubAuthenticationIntegrationTest extends IntegrationTestSupport {

private static final int organizationId = 1;

private final MockWebServer gitHubMockServer = new MockWebServer();

@BeforeEach
void before() throws Exception {

if (!prepare().hasAuth("github")) {
prepare().mountAuth("github");
}

prepare().getVaultOperations()
.doWithSession(
restOperations -> restOperations.postForEntity("auth/github/config", Map.of("organization_id", 1,
"base_url", "http://localhost:%d".formatted(gitHubMockServer.getPort())), Map.class));
}

@AfterEach
void after() throws IOException {
gitHubMockServer.shutdown();
}

@Test
void shouldLoginSuccessfully() {
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(organizationId),
gitHubTeamResponse(organizationId));

GitHubAuthentication authentication = new GitHubAuthentication(
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN").build(), restTemplate);
VaultToken loginToken = authentication.login();

assertThat(loginToken.getToken()).isNotNull();
}

@Test
void shouldFailIfOrganizationIsNotTheSame() {
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
var wrongOrganizationId = organizationId + 1;
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(wrongOrganizationId),
gitHubTeamResponse(wrongOrganizationId));

GitHubAuthentication authentication = new GitHubAuthentication(
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN2").build(), restTemplate);

assertThatThrownBy(authentication::login).isInstanceOf(VaultLoginException.class)
.hasMessageContaining("Cannot login using GitHub: user is not part of required org");
}

private String gitHubUserResponse() {
return """
{
"login": "octocat",
"id": 100
}
""";
}

private String gitHubOrganizationResponse(int organizationId) {
return """
[
{
"login": "Foo bar organization",
"id": %d
}
]
""".formatted(organizationId);
}

private String gitHubTeamResponse(int organizationId) {
return """
[
{
"id": 45,
"name": "Justice League",
"slug": "justice-league",
"organization": {
"id": %d
}
}
]
""".formatted(organizationId);
}

private void setupGithubMockServer(String userJson, String orgJson, String teamJson) {
gitHubMockServer.setDispatcher(new Dispatcher() {

@Override
public MockResponse dispatch(RecordedRequest request) {

return switch (request.getPath()) {
case "/user" -> new MockResponse().setResponseCode(200).setBody(userJson);
case "/user/orgs?per_page=100" -> new MockResponse().setResponseCode(200).setBody(orgJson);
case "/user/teams?per_page=100" -> new MockResponse().setResponseCode(200).setBody(teamJson);
default -> new MockResponse().setResponseCode(404);
};
}
});
}

}
Loading