From dacdae5b283e4d6730a978bf35f2feb5af48070e Mon Sep 17 00:00:00 2001 From: Derek Ho Date: Fri, 20 Dec 2024 15:42:19 -0500 Subject: [PATCH] Adds JTI and expiration field support for API Tokens (#4967) Signed-off-by: Derek Ho --- .../security/OpenSearchSecurityPlugin.java | 2 +- .../security/action/apitokens/ApiToken.java | 77 +++++++---- .../action/apitokens/ApiTokenAction.java | 40 ++++-- .../action/apitokens/ApiTokenRepository.java | 37 +++++- .../jwt/ExpiringBearerAuthToken.java | 9 ++ .../security/authtoken/jwt/JwtVendor.java | 71 ++++++++++ .../identity/SecurityTokenManager.java | 56 ++++++-- .../securityconf/DynamicConfigModel.java | 2 + .../securityconf/DynamicConfigModelV7.java | 7 + .../securityconf/impl/v7/ConfigV7.java | 51 ++++++++ .../action/apitokens/ApiTokenActionTest.java | 1 - .../apitokens/ApiTokenIndexHandlerTest.java | 20 +-- .../apitokens/ApiTokenRepositoryTest.java | 121 ++++++++++++++++++ .../action/apitokens/ApiTokenTest.java | 96 ++++++++++++++ .../security/authtoken/jwt/JwtVendorTest.java | 121 ++++++++++++++++++ .../identity/SecurityTokenManagerTest.java | 58 +++++++++ 16 files changed, 706 insertions(+), 63 deletions(-) create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java create mode 100644 src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java diff --git a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java index 381e181942..c30ef098cb 100644 --- a/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java +++ b/src/main/java/org/opensearch/security/OpenSearchSecurityPlugin.java @@ -636,7 +636,7 @@ public List getRestHandlers( ) ); handlers.add(new CreateOnBehalfOfTokenAction(tokenManager)); - handlers.add(new ApiTokenAction(cs, threadPool, localClient)); + handlers.add(new ApiTokenAction(cs, localClient, tokenManager)); handlers.addAll( SecurityRestApiActions.getHandler( settings, diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java index 2a37c8a44c..d8be267da3 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiToken.java @@ -16,6 +16,8 @@ import java.util.ArrayList; import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnore; + import org.opensearch.core.xcontent.ToXContent; import org.opensearch.core.xcontent.XContentBuilder; import org.opensearch.core.xcontent.XContentParser; @@ -28,35 +30,38 @@ public class ApiToken implements ToXContent { public static final String INDEX_PERMISSIONS_FIELD = "index_permissions"; public static final String INDEX_PATTERN_FIELD = "index_pattern"; public static final String ALLOWED_ACTIONS_FIELD = "allowed_actions"; + public static final String EXPIRATION_FIELD = "expiration"; - private String name; + private final String name; private final String jti; private final Instant creationTime; - private List clusterPermissions; - private List indexPermissions; + private final List clusterPermissions; + private final List indexPermissions; + private final long expiration; - public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions) { + public ApiToken(String name, String jti, List clusterPermissions, List indexPermissions, Long expiration) { this.creationTime = Instant.now(); - this.name = name; this.jti = jti; + this.name = name; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; - + this.expiration = expiration; } public ApiToken( - String description, + String name, String jti, List clusterPermissions, List indexPermissions, - Instant creationTime + Instant creationTime, + Long expiration ) { - this.name = description; + this.name = name; this.jti = jti; this.clusterPermissions = clusterPermissions; this.indexPermissions = indexPermissions; this.creationTime = creationTime; - + this.expiration = expiration; } public static class IndexPermission implements ToXContent { @@ -84,6 +89,36 @@ public XContentBuilder toXContent(XContentBuilder builder, Params params) throws builder.endObject(); return builder; } + + public static IndexPermission fromXContent(XContentParser parser) throws IOException { + List indexPatterns = new ArrayList<>(); + List allowedActions = new ArrayList<>(); + + XContentParser.Token token; + String currentFieldName = null; + + while ((token = parser.nextToken()) != XContentParser.Token.END_OBJECT) { + if (token == XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName(); + } else if (token == XContentParser.Token.START_ARRAY) { + switch (currentFieldName) { + case INDEX_PATTERN_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + indexPatterns.add(parser.text()); + } + break; + case ALLOWED_ACTIONS_FIELD: + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + allowedActions.add(parser.text()); + } + break; + } + } + } + + return new IndexPermission(indexPatterns, allowedActions); + } + } /** @@ -109,6 +144,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { List clusterPermissions = new ArrayList<>(); List indexPermissions = new ArrayList<>(); Instant creationTime = null; + long expiration = 0; XContentParser.Token token; String currentFieldName = null; @@ -127,6 +163,9 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { case CREATION_TIME_FIELD: creationTime = Instant.ofEpochMilli(parser.longValue()); break; + case EXPIRATION_FIELD: + expiration = parser.longValue(); + break; } } else if (token == XContentParser.Token.START_ARRAY) { switch (currentFieldName) { @@ -146,7 +185,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException { } } - return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime); + return new ApiToken(name, jti, clusterPermissions, indexPermissions, creationTime, expiration); } private static IndexPermission parseIndexPermission(XContentParser parser) throws IOException { @@ -174,7 +213,6 @@ private static IndexPermission parseIndexPermission(XContentParser parser) throw } } } - return new IndexPermission(indexPatterns, allowedActions); } @@ -182,10 +220,11 @@ public String getName() { return name; } - public void setName(String name) { - this.name = name; + public Long getExpiration() { + return expiration; } + @JsonIgnore public String getJti() { return jti; } @@ -198,12 +237,8 @@ public List getClusterPermissions() { return clusterPermissions; } - public void setClusterPermissions(List clusterPermissions) { - this.clusterPermissions = clusterPermissions; - } - @Override - public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Params params) throws IOException { + public XContentBuilder toXContent(XContentBuilder xContentBuilder, Params params) throws IOException { xContentBuilder.startObject(); xContentBuilder.field(NAME_FIELD, name); xContentBuilder.field(JTI_FIELD, jti); @@ -217,8 +252,4 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Pa public List getIndexPermissions() { return indexPermissions; } - - public void setIndexPermissions(List indexPermissions) { - this.indexPermissions = indexPermissions; - } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java index a9dd54e80b..e2e373812f 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenAction.java @@ -12,9 +12,11 @@ package org.opensearch.security.action.apitokens; import java.io.IOException; +import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import com.google.common.collect.ImmutableList; @@ -28,8 +30,7 @@ import org.opensearch.rest.BytesRestResponse; import org.opensearch.rest.RestHandler; import org.opensearch.rest.RestRequest; -import org.opensearch.security.util.ParsingUtils; -import org.opensearch.threadpool.ThreadPool; +import org.opensearch.security.identity.SecurityTokenManager; import static org.opensearch.rest.RestRequest.Method.DELETE; import static org.opensearch.rest.RestRequest.Method.GET; @@ -37,10 +38,13 @@ import static org.opensearch.security.action.apitokens.ApiToken.ALLOWED_ACTIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.CLUSTER_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.CREATION_TIME_FIELD; +import static org.opensearch.security.action.apitokens.ApiToken.EXPIRATION_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PATTERN_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.INDEX_PERMISSIONS_FIELD; import static org.opensearch.security.action.apitokens.ApiToken.NAME_FIELD; import static org.opensearch.security.dlic.rest.support.Utils.addRoutesPrefix; +import static org.opensearch.security.util.ParsingUtils.safeMapList; +import static org.opensearch.security.util.ParsingUtils.safeStringList; public class ApiTokenAction extends BaseRestHandler { private final ApiTokenRepository apiTokenRepository; @@ -53,8 +57,8 @@ public class ApiTokenAction extends BaseRestHandler { ) ); - public ApiTokenAction(ClusterService clusterService, ThreadPool threadPool, Client client) { - this.apiTokenRepository = new ApiTokenRepository(client, clusterService); + public ApiTokenAction(ClusterService clusterService, Client client, SecurityTokenManager securityTokenManager) { + this.apiTokenRepository = new ApiTokenRepository(client, clusterService, securityTokenManager); } @Override @@ -94,6 +98,9 @@ private RestChannelConsumer handleGet(RestRequest request, NodeClient client) { builder.startObject(); builder.field(NAME_FIELD, token.getName()); builder.field(CREATION_TIME_FIELD, token.getCreationTime().toEpochMilli()); + builder.field(EXPIRATION_FIELD, token.getExpiration()); + builder.field(CLUSTER_PERMISSIONS_FIELD, token.getClusterPermissions()); + builder.field(INDEX_PERMISSIONS_FIELD, token.getIndexPermissions()); builder.endObject(); } builder.endArray(); @@ -122,11 +129,12 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { String token = apiTokenRepository.createApiToken( (String) requestBody.get(NAME_FIELD), clusterPermissions, - indexPermissions + indexPermissions, + (Long) requestBody.getOrDefault(EXPIRATION_FIELD, Instant.now().toEpochMilli() + TimeUnit.DAYS.toMillis(30)) ); builder.startObject(); - builder.field("token", token); + builder.field("Api Token: ", token); builder.endObject(); response = new BytesRestResponse(RestStatus.OK, builder); @@ -146,14 +154,14 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) { * Extracts cluster permissions from the request body */ List extractClusterPermissions(Map requestBody) { - return ParsingUtils.safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); + return safeStringList(requestBody.get(CLUSTER_PERMISSIONS_FIELD), CLUSTER_PERMISSIONS_FIELD); } /** * Extracts and builds index permissions from the request body */ List extractIndexPermissions(Map requestBody) { - List> indexPerms = ParsingUtils.safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); + List> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList()); } @@ -166,10 +174,10 @@ ApiToken.IndexPermission createIndexPermission(Map indexPerm) { if (indexPatternObj instanceof String) { indexPatterns = Collections.singletonList((String) indexPatternObj); } else { - indexPatterns = ParsingUtils.safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); + indexPatterns = safeStringList(indexPatternObj, INDEX_PATTERN_FIELD); } - List allowedActions = ParsingUtils.safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); + List allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD); return new ApiToken.IndexPermission(indexPatterns, allowedActions); } @@ -182,6 +190,13 @@ void validateRequestParameters(Map requestBody) { throw new IllegalArgumentException("Missing required parameter: " + NAME_FIELD); } + if (requestBody.containsKey(EXPIRATION_FIELD)) { + Object expiration = requestBody.get(EXPIRATION_FIELD); + if (!(expiration instanceof Long)) { + throw new IllegalArgumentException(EXPIRATION_FIELD + " must be a long"); + } + } + if (requestBody.containsKey(CLUSTER_PERMISSIONS_FIELD)) { Object permissions = requestBody.get(CLUSTER_PERMISSIONS_FIELD); if (!(permissions instanceof List)) { @@ -190,10 +205,7 @@ void validateRequestParameters(Map requestBody) { } if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) { - List> indexPermsList = ParsingUtils.safeMapList( - requestBody.get(INDEX_PERMISSIONS_FIELD), - INDEX_PERMISSIONS_FIELD - ); + List> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD); validateIndexPermissionsList(indexPermsList); } } diff --git a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java index 7656e350dc..ce81aceb4b 100644 --- a/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java +++ b/src/main/java/org/opensearch/security/action/apitokens/ApiTokenRepository.java @@ -14,22 +14,51 @@ import java.util.List; import java.util.Map; +import com.google.common.annotations.VisibleForTesting; + import org.opensearch.client.Client; import org.opensearch.cluster.service.ClusterService; import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; public class ApiTokenRepository { private final ApiTokenIndexHandler apiTokenIndexHandler; + private final SecurityTokenManager securityTokenManager; - public ApiTokenRepository(Client client, ClusterService clusterService) { + public ApiTokenRepository(Client client, ClusterService clusterService, SecurityTokenManager tokenManager) { apiTokenIndexHandler = new ApiTokenIndexHandler(client, clusterService); + securityTokenManager = tokenManager; + } + + private ApiTokenRepository(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + this.apiTokenIndexHandler = apiTokenIndexHandler; + this.securityTokenManager = securityTokenManager; + } + + @VisibleForTesting + static ApiTokenRepository forTest(ApiTokenIndexHandler apiTokenIndexHandler, SecurityTokenManager securityTokenManager) { + return new ApiTokenRepository(apiTokenIndexHandler, securityTokenManager); } - public String createApiToken(String name, List clusterPermissions, List indexPermissions) { + public String createApiToken( + String name, + List clusterPermissions, + List indexPermissions, + Long expiration + ) { apiTokenIndexHandler.createApiTokenIndexIfAbsent(); - // TODO: Implement logic of creating JTI to match against during authc/z // TODO: Add validation on whether user is creating a token with a subset of their permissions - return apiTokenIndexHandler.indexTokenMetadata(new ApiToken(name, "test-token", clusterPermissions, indexPermissions)); + ExpiringBearerAuthToken token = securityTokenManager.issueApiToken(name, expiration, clusterPermissions, indexPermissions); + ApiToken apiToken = new ApiToken( + name, + securityTokenManager.encryptToken(token.getCompleteToken()), + clusterPermissions, + indexPermissions, + expiration + ); + apiTokenIndexHandler.indexTokenMetadata(apiToken); + return token.getCompleteToken(); } public void deleteApiToken(String name) throws ApiTokenException, IndexNotFoundException { diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java index a0879cd4da..7b321f2001 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/ExpiringBearerAuthToken.java @@ -10,6 +10,8 @@ */ package org.opensearch.security.authtoken.jwt; +import java.time.Duration; +import java.time.Instant; import java.util.Date; import org.opensearch.identity.tokens.BearerAuthToken; @@ -26,6 +28,13 @@ public ExpiringBearerAuthToken(final String serializedToken, final String subjec this.expiresInSeconds = expiresInSeconds; } + public ExpiringBearerAuthToken(final String serializedToken, final String subject, final Date expiry) { + super(serializedToken); + this.subject = subject; + this.expiry = expiry; + this.expiresInSeconds = Duration.between(Instant.now(), expiry.toInstant()).getSeconds(); + } + public String getSubject() { return subject; } diff --git a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java index e21d9257ff..75ce45912a 100644 --- a/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java +++ b/src/main/java/org/opensearch/security/authtoken/jwt/JwtVendor.java @@ -11,7 +11,11 @@ package org.opensearch.security.authtoken.jwt; +import java.io.IOException; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.text.ParseException; +import java.util.ArrayList; import java.util.Base64; import java.util.Date; import java.util.List; @@ -24,6 +28,9 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.security.action.apitokens.ApiToken; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -148,4 +155,68 @@ public ExpiringBearerAuthToken createJwt( return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime, expirySeconds); } + + @SuppressWarnings("removal") + public ExpiringBearerAuthToken createJwt( + final String issuer, + final String subject, + final String audience, + final long expiration, + final List clusterPermissions, + final List indexPermissions + ) throws JOSEException, ParseException, IOException { + final long currentTimeMs = timeProvider.getAsLong(); + final Date now = new Date(currentTimeMs); + + final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder(); + claimsBuilder.issuer(issuer); + claimsBuilder.issueTime(now); + claimsBuilder.subject(subject); + claimsBuilder.audience(audience); + claimsBuilder.notBeforeTime(now); + + final Date expiryTime = new Date(expiration); + claimsBuilder.expirationTime(expiryTime); + + if (clusterPermissions != null) { + final String listOfClusterPermissions = String.join(",", clusterPermissions); + claimsBuilder.claim("cp", encryptString(listOfClusterPermissions)); + } + + if (indexPermissions != null) { + List permissionStrings = new ArrayList<>(); + for (ApiToken.IndexPermission permission : indexPermissions) { + permissionStrings.add(permission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString()); + } + final String listOfIndexPermissions = String.join(",", permissionStrings); + claimsBuilder.claim("ip", encryptString(listOfIndexPermissions)); + } + + final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.parse(signingKey.getAlgorithm().getName())).build(); + + final SignedJWT signedJwt = AccessController.doPrivileged( + (PrivilegedAction) () -> new SignedJWT(header, claimsBuilder.build()) + ); + + // Sign the JWT so it can be serialized + signedJwt.sign(signer); + + if (logger.isDebugEnabled()) { + logger.debug( + "Created JWT: " + signedJwt.serialize() + "\n" + signedJwt.getHeader().toJSONObject() + "\n" + signedJwt.getJWTClaimsSet() + ); + } + + return new ExpiringBearerAuthToken(signedJwt.serialize(), subject, expiryTime); + } + + /* Returns the encrypted string based on encryption settings */ + public String encryptString(final String input) { + return encryptionDecryptionUtil.encrypt(input); + } + + /* Returns the decrypted string based on encryption settings */ + public String decryptString(final String input) { + return encryptionDecryptionUtil.decrypt(input); + } } diff --git a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java index 8a0c3e85f1..ca5a17b6f7 100644 --- a/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java +++ b/src/main/java/org/opensearch/security/identity/SecurityTokenManager.java @@ -11,9 +11,10 @@ package org.opensearch.security.identity; +import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.Set; -import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -27,6 +28,7 @@ import org.opensearch.identity.tokens.AuthToken; import org.opensearch.identity.tokens.OnBehalfOfClaims; import org.opensearch.identity.tokens.TokenManager; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; import org.opensearch.security.authtoken.jwt.JwtVendor; import org.opensearch.security.securityconf.ConfigModel; @@ -50,7 +52,8 @@ public class SecurityTokenManager implements TokenManager { private final ThreadPool threadPool; private final UserService userService; - private JwtVendor jwtVendor = null; + private JwtVendor oboJwtVendor = null; + private JwtVendor apiTokenJwtVendor = null; private ConfigModel configModel = null; public SecurityTokenManager(final ClusterService cs, final ThreadPool threadPool, final UserService userService) { @@ -67,11 +70,14 @@ public void onConfigModelChanged(final ConfigModel configModel) { @Subscribe public void onDynamicConfigModelChanged(final DynamicConfigModel dcm) { final Settings oboSettings = dcm.getDynamicOnBehalfOfSettings(); - final Boolean enabled = oboSettings.getAsBoolean("enabled", false); - if (enabled) { - jwtVendor = createJwtVendor(oboSettings); - } else { - jwtVendor = null; + final Boolean oboEnabled = oboSettings.getAsBoolean("enabled", false); + if (oboEnabled) { + oboJwtVendor = createJwtVendor(oboSettings); + } + final Settings apiTokenSettings = dcm.getDynamicApiTokenSettings(); + final Boolean apiTokenEnabled = apiTokenSettings.getAsBoolean("enabled", false); + if (apiTokenEnabled) { + apiTokenJwtVendor = createJwtVendor(apiTokenSettings); } } @@ -86,7 +92,11 @@ JwtVendor createJwtVendor(final Settings settings) { } public boolean issueOnBehalfOfTokenAllowed() { - return jwtVendor != null && configModel != null; + return oboJwtVendor != null && configModel != null; + } + + public boolean issueApiTokenAllowed() { + return apiTokenJwtVendor != null && configModel != null; } @Override @@ -116,13 +126,13 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final final Set mappedRoles = configModel.mapSecurityRoles(user, callerAddress); try { - return jwtVendor.createJwt( + return oboJwtVendor.createJwt( cs.getClusterName().value(), user.getName(), claims.getAudience(), claims.getExpiration(), - mappedRoles.stream().collect(Collectors.toList()), - user.getRoles().stream().collect(Collectors.toList()), + new ArrayList<>(mappedRoles), + new ArrayList<>(user.getRoles()), false ); } catch (final Exception ex) { @@ -131,6 +141,30 @@ public ExpiringBearerAuthToken issueOnBehalfOfToken(final Subject subject, final } } + public ExpiringBearerAuthToken issueApiToken( + final String name, + final Long expiration, + final List clusterPermissions, + final List indexPermissions + ) { + final User user = threadPool.getThreadContext().getTransient(ConfigConstants.OPENDISTRO_SECURITY_USER); + + try { + return apiTokenJwtVendor.createJwt(cs.getClusterName().value(), name, name, expiration, clusterPermissions, indexPermissions); + } catch (final Exception ex) { + logger.error("Error creating Api Token for " + user.getName(), ex); + throw new OpenSearchSecurityException("Unable to generate Api Token"); + } + } + + public String encryptToken(final String token) { + return apiTokenJwtVendor.encryptString(token); + } + + public String decryptString(final String input) { + return apiTokenJwtVendor.decryptString(input); + } + @Override public AuthToken issueServiceAccountToken(final String serviceId) { try { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java index 064f555a75..0d56a41c23 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModel.java @@ -110,6 +110,8 @@ public abstract class DynamicConfigModel { public abstract Settings getDynamicOnBehalfOfSettings(); + public abstract Settings getDynamicApiTokenSettings(); + protected final Map authImplMap = new HashMap<>(); public DynamicConfigModel() { diff --git a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java index 4bc9e82882..9c90e2341f 100644 --- a/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java +++ b/src/main/java/org/opensearch/security/securityconf/DynamicConfigModelV7.java @@ -234,6 +234,13 @@ public Settings getDynamicOnBehalfOfSettings() { .build(); } + @Override + public Settings getDynamicApiTokenSettings() { + return Settings.builder() + .put(Settings.builder().loadFromSource(config.dynamic.api_tokens.configAsJson(), XContentType.JSON).build()) + .build(); + } + private void buildAAA() { final SortedSet restAuthDomains0 = new TreeSet<>(); diff --git a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java index 77fb973a52..6555c0838d 100644 --- a/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java +++ b/src/main/java/org/opensearch/security/securityconf/impl/v7/ConfigV7.java @@ -86,6 +86,7 @@ public static class Dynamic { public String transport_userrname_attribute; public boolean do_not_fail_on_forbidden_empty; public OnBehalfOfSettings on_behalf_of = new OnBehalfOfSettings(); + public ApiTokenSettings api_tokens = new ApiTokenSettings(); @Override public String toString() { @@ -101,6 +102,8 @@ public String toString() { + authz + ", on_behalf_of=" + on_behalf_of + + ", api_tokens=" + + api_tokens + "]"; } } @@ -495,4 +498,52 @@ public String toString() { } } + public static class ApiTokenSettings { + @JsonProperty("enabled") + private Boolean enabled = Boolean.FALSE; + @JsonProperty("signing_key") + private String signingKey; + @JsonProperty("encryption_key") + private String encryptionKey; + + @JsonIgnore + public String configAsJson() { + try { + return DefaultObjectMapper.writeValueAsString(this, false); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean oboEnabled) { + this.enabled = oboEnabled; + } + + public String getSigningKey() { + return signingKey; + } + + public void setSigningKey(String signingKey) { + this.signingKey = signingKey; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + public void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + } + + @Override + public String toString() { + return "ApiTokenSettings [ enabled=" + enabled + ", signing_key=" + signingKey + ", encryption_key=" + encryptionKey + "]"; + } + + } + } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java index 78a4e13dbb..483fe7c9d7 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenActionTest.java @@ -100,5 +100,4 @@ public void testExtractClusterPermissions() { requestBody.put("cluster_permissions", Arrays.asList("perm1", "perm2")); assertThat(apiTokenAction.extractClusterPermissions(requestBody), is(Arrays.asList("perm1", "perm2"))); } - } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java index 1b5c295b92..7e03c14851 100644 --- a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenIndexHandlerTest.java @@ -192,10 +192,11 @@ public void testIndexTokenStoresTokenPayload() { ); ApiToken token = new ApiToken( "test-token-description", - "test-jti", + "test-token-jti", clusterPermissions, indexPermissions, - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Mock the index method with ActionListener @@ -216,6 +217,7 @@ public void testIndexTokenStoresTokenPayload() { return null; }).when(client).index(any(IndexRequest.class), listenerCaptor.capture()); + indexHandler.indexTokenMetadata(token); // Verify the index request @@ -228,8 +230,8 @@ public void testIndexTokenStoresTokenPayload() { // verify contents String source = capturedRequest.source().utf8ToString(); assertThat(source, containsString("test-token-description")); - assertThat(source, containsString("test-jti")); assertThat(source, containsString("cluster:admin/something")); + assertThat(source, containsString("test-token-jti")); assertThat(source, containsString("test-index-*")); } @@ -243,25 +245,27 @@ public void testGetTokenPayloads() throws IOException { // First token ApiToken token1 = new ApiToken( "token1-description", - "jti1", + "token1-jti", Arrays.asList("cluster:admin/something"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index1-*"), Arrays.asList("read") )), - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Second token ApiToken token2 = new ApiToken( "token2-description", - "jti2", + "token2-jti", Arrays.asList("cluster:admin/other"), Arrays.asList(new ApiToken.IndexPermission( Arrays.asList("index2-*"), Arrays.asList("write") )), - Instant.now() + Instant.now(), + Long.MAX_VALUE ); // Convert tokens to XContent and create SearchHits @@ -293,11 +297,9 @@ public void testGetTokenPayloads() throws IOException { assertThat(resultTokens.containsKey("token2-description"), is(true)); ApiToken resultToken1 = resultTokens.get("token1-description"); - assertThat(resultToken1.getJti(), equalTo("jti1")); assertThat(resultToken1.getClusterPermissions(), contains("cluster:admin/something")); ApiToken resultToken2 = resultTokens.get("token2-description"); - assertThat(resultToken2.getJti(), equalTo("jti2")); assertThat(resultToken2.getClusterPermissions(), contains("cluster:admin/other")); } diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java new file mode 100644 index 0000000000..03a2e2c30e --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenRepositoryTest.java @@ -0,0 +1,121 @@ +/* + * 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.action.apitokens; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.index.IndexNotFoundException; +import org.opensearch.security.authtoken.jwt.ExpiringBearerAuthToken; +import org.opensearch.security.identity.SecurityTokenManager; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ApiTokenRepositoryTest { + @Mock + private SecurityTokenManager securityTokenManager; + + @Mock + private ApiTokenIndexHandler apiTokenIndexHandler; + + private ApiTokenRepository repository; + + @Before + public void setUp() { + apiTokenIndexHandler = mock(ApiTokenIndexHandler.class); + securityTokenManager = mock(SecurityTokenManager.class); + repository = ApiTokenRepository.forTest(apiTokenIndexHandler, securityTokenManager); + } + + @Test + public void testDeleteApiToken() throws ApiTokenException { + String tokenName = "test-token"; + + repository.deleteApiToken(tokenName); + + verify(apiTokenIndexHandler).deleteToken(tokenName); + } + + @Test + public void testGetApiTokens() throws IndexNotFoundException { + Map expectedTokens = new HashMap<>(); + expectedTokens.put("token1", new ApiToken("token1", "token1-jti", Arrays.asList("perm1"), Arrays.asList(), Long.MAX_VALUE)); + when(apiTokenIndexHandler.getTokenMetadatas()).thenReturn(expectedTokens); + + Map result = repository.getApiTokens(); + + assertThat(result, equalTo(expectedTokens)); + verify(apiTokenIndexHandler).getTokenMetadatas(); + } + + @Test + public void testCreateApiToken() { + String tokenName = "test-token"; + List clusterPermissions = Arrays.asList("cluster:admin"); + List indexPermissions = Arrays.asList( + new ApiToken.IndexPermission(Arrays.asList("test-*"), Arrays.asList("read")) + ); + Long expiration = 3600L; + + String completeToken = "complete-token"; + String encryptedToken = "encrypted-token"; + ExpiringBearerAuthToken bearerToken = mock(ExpiringBearerAuthToken.class); + when(bearerToken.getCompleteToken()).thenReturn(completeToken); + when(securityTokenManager.issueApiToken(any(), any(), any(), any())).thenReturn(bearerToken); + when(securityTokenManager.encryptToken(completeToken)).thenReturn(encryptedToken); + + String result = repository.createApiToken(tokenName, clusterPermissions, indexPermissions, expiration); + + verify(apiTokenIndexHandler).createApiTokenIndexIfAbsent(); + verify(securityTokenManager).issueApiToken(any(), any(), any(), any()); + verify(securityTokenManager).encryptToken(completeToken); + verify(apiTokenIndexHandler).indexTokenMetadata( + argThat( + token -> token.getName().equals(tokenName) + && token.getJti().equals(encryptedToken) + && token.getClusterPermissions().equals(clusterPermissions) + && token.getIndexPermissions().equals(indexPermissions) + ) + ); + assertThat(result, equalTo(completeToken)); + } + + @Test(expected = IndexNotFoundException.class) + public void testGetApiTokensThrowsIndexNotFoundException() throws IndexNotFoundException { + when(apiTokenIndexHandler.getTokenMetadatas()).thenThrow(new IndexNotFoundException("test-index")); + + repository.getApiTokens(); + + } + + @Test(expected = ApiTokenException.class) + public void testDeleteApiTokenThrowsApiTokenException() throws ApiTokenException { + String tokenName = "test-token"; + doThrow(new ApiTokenException("Token not found")).when(apiTokenIndexHandler).deleteToken(tokenName); + + repository.deleteApiToken(tokenName); + } +} diff --git a/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java new file mode 100644 index 0000000000..4951507359 --- /dev/null +++ b/src/test/java/org/opensearch/security/action/apitokens/ApiTokenTest.java @@ -0,0 +1,96 @@ +/* + * 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.action.apitokens; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import org.junit.Before; +import org.junit.Test; + +import org.opensearch.client.Client; +import org.opensearch.client.IndicesAdminClient; +import org.opensearch.cluster.metadata.Metadata; +import org.opensearch.cluster.service.ClusterService; +import org.opensearch.common.settings.Settings; +import org.opensearch.common.util.concurrent.ThreadContext; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; + +import org.mockito.Mock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ApiTokenTest { + + @Mock + private Client client; + + @Mock + private IndicesAdminClient indicesAdminClient; + + @Mock + private ClusterService clusterService; + + @Mock + private Metadata metadata; + + private ApiTokenIndexHandler indexHandler; + + @Before + public void setup() { + + client = mock(Client.class, RETURNS_DEEP_STUBS); + indicesAdminClient = mock(IndicesAdminClient.class); + clusterService = mock(ClusterService.class, RETURNS_DEEP_STUBS); + metadata = mock(Metadata.class); + + when(client.admin().indices()).thenReturn(indicesAdminClient); + + when(clusterService.state().metadata()).thenReturn(metadata); + + ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + when(client.threadPool().getThreadContext()).thenReturn(threadContext); + + indexHandler = new ApiTokenIndexHandler(client, clusterService); + } + + @Test + public void testIndexPermissionToStringFromString() throws IOException { + String indexPermissionString = "{\"index_pattern\":[\"index1\",\"index2\"],\"allowed_actions\":[\"action1\",\"action2\"]}"; + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission( + Arrays.asList("index1", "index2"), + Arrays.asList("action1", "action2") + ); + assertThat( + indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS).toString(), + equalTo(indexPermissionString) + ); + + XContentParser parser = XContentType.JSON.xContent() + .createParser(NamedXContentRegistry.EMPTY, DeprecationHandler.THROW_UNSUPPORTED_OPERATION, indexPermissionString); + + ApiToken.IndexPermission indexPermissionFromString = ApiToken.IndexPermission.fromXContent(parser); + assertThat(indexPermissionFromString.getIndexPatterns(), equalTo(List.of("index1", "index2"))); + assertThat(indexPermissionFromString.getAllowedActions(), equalTo(List.of("action1", "action2"))); + } + +} diff --git a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java index ca8b4ad14d..48aae6f9b8 100644 --- a/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java +++ b/src/test/java/org/opensearch/security/authtoken/jwt/JwtVendorTest.java @@ -11,6 +11,7 @@ package org.opensearch.security.authtoken.jwt; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.List; @@ -30,11 +31,19 @@ import org.opensearch.OpenSearchException; import org.opensearch.common.collect.Tuple; import org.opensearch.common.settings.Settings; +import org.opensearch.common.xcontent.XContentFactory; +import org.opensearch.common.xcontent.XContentType; +import org.opensearch.core.xcontent.DeprecationHandler; +import org.opensearch.core.xcontent.NamedXContentRegistry; +import org.opensearch.core.xcontent.ToXContent; +import org.opensearch.core.xcontent.XContentParser; +import org.opensearch.security.action.apitokens.ApiToken; import org.opensearch.security.support.ConfigConstants; import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jwt.SignedJWT; +import joptsimple.internal.Strings; import org.mockito.ArgumentCaptor; import static org.hamcrest.MatcherAssert.assertThat; @@ -270,4 +279,116 @@ public void testCreateJwtLogsCorrectly() throws Exception { final String[] parts = logMessage.split("\\."); assertTrue(parts.length >= 3); } + + @Test + public void testCreateJwtForApiTokenSuccess() throws Exception { + final String issuer = "cluster_0"; + final String subject = "test-token"; + final String audience = "test-token"; + final List clusterPermissions = List.of("cluster:admin/*"); + ApiToken.IndexPermission indexPermission = new ApiToken.IndexPermission(List.of("*"), List.of("read")); + final List indexPermissions = List.of(indexPermission); + final String expectedClusterPermissions = "cluster:admin/*"; + final String expectedIndexPermissions = indexPermission.toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) + .toString(); + + LongSupplier currentTime = () -> (long) 100; + String claimsEncryptionKey = "1234567890123456"; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + final JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + final ExpiringBearerAuthToken authToken = jwtVendor.createJwt( + issuer, + subject, + audience, + Long.MAX_VALUE, + clusterPermissions, + indexPermissions + ); + + SignedJWT signedJWT = SignedJWT.parse(authToken.getCompleteToken()); + + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iss"), equalTo(issuer)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("sub"), equalTo(subject)); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("aud").toString(), equalTo("[" + audience + "]")); + assertThat(signedJWT.getJWTClaimsSet().getClaims().get("iat"), is(notNullValue())); + // Allow for millisecond to second conversion flexibility + assertThat(((Date) signedJWT.getJWTClaimsSet().getClaims().get("exp")).getTime() / 1000, equalTo(Long.MAX_VALUE / 1000)); + + EncryptionDecryptionUtil encryptionUtil = new EncryptionDecryptionUtil(claimsEncryptionKey); + assertThat( + encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("cp").toString()), + equalTo(expectedClusterPermissions) + ); + assertThat(encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()), equalTo(expectedIndexPermissions)); + + XContentParser parser = XContentType.JSON.xContent() + .createParser( + NamedXContentRegistry.EMPTY, + DeprecationHandler.THROW_UNSUPPORTED_OPERATION, + encryptionUtil.decrypt(signedJWT.getJWTClaimsSet().getClaims().get("ip").toString()) + ); + ApiToken.IndexPermission indexPermission1 = ApiToken.IndexPermission.fromXContent(parser); + + // Index permission deserialization works as expected + assertThat(indexPermission1.getIndexPatterns(), equalTo(indexPermission.getIndexPatterns())); + assertThat(indexPermission1.getAllowedActions(), equalTo(indexPermission.getAllowedActions())); + } + + @Test + public void testEncryptJwtCorrectly() { + String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); + String token = + "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJkZXJlayI6ImlzIGF3ZXNvbWUifQ.aPp9mSaBRBUzMJ8V_MYWUs8UoGYnJDNVriu3B9MRJpPNZtOhnIfATE0Ghmms2bGRNw9rmyRn1VIDQRmxSOTu3w"; + String expectedEncryptedToken = + "k3JQNRXR57Y4V4W1LNkpEP7FTJZos7fySJDJDGuBQXe7pi9aiEIGJ7JqjezssGRZ1AZGD/QTPQ0jjaV+rEICxBO9oyfTYWIoDdnAg5LijqPAzaULp48hi+/dqXXAAhi1zIlCSjqTDoZMTyjFxq4aRlPLjjQFuVxR3gIDMNnAUnvmFu5xh5AiVeKa1dwGy5X34Ou2i9pnQzmEDJDnf6mh7w2ODkDThJGh8JUlsUlfZEq6NwVN1XNyOr2IhPd3IZYUMgN3vWHyfjs6uwQNyHKHHcxIj4P8bJXLIGxJy3+LV5Y="; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + LongSupplier currentTime = () -> (long) 100; + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + assertThat(jwtVendor.encryptString(token), equalTo(expectedEncryptedToken)); + } + + @Test + public void testEncryptDecryptClusterIndexPermissionsCorrectly() throws IOException { + String claimsEncryptionKey = BaseEncoding.base64().encode("1234567890123456".getBytes(StandardCharsets.UTF_8)); + String clusterPermissions = "cluster:admin/*,cluster:*"; + String encryptedClusterPermissions = "P+KGUkpANJHzHGKVSqJhIyHOKS+JCLOanxCOBWSgZNk="; + // "{\"index_pattern\":[\"*\"],\"allowed_actions\":[\"read\"]},{\"index_pattern\":[\".*\"],\"allowed_actions\":[\"write\"]}" + String indexPermissions = Strings.join( + List.of( + new ApiToken.IndexPermission(List.of("*"), List.of("read")).toXContent( + XContentFactory.jsonBuilder(), + ToXContent.EMPTY_PARAMS + ).toString(), + new ApiToken.IndexPermission(List.of(".*"), List.of("write")).toXContent( + XContentFactory.jsonBuilder(), + ToXContent.EMPTY_PARAMS + ).toString() + ), + "," + ); + String encryptedIndexPermissions = + "Y9ssHcl6spHC2/zy+L1P0y8e2+T+jGgXcP02DWGeTMk/3KiI4Ik0Df7oXMf9l/Ba0emk9LClnHsJi8iFwRh7ii1Pxb3CTHS/d+p7a3bA6rtJjgOjGlbjdWTdj4+87uBJynsR5CAlUMLeTrjbPe/nWw=="; + Settings settings = Settings.builder().put("signing_key", signingKeyB64Encoded).put("encryption_key", claimsEncryptionKey).build(); + LongSupplier currentTime = () -> (long) 100; + JwtVendor jwtVendor = new JwtVendor(settings, Optional.of(currentTime)); + + // encrypt decrypt cluster permissions + assertThat(jwtVendor.encryptString(clusterPermissions), equalTo(encryptedClusterPermissions)); + assertThat(jwtVendor.decryptString(encryptedClusterPermissions), equalTo(clusterPermissions)); + + // encrypt decrypt index permissions + assertThat(jwtVendor.encryptString(indexPermissions), equalTo(encryptedIndexPermissions)); + assertThat(jwtVendor.decryptString(encryptedIndexPermissions), equalTo(indexPermissions)); + } + + @Test + public void testKeyTooShortThrowsException() { + String claimsEncryptionKey = RandomStringUtils.randomAlphanumeric(16); + String tooShortKey = BaseEncoding.base64().encode("short_key".getBytes()); + Settings settings = Settings.builder().put("signing_key", tooShortKey).put("encryption_key", claimsEncryptionKey).build(); + final Throwable exception = assertThrows(OpenSearchException.class, () -> { new JwtVendor(settings, Optional.empty()); }); + + assertThat(exception.getMessage(), containsString("The secret length must be at least 256 bits")); + } + } diff --git a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java index d686b145b2..7ecbb6da34 100644 --- a/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java +++ b/src/test/java/org/opensearch/security/identity/SecurityTokenManagerTest.java @@ -81,6 +81,7 @@ public void after() { verifyNoMoreInteractions(userService); } + @Test public void onConfigModelChanged_oboNotSupported() { final ConfigModel configModel = mock(ConfigModel.class); @@ -107,6 +108,7 @@ public void onDynamicConfigModelChanged_JwtVendorDisabled() { final Settings settings = Settings.builder().put("enabled", false).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); tokenManager.onDynamicConfigModelChanged(dcm); assertThat(tokenManager.issueOnBehalfOfTokenAllowed(), equalTo(false)); @@ -119,6 +121,7 @@ private DynamicConfigModel createMockJwtVendorInTokenManager() { final Settings settings = Settings.builder().put("enabled", true).build(); final DynamicConfigModel dcm = mock(DynamicConfigModel.class); when(dcm.getDynamicOnBehalfOfSettings()).thenReturn(settings); + when(dcm.getDynamicApiTokenSettings()).thenReturn(settings); doAnswer((invocation) -> jwtVendor).when(tokenManager).createJwtVendor(settings); tokenManager.onDynamicConfigModelChanged(dcm); return dcm; @@ -245,4 +248,59 @@ public void issueOnBehalfOfToken_success() throws Exception { verify(cs).getClusterName(); verify(threadPool).getThreadContext(); } + + @Test + public void issueApiToken_success() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void encryptCallsJwtEncrypt() throws Exception { + doAnswer(invockation -> new ClusterName("cluster17")).when(cs).getClusterName(); + final ThreadContext threadContext = new ThreadContext(Settings.EMPTY); + threadContext.putTransient(ConfigConstants.OPENDISTRO_SECURITY_USER, new User("Jon", List.of(), null)); + when(threadPool.getThreadContext()).thenReturn(threadContext); + final ConfigModel configModel = mock(ConfigModel.class); + tokenManager.onConfigModelChanged(configModel); + + createMockJwtVendorInTokenManager(); + + final ExpiringBearerAuthToken authToken = mock(ExpiringBearerAuthToken.class); + when(jwtVendor.createJwt(anyString(), anyString(), anyString(), anyLong(), any(), any())).thenReturn(authToken); + final AuthToken returnedToken = tokenManager.issueApiToken("elmo", Long.MAX_VALUE, List.of("*"), List.of()); + + assertThat(returnedToken, equalTo(authToken)); + + verify(cs).getClusterName(); + verify(threadPool).getThreadContext(); + } + + @Test + public void testEncryptTokenCallsJwtEncrypt() throws Exception { + String tokenToEncrypt = "test-token"; + String encryptedToken = "encrypted-test-token"; + createMockJwtVendorInTokenManager(); + when(jwtVendor.encryptString(tokenToEncrypt)).thenReturn(encryptedToken); + + String result = tokenManager.encryptToken(tokenToEncrypt); + + assertThat(result, equalTo(encryptedToken)); + verify(jwtVendor).encryptString(tokenToEncrypt); + } }