Skip to content

Commit 5ce0c64

Browse files
Support profile activation for custom access tokens (elastic#134561)
Introducing a new `CustomTokenAuthenticator` interface which adds support for extracting authentication tokens from the `Grant` objects. The new interface allows extending `TransportActivateProfileAction` to support profile creation for custom access token types.
1 parent 5c3b3b2 commit 5ce0c64

File tree

10 files changed

+396
-34
lines changed

10 files changed

+396
-34
lines changed

x-pack/plugin/core/src/main/java/module-info.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@
234234
exports org.elasticsearch.xpack.core.watcher.trigger;
235235
exports org.elasticsearch.xpack.core.watcher.watch;
236236
exports org.elasticsearch.xpack.core.watcher;
237-
exports org.elasticsearch.xpack.core.security.authc.apikey;
238237
exports org.elasticsearch.xpack.core.common.chunks;
239238

240239
provides org.elasticsearch.action.admin.cluster.node.info.ComponentVersionNumber

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/SecurityExtension.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
import org.elasticsearch.threadpool.ThreadPool;
1616
import org.elasticsearch.watcher.ResourceWatcherService;
1717
import org.elasticsearch.xpack.core.security.authc.AuthenticationFailureHandler;
18+
import org.elasticsearch.xpack.core.security.authc.CustomAuthenticator;
1819
import org.elasticsearch.xpack.core.security.authc.Realm;
19-
import org.elasticsearch.xpack.core.security.authc.apikey.CustomAuthenticator;
2020
import org.elasticsearch.xpack.core.security.authc.service.NodeLocalServiceAccountTokenStore;
2121
import org.elasticsearch.xpack.core.security.authc.service.ServiceAccountTokenStore;
2222
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper;
Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@
55
* 2.0.
66
*/
77

8-
package org.elasticsearch.xpack.core.security.authc.apikey;
8+
package org.elasticsearch.xpack.core.security.authc;
99

1010
import org.elasticsearch.action.ActionListener;
1111
import org.elasticsearch.common.util.concurrent.ThreadContext;
1212
import org.elasticsearch.core.Nullable;
13-
import org.elasticsearch.xpack.core.security.authc.Authentication;
14-
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
15-
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
1613

1714
/**
1815
* An extension point to provide a custom authenticator implementation. For example, a custom API key or a custom OAuth2
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.core.security.authc;
9+
10+
import org.elasticsearch.action.ActionListener;
11+
import org.elasticsearch.core.Nullable;
12+
import org.elasticsearch.xpack.core.security.action.Grant;
13+
14+
/**
15+
* Represents a custom authenticator that supports access token authentication method.
16+
*/
17+
public interface CustomTokenAuthenticator extends CustomAuthenticator {
18+
19+
/**
20+
* Called to extract {@code AuthenticationToken} for the {@link Grant#ACCESS_TOKEN_GRANT_TYPE}.
21+
*
22+
* <p>
23+
* Note: Currently, this method is only called to extract token during user profile activation.
24+
* The extracted token will be used to call the {@link #authenticate(AuthenticationToken, ActionListener)}
25+
* method, before creating a user profile.
26+
*
27+
* <p>
28+
* To opt-out, implementors should return {@code null} if profile activation is not supported.
29+
*
30+
* @param grant grant that holds end-user credentials
31+
* @return an authentication token if grant holds credentials
32+
* that are supported by this authenticator
33+
*/
34+
@Nullable
35+
default AuthenticationToken extractGrantAccessToken(Grant grant) {
36+
return null;
37+
}
38+
}
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.profile;
9+
10+
import org.elasticsearch.ElasticsearchSecurityException;
11+
import org.elasticsearch.action.ActionListener;
12+
import org.elasticsearch.common.settings.SecureString;
13+
import org.elasticsearch.common.settings.Settings;
14+
import org.elasticsearch.common.util.concurrent.ThreadContext;
15+
import org.elasticsearch.plugins.Plugin;
16+
import org.elasticsearch.test.SecurityIntegTestCase;
17+
import org.elasticsearch.xpack.core.security.SecurityExtension;
18+
import org.elasticsearch.xpack.core.security.action.Grant;
19+
import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileAction;
20+
import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileRequest;
21+
import org.elasticsearch.xpack.core.security.action.profile.ActivateProfileResponse;
22+
import org.elasticsearch.xpack.core.security.action.profile.Profile;
23+
import org.elasticsearch.xpack.core.security.authc.Authentication;
24+
import org.elasticsearch.xpack.core.security.authc.AuthenticationResult;
25+
import org.elasticsearch.xpack.core.security.authc.AuthenticationToken;
26+
import org.elasticsearch.xpack.core.security.authc.CustomAuthenticator;
27+
import org.elasticsearch.xpack.core.security.authc.CustomTokenAuthenticator;
28+
import org.elasticsearch.xpack.core.security.authc.support.Hasher;
29+
import org.elasticsearch.xpack.core.security.user.User;
30+
import org.elasticsearch.xpack.security.LocalStateSecurity;
31+
import org.junit.Before;
32+
33+
import java.nio.file.Path;
34+
import java.util.ArrayList;
35+
import java.util.Collection;
36+
import java.util.List;
37+
38+
import static org.elasticsearch.test.SecuritySettingsSourceField.TEST_PASSWORD_SECURE_STRING;
39+
import static org.hamcrest.Matchers.anEmptyMap;
40+
import static org.hamcrest.Matchers.contains;
41+
import static org.hamcrest.Matchers.containsString;
42+
import static org.hamcrest.Matchers.emptyIterable;
43+
import static org.hamcrest.Matchers.equalTo;
44+
import static org.hamcrest.Matchers.is;
45+
import static org.hamcrest.Matchers.notNullValue;
46+
import static org.hamcrest.Matchers.nullValue;
47+
48+
public class ProfileCustomAuthenticatorIntegTests extends SecurityIntegTestCase {
49+
50+
private static final Authentication.RealmRef TEST_REALM_REF = new Authentication.RealmRef("cloud-saml", "saml", "test-node");
51+
private static final String TEST_USERNAME = "spiderman";
52+
private static final String TEST_ROLE_NAME = "admin";
53+
54+
private static final TestCustomTokenAuthenticator authenticator = new TestCustomTokenAuthenticator();
55+
56+
@Override
57+
protected Collection<Class<? extends Plugin>> nodePlugins() {
58+
var plugins = new ArrayList<>(super.nodePlugins());
59+
plugins.remove(LocalStateSecurity.class);
60+
plugins.add(TestCustomAuthenticatorSecurityPlugin.class);
61+
return plugins;
62+
}
63+
64+
@Override
65+
protected boolean addMockHttpTransport() {
66+
return false;
67+
}
68+
69+
@Override
70+
protected void doAssertXPackIsInstalled() {
71+
// avoids tripping the assertion due to missing LocalStateSecurity
72+
}
73+
74+
@Before
75+
public void resetAuthenticator() {
76+
authenticator.reset();
77+
}
78+
79+
@Override
80+
protected String configUsers() {
81+
final Hasher passwdHasher = getFastStoredHashAlgoForTests();
82+
final String usersPasswdHashed = new String(passwdHasher.hash(TEST_PASSWORD_SECURE_STRING));
83+
return super.configUsers() + "file_user:" + usersPasswdHashed + "\n";
84+
}
85+
86+
@Override
87+
protected String configUsersRoles() {
88+
return super.configUsersRoles() + """
89+
editor:file_user""";
90+
}
91+
92+
public void testProfileActivationSuccess() {
93+
final SecureString accessToken = new SecureString("strawberries".toCharArray());
94+
final Profile profile = doActivateProfileWithAccessToken(accessToken);
95+
96+
assertThat(authenticator.extractedGrantTokens(), contains(new TestCustomAccessToken(accessToken)));
97+
assertThat(authenticator.authenticatedTokens(), contains(new TestCustomAccessToken(accessToken)));
98+
99+
assertThat(profile.user().realmName(), equalTo(TEST_REALM_REF.getName()));
100+
assertThat(profile.user().username(), equalTo(TEST_USERNAME));
101+
assertThat(profile.user().roles(), contains(TEST_ROLE_NAME));
102+
assertThat(profile.user().domainName(), nullValue());
103+
assertThat(profile.user().fullName(), nullValue());
104+
assertThat(profile.user().email(), nullValue());
105+
}
106+
107+
public void testProfileActivationFailure() {
108+
authenticator.setAuthFailure(new Exception("simulate authentication failure"));
109+
110+
final SecureString accessToken = new SecureString("blueberries".toCharArray());
111+
var e = expectThrows(ElasticsearchSecurityException.class, () -> doActivateProfileWithAccessToken(accessToken));
112+
113+
assertThat(e.getMessage(), equalTo("error attempting to authenticate request"));
114+
assertThat(e.getCause(), notNullValue());
115+
assertThat(e.getCause().getMessage(), equalTo("simulate authentication failure"));
116+
117+
assertThat(authenticator.extractedGrantTokens(), contains(new TestCustomAccessToken(accessToken)));
118+
assertThat(authenticator.authenticatedTokens(), contains(new TestCustomAccessToken(accessToken)));
119+
}
120+
121+
public void testProfileActivationNotHandled() {
122+
// extract token returns null -> no applicable auth handler
123+
authenticator.setShouldExtractAccessToken(false);
124+
125+
final SecureString accessToken = new SecureString("blackberries".toCharArray());
126+
var e = expectThrows(ElasticsearchSecurityException.class, () -> doActivateProfileWithAccessToken(accessToken));
127+
assertThat(
128+
e.getMessage(),
129+
containsString("unable to authenticate user [_bearer_token] for action [cluster:admin/xpack/security/profile/activate]")
130+
);
131+
assertThat(e.getCause(), nullValue());
132+
133+
assertThat(authenticator.extractedGrantTokens(), is(emptyIterable()));
134+
assertThat(authenticator.authenticatedTokens(), is(emptyIterable()));
135+
}
136+
137+
public void testProfileActivationWithPassword() {
138+
Profile profile = doActivateProfileWithPassword("file_user", TEST_PASSWORD_SECURE_STRING.clone());
139+
assertThat(profile.user().realmName(), equalTo("file"));
140+
141+
// the authenticator should not be called for password grant type
142+
assertThat(authenticator.isCalledOnce(), is(false));
143+
assertThat(authenticator.extractedGrantTokens(), is(emptyIterable()));
144+
assertThat(authenticator.authenticatedTokens(), is(emptyIterable()));
145+
}
146+
147+
private Profile doActivateProfileWithAccessToken(SecureString token) {
148+
final ActivateProfileRequest activateProfileRequest = new ActivateProfileRequest();
149+
activateProfileRequest.getGrant().setType("access_token");
150+
activateProfileRequest.getGrant().setAccessToken(token);
151+
152+
final ActivateProfileResponse activateProfileResponse = client().execute(ActivateProfileAction.INSTANCE, activateProfileRequest)
153+
.actionGet();
154+
final Profile profile = activateProfileResponse.getProfile();
155+
assertThat(profile, notNullValue());
156+
assertThat(profile.applicationData(), anEmptyMap());
157+
return profile;
158+
}
159+
160+
private Profile doActivateProfileWithPassword(String username, SecureString password) {
161+
final ActivateProfileRequest activateProfileRequest = new ActivateProfileRequest();
162+
activateProfileRequest.getGrant().setType("password");
163+
activateProfileRequest.getGrant().setPassword(password);
164+
activateProfileRequest.getGrant().setUsername(username);
165+
166+
final ActivateProfileResponse activateProfileResponse = client().execute(ActivateProfileAction.INSTANCE, activateProfileRequest)
167+
.actionGet();
168+
final Profile profile = activateProfileResponse.getProfile();
169+
assertThat(profile, notNullValue());
170+
assertThat(profile.applicationData(), anEmptyMap());
171+
return profile;
172+
}
173+
174+
private static class TestCustomTokenAuthenticator implements CustomTokenAuthenticator {
175+
176+
private Exception failure = null;
177+
private boolean shouldExtractAccessToken = true;
178+
private final List<TestCustomAccessToken> extractedGrantTokens = new ArrayList<>();
179+
private final List<TestCustomAccessToken> authenticatedTokens = new ArrayList<>();
180+
private boolean calledOnce = false;
181+
182+
public List<TestCustomAccessToken> extractedGrantTokens() {
183+
return extractedGrantTokens;
184+
}
185+
186+
public List<TestCustomAccessToken> authenticatedTokens() {
187+
return authenticatedTokens;
188+
}
189+
190+
public boolean isCalledOnce() {
191+
return calledOnce;
192+
}
193+
194+
public void reset() {
195+
extractedGrantTokens.clear();
196+
authenticatedTokens.clear();
197+
shouldExtractAccessToken = true;
198+
failure = null;
199+
calledOnce = false;
200+
}
201+
202+
public void setAuthFailure(Exception failure) {
203+
this.failure = failure;
204+
}
205+
206+
public void setShouldExtractAccessToken(boolean shouldExtractAccessToken) {
207+
this.shouldExtractAccessToken = shouldExtractAccessToken;
208+
}
209+
210+
@Override
211+
public boolean supports(AuthenticationToken token) {
212+
return token instanceof TestCustomAccessToken;
213+
}
214+
215+
@Override
216+
public AuthenticationToken extractToken(ThreadContext context) {
217+
throw new IllegalStateException("should never be called");
218+
}
219+
220+
@Override
221+
public AuthenticationToken extractGrantAccessToken(Grant grant) {
222+
calledOnce = true;
223+
if (Grant.ACCESS_TOKEN_GRANT_TYPE.equals(grant.getType())) {
224+
if (shouldExtractAccessToken) {
225+
var accessToken = new TestCustomAccessToken(grant.getAccessToken());
226+
extractedGrantTokens.add(accessToken);
227+
return accessToken;
228+
}
229+
}
230+
return null;
231+
}
232+
233+
@Override
234+
public void authenticate(AuthenticationToken token, ActionListener<AuthenticationResult<Authentication>> listener) {
235+
if (false == token instanceof TestCustomAccessToken) {
236+
listener.onResponse(AuthenticationResult.notHandled());
237+
return;
238+
}
239+
calledOnce = true;
240+
var customAccessToken = (TestCustomAccessToken) token;
241+
authenticatedTokens.add(customAccessToken);
242+
if (failure != null) {
243+
listener.onFailure(failure);
244+
} else {
245+
listener.onResponse(
246+
AuthenticationResult.success(
247+
Authentication.newRealmAuthentication(new User(TEST_USERNAME, TEST_ROLE_NAME), TEST_REALM_REF).token()
248+
)
249+
);
250+
}
251+
}
252+
}
253+
254+
private record TestCustomAccessToken(SecureString token) implements AuthenticationToken {
255+
256+
@Override
257+
public String principal() {
258+
return "test-access-token";
259+
}
260+
261+
@Override
262+
public Object credentials() {
263+
return token;
264+
}
265+
266+
@Override
267+
public void clearCredentials() {
268+
token.clone();
269+
}
270+
}
271+
272+
public static class TestCustomAuthenticatorSecurityPlugin extends LocalStateSecurity {
273+
274+
public TestCustomAuthenticatorSecurityPlugin(Settings settings, Path configPath) throws Exception {
275+
super(settings, configPath);
276+
}
277+
278+
@Override
279+
protected List<SecurityExtension> securityExtensions() {
280+
return List.of(new TestCustomSecurityExtension());
281+
}
282+
}
283+
284+
private static class TestCustomSecurityExtension implements SecurityExtension {
285+
286+
@Override
287+
public String extensionName() {
288+
return "test-custom-token-authenticators-extension";
289+
}
290+
291+
@Override
292+
public List<CustomAuthenticator> getCustomAuthenticators(SecurityComponents components) {
293+
return List.of(authenticator);
294+
}
295+
}
296+
}

0 commit comments

Comments
 (0)