Skip to content

Introduce API Tokens #56

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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6ae7090
Merge branch 'main' of github.com:opensearch-project/security into HEAD
derek-ho Nov 14, 2024
3177c34
Scaffolding for POST/DELETE/GET api tokens calls (#4921)
derek-ho Dec 16, 2024
dacdae5
Adds JTI and expiration field support for API Tokens (#4967)
derek-ho Dec 20, 2024
e255e14
Merge branch 'main' of github.com:opensearch-project/security into HEAD
derek-ho Dec 20, 2024
190bfec
Merge branch 'main' of github.com:opensearch-project/security into HEAD
derek-ho Jan 17, 2025
79f0c46
Api token authc/z implementation with Cache (#4992)
derek-ho Feb 4, 2025
a8b4ac1
Subset of permissions check on creation (#5012)
derek-ho Feb 21, 2025
3776667
Merge branch 'main' of github.com:opensearch-project/security into HEAD
derek-ho Mar 11, 2025
8750e8b
Change API token index actions to use action listeners and limit to 1…
derek-ho Mar 24, 2025
7b8b069
Merge branch 'main' into feature/api-tokens-cwperx
cwperks May 22, 2025
12c0f9c
Fix naming
cwperks May 22, 2025
896e9e2
Use one PrivilegesEvaluatorContext
cwperks May 22, 2025
97db90d
Handle authz
cwperks May 22, 2025
362e67f
fix unit tests
cwperks May 22, 2025
fa98ae2
Fix tests
cwperks May 23, 2025
f3cd485
Merge branch 'main' into feature/api-tokens-cwperx
cwperks May 27, 2025
68107ff
Add integrationTests for API Token
cwperks May 27, 2025
f5b965a
Add more integration tests
cwperks May 27, 2025
ba93aa3
Add token prefix
cwperks May 28, 2025
e35d3ef
Merge branch 'main' into feature/api-tokens-cwperx
cwperks Jun 23, 2025
c028420
Rebase with main
cwperks Jun 23, 2025
109c1ef
Merge branch 'main' into feature/api-tokens-cwperx
cwperks Jun 25, 2025
3a71078
Fix compilation issues
cwperks Jun 25, 2025
dad7551
Add to CHANGELOG
cwperks Jun 25, 2025
30f4f6f
Merge branch 'main' into feature/api-tokens-cwperx
cwperks Jul 21, 2025
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* Create a mechanism for plugins to explicitly declare actions they need to perform with their assigned PluginSubject ([#5341](https://github.com/opensearch-project/security/pull/5341))
* Moves OpenSAML jars to a Shadow Jar configuration to facilitate its use in FIPS enabled environments ([#5400](https://github.com/opensearch-project/security/pull/5404))
* Replaced the standard distribution of BouncyCastle with BC-FIPS ([#5439](https://github.com/opensearch-project/security/pull/5439))
* Introduce API Tokens with `cluster_permissions` and `index_permissions` directly associated with the token ([#5443](https://github.com/opensearch-project/security/pull/5443))
* Introduced setting `plugins.security.privileges_evaluation.precomputed_privileges.enabled` ([#5465](https://github.com/opensearch-project/security/pull/5465))
* Optimized wildcard matching runtime performance ([#5470](https://github.com/opensearch-project/security/pull/5470))
* Optimized performance for construction of internal action privileges data structure ([#5470](https://github.com/opensearch-project/security/pull/5470))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.security.privileges;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import com.carrotsearch.randomizedtesting.annotations.ThreadLeakScope;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.http.HttpStatus;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.opensearch.common.xcontent.XContentFactory;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.test.framework.ApiTokenConfig;
import org.opensearch.test.framework.TestSecurityConfig;
import org.opensearch.test.framework.cluster.ClusterManager;
import org.opensearch.test.framework.cluster.LocalCluster;
import org.opensearch.test.framework.cluster.TestRestClient;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.opensearch.security.support.ConfigConstants.SECURITY_RESTAPI_ROLES_ENABLED;
import static org.opensearch.test.framework.TestSecurityConfig.AuthcDomain.AUTHC_HTTPBASIC_INTERNAL;
import static org.opensearch.test.framework.TestSecurityConfig.Role.ALL_ACCESS;

@RunWith(com.carrotsearch.randomizedtesting.RandomizedRunner.class)
@ThreadLeakScope(ThreadLeakScope.Scope.NONE)
public class ApiTokenTest {

public static final String POINTER_USERNAME = "/user_name";

static final TestSecurityConfig.User ADMIN_USER = new TestSecurityConfig.User("admin").roles(ALL_ACCESS);
static final TestSecurityConfig.User REGULAR_USER = new TestSecurityConfig.User("regular_user");

private static final String CREATE_API_TOKEN_PATH = "_plugins/_security/api/apitokens";
private static final String signingKey = Base64.getEncoder()
.encodeToString(
"jwt signing key for api token authentication backend for testing of API Token authentication".getBytes(StandardCharsets.UTF_8)
);
private static final String alternativeSigningKey = Base64.getEncoder()
.encodeToString(
"alternativeSigningKeyalternativeSigningKeyalternativeSigningKeyalternativeSigningKey".getBytes(StandardCharsets.UTF_8)
);

public static final String ADMIN_USER_NAME = "admin";
public static final String REGULAR_USER_NAME = "regular_user";
public static final String DEFAULT_PASSWORD = "secret";
public static final String NEW_PASSWORD = "testPassword123!!";
public static final String TEST_TOKEN_SUBJECT = "token:test-token";
public static final String TEST_TOKEN_PAYLOAD = """
{
"name": "test-token",
"cluster_permissions": ["cluster_monitor"]
}
""";

public static final String TEST_TOKEN_INVALID_PAYLOAD = """
{
"name": "test-token",
"cluster_permissions": ["cluster_monitor"],
"expiration": "wrong"
}
""";

public static final String TEST_TOKEN_INVALID_PARAMETER_IN_PAYLOAD = """
{
"name": "test-token",
"cluster_permissions": ["cluster_monitor"],
"foo": "bar"
}
""";

public static final String CURRENT_AND_NEW_PASSWORDS = "{ \"current_password\": \""
+ DEFAULT_PASSWORD
+ "\", \"password\": \""
+ NEW_PASSWORD
+ "\" }";

private static ApiTokenConfig defaultApiTokenConfig() {
return new ApiTokenConfig().enabled(true).signingKey(signingKey);
}

@ClassRule
public static final LocalCluster cluster = new LocalCluster.Builder().clusterManager(ClusterManager.SINGLENODE)
.anonymousAuth(false)
.users(ADMIN_USER, REGULAR_USER)
.nodeSettings(
Map.of(
SECURITY_RESTAPI_ROLES_ENABLED,
List.of("user_" + ADMIN_USER.getName() + "__" + ALL_ACCESS.getName()),
"plugins.security.unsupported.restapi.allow_securityconfig_modification",
true
)
)
.authc(AUTHC_HTTPBASIC_INTERNAL)
.apiToken(defaultApiTokenConfig())
.build();

@Before
public void before() {
patchApiTokenConfig(defaultApiTokenConfig());
}

@Test
public void testAuthInfoEndpoint() {
String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD);
Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken);
authenticateWithApiToken(authHeader, HttpStatus.SC_OK);
}

@Test
public void testCallingClusterHealthWithApiToken_success() {
String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD);
Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken);
try (TestRestClient client = cluster.getRestClient(authHeader)) {
TestRestClient.HttpResponse response = client.get("_cluster/health");
response.assertStatusCode(HttpStatus.SC_OK);
}
}

@Test
public void shouldNotAuthenticateWithATamperedAPIToken() {
String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD);
apiToken = apiToken.substring(0, apiToken.length() - 1); // tampering the token
Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken);
authenticateWithApiToken(authHeader, HttpStatus.SC_UNAUTHORIZED);
}

@Test
public void shouldNotBeAbleToUseTokenToGenerateMoreTokens() {
String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD);
Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken);

try (TestRestClient client = cluster.getRestClient(authHeader)) {
TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD);
response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED);
}
}

@Test
public void testAccountApiForbiddenWithApiToken() {
String apiToken = generateApiToken(TEST_TOKEN_PAYLOAD);
Header authHeader = new BasicHeader("Authorization", "Bearer " + apiToken);

try (TestRestClient client = cluster.getRestClient(authHeader)) {
TestRestClient.HttpResponse response = client.putJson("_plugins/_security/api/account", CURRENT_AND_NEW_PASSWORDS);
response.assertStatusCode(HttpStatus.SC_UNAUTHORIZED);
}
}

@Test
public void testRegularUserShouldNotBeAbleToGenerateApiToken() {
try (TestRestClient client = cluster.getRestClient(REGULAR_USER)) {
TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_PAYLOAD);
response.assertStatusCode(HttpStatus.SC_FORBIDDEN);
}
}

@Test
public void shouldNotAuthenticateWithInvalidExpiration() {
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_INVALID_PAYLOAD);
response.assertStatusCode(HttpStatus.SC_BAD_REQUEST);
assertThat(response.getTextFromJsonBody("/error"), equalTo("Invalid request: expiration must be a long"));
}
}

@Test
public void shouldNotAuthenticateWithInvalidAPIParameter() {
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, TEST_TOKEN_INVALID_PARAMETER_IN_PAYLOAD);
response.assertStatusCode(HttpStatus.SC_BAD_REQUEST);
assertThat(response.getTextFromJsonBody("/error"), equalTo("Invalid request: Unknown field in request: foo"));
}
}

@Test
public void shouldNotAllowTokenWhenApiTokensAreDisabled() {
final Header apiTokenHeader = new BasicHeader("Authorization", "Bearer " + generateApiToken(TEST_TOKEN_PAYLOAD));
authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_OK);

// Disable API Tokens via config and see that the authenticator doesn't authorize
patchApiTokenConfig(defaultApiTokenConfig().enabled(false));
authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_UNAUTHORIZED);

// Re-enable API Tokens via config and see that the authenticator is working again
patchApiTokenConfig(defaultApiTokenConfig().enabled(true));
authenticateWithApiToken(apiTokenHeader, HttpStatus.SC_OK);
}

@Test
public void apiTokenSigningCheckChangeIsDetected() {
final Header apiTokenOriginalKey = new BasicHeader("Authorization", "Bearer " + generateApiToken(TEST_TOKEN_PAYLOAD));
authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_OK);

// Change the signing key
patchApiTokenConfig(defaultApiTokenConfig().signingKey(alternativeSigningKey));

// Original key should no longer work
authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_UNAUTHORIZED);

// Generate new key, check that it is valid
final Header apiTokenOtherKey = new BasicHeader("Authorization", "Bearer " + generateApiToken(TEST_TOKEN_PAYLOAD));
authenticateWithApiToken(apiTokenOtherKey, HttpStatus.SC_OK);

// Change back to the original signing key and the original key still works, and the new key doesn't
patchApiTokenConfig(defaultApiTokenConfig());
authenticateWithApiToken(apiTokenOriginalKey, HttpStatus.SC_OK);
authenticateWithApiToken(apiTokenOtherKey, HttpStatus.SC_UNAUTHORIZED);
}

private String generateApiToken(String payload) {
try (TestRestClient client = cluster.getRestClient(ADMIN_USER)) {
TestRestClient.HttpResponse response = client.postJson(CREATE_API_TOKEN_PATH, payload);
response.assertStatusCode(HttpStatus.SC_OK);
return response.getTextFromJsonBody("/token").toString();
}
}

private void authenticateWithApiToken(Header authHeader, int expectedStatusCode) {
try (TestRestClient client = cluster.getRestClient(authHeader)) {
TestRestClient.HttpResponse response = client.getAuthInfo();
response.assertStatusCode(expectedStatusCode);
assertThat(response.getStatusCode(), equalTo(expectedStatusCode));
if (expectedStatusCode == HttpStatus.SC_OK) {
String username = response.getTextFromJsonBody(POINTER_USERNAME);
assertThat(username, equalTo(ApiTokenTest.TEST_TOKEN_SUBJECT));
}
}
}

private void patchApiTokenConfig(final ApiTokenConfig apiTokenConfig) {
try (final TestRestClient adminClient = cluster.getRestClient(cluster.getAdminCertificate())) {
final XContentBuilder configBuilder = XContentFactory.jsonBuilder();
configBuilder.value(apiTokenConfig);

final String patchBody = "[{ \"op\": \"replace\", \"path\": \"/config/dynamic/api_tokens\", \"value\":"
+ configBuilder.toString()
+ "}]";
final var response = adminClient.patch("_plugins/_security/api/securityconfig", patchBody);
response.assertStatusCode(HttpStatus.SC_OK);
} catch (final IOException ex) {
throw new RuntimeException(ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*
*/
package org.opensearch.test.framework;

import java.io.IOException;

import org.opensearch.core.xcontent.ToXContentObject;
import org.opensearch.core.xcontent.XContentBuilder;

public class ApiTokenConfig implements ToXContentObject {
private Boolean enabled;
private String signing_key;

public ApiTokenConfig enabled(Boolean enabled) {
this.enabled = enabled;
return this;
}

public ApiTokenConfig signingKey(String signing_key) {
this.signing_key = signing_key;
return this;
}

@Override
public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException {
xContentBuilder.startObject();
xContentBuilder.field("enabled", enabled);
xContentBuilder.field("signing_key", signing_key);
xContentBuilder.endObject();
return xContentBuilder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ public TestSecurityConfig onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) {
return this;
}

public TestSecurityConfig apiToken(ApiTokenConfig apiTokenConfig) {
config.apiTokenConfig(apiTokenConfig);
return this;
}

public TestSecurityConfig authc(AuthcDomain authcDomain) {
config.authc(authcDomain);
return this;
Expand Down Expand Up @@ -265,6 +270,7 @@ public static class Config implements ToXContentObject {
private Boolean doNotFailOnForbidden;
private XffConfig xffConfig;
private OnBehalfOfConfig onBehalfOfConfig;
private ApiTokenConfig apiTokenConfig;
private Map<String, AuthcDomain> authcDomainMap = new LinkedHashMap<>();

private AuthFailureListeners authFailureListeners;
Expand All @@ -290,6 +296,11 @@ public Config onBehalfOfConfig(OnBehalfOfConfig onBehalfOfConfig) {
return this;
}

public Config apiTokenConfig(ApiTokenConfig apiTokenConfig) {
this.apiTokenConfig = apiTokenConfig;
return this;
}

public Config authc(AuthcDomain authcDomain) {
authcDomainMap.put(authcDomain.id, authcDomain);
return this;
Expand All @@ -314,6 +325,10 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params
xContentBuilder.field("on_behalf_of", onBehalfOfConfig);
}

if (apiTokenConfig != null) {
xContentBuilder.field("api_tokens", apiTokenConfig);
}

if (anonymousAuth || (xffConfig != null)) {
xContentBuilder.startObject("http");
xContentBuilder.field("anonymous_auth_enabled", anonymousAuth);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import org.opensearch.security.action.configupdate.ConfigUpdateResponse;
import org.opensearch.security.securityconf.impl.CType;
import org.opensearch.security.support.ConfigConstants;
import org.opensearch.test.framework.ApiTokenConfig;
import org.opensearch.test.framework.AuditConfiguration;
import org.opensearch.test.framework.AuthFailureListeners;
import org.opensearch.test.framework.AuthzDomain;
Expand Down Expand Up @@ -561,6 +562,11 @@ public Builder onBehalfOf(OnBehalfOfConfig onBehalfOfConfig) {
return this;
}

public Builder apiToken(ApiTokenConfig apiTokenConfig) {
testSecurityConfig.apiToken(apiTokenConfig);
return this;
}

public Builder loadConfigurationIntoIndex(boolean loadConfigurationIntoIndex) {
this.loadConfigurationIntoIndex = loadConfigurationIntoIndex;
return this;
Expand Down
Loading
Loading