Skip to content

Commit

Permalink
Adds JTI and expiration field support for API Tokens (#4967)
Browse files Browse the repository at this point in the history
Signed-off-by: Derek Ho <[email protected]>
  • Loading branch information
derek-ho authored Dec 20, 2024
1 parent 3177c34 commit dacdae5
Show file tree
Hide file tree
Showing 16 changed files with 706 additions and 63 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ public List<RestHandler> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> clusterPermissions;
private List<IndexPermission> indexPermissions;
private final List<String> clusterPermissions;
private final List<IndexPermission> indexPermissions;
private final long expiration;

public ApiToken(String name, String jti, List<String> clusterPermissions, List<IndexPermission> indexPermissions) {
public ApiToken(String name, String jti, List<String> clusterPermissions, List<IndexPermission> 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<String> clusterPermissions,
List<IndexPermission> 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 {
Expand Down Expand Up @@ -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<String> indexPatterns = new ArrayList<>();
List<String> 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);
}

}

/**
Expand All @@ -109,6 +144,7 @@ public static ApiToken fromXContent(XContentParser parser) throws IOException {
List<String> clusterPermissions = new ArrayList<>();
List<IndexPermission> indexPermissions = new ArrayList<>();
Instant creationTime = null;
long expiration = 0;

XContentParser.Token token;
String currentFieldName = null;
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -174,18 +213,18 @@ private static IndexPermission parseIndexPermission(XContentParser parser) throw
}
}
}

return new IndexPermission(indexPatterns, allowedActions);
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
public Long getExpiration() {
return expiration;
}

@JsonIgnore
public String getJti() {
return jti;
}
Expand All @@ -198,12 +237,8 @@ public List<String> getClusterPermissions() {
return clusterPermissions;
}

public void setClusterPermissions(List<String> 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);
Expand All @@ -217,8 +252,4 @@ public XContentBuilder toXContent(XContentBuilder xContentBuilder, ToXContent.Pa
public List<IndexPermission> getIndexPermissions() {
return indexPermissions;
}

public void setIndexPermissions(List<IndexPermission> indexPermissions) {
this.indexPermissions = indexPermissions;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,19 +30,21 @@
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;
import static org.opensearch.rest.RestRequest.Method.POST;
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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -146,14 +154,14 @@ private RestChannelConsumer handlePost(RestRequest request, NodeClient client) {
* Extracts cluster permissions from the request body
*/
List<String> extractClusterPermissions(Map<String, Object> 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<ApiToken.IndexPermission> extractIndexPermissions(Map<String, Object> requestBody) {
List<Map<String, Object>> indexPerms = ParsingUtils.safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
List<Map<String, Object>> indexPerms = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
return indexPerms.stream().map(this::createIndexPermission).collect(Collectors.toList());
}

Expand All @@ -166,10 +174,10 @@ ApiToken.IndexPermission createIndexPermission(Map<String, Object> 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<String> allowedActions = ParsingUtils.safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD);
List<String> allowedActions = safeStringList(indexPerm.get(ALLOWED_ACTIONS_FIELD), ALLOWED_ACTIONS_FIELD);

return new ApiToken.IndexPermission(indexPatterns, allowedActions);
}
Expand All @@ -182,6 +190,13 @@ void validateRequestParameters(Map<String, Object> 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)) {
Expand All @@ -190,10 +205,7 @@ void validateRequestParameters(Map<String, Object> requestBody) {
}

if (requestBody.containsKey(INDEX_PERMISSIONS_FIELD)) {
List<Map<String, Object>> indexPermsList = ParsingUtils.safeMapList(
requestBody.get(INDEX_PERMISSIONS_FIELD),
INDEX_PERMISSIONS_FIELD
);
List<Map<String, Object>> indexPermsList = safeMapList(requestBody.get(INDEX_PERMISSIONS_FIELD), INDEX_PERMISSIONS_FIELD);
validateIndexPermissionsList(indexPermsList);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> clusterPermissions, List<ApiToken.IndexPermission> indexPermissions) {
public String createApiToken(
String name,
List<String> clusterPermissions,
List<ApiToken.IndexPermission> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Loading

0 comments on commit dacdae5

Please sign in to comment.