Skip to content

Commit fdcab61

Browse files
nbaarsmp911de
authored andcommitted
Add GitHub authentication.
Closes gh-821 Original pull request: gh-853
1 parent af060ac commit fdcab61

File tree

5 files changed

+385
-1
lines changed

5 files changed

+385
-1
lines changed

spring-vault-core/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,13 @@
321321
<scope>test</scope>
322322
</dependency>
323323

324+
<dependency>
325+
<groupId>com.squareup.okhttp3</groupId>
326+
<artifactId>mockwebserver</artifactId>
327+
<version>${okhttp3.version}</version>
328+
<scope>test</scope>
329+
</dependency>
330+
324331
<!-- Logging -->
325332

326333
<dependency>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2017-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.vault.authentication;
17+
18+
import java.util.Map;
19+
import org.apache.commons.logging.Log;
20+
import org.apache.commons.logging.LogFactory;
21+
import org.springframework.util.Assert;
22+
import org.springframework.vault.VaultException;
23+
import org.springframework.vault.support.VaultResponse;
24+
import org.springframework.vault.support.VaultToken;
25+
import org.springframework.web.client.RestClientException;
26+
import org.springframework.web.client.RestOperations;
27+
28+
/**
29+
* GitHub's authentication method can be used to authenticate with Vault using a GitHub
30+
* personal access token.
31+
*
32+
* @author Nanne Baars
33+
* @since 3.2
34+
* @see GitHubAuthentication
35+
* @see RestOperations
36+
* @see <a href="https://www.vaultproject.io/api-docs/auth/github">GitHub Auth Backend</a>
37+
*/
38+
public class GitHubAuthentication implements ClientAuthentication, AuthenticationStepsFactory {
39+
40+
private static final Log logger = LogFactory.getLog(GitHubAuthentication.class);
41+
42+
private final GitHubAuthenticationOptions options;
43+
44+
private final RestOperations restOperations;
45+
46+
/**
47+
* Create a {@link GitHubAuthentication} using {@link GitHubAuthenticationOptions} and
48+
* {@link RestOperations}.
49+
* @param options must not be {@literal null}.
50+
* @param restOperations must not be {@literal null}.
51+
*/
52+
public GitHubAuthentication(GitHubAuthenticationOptions options, RestOperations restOperations) {
53+
54+
Assert.notNull(options, "GithubAuthenticationOptions must not be null");
55+
Assert.notNull(restOperations, "RestOperations must not be null");
56+
57+
this.options = options;
58+
this.restOperations = restOperations;
59+
}
60+
61+
@Override
62+
public AuthenticationSteps getAuthenticationSteps() {
63+
return AuthenticationSteps.fromSupplier(options.getTokenSupplier())
64+
.map(token -> getGitHubLogin(token))
65+
.login(AuthenticationUtil.getLoginPath(this.options.getPath()));
66+
}
67+
68+
@Override
69+
public VaultToken login() throws VaultException {
70+
71+
Map<String, String> login = getGitHubLogin(this.options.getTokenSupplier().get());
72+
73+
try {
74+
75+
VaultResponse response = this.restOperations
76+
.postForObject(AuthenticationUtil.getLoginPath(this.options.getPath()), login, VaultResponse.class);
77+
Assert.state(response != null && response.getAuth() != null, "Auth field must not be null");
78+
79+
logger.debug("Login successful using GitHub authentication");
80+
81+
return LoginTokenUtil.from(response.getAuth());
82+
}
83+
catch (RestClientException e) {
84+
throw VaultLoginException.create("GitHub", e);
85+
}
86+
}
87+
88+
private static Map<String, String> getGitHubLogin(String token) {
89+
return Map.of("token", token);
90+
}
91+
92+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* Copyright 2017-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.vault.authentication;
17+
18+
import java.util.function.Supplier;
19+
import org.springframework.lang.Nullable;
20+
import org.springframework.util.Assert;
21+
22+
/**
23+
* Authentication options for {@link GitHubAuthentication}.
24+
* <p>
25+
* Authentication options provide the role and the token.
26+
* {@link GitHubAuthenticationOptions} can be constructed using {@link #builder()}.
27+
* Instances of this class are immutable once constructed.
28+
*
29+
* @author Nanne Baars
30+
* @author Mark Paluch
31+
* @since 3.2
32+
* @see GitHubAuthentication
33+
* @see #builder()
34+
*/
35+
public class GitHubAuthenticationOptions {
36+
37+
public static final String DEFAULT_GITHUB_AUTHENTICATION_PATH = "github";
38+
39+
/**
40+
* Path of the GitHub authentication backend mount. Optional and defaults to
41+
* {@literal github}.
42+
*/
43+
private final String path;
44+
45+
/**
46+
* Supplier instance to obtain the GitHub personal access token.
47+
*/
48+
private final Supplier<String> tokenSupplier;
49+
50+
private GitHubAuthenticationOptions(Supplier<String> tokenSupplier, String path) {
51+
52+
this.tokenSupplier = tokenSupplier;
53+
this.path = path;
54+
}
55+
56+
/**
57+
* @return a new {@link GitHubAuthenticationOptions}.
58+
*/
59+
public static GithubAuthenticationOptionsBuilder builder() {
60+
return new GithubAuthenticationOptionsBuilder();
61+
}
62+
63+
/**
64+
* @return access token to use.
65+
*/
66+
public Supplier<String> getTokenSupplier() {
67+
return this.tokenSupplier;
68+
}
69+
70+
/**
71+
* @return the path of the GitHub authentication backend mount.
72+
*/
73+
public String getPath() {
74+
return this.path;
75+
}
76+
77+
/**
78+
* Builder for {@link GitHubAuthenticationOptions}.
79+
*/
80+
public static class GithubAuthenticationOptionsBuilder {
81+
82+
private String path = DEFAULT_GITHUB_AUTHENTICATION_PATH;
83+
84+
@Nullable
85+
private Supplier<String> tokenSupplier;
86+
87+
/**
88+
* Configure the mount path.
89+
* @param path must not be {@literal null} or empty.
90+
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
91+
*/
92+
public GithubAuthenticationOptionsBuilder path(String path) {
93+
94+
Assert.hasText(path, "Path must not be empty");
95+
96+
this.path = path;
97+
return this;
98+
}
99+
100+
/**
101+
* Configure the GitHub token. Vault authentication will use this token as
102+
* singleton. If you want to provide a dynamic token that can change over time,
103+
* see {@link #tokenSupplier(Supplier)}.
104+
* @param token must not be {@literal null}.
105+
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
106+
*/
107+
public GithubAuthenticationOptionsBuilder token(String token) {
108+
109+
Assert.hasText(token, "Token must not be empty");
110+
111+
return tokenSupplier(() -> token);
112+
}
113+
114+
/**
115+
* Configure the {@link Supplier} to obtain a token.
116+
* @param tokenSupplier must not be {@literal null}.
117+
* @return {@code this} {@link GithubAuthenticationOptionsBuilder}.
118+
*/
119+
public GithubAuthenticationOptionsBuilder tokenSupplier(Supplier<String> tokenSupplier) {
120+
121+
Assert.notNull(tokenSupplier, "Token supplier must not be null");
122+
123+
this.tokenSupplier = tokenSupplier;
124+
return this;
125+
}
126+
127+
/**
128+
* Build a new {@link GitHubAuthenticationOptions} instance.
129+
* @return a new {@link GitHubAuthenticationOptions}.
130+
*/
131+
public GitHubAuthenticationOptions build() {
132+
133+
Assert.notNull(this.tokenSupplier, "Token must not be null");
134+
135+
return new GitHubAuthenticationOptions(this.tokenSupplier, this.path);
136+
}
137+
138+
}
139+
140+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2017-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.vault.authentication;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
20+
21+
import java.io.IOException;
22+
import java.util.Map;
23+
import okhttp3.mockwebserver.Dispatcher;
24+
import okhttp3.mockwebserver.MockResponse;
25+
import okhttp3.mockwebserver.MockWebServer;
26+
import okhttp3.mockwebserver.RecordedRequest;
27+
import org.junit.jupiter.api.AfterEach;
28+
import org.junit.jupiter.api.BeforeEach;
29+
import org.junit.jupiter.api.Test;
30+
import org.springframework.vault.support.VaultToken;
31+
import org.springframework.vault.util.IntegrationTestSupport;
32+
import org.springframework.vault.util.Settings;
33+
import org.springframework.vault.util.TestRestTemplateFactory;
34+
import org.springframework.web.client.RestTemplate;
35+
36+
/**
37+
* Integration tests for {@link GitHubAuthentication} using
38+
* {@link AuthenticationStepsExecutor}.
39+
*
40+
* @author Nanne Baars
41+
* @author Mark Paluch
42+
*/
43+
class GitHubAuthenticationIntegrationTest extends IntegrationTestSupport {
44+
45+
private static final int organizationId = 1;
46+
47+
private final MockWebServer gitHubMockServer = new MockWebServer();
48+
49+
@BeforeEach
50+
void before() throws Exception {
51+
52+
if (!prepare().hasAuth("github")) {
53+
prepare().mountAuth("github");
54+
}
55+
56+
prepare().getVaultOperations()
57+
.doWithSession(
58+
restOperations -> restOperations.postForEntity("auth/github/config", Map.of("organization_id", 1,
59+
"base_url", "http://localhost:%d".formatted(gitHubMockServer.getPort())), Map.class));
60+
}
61+
62+
@AfterEach
63+
void after() throws IOException {
64+
gitHubMockServer.shutdown();
65+
}
66+
67+
@Test
68+
void shouldLoginSuccessfully() {
69+
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
70+
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(organizationId),
71+
gitHubTeamResponse(organizationId));
72+
73+
GitHubAuthentication authentication = new GitHubAuthentication(
74+
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN").build(), restTemplate);
75+
VaultToken loginToken = authentication.login();
76+
77+
assertThat(loginToken.getToken()).isNotNull();
78+
}
79+
80+
@Test
81+
void shouldFailIfOrganizationIsNotTheSame() {
82+
RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration());
83+
var wrongOrganizationId = organizationId + 1;
84+
setupGithubMockServer(gitHubUserResponse(), gitHubOrganizationResponse(wrongOrganizationId),
85+
gitHubTeamResponse(wrongOrganizationId));
86+
87+
GitHubAuthentication authentication = new GitHubAuthentication(
88+
GitHubAuthenticationOptions.builder().tokenSupplier(() -> "TOKEN2").build(), restTemplate);
89+
90+
assertThatThrownBy(authentication::login).isInstanceOf(VaultLoginException.class)
91+
.hasMessageContaining("Cannot login using GitHub: user is not part of required org");
92+
}
93+
94+
private String gitHubUserResponse() {
95+
return """
96+
{
97+
"login": "octocat",
98+
"id": 100
99+
}
100+
""";
101+
}
102+
103+
private String gitHubOrganizationResponse(int organizationId) {
104+
return """
105+
[
106+
{
107+
"login": "Foo bar organization",
108+
"id": %d
109+
}
110+
]
111+
""".formatted(organizationId);
112+
}
113+
114+
private String gitHubTeamResponse(int organizationId) {
115+
return """
116+
[
117+
{
118+
"id": 45,
119+
"name": "Justice League",
120+
"slug": "justice-league",
121+
"organization": {
122+
"id": %d
123+
}
124+
}
125+
]
126+
""".formatted(organizationId);
127+
}
128+
129+
private void setupGithubMockServer(String userJson, String orgJson, String teamJson) {
130+
gitHubMockServer.setDispatcher(new Dispatcher() {
131+
132+
@Override
133+
public MockResponse dispatch(RecordedRequest request) {
134+
135+
return switch (request.getPath()) {
136+
case "/user" -> new MockResponse().setResponseCode(200).setBody(userJson);
137+
case "/user/orgs?per_page=100" -> new MockResponse().setResponseCode(200).setBody(orgJson);
138+
case "/user/teams?per_page=100" -> new MockResponse().setResponseCode(200).setBody(teamJson);
139+
default -> new MockResponse().setResponseCode(404);
140+
};
141+
}
142+
});
143+
}
144+
145+
}

0 commit comments

Comments
 (0)