diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index 7d4ff21c..96194f2c 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -321,6 +321,13 @@ test + + com.squareup.okhttp3 + mockwebserver + ${okhttp3.version} + test + + diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthentication.java new file mode 100644 index 00000000..197a5247 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthentication.java @@ -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 GitHub Auth Backend + */ +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 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 getGitHubLogin(String token) { + return Map.of("token", token); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthenticationOptions.java new file mode 100644 index 00000000..3edc0ba4 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/GitHubAuthenticationOptions.java @@ -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}. + *

+ * 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 tokenSupplier; + + private GitHubAuthenticationOptions(Supplier 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 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 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 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); + } + + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/GitHubAuthenticationIntegrationTest.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GitHubAuthenticationIntegrationTest.java new file mode 100644 index 00000000..5893afd7 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/GitHubAuthenticationIntegrationTest.java @@ -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); + }; + } + }); + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java index fea4e794..9be1e9ec 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java @@ -43,7 +43,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.springframework.vault.authentication.JwtAuthentication.DEFAULT_JWT_AUTHENTICATION_PATH; /** - * Integration tests for {@link KubernetesAuthentication} using + * Integration tests for {@link JwtAuthentication} using * {@link AuthenticationStepsExecutor}. * * @author Nanne Baars