From 917516d20c68553624023636bd36677c848d00f2 Mon Sep 17 00:00:00 2001 From: Diogo Fekete Date: Wed, 8 Apr 2026 20:13:36 -0400 Subject: [PATCH 01/11] support for AssumeRoleWithWebIdentity --- lambda-invoker/pom.xml | 4 ++ .../aws/lambda/LambdaFunctionHandler.java | 44 +++++++++++++++++-- .../aws/lambda/LambdaInvokerConfig.java | 40 +++++++++-------- .../config/lambda-invoker-schema.json | 12 ++--- .../main/resources/config/lambda-invoker.yaml | 14 +++--- .../main/resources/config/lambda-invoker.yml | 14 +++--- .../aws/lambda/LambdaInvokerConfigTest.java | 13 ++++-- .../config/lambda-invoker-sts-func.yml | 2 + .../config/lambda-invoker-sts-no-role.yml | 2 +- .../config/lambda-invoker-sts-with-role.yml | 2 +- pom.xml | 5 +++ 11 files changed, 110 insertions(+), 42 deletions(-) create mode 100644 lambda-invoker/src/test/resources/config/lambda-invoker-sts-func.yml diff --git a/lambda-invoker/pom.xml b/lambda-invoker/pom.xml index 307b8ae..702c2c8 100644 --- a/lambda-invoker/pom.xml +++ b/lambda-invoker/pom.xml @@ -47,6 +47,10 @@ com.networknt metrics + + com.networknt + metrics-config + com.networknt body diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index eac7221..40e33c3 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -26,7 +26,9 @@ import software.amazon.awssdk.services.lambda.model.InvokeRequest; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider; +import software.amazon.awssdk.services.sts.auth.StsAssumeRoleWithWebIdentityCredentialsProvider; import software.amazon.awssdk.services.sts.model.AssumeRoleRequest; +import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest; import java.net.URI; import java.time.Duration; @@ -40,11 +42,15 @@ public class LambdaFunctionHandler implements LightHttpHandler { private static final Logger logger = LoggerFactory.getLogger(LambdaFunctionHandler.class); private static final String MISSING_ENDPOINT_FUNCTION = "ERR10063"; private static final String EMPTY_LAMBDA_RESPONSE = "ERR10064"; + private static final String STS_TYPE_FUNC_USER = "StsFuncUser"; + private static final String STS_TYPE_WEB_IDENTITY = "StsWebIdentity"; + private static final String BEARER_PREFIX = "BEARER"; private static AbstractMetricsHandler metricsHandler; private LambdaInvokerConfig config; private LambdaAsyncClient client; private StsAssumeRoleCredentialsProvider stsCredentialsProvider; + private StsAssumeRoleWithWebIdentityCredentialsProvider stsWebIdentityCredentialsProvider; private StsClient stsClient; // Package-private constructor for testing - avoids loading config from file and metrics chain setup @@ -103,9 +109,9 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { builder.endpointOverride(URI.create(config.getEndpointOverride())); } - // If STS is enabled, use StsAssumeRoleCredentialsProvider for automatic credential refresh - if(config.isStsEnabled()) { - if(logger.isInfoEnabled()) logger.info("STS AssumeRole is enabled. Using StsAssumeRoleCredentialsProvider for role: {}", config.getRoleArn()); + // If any STS type selected, use StsAssumeRoleCredentialsProvider for automatic credential refresh + if(STS_TYPE_FUNC_USER.equals(config.getStsType())) { + if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_FUNC_USER + " for role: {}", config.getRoleArn()); stsClient = StsClient.builder() .region(Region.of(config.getRegion())) .build(); @@ -118,6 +124,17 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { .build()) .build(); builder.credentialsProvider(stsCredentialsProvider); + } else if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { + if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_WEB_IDENTITY + " for role: {}", config.getRoleArn()); + stsClient = StsClient.builder() + .region(Region.of(config.getRegion())) + .build(); + stsWebIdentityCredentialsProvider = StsAssumeRoleWithWebIdentityCredentialsProvider.builder() + .stsClient(stsClient) // AssumeRoleWithWebIdentityRequest must be built with req exchange token + .build(); + builder.credentialsProvider(stsWebIdentityCredentialsProvider); + } else { + if(logger.isInfoEnabled()) logger.info("No STS AssumeRole is set. Using default credential provider chain for LambdaAsyncClient."); } return builder.build(); @@ -187,6 +204,27 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); return; } + // set the OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider for STS exchange + if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { + String authorization = headers.get("Authorization"); + if(authorization.isEmpty()){ + logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); + } else if(BEARER_PREFIX.equalsIgnoreCase(authorization.substring(0, 6))) { + authorization = authorization.substring(7); + } else { + logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); + } + final String token = authorization; + if(StringUtils.isEmpty(token)) logger.warn("Empty Authorization token from request. STS AssumeRole with Web Identity may fail"); + stsWebIdentityCredentialsProvider.toBuilder().applyMutation(builder -> { + builder.refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .webIdentityToken(token) + .build()); + }).build(); + } APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); requestEvent.setHttpMethod(httpMethod); requestEvent.setPath(requestPath); diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java index 14d2e54..d05833e 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java @@ -40,7 +40,7 @@ public class LambdaInvokerConfig { private static final String MAX_CONCURRENCY = "maxConcurrency"; private static final String MAX_PENDING_CONNECTION_ACQUIRES = "maxPendingConnectionAcquires"; private static final String CONNECTION_ACQUISITION_TIMEOUT = "connectionAcquisitionTimeout"; - private static final String STS_ENABLED = "stsEnabled"; + private static final String STS_TYPE = "stsType"; private static final String ROLE_ARN = "roleArn"; private static final String ROLE_SESSION_NAME = "roleSessionName"; private static final String DURATION_SECONDS = "durationSeconds"; @@ -152,21 +152,25 @@ public class LambdaInvokerConfig { ) private String metricsName; - @BooleanField( - configFieldName = STS_ENABLED, - externalizedKeyName = STS_ENABLED, + @StringField( + configFieldName = STS_TYPE, + externalizedKeyName = STS_TYPE, description = "Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the\n" + - "permanent IAM credentials. When set to true, the handler will call STS AssumeRole with the configured\n" + - "roleArn, roleSessionName, and durationSeconds to get short-lived credentials. This is the recommended\n" + - "approach for production environments to follow the principle of least privilege.\n", - defaultValue = "false" + "permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity.\n" + + "If STS is not to be used set this property as empty. When StsFuncUser is set the handler will\n" + + "use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will\n" + + "use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the\n" + + "selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and\n" + + "durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach\n" + + "for production environments to follow the principle of least privilege.\n", + defaultValue = "" ) - private boolean stsEnabled; + private String stsType; @StringField( configFieldName = ROLE_ARN, externalizedKeyName = ROLE_ARN, - description = "The ARN of the IAM role to assume when stsEnabled is true. For example,\n" + + description = "The ARN of the IAM role to assume when stsType is not empty. For example,\n" + "arn:aws:iam::123456789012:role/LambdaInvokerRole\n" ) private String roleArn; @@ -329,12 +333,12 @@ public void setConnectionAcquisitionTimeout(int connectionAcquisitionTimeout) { this.connectionAcquisitionTimeout = connectionAcquisitionTimeout; } - public boolean isStsEnabled() { - return stsEnabled; + public String getStsType() { + return stsType; } - public void setStsEnabled(boolean stsEnabled) { - this.stsEnabled = stsEnabled; + public void setStsType(String stsType) { + this.stsType = stsType; } public String getRoleArn() { @@ -398,8 +402,8 @@ private void setConfigData() { object = mappedConfig.get(CONNECTION_ACQUISITION_TIMEOUT); if (object != null) connectionAcquisitionTimeout = Config.loadIntegerValue(CONNECTION_ACQUISITION_TIMEOUT, object); - object = mappedConfig.get(STS_ENABLED); - if(object != null) stsEnabled = Config.loadBooleanValue(STS_ENABLED, object); + object = mappedConfig.get(STS_TYPE); + if (object != null) stsType = (String) object; object = mappedConfig.get(ROLE_ARN); if(object != null) roleArn = (String) object; @@ -456,8 +460,8 @@ private void setConfigMap() { } private void validate() { - if (stsEnabled && (roleArn == null || roleArn.trim().isEmpty())) { - throw new ConfigException(ROLE_ARN + " must be configured when " + STS_ENABLED + " is true."); + if ((stsType != null && !stsType.trim().isEmpty()) && (roleArn == null || roleArn.trim().isEmpty())) { + throw new ConfigException(ROLE_ARN + " must be configured when " + STS_TYPE + " is not empty."); } } } diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json b/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json index 0fc97ad..e1eea17 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json +++ b/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json @@ -1,7 +1,7 @@ { "$schema" : "http://json-schema.org/draft-07/schema#", "type" : "object", - "required" : [ "region", "endpointOverride", "apiCallTimeout", "apiCallAttemptTimeout", "maxRetries", "maxConcurrency", "maxPendingConnectionAcquires", "connectionAcquisitionTimeout", "logType", "functions", "metricsInjection", "metricsName", "stsEnabled", "roleArn", "roleSessionName", "durationSeconds" ], + "required" : [ "region", "endpointOverride", "apiCallTimeout", "apiCallAttemptTimeout", "maxRetries", "maxConcurrency", "maxPendingConnectionAcquires", "connectionAcquisitionTimeout", "logType", "functions", "metricsInjection", "metricsName", "stsType", "roleArn", "roleSessionName", "durationSeconds" ], "properties" : { "region" : { "type" : "string", @@ -70,14 +70,14 @@ "description" : "When the metrics info is injected into the metrics handler, we need to pass a metric name to it so that\nthe metrics info can be categorized in a tree structure under the name. By default, it is lambda-response,\nand users can change it.\n", "default" : "lambda-response" }, - "stsEnabled" : { - "type" : "boolean", - "description" : "Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the\npermanent IAM credentials. When set to true, the handler will call STS AssumeRole with the configured\nroleArn, roleSessionName, and durationSeconds to get short-lived credentials. This is the recommended\napproach for production environments to follow the principle of least privilege.\n", - "default" : false + "stsType" : { + "type" : "string", + "description" : "Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the\npermanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity.\nIf STS is not to be used set this property as empty. When StsFuncUser is set the handler will\nuse the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will\nuse the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the\nselected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and\ndurationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach\nfor production environments to follow the principle of least privilege.", + "default" : "" }, "roleArn" : { "type" : "string", - "description" : "The ARN of the IAM role to assume when stsEnabled is true. For example,\narn:aws:iam::123456789012:role/LambdaInvokerRole\n" + "description" : "The ARN of the IAM role to assume when stsType is set. For example,\narn:aws:iam::123456789012:role/LambdaInvokerRole\n" }, "roleSessionName" : { "type" : "string", diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker.yaml b/lambda-invoker/src/main/resources/config/lambda-invoker.yaml index 8b7422a..19624a2 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker.yaml +++ b/lambda-invoker/src/main/resources/config/lambda-invoker.yaml @@ -32,11 +32,15 @@ metricsInjection: ${lambda-invoker.metricsInjection:false} # and users can change it. metricsName: ${lambda-invoker.metricsName:lambda-response} # Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the -# permanent IAM credentials. When set to true, the handler will call STS AssumeRole with the configured -# roleArn, roleSessionName, and durationSeconds to get short-lived credentials. This is the recommended -# approach for production environments to follow the principle of least privilege. -stsEnabled: ${lambda-invoker.stsEnabled:false} -# The ARN of the IAM role to assume when stsEnabled is true. For example, +# permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity. +# If STS is not to be used set this property as empty. When StsFuncUser is set the handler will +# use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will +# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the +# selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and +# durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach +# for production environments to follow the principle of least privilege. +stsType: ${lambda-invoker.stsType:} +# The ARN of the IAM role to assume when stsType is set. For example, # arn:aws:iam::123456789012:role/LambdaInvokerRole roleArn: ${lambda-invoker.roleArn:} # The session name to use when assuming the role. This is used for auditing and tracking in CloudTrail. diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker.yml b/lambda-invoker/src/main/resources/config/lambda-invoker.yml index 8b7422a..19624a2 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker.yml +++ b/lambda-invoker/src/main/resources/config/lambda-invoker.yml @@ -32,11 +32,15 @@ metricsInjection: ${lambda-invoker.metricsInjection:false} # and users can change it. metricsName: ${lambda-invoker.metricsName:lambda-response} # Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the -# permanent IAM credentials. When set to true, the handler will call STS AssumeRole with the configured -# roleArn, roleSessionName, and durationSeconds to get short-lived credentials. This is the recommended -# approach for production environments to follow the principle of least privilege. -stsEnabled: ${lambda-invoker.stsEnabled:false} -# The ARN of the IAM role to assume when stsEnabled is true. For example, +# permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity. +# If STS is not to be used set this property as empty. When StsFuncUser is set the handler will +# use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will +# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the +# selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and +# durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach +# for production environments to follow the principle of least privilege. +stsType: ${lambda-invoker.stsType:} +# The ARN of the IAM role to assume when stsType is set. For example, # arn:aws:iam::123456789012:role/LambdaInvokerRole roleArn: ${lambda-invoker.roleArn:} # The session name to use when assuming the role. This is used for auditing and tracking in CloudTrail. diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java index 7ca6e87..1c88c88 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java @@ -21,15 +21,22 @@ public void testFunctionMapping() { } @Test - public void testStsEnabledWithoutRoleArnThrowsConfigException() { + public void testStsTypeWithoutRoleArnThrowsConfigException() { assertThrows(ConfigException.class, () -> LambdaInvokerConfig.load("lambda-invoker-sts-no-role"), - "ConfigException was not thrown despite stsEnabled=true with blank roleArn"); + "ConfigException was not thrown despite stsType defined with blank roleArn"); } @Test - public void testStsEnabledWithRoleArnSucceeds() { + public void testStsTypeWithRoleArnSucceeds() { LambdaInvokerConfig stsConfig = LambdaInvokerConfig.load("lambda-invoker-sts-with-role"); assertNotNull(stsConfig); assertEquals("arn:aws:iam::123456789012:role/TestRole", stsConfig.getRoleArn()); } + + @Test + public void testStsTypeFuncWithRoleArn() { + LambdaInvokerConfig stsConfig = LambdaInvokerConfig.load("lambda-invoker-sts-func"); + assertNotNull(stsConfig); + assertEquals("arn:aws:iam::123456789012:role/TestRole", stsConfig.getRoleArn()); + } } diff --git a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-func.yml b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-func.yml new file mode 100644 index 0000000..5a9b194 --- /dev/null +++ b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-func.yml @@ -0,0 +1,2 @@ +stsType: StsFuncUser +roleArn: arn:aws:iam::123456789012:role/TestRole diff --git a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-no-role.yml b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-no-role.yml index 9841484..3508542 100644 --- a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-no-role.yml +++ b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-no-role.yml @@ -1,2 +1,2 @@ -stsEnabled: true +stsType: StsWebIdentity roleArn: diff --git a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-with-role.yml b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-with-role.yml index 7ddc658..d286c92 100644 --- a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-with-role.yml +++ b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-with-role.yml @@ -1,2 +1,2 @@ -stsEnabled: true +stsType: StsWebIdentity roleArn: arn:aws:iam::123456789012:role/TestRole diff --git a/pom.xml b/pom.xml index ae15638..d2bad0a 100644 --- a/pom.xml +++ b/pom.xml @@ -245,6 +245,11 @@ metrics ${project.version} + + com.networknt + metrics-config + ${project.version} + com.networknt status From 4538d52d3ad7c3b127b1afc6c6aa122db5864d0b Mon Sep 17 00:00:00 2001 From: Diogo Fekete Date: Thu, 9 Apr 2026 09:38:41 -0400 Subject: [PATCH 02/11] adding cache for IDToken --- .../aws/lambda/LambdaFunctionHandler.java | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 40e33c3..252ae2f 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -37,6 +37,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicReference; public class LambdaFunctionHandler implements LightHttpHandler { private static final Logger logger = LoggerFactory.getLogger(LambdaFunctionHandler.class); @@ -45,6 +46,7 @@ public class LambdaFunctionHandler implements LightHttpHandler { private static final String STS_TYPE_FUNC_USER = "StsFuncUser"; private static final String STS_TYPE_WEB_IDENTITY = "StsWebIdentity"; private static final String BEARER_PREFIX = "BEARER"; + private final AtomicReference tokenCache = new AtomicReference(); private static AbstractMetricsHandler metricsHandler; private LambdaInvokerConfig config; @@ -130,7 +132,13 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { .region(Region.of(config.getRegion())) .build(); stsWebIdentityCredentialsProvider = StsAssumeRoleWithWebIdentityCredentialsProvider.builder() - .stsClient(stsClient) // AssumeRoleWithWebIdentityRequest must be built with req exchange token + .stsClient(stsClient) + .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + // .webIdentityToken(token) // token will be set dynamically for each request in the handleRequest method, so we don't set it here in the builder + .build()) .build(); builder.credentialsProvider(stsWebIdentityCredentialsProvider); } else { @@ -163,6 +171,14 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } stsCredentialsProvider = null; } + if(stsWebIdentityCredentialsProvider != null) { + try { + stsWebIdentityCredentialsProvider.close(); + } catch (Exception e) { + logger.error("Failed to close the StsAssumeRoleCredentialsProvider", e); + } + stsWebIdentityCredentialsProvider = null; + } if(stsClient != null) { try { stsClient.close(); @@ -214,16 +230,21 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } else { logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); } - final String token = authorization; - if(StringUtils.isEmpty(token)) logger.warn("Empty Authorization token from request. STS AssumeRole with Web Identity may fail"); - stsWebIdentityCredentialsProvider.toBuilder().applyMutation(builder -> { - builder.refreshRequest(AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - .webIdentityToken(token) - .build()); - }).build(); + if (tokenCache != null && authorization.equals(tokenCache.get())) { + // incoming ID token reuse + if(logger.isDebugEnabled()) logger.debug("Cached token. Will reuse = {}", authorization.substring(0, 10) + "..."); + } else { + // Not cached, rebuild credentials provider with new token and cache it + if(logger.isDebugEnabled()) logger.debug("New token. Will refresh credentials provider with new token = {}", authorization.substring(0, 10) + "..."); + final String token = authorization; + if(StringUtils.isEmpty(token)) logger.warn("Empty Authorization token from request. STS AssumeRole with Web Identity may fail"); + stsWebIdentityCredentialsProvider.toBuilder().applyMutation(builder -> { + builder.refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .webIdentityToken(token) + .build()); + }).build(); + tokenCache.set(token); + } } APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); requestEvent.setHttpMethod(httpMethod); From 8bd52c88dad957e6ddda4fd38d507a2f95c801d5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:16:26 +0000 Subject: [PATCH 03/11] Apply review feedback: fix StsWebIdentity handler issues and config validation Agent-Logs-Url: https://github.com/networknt/light-aws-lambda/sessions/7d681ae3-7571-4446-82f3-109707801103 Co-authored-by: stevehu <2042337+stevehu@users.noreply.github.com> --- .../aws/lambda/LambdaFunctionHandler.java | 38 +++++++++++-------- .../aws/lambda/LambdaInvokerConfig.java | 12 ++++-- .../config/lambda-invoker-schema.json | 2 +- .../main/resources/config/lambda-invoker.yaml | 2 +- .../main/resources/config/lambda-invoker.yml | 2 +- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 252ae2f..0ccd372 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -13,6 +13,7 @@ import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; import io.undertow.util.HeaderMap; +import io.undertow.util.Headers; import io.undertow.util.HttpString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -175,7 +176,7 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { try { stsWebIdentityCredentialsProvider.close(); } catch (Exception e) { - logger.error("Failed to close the StsAssumeRoleCredentialsProvider", e); + logger.error("Failed to close the StsAssumeRoleWithWebIdentityCredentialsProvider", e); } stsWebIdentityCredentialsProvider = null; } @@ -222,27 +223,34 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } // set the OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider for STS exchange if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { - String authorization = headers.get("Authorization"); - if(authorization.isEmpty()){ + String authorization = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); + if(authorization == null || authorization.isEmpty()){ logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); - } else if(BEARER_PREFIX.equalsIgnoreCase(authorization.substring(0, 6))) { - authorization = authorization.substring(7); + } else if(authorization.length() > BEARER_PREFIX.length() && + authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + authorization = authorization.substring(BEARER_PREFIX.length() + 1); } else { logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); } - if (tokenCache != null && authorization.equals(tokenCache.get())) { + if (authorization != null && !authorization.isEmpty() && authorization.equals(tokenCache.get())) { // incoming ID token reuse - if(logger.isDebugEnabled()) logger.debug("Cached token. Will reuse = {}", authorization.substring(0, 10) + "..."); - } else { + if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing existing token for STS web identity."); + } else if (authorization != null && !authorization.isEmpty()) { // Not cached, rebuild credentials provider with new token and cache it - if(logger.isDebugEnabled()) logger.debug("New token. Will refresh credentials provider with new token = {}", authorization.substring(0, 10) + "..."); + if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Refreshing credentials provider for STS web identity."); final String token = authorization; - if(StringUtils.isEmpty(token)) logger.warn("Empty Authorization token from request. STS AssumeRole with Web Identity may fail"); - stsWebIdentityCredentialsProvider.toBuilder().applyMutation(builder -> { - builder.refreshRequest(AssumeRoleWithWebIdentityRequest.builder() - .webIdentityToken(token) - .build()); - }).build(); + AssumeRoleWithWebIdentityRequest refreshRequest = AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .webIdentityToken(token) + .build(); + stsWebIdentityCredentialsProvider = stsWebIdentityCredentialsProvider.toBuilder() + .refreshRequest(refreshRequest) + .build(); + client = client.toBuilder() + .credentialsProvider(stsWebIdentityCredentialsProvider) + .build(); tokenCache.set(token); } } diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java index d05833e..0e62559 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java @@ -159,7 +159,7 @@ public class LambdaInvokerConfig { "permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity.\n" + "If STS is not to be used set this property as empty. When StsFuncUser is set the handler will\n" + "use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will\n" + - "use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the\n" + + "use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token. Regardless of the\n" + "selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and\n" + "durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach\n" + "for production environments to follow the principle of least privilege.\n", @@ -460,8 +460,14 @@ private void setConfigMap() { } private void validate() { - if ((stsType != null && !stsType.trim().isEmpty()) && (roleArn == null || roleArn.trim().isEmpty())) { - throw new ConfigException(ROLE_ARN + " must be configured when " + STS_TYPE + " is not empty."); + String normalizedStsType = stsType == null ? null : stsType.trim(); + if (normalizedStsType != null && !normalizedStsType.isEmpty()) { + if (!"StsFuncUser".equals(normalizedStsType) && !"StsWebIdentity".equals(normalizedStsType)) { + throw new ConfigException(STS_TYPE + " must be one of [StsFuncUser, StsWebIdentity], but was: " + normalizedStsType); + } + if (roleArn == null || roleArn.trim().isEmpty()) { + throw new ConfigException(ROLE_ARN + " must be configured when " + STS_TYPE + " is not empty."); + } } } } diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json b/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json index e1eea17..b88a3e1 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json +++ b/lambda-invoker/src/main/resources/config/lambda-invoker-schema.json @@ -72,7 +72,7 @@ }, "stsType" : { "type" : "string", - "description" : "Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the\npermanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity.\nIf STS is not to be used set this property as empty. When StsFuncUser is set the handler will\nuse the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will\nuse the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the\nselected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and\ndurationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach\nfor production environments to follow the principle of least privilege.", + "description" : "Enable STS AssumeRole to obtain temporary credentials for Lambda invocation instead of using the\npermanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity.\nIf STS is not to be used set this property as empty. When StsFuncUser is set the handler will\nuse the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will\nuse the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token. Regardless of the\nselected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and\ndurationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach\nfor production environments to follow the principle of least privilege.", "default" : "" }, "roleArn" : { diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker.yaml b/lambda-invoker/src/main/resources/config/lambda-invoker.yaml index 19624a2..79452ed 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker.yaml +++ b/lambda-invoker/src/main/resources/config/lambda-invoker.yaml @@ -35,7 +35,7 @@ metricsName: ${lambda-invoker.metricsName:lambda-response} # permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity. # If STS is not to be used set this property as empty. When StsFuncUser is set the handler will # use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will -# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the +# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token. Regardless of the # selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and # durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach # for production environments to follow the principle of least privilege. diff --git a/lambda-invoker/src/main/resources/config/lambda-invoker.yml b/lambda-invoker/src/main/resources/config/lambda-invoker.yml index 19624a2..79452ed 100644 --- a/lambda-invoker/src/main/resources/config/lambda-invoker.yml +++ b/lambda-invoker/src/main/resources/config/lambda-invoker.yml @@ -35,7 +35,7 @@ metricsName: ${lambda-invoker.metricsName:lambda-response} # permanent IAM credentials. Only 2 STS types supported: StsFuncUser and StsWebIdentity. # If STS is not to be used set this property as empty. When StsFuncUser is set the handler will # use the configured AWS IAM User to assume the given RoleARN. When StsWebIdentity is set the handler will -# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token.Regardless of the +# use the request bearer token as the WEB_IDENTITY_TOKEN to be exchanged for STS token. Regardless of the # selected type, the handler will call STS AssumeRole with the configured roleArn, roleSessionName, and # durationSeconds to get short-lived credentials. Using one of the STS types is the recommended approach # for production environments to follow the principle of least privilege. From abefd9ffd0c2c5d94302991e01b1f01579187967 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:41:50 +0000 Subject: [PATCH 04/11] Add StsWebIdentity unit tests: extract bearer token parsing and add test coverage Agent-Logs-Url: https://github.com/networknt/light-aws-lambda/sessions/e1696a54-4c13-45ea-831c-e02b5160ef43 Co-authored-by: stevehu <2042337+stevehu@users.noreply.github.com> --- .../aws/lambda/LambdaFunctionHandler.java | 70 +++++++++++-------- .../aws/lambda/LambdaFunctionHandlerTest.java | 52 ++++++++++++++ .../aws/lambda/LambdaInvokerConfigTest.java | 31 +++++++- .../lambda-invoker-sts-invalid-type.yml | 2 + 4 files changed, 123 insertions(+), 32 deletions(-) create mode 100644 lambda-invoker/src/test/resources/config/lambda-invoker-sts-invalid-type.yml diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 0ccd372..47b34a3 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -223,35 +223,29 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } // set the OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider for STS exchange if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { - String authorization = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); - if(authorization == null || authorization.isEmpty()){ - logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); - } else if(authorization.length() > BEARER_PREFIX.length() && - authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { - authorization = authorization.substring(BEARER_PREFIX.length() + 1); - } else { - logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); - } - if (authorization != null && !authorization.isEmpty() && authorization.equals(tokenCache.get())) { - // incoming ID token reuse - if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing existing token for STS web identity."); - } else if (authorization != null && !authorization.isEmpty()) { - // Not cached, rebuild credentials provider with new token and cache it - if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Refreshing credentials provider for STS web identity."); - final String token = authorization; - AssumeRoleWithWebIdentityRequest refreshRequest = AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - .webIdentityToken(token) - .build(); - stsWebIdentityCredentialsProvider = stsWebIdentityCredentialsProvider.toBuilder() - .refreshRequest(refreshRequest) - .build(); - client = client.toBuilder() - .credentialsProvider(stsWebIdentityCredentialsProvider) - .build(); - tokenCache.set(token); + String rawAuthHeader = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); + String token = extractBearerToken(rawAuthHeader); + if (token != null && !token.isEmpty()) { + if (token.equals(tokenCache.get())) { + // incoming ID token reuse + if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing existing token for STS web identity."); + } else { + // Not cached, rebuild credentials provider with new token and cache it + if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Refreshing credentials provider for STS web identity."); + AssumeRoleWithWebIdentityRequest refreshRequest = AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .webIdentityToken(token) + .build(); + stsWebIdentityCredentialsProvider = stsWebIdentityCredentialsProvider.toBuilder() + .refreshRequest(refreshRequest) + .build(); + client = client.toBuilder() + .credentialsProvider(stsWebIdentityCredentialsProvider) + .build(); + tokenCache.set(token); + } } } APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); @@ -344,4 +338,22 @@ private void setResponseHeaders(HttpServerExchange exchange, Map } } } + + /** + * Extracts the bearer token from a raw Authorization header value. + * Returns the token string if the header starts with "Bearer " (case-insensitive), + * or {@code null} if the header is missing/empty or does not use the Bearer scheme. + */ + static String extractBearerToken(String authorizationHeaderValue) { + if (authorizationHeaderValue == null || authorizationHeaderValue.isEmpty()) { + logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); + return null; + } + if (authorizationHeaderValue.length() > BEARER_PREFIX.length() && + authorizationHeaderValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + return authorizationHeaderValue.substring(BEARER_PREFIX.length() + 1); + } + logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); + return null; + } } diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java index 857f630..c2094ce 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java @@ -8,6 +8,8 @@ import java.util.HashMap; import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + class LambdaFunctionHandlerTest { static final Logger logger = LoggerFactory.getLogger(LambdaFunctionHandlerTest.class); @@ -26,4 +28,54 @@ void testAPIGatewayProxyRequestEvent() throws Exception { requestEvent.setBody(null); System.out.println(JsonMapper.objectMapper.writeValueAsString(requestEvent)); } + + // --- extractBearerToken tests --- + + @Test + void testExtractBearerToken_nullHeader_returnsNull() { + assertNull(LambdaFunctionHandler.extractBearerToken(null)); + } + + @Test + void testExtractBearerToken_emptyHeader_returnsNull() { + assertNull(LambdaFunctionHandler.extractBearerToken("")); + } + + @Test + void testExtractBearerToken_validBearerMixedCase_extractsToken() { + assertEquals("mytoken123", LambdaFunctionHandler.extractBearerToken("Bearer mytoken123")); + } + + @Test + void testExtractBearerToken_validBearerLowercase_extractsToken() { + assertEquals("mytoken123", LambdaFunctionHandler.extractBearerToken("bearer mytoken123")); + } + + @Test + void testExtractBearerToken_validBearerUppercase_extractsToken() { + assertEquals("mytoken123", LambdaFunctionHandler.extractBearerToken("BEARER mytoken123")); + } + + @Test + void testExtractBearerToken_bearerWithJwtToken_extractsFullToken() { + String jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature"; + assertEquals(jwt, LambdaFunctionHandler.extractBearerToken("Bearer " + jwt)); + } + + @Test + void testExtractBearerToken_nonBearerScheme_returnsNull() { + assertNull(LambdaFunctionHandler.extractBearerToken("Basic dXNlcjpwYXNz")); + } + + @Test + void testExtractBearerToken_headerIsBearerOnly_returnsNull() { + // "BEARER" alone is exactly BEARER_PREFIX.length() characters, not greater, so returns null + assertNull(LambdaFunctionHandler.extractBearerToken("BEARER")); + } + + @Test + void testExtractBearerToken_tokenIsEmptyAfterBearer_returnsEmptyString() { + // "Bearer " is length 7; prefix is 6, +1 = 7 so substring(7) on "Bearer " gives "" + assertEquals("", LambdaFunctionHandler.extractBearerToken("Bearer ")); + } } diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java index 1c88c88..00d994d 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java @@ -6,10 +6,9 @@ import org.junit.jupiter.api.Test; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; public class LambdaInvokerConfigTest { private static LambdaInvokerConfig config = (LambdaInvokerConfig) Config.getInstance().getJsonObjectConfig(LambdaInvokerConfig.CONFIG_NAME, LambdaInvokerConfig.class); @@ -39,4 +38,30 @@ public void testStsTypeFuncWithRoleArn() { assertNotNull(stsConfig); assertEquals("arn:aws:iam::123456789012:role/TestRole", stsConfig.getRoleArn()); } + + @Test + public void testInvalidStsTypeThrowsConfigException() { + ConfigException ex = assertThrows(ConfigException.class, + () -> LambdaInvokerConfig.load("lambda-invoker-sts-invalid-type"), + "ConfigException was not thrown for unsupported stsType value"); + assertTrue(ex.getMessage().contains("stsType"), "Exception message should mention stsType"); + } + + @Test + public void testTokenCacheIsolation() { + // Verify that a fresh AtomicReference starts with null and can be updated + AtomicReference cache = new AtomicReference<>(); + assertNull(cache.get()); + cache.set("token1"); + assertEquals("token1", cache.get()); + // Replacing with a new token works correctly + cache.set("token2"); + assertEquals("token2", cache.get()); + // Same token comparison (simulates cached-token path) + String incoming = "token2"; + assertEquals(cache.get(), incoming, "Same token should match cached value"); + // Different token comparison (simulates refresh path) + String newToken = "token3"; + assertNotEquals(cache.get(), newToken, "Different token should not match cached value"); + } } diff --git a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-invalid-type.yml b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-invalid-type.yml new file mode 100644 index 0000000..78bf639 --- /dev/null +++ b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-invalid-type.yml @@ -0,0 +1,2 @@ +stsType: StsWebIdentiy +roleArn: arn:aws:iam::123456789012:role/TestRole From bacb0107967b29a11e54f9c415a5e624334bd90d Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 09:48:54 -0400 Subject: [PATCH 05/11] Update lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../java/com/networknt/aws/lambda/LambdaFunctionHandler.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 47b34a3..bf2da1d 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -349,8 +349,9 @@ static String extractBearerToken(String authorizationHeaderValue) { logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); return null; } - if (authorizationHeaderValue.length() > BEARER_PREFIX.length() && - authorizationHeaderValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) { + if (authorizationHeaderValue.length() > BEARER_PREFIX.length() + 1 && + authorizationHeaderValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()) && + authorizationHeaderValue.charAt(BEARER_PREFIX.length()) == ' ') { return authorizationHeaderValue.substring(BEARER_PREFIX.length() + 1); } logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); From ccc9b084d62b6d6ffd14359b28717cf37dd4052f Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 09:50:00 -0400 Subject: [PATCH 06/11] Update lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../aws/lambda/LambdaInvokerConfigTest.java | 63 ++++++++++++++----- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java index 00d994d..b6cfa74 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java @@ -48,20 +48,53 @@ public void testInvalidStsTypeThrowsConfigException() { } @Test - public void testTokenCacheIsolation() { - // Verify that a fresh AtomicReference starts with null and can be updated - AtomicReference cache = new AtomicReference<>(); - assertNull(cache.get()); - cache.set("token1"); - assertEquals("token1", cache.get()); - // Replacing with a new token works correctly - cache.set("token2"); - assertEquals("token2", cache.get()); - // Same token comparison (simulates cached-token path) - String incoming = "token2"; - assertEquals(cache.get(), incoming, "Same token should match cached value"); - // Different token comparison (simulates refresh path) - String newToken = "token3"; - assertNotEquals(cache.get(), newToken, "Different token should not match cached value"); + public void testTokenRefreshDecisionWhenAuthorizationMissing() { + AtomicReference cache = new AtomicReference<>("token1"); + + assertFalse(shouldRefreshStsWebIdentityToken(cache, null), + "Missing Authorization should not trigger a refresh"); + assertEquals("token1", cache.get(), "Cached token should remain unchanged when Authorization is missing"); + } + + @Test + public void testTokenRefreshDecisionWhenTokenUnchanged() { + AtomicReference cache = new AtomicReference<>("token2"); + + assertFalse(shouldRefreshStsWebIdentityToken(cache, "Bearer token2"), + "Same token should use the cached fast-path and avoid refresh"); + assertEquals("token2", cache.get(), "Cached token should remain unchanged when the token matches"); + } + + @Test + public void testTokenRefreshDecisionWhenTokenChanges() { + AtomicReference cache = new AtomicReference<>("token2"); + + assertTrue(shouldRefreshStsWebIdentityToken(cache, "Bearer token3"), + "Different token should trigger a refresh"); + assertEquals("token3", cache.get(), "Cached token should be updated after a token change"); + } + + private static boolean shouldRefreshStsWebIdentityToken(AtomicReference cache, String authorization) { + String incomingToken = extractAuthorizationToken(authorization); + if (incomingToken == null || incomingToken.isBlank()) { + return false; + } + + String cachedToken = cache.get(); + if (incomingToken.equals(cachedToken)) { + return false; + } + + cache.set(incomingToken); + return true; + } + + private static String extractAuthorizationToken(String authorization) { + if (authorization == null) { + return null; + } + + String prefix = "Bearer "; + return authorization.startsWith(prefix) ? authorization.substring(prefix.length()) : authorization; } } From 8bfb40ef265137ee0a20402137faa6710eeded9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:52:48 +0000 Subject: [PATCH 07/11] Fix stsType normalization: assign trimmed value back to field in validate() Agent-Logs-Url: https://github.com/networknt/light-aws-lambda/sessions/2cd98e34-9726-4923-b74b-1cb85395a819 Co-authored-by: stevehu <2042337+stevehu@users.noreply.github.com> --- .../com/networknt/aws/lambda/LambdaInvokerConfig.java | 3 +++ .../networknt/aws/lambda/LambdaInvokerConfigTest.java | 9 +++++++++ .../config/lambda-invoker-sts-trailing-space.yml | 2 ++ 3 files changed, 14 insertions(+) create mode 100644 lambda-invoker/src/test/resources/config/lambda-invoker-sts-trailing-space.yml diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java index 0e62559..6f0fce7 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaInvokerConfig.java @@ -461,6 +461,9 @@ private void setConfigMap() { private void validate() { String normalizedStsType = stsType == null ? null : stsType.trim(); + // Write normalized value back so downstream equals() comparisons work correctly + // even when the config value has leading/trailing whitespace (e.g. "StsWebIdentity "). + stsType = normalizedStsType; if (normalizedStsType != null && !normalizedStsType.isEmpty()) { if (!"StsFuncUser".equals(normalizedStsType) && !"StsWebIdentity".equals(normalizedStsType)) { throw new ConfigException(STS_TYPE + " must be one of [StsFuncUser, StsWebIdentity], but was: " + normalizedStsType); diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java index b6cfa74..b8ca485 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java @@ -47,6 +47,15 @@ public void testInvalidStsTypeThrowsConfigException() { assertTrue(ex.getMessage().contains("stsType"), "Exception message should mention stsType"); } + @Test + public void testStsTypeWithTrailingSpaceIsNormalized() { + // "StsWebIdentity " (trailing space) must be trimmed so getStsType() equals "StsWebIdentity" + LambdaInvokerConfig stsConfig = LambdaInvokerConfig.load("lambda-invoker-sts-trailing-space"); + assertNotNull(stsConfig); + assertEquals("StsWebIdentity", stsConfig.getStsType(), + "validate() should strip trailing whitespace and write normalized value back to stsType"); + } + @Test public void testTokenRefreshDecisionWhenAuthorizationMissing() { AtomicReference cache = new AtomicReference<>("token1"); diff --git a/lambda-invoker/src/test/resources/config/lambda-invoker-sts-trailing-space.yml b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-trailing-space.yml new file mode 100644 index 0000000..bd258e5 --- /dev/null +++ b/lambda-invoker/src/test/resources/config/lambda-invoker-sts-trailing-space.yml @@ -0,0 +1,2 @@ +stsType: "StsWebIdentity " +roleArn: arn:aws:iam::123456789012:role/TestRole From 5c7a93cab1c8f472ec240d7e38ed4c633c3ba5ec Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 10:39:36 -0400 Subject: [PATCH 08/11] address the compiler issue introduced by Copilot --- .../aws/lambda/LambdaFunctionHandler.java | 84 +++++++++++-------- .../aws/lambda/LambdaFunctionHandlerTest.java | 6 +- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index bf2da1d..a78d8b8 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -17,6 +17,7 @@ import io.undertow.util.HttpString; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.http.async.SdkAsyncHttpClient; @@ -79,6 +80,44 @@ public LambdaFunctionHandler() { } private LambdaAsyncClient initClient(LambdaInvokerConfig config) { + AwsCredentialsProvider credentialsProvider = null; + // If any STS type selected, use the matching credentials provider for automatic refresh. + if(STS_TYPE_FUNC_USER.equals(config.getStsType())) { + if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_FUNC_USER + " for role: {}", config.getRoleArn()); + stsClient = StsClient.builder() + .region(Region.of(config.getRegion())) + .build(); + stsCredentialsProvider = StsAssumeRoleCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(AssumeRoleRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .build()) + .build(); + credentialsProvider = stsCredentialsProvider; + } else if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { + if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_WEB_IDENTITY + " for role: {}", config.getRoleArn()); + stsClient = StsClient.builder() + .region(Region.of(config.getRegion())) + .build(); + stsWebIdentityCredentialsProvider = StsAssumeRoleWithWebIdentityCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + // .webIdentityToken(token) // token will be set dynamically for each request in the handleRequest method, so we don't set it here in the builder + .build()) + .build(); + credentialsProvider = stsWebIdentityCredentialsProvider; + } else { + if(logger.isInfoEnabled()) logger.info("No STS AssumeRole is set. Using default credential provider chain for LambdaAsyncClient."); + } + return buildLambdaClient(config, credentialsProvider); + } + + private LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsProvider credentialsProvider) { SdkAsyncHttpClient asyncHttpClient = NettyNioAsyncHttpClient.builder() .readTimeout(Duration.ofMillis(config.getApiCallAttemptTimeout())) .writeTimeout(Duration.ofMillis(config.getApiCallAttemptTimeout())) @@ -112,38 +151,8 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { builder.endpointOverride(URI.create(config.getEndpointOverride())); } - // If any STS type selected, use StsAssumeRoleCredentialsProvider for automatic credential refresh - if(STS_TYPE_FUNC_USER.equals(config.getStsType())) { - if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_FUNC_USER + " for role: {}", config.getRoleArn()); - stsClient = StsClient.builder() - .region(Region.of(config.getRegion())) - .build(); - stsCredentialsProvider = StsAssumeRoleCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(AssumeRoleRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - .build()) - .build(); - builder.credentialsProvider(stsCredentialsProvider); - } else if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { - if(logger.isInfoEnabled()) logger.info("STS AssumeRole is set to " + STS_TYPE_WEB_IDENTITY + " for role: {}", config.getRoleArn()); - stsClient = StsClient.builder() - .region(Region.of(config.getRegion())) - .build(); - stsWebIdentityCredentialsProvider = StsAssumeRoleWithWebIdentityCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - // .webIdentityToken(token) // token will be set dynamically for each request in the handleRequest method, so we don't set it here in the builder - .build()) - .build(); - builder.credentialsProvider(stsWebIdentityCredentialsProvider); - } else { - if(logger.isInfoEnabled()) logger.info("No STS AssumeRole is set. Using default credential provider chain for LambdaAsyncClient."); + if(credentialsProvider != null) { + builder.credentialsProvider(credentialsProvider); } return builder.build(); @@ -241,9 +250,14 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { stsWebIdentityCredentialsProvider = stsWebIdentityCredentialsProvider.toBuilder() .refreshRequest(refreshRequest) .build(); - client = client.toBuilder() - .credentialsProvider(stsWebIdentityCredentialsProvider) - .build(); + if(client != null) { + try { + client.close(); + } catch (Exception e) { + logger.error("Failed to close the existing LambdaAsyncClient during STS web identity refresh", e); + } + } + client = buildLambdaClient(config, stsWebIdentityCredentialsProvider); tokenCache.set(token); } } diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java index c2094ce..c3a63fa 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java @@ -74,8 +74,8 @@ void testExtractBearerToken_headerIsBearerOnly_returnsNull() { } @Test - void testExtractBearerToken_tokenIsEmptyAfterBearer_returnsEmptyString() { - // "Bearer " is length 7; prefix is 6, +1 = 7 so substring(7) on "Bearer " gives "" - assertEquals("", LambdaFunctionHandler.extractBearerToken("Bearer ")); + void testExtractBearerToken_tokenIsEmptyAfterBearer_returnsNull() { + // Empty bearer tokens are treated as invalid and return null so the STS path skips refresh. + assertNull(LambdaFunctionHandler.extractBearerToken("Bearer ")); } } From 17414d22ca15d50898f8785d3316937777d6f7eb Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 10:57:27 -0400 Subject: [PATCH 09/11] address Copilot comments --- .../aws/lambda/LambdaFunctionHandler.java | 147 +++++++++++------- .../aws/lambda/LambdaFunctionHandlerTest.java | 78 ++++++++++ .../aws/lambda/LambdaInvokerConfigTest.java | 5 +- 3 files changed, 170 insertions(+), 60 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index a78d8b8..7155429 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -48,15 +48,47 @@ public class LambdaFunctionHandler implements LightHttpHandler { private static final String STS_TYPE_FUNC_USER = "StsFuncUser"; private static final String STS_TYPE_WEB_IDENTITY = "StsWebIdentity"; private static final String BEARER_PREFIX = "BEARER"; + private static final String INVALID_WEB_IDENTITY_TOKEN_MESSAGE = "Missing or invalid Bearer token for STS web identity"; private final AtomicReference tokenCache = new AtomicReference(); private static AbstractMetricsHandler metricsHandler; private LambdaInvokerConfig config; private LambdaAsyncClient client; private StsAssumeRoleCredentialsProvider stsCredentialsProvider; - private StsAssumeRoleWithWebIdentityCredentialsProvider stsWebIdentityCredentialsProvider; private StsClient stsClient; + static final class RequestScopedLambdaClient implements AutoCloseable { + private final LambdaAsyncClient client; + private final StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider; + + RequestScopedLambdaClient(LambdaAsyncClient client, StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider) { + this.client = client; + this.credentialsProvider = credentialsProvider; + } + + LambdaAsyncClient getClient() { + return client; + } + + @Override + public void close() { + if(client != null) { + try { + client.close(); + } catch (Exception e) { + logger.error("Failed to close the request-scoped LambdaAsyncClient", e); + } + } + if(credentialsProvider != null) { + try { + credentialsProvider.close(); + } catch (Exception e) { + logger.error("Failed to close the request-scoped StsAssumeRoleWithWebIdentityCredentialsProvider", e); + } + } + } + } + // Package-private constructor for testing - avoids loading config from file and metrics chain setup LambdaFunctionHandler(LambdaInvokerConfig config) { this.config = config; @@ -101,23 +133,15 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { stsClient = StsClient.builder() .region(Region.of(config.getRegion())) .build(); - stsWebIdentityCredentialsProvider = StsAssumeRoleWithWebIdentityCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - // .webIdentityToken(token) // token will be set dynamically for each request in the handleRequest method, so we don't set it here in the builder - .build()) - .build(); - credentialsProvider = stsWebIdentityCredentialsProvider; + // Request-scoped clients are created in handleRequest after validating the incoming bearer token. + return null; } else { if(logger.isInfoEnabled()) logger.info("No STS AssumeRole is set. Using default credential provider chain for LambdaAsyncClient."); } return buildLambdaClient(config, credentialsProvider); } - private LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsProvider credentialsProvider) { + LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsProvider credentialsProvider) { SdkAsyncHttpClient asyncHttpClient = NettyNioAsyncHttpClient.builder() .readTimeout(Duration.ofMillis(config.getApiCallAttemptTimeout())) .writeTimeout(Duration.ofMillis(config.getApiCallAttemptTimeout())) @@ -158,6 +182,29 @@ private LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCrede return builder.build(); } + RequestScopedLambdaClient buildRequestScopedWebIdentityClient(LambdaInvokerConfig config, String token) { + StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider = + StsAssumeRoleWithWebIdentityCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .webIdentityToken(token) + .build()) + .build(); + try { + return new RequestScopedLambdaClient(buildLambdaClient(config, credentialsProvider), credentialsProvider); + } catch (RuntimeException e) { + try { + credentialsProvider.close(); + } catch (Exception closeException) { + logger.error("Failed to close the request-scoped StsAssumeRoleWithWebIdentityCredentialsProvider after client creation failure", closeException); + } + throw e; + } + } + @Override public void handleRequest(HttpServerExchange exchange) throws Exception { LambdaInvokerConfig newConfig = LambdaInvokerConfig.load(); @@ -181,14 +228,6 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } stsCredentialsProvider = null; } - if(stsWebIdentityCredentialsProvider != null) { - try { - stsWebIdentityCredentialsProvider.close(); - } catch (Exception e) { - logger.error("Failed to close the StsAssumeRoleWithWebIdentityCredentialsProvider", e); - } - stsWebIdentityCredentialsProvider = null; - } if(stsClient != null) { try { stsClient.close(); @@ -230,37 +269,25 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); return; } - // set the OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider for STS exchange + LambdaAsyncClient requestClient = client; + RequestScopedLambdaClient requestScopedClient = null; if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { String rawAuthHeader = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); String token = extractBearerToken(rawAuthHeader); - if (token != null && !token.isEmpty()) { - if (token.equals(tokenCache.get())) { - // incoming ID token reuse - if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing existing token for STS web identity."); - } else { - // Not cached, rebuild credentials provider with new token and cache it - if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Refreshing credentials provider for STS web identity."); - AssumeRoleWithWebIdentityRequest refreshRequest = AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - .webIdentityToken(token) - .build(); - stsWebIdentityCredentialsProvider = stsWebIdentityCredentialsProvider.toBuilder() - .refreshRequest(refreshRequest) - .build(); - if(client != null) { - try { - client.close(); - } catch (Exception e) { - logger.error("Failed to close the existing LambdaAsyncClient during STS web identity refresh", e); - } - } - client = buildLambdaClient(config, stsWebIdentityCredentialsProvider); - tokenCache.set(token); - } + if(token == null || token.isEmpty()) { + exchange.setStatusCode(401); + exchange.getResponseSender().send(INVALID_WEB_IDENTITY_TOKEN_MESSAGE); + if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); + return; } + if(token.equals(tokenCache.get())) { + if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing request-scoped STS web identity configuration."); + } else { + if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Building request-scoped STS web identity client."); + tokenCache.set(token); + } + requestScopedClient = buildRequestScopedWebIdentityClient(config, token); + requestClient = requestScopedClient.getClient(); } APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); requestEvent.setHttpMethod(httpMethod); @@ -271,18 +298,24 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { requestEvent.setBody(body); String requestBody = JsonMapper.objectMapper.writeValueAsString(requestEvent); if(logger.isTraceEnabled()) logger.trace("requestBody = {}", requestBody); - String res = invokeFunction(client, functionName, requestBody); - if(logger.isDebugEnabled()) logger.debug("response = {}", res); - if(res == null) { - setExchangeStatus(exchange, EMPTY_LAMBDA_RESPONSE, functionName); + try { + String res = invokeFunction(requestClient, functionName, requestBody); + if(logger.isDebugEnabled()) logger.debug("response = {}", res); + if(res == null) { + setExchangeStatus(exchange, EMPTY_LAMBDA_RESPONSE, functionName); + if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); + return; + } + APIGatewayProxyResponseEvent responseEvent = JsonMapper.fromJson(res, APIGatewayProxyResponseEvent.class); + setResponseHeaders(exchange, responseEvent.getHeaders()); + exchange.setStatusCode(responseEvent.getStatusCode()); + exchange.getResponseSender().send(responseEvent.getBody()); if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); - return; + } finally { + if(requestScopedClient != null) { + requestScopedClient.close(); + } } - APIGatewayProxyResponseEvent responseEvent = JsonMapper.fromJson(res, APIGatewayProxyResponseEvent.class); - setResponseHeaders(exchange, responseEvent.getHeaders()); - exchange.setStatusCode(responseEvent.getStatusCode()); - exchange.getResponseSender().send(responseEvent.getBody()); - if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); } private String invokeFunction(LambdaAsyncClient client, String functionName, String requestBody) { diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java index c3a63fa..a7ad91a 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java @@ -5,6 +5,10 @@ import org.junit.jupiter.api.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.services.lambda.LambdaAsyncClient; + +import java.lang.reflect.Proxy; import java.util.HashMap; import java.util.Map; @@ -13,6 +17,51 @@ class LambdaFunctionHandlerTest { static final Logger logger = LoggerFactory.getLogger(LambdaFunctionHandlerTest.class); + private static class TestLambdaFunctionHandler extends LambdaFunctionHandler { + int buildLambdaClientCalls; + + TestLambdaFunctionHandler(LambdaInvokerConfig config) { + super(config); + } + + @Override + LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsProvider credentialsProvider) { + buildLambdaClientCalls++; + return (LambdaAsyncClient) Proxy.newProxyInstance( + LambdaAsyncClient.class.getClassLoader(), + new Class[]{LambdaAsyncClient.class}, + (proxy, method, args) -> { + if("close".equals(method.getName())) { + return null; + } + if(method.getReturnType().equals(boolean.class)) { + return false; + } + if(method.getReturnType().equals(int.class)) { + return 0; + } + return null; + } + ); + } + } + + private LambdaInvokerConfig webIdentityConfig() { + LambdaInvokerConfig config = new LambdaInvokerConfig(); + config.setRegion("us-east-1"); + config.setApiCallTimeout(60000); + config.setApiCallAttemptTimeout(20000); + config.setMaxConcurrency(10); + config.setMaxPendingConnectionAcquires(100); + config.setConnectionAcquisitionTimeout(10); + config.setMaxRetries(0); + config.setRoleArn("arn:aws:iam::123456789012:role/LambdaInvokerRole"); + config.setRoleSessionName("test-session"); + config.setDurationSeconds(900); + config.setStsType("StsWebIdentity"); + return config; + } + @Test void testAPIGatewayProxyRequestEvent() throws Exception { APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); @@ -78,4 +127,33 @@ void testExtractBearerToken_tokenIsEmptyAfterBearer_returnsNull() { // Empty bearer tokens are treated as invalid and return null so the STS path skips refresh. assertNull(LambdaFunctionHandler.extractBearerToken("Bearer ")); } + + @Test + void testBuildRequestScopedWebIdentityClient_buildsPerRequestClient() { + TestLambdaFunctionHandler handler = new TestLambdaFunctionHandler(webIdentityConfig()); + + try (LambdaFunctionHandler.RequestScopedLambdaClient requestClient = + handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-1")) { + assertNotNull(requestClient); + assertNotNull(requestClient.getClient()); + } + + assertEquals(1, handler.buildLambdaClientCalls); + } + + @Test + void testBuildRequestScopedWebIdentityClient_buildsNewClientForEachToken() { + TestLambdaFunctionHandler handler = new TestLambdaFunctionHandler(webIdentityConfig()); + + try (LambdaFunctionHandler.RequestScopedLambdaClient first = + handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-1"); + LambdaFunctionHandler.RequestScopedLambdaClient second = + handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-2")) { + assertNotNull(first.getClient()); + assertNotNull(second.getClient()); + assertNotSame(first.getClient(), second.getClient()); + } + + assertEquals(2, handler.buildLambdaClientCalls); + } } diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java index b8ca485..37ace75 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaInvokerConfigTest.java @@ -1,6 +1,5 @@ package com.networknt.aws.lambda; -import com.networknt.config.Config; import com.networknt.config.ConfigException; import com.networknt.config.JsonMapper; import org.junit.jupiter.api.Test; @@ -11,11 +10,11 @@ import static org.junit.jupiter.api.Assertions.*; public class LambdaInvokerConfigTest { - private static LambdaInvokerConfig config = (LambdaInvokerConfig) Config.getInstance().getJsonObjectConfig(LambdaInvokerConfig.CONFIG_NAME, LambdaInvokerConfig.class); - @Test public void testFunctionMapping() { + LambdaInvokerConfig config = LambdaInvokerConfig.load("lambda-invoker-sts-with-role"); Map functions = config.getFunctions(); + assertNotNull(functions); System.out.println(JsonMapper.toJson(functions)); } From 24d8a616cd4d32b180c88b3a856ffc45f9525036 Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 11:17:37 -0400 Subject: [PATCH 10/11] change to reuse client --- .../aws/lambda/LambdaFunctionHandler.java | 170 +++++++++++------- .../aws/lambda/LambdaFunctionHandlerTest.java | 37 ++-- 2 files changed, 122 insertions(+), 85 deletions(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 7155429..7733cb6 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -18,6 +18,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; import software.amazon.awssdk.http.async.SdkAsyncHttpClient; @@ -33,13 +34,16 @@ import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; +import java.util.Base64; import java.util.Deque; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; -import java.util.concurrent.atomic.AtomicReference; public class LambdaFunctionHandler implements LightHttpHandler { private static final Logger logger = LoggerFactory.getLogger(LambdaFunctionHandler.class); @@ -49,41 +53,72 @@ public class LambdaFunctionHandler implements LightHttpHandler { private static final String STS_TYPE_WEB_IDENTITY = "StsWebIdentity"; private static final String BEARER_PREFIX = "BEARER"; private static final String INVALID_WEB_IDENTITY_TOKEN_MESSAGE = "Missing or invalid Bearer token for STS web identity"; - private final AtomicReference tokenCache = new AtomicReference(); private static AbstractMetricsHandler metricsHandler; private LambdaInvokerConfig config; private LambdaAsyncClient client; private StsAssumeRoleCredentialsProvider stsCredentialsProvider; + private MutableStsWebIdentityCredentialsProvider stsWebIdentityCredentialsProvider; private StsClient stsClient; - static final class RequestScopedLambdaClient implements AutoCloseable { - private final LambdaAsyncClient client; - private final StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider; + static final class MutableStsWebIdentityCredentialsProvider implements AwsCredentialsProvider, AutoCloseable { + private final LambdaInvokerConfig config; + private final StsClient stsClient; + private StsAssumeRoleWithWebIdentityCredentialsProvider delegate; + private String tokenFingerprint; - RequestScopedLambdaClient(LambdaAsyncClient client, StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider) { - this.client = client; - this.credentialsProvider = credentialsProvider; + MutableStsWebIdentityCredentialsProvider(LambdaInvokerConfig config, StsClient stsClient) { + this.config = config; + this.stsClient = stsClient; } - LambdaAsyncClient getClient() { - return client; + synchronized boolean updateToken(String token) { + String nextFingerprint = fingerprintToken(token); + if(nextFingerprint.equals(tokenFingerprint) && delegate != null) { + return false; + } + StsAssumeRoleWithWebIdentityCredentialsProvider nextDelegate = + StsAssumeRoleWithWebIdentityCredentialsProvider.builder() + .stsClient(stsClient) + .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() + .roleArn(config.getRoleArn()) + .roleSessionName(config.getRoleSessionName()) + .durationSeconds(config.getDurationSeconds()) + .webIdentityToken(token) + .build()) + .build(); + StsAssumeRoleWithWebIdentityCredentialsProvider previousDelegate = delegate; + delegate = nextDelegate; + tokenFingerprint = nextFingerprint; + closeDelegate(previousDelegate); + return true; + } + + synchronized String getTokenFingerprint() { + return tokenFingerprint; } @Override - public void close() { - if(client != null) { - try { - client.close(); - } catch (Exception e) { - logger.error("Failed to close the request-scoped LambdaAsyncClient", e); - } + public synchronized AwsCredentials resolveCredentials() { + if(delegate == null) { + throw new IllegalStateException("STS web identity credentials provider has not been initialized with a bearer token"); } - if(credentialsProvider != null) { + return delegate.resolveCredentials(); + } + + @Override + public synchronized void close() { + closeDelegate(delegate); + delegate = null; + tokenFingerprint = null; + } + + private void closeDelegate(StsAssumeRoleWithWebIdentityCredentialsProvider provider) { + if(provider != null) { try { - credentialsProvider.close(); + provider.close(); } catch (Exception e) { - logger.error("Failed to close the request-scoped StsAssumeRoleWithWebIdentityCredentialsProvider", e); + logger.error("Failed to close the StsAssumeRoleWithWebIdentityCredentialsProvider", e); } } } @@ -133,14 +168,18 @@ private LambdaAsyncClient initClient(LambdaInvokerConfig config) { stsClient = StsClient.builder() .region(Region.of(config.getRegion())) .build(); - // Request-scoped clients are created in handleRequest after validating the incoming bearer token. - return null; + stsWebIdentityCredentialsProvider = buildMutableStsWebIdentityCredentialsProvider(config, stsClient); + credentialsProvider = stsWebIdentityCredentialsProvider; } else { if(logger.isInfoEnabled()) logger.info("No STS AssumeRole is set. Using default credential provider chain for LambdaAsyncClient."); } return buildLambdaClient(config, credentialsProvider); } + MutableStsWebIdentityCredentialsProvider buildMutableStsWebIdentityCredentialsProvider(LambdaInvokerConfig config, StsClient stsClient) { + return new MutableStsWebIdentityCredentialsProvider(config, stsClient); + } + LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsProvider credentialsProvider) { SdkAsyncHttpClient asyncHttpClient = NettyNioAsyncHttpClient.builder() .readTimeout(Duration.ofMillis(config.getApiCallAttemptTimeout())) @@ -182,27 +221,15 @@ LambdaAsyncClient buildLambdaClient(LambdaInvokerConfig config, AwsCredentialsPr return builder.build(); } - RequestScopedLambdaClient buildRequestScopedWebIdentityClient(LambdaInvokerConfig config, String token) { - StsAssumeRoleWithWebIdentityCredentialsProvider credentialsProvider = - StsAssumeRoleWithWebIdentityCredentialsProvider.builder() - .stsClient(stsClient) - .refreshRequest(AssumeRoleWithWebIdentityRequest.builder() - .roleArn(config.getRoleArn()) - .roleSessionName(config.getRoleSessionName()) - .durationSeconds(config.getDurationSeconds()) - .webIdentityToken(token) - .build()) - .build(); - try { - return new RequestScopedLambdaClient(buildLambdaClient(config, credentialsProvider), credentialsProvider); - } catch (RuntimeException e) { - try { - credentialsProvider.close(); - } catch (Exception closeException) { - logger.error("Failed to close the request-scoped StsAssumeRoleWithWebIdentityCredentialsProvider after client creation failure", closeException); - } - throw e; + boolean updateWebIdentityToken(String token) { + if(stsWebIdentityCredentialsProvider == null) { + throw new IllegalStateException("STS web identity credentials provider is not configured"); } + return stsWebIdentityCredentialsProvider.updateToken(token); + } + + String currentWebIdentityTokenFingerprint() { + return stsWebIdentityCredentialsProvider == null ? null : stsWebIdentityCredentialsProvider.getTokenFingerprint(); } @Override @@ -228,6 +255,14 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { } stsCredentialsProvider = null; } + if(stsWebIdentityCredentialsProvider != null) { + try { + stsWebIdentityCredentialsProvider.close(); + } catch (Exception e) { + logger.error("Failed to close the StsAssumeRoleWithWebIdentityCredentialsProvider", e); + } + stsWebIdentityCredentialsProvider = null; + } if(stsClient != null) { try { stsClient.close(); @@ -269,8 +304,6 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); return; } - LambdaAsyncClient requestClient = client; - RequestScopedLambdaClient requestScopedClient = null; if(STS_TYPE_WEB_IDENTITY.equals(config.getStsType())) { String rawAuthHeader = exchange.getRequestHeaders().getFirst(Headers.AUTHORIZATION); String token = extractBearerToken(rawAuthHeader); @@ -280,14 +313,11 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); return; } - if(token.equals(tokenCache.get())) { - if(logger.isDebugEnabled()) logger.debug("Cached Authorization token detected. Reusing request-scoped STS web identity configuration."); + if(updateWebIdentityToken(token)) { + if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Refreshed the shared STS web identity credentials provider."); } else { - if(logger.isDebugEnabled()) logger.debug("Authorization token changed. Building request-scoped STS web identity client."); - tokenCache.set(token); + if(logger.isDebugEnabled()) logger.debug("Authorization token unchanged. Reusing the shared STS web identity credentials provider."); } - requestScopedClient = buildRequestScopedWebIdentityClient(config, token); - requestClient = requestScopedClient.getClient(); } APIGatewayProxyRequestEvent requestEvent = new APIGatewayProxyRequestEvent(); requestEvent.setHttpMethod(httpMethod); @@ -298,24 +328,18 @@ public void handleRequest(HttpServerExchange exchange) throws Exception { requestEvent.setBody(body); String requestBody = JsonMapper.objectMapper.writeValueAsString(requestEvent); if(logger.isTraceEnabled()) logger.trace("requestBody = {}", requestBody); - try { - String res = invokeFunction(requestClient, functionName, requestBody); - if(logger.isDebugEnabled()) logger.debug("response = {}", res); - if(res == null) { - setExchangeStatus(exchange, EMPTY_LAMBDA_RESPONSE, functionName); - if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); - return; - } - APIGatewayProxyResponseEvent responseEvent = JsonMapper.fromJson(res, APIGatewayProxyResponseEvent.class); - setResponseHeaders(exchange, responseEvent.getHeaders()); - exchange.setStatusCode(responseEvent.getStatusCode()); - exchange.getResponseSender().send(responseEvent.getBody()); + String res = invokeFunction(client, functionName, requestBody); + if(logger.isDebugEnabled()) logger.debug("response = {}", res); + if(res == null) { + setExchangeStatus(exchange, EMPTY_LAMBDA_RESPONSE, functionName); if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); - } finally { - if(requestScopedClient != null) { - requestScopedClient.close(); - } + return; } + APIGatewayProxyResponseEvent responseEvent = JsonMapper.fromJson(res, APIGatewayProxyResponseEvent.class); + setResponseHeaders(exchange, responseEvent.getHeaders()); + exchange.setStatusCode(responseEvent.getStatusCode()); + exchange.getResponseSender().send(responseEvent.getBody()); + if(config.isMetricsInjection() && metricsHandler != null) metricsHandler.injectMetrics(exchange, startTime, config.getMetricsName(), endpoint); } private String invokeFunction(LambdaAsyncClient client, String functionName, String requestBody) { @@ -393,7 +417,7 @@ private void setResponseHeaders(HttpServerExchange exchange, Map */ static String extractBearerToken(String authorizationHeaderValue) { if (authorizationHeaderValue == null || authorizationHeaderValue.isEmpty()) { - logger.warn("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); + if(logger.isDebugEnabled()) logger.debug("Missing Authorization header from request. STS AssumeRole with Web Identity may fail"); return null; } if (authorizationHeaderValue.length() > BEARER_PREFIX.length() + 1 && @@ -401,7 +425,17 @@ static String extractBearerToken(String authorizationHeaderValue) { authorizationHeaderValue.charAt(BEARER_PREFIX.length()) == ' ') { return authorizationHeaderValue.substring(BEARER_PREFIX.length() + 1); } - logger.warn("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); + if(logger.isDebugEnabled()) logger.debug("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); return null; } + + static String fingerprintToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hashed); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available", e); + } + } } diff --git a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java index a7ad91a..02f5242 100644 --- a/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java +++ b/lambda-invoker/src/test/java/com/networknt/aws/lambda/LambdaFunctionHandlerTest.java @@ -129,31 +129,34 @@ void testExtractBearerToken_tokenIsEmptyAfterBearer_returnsNull() { } @Test - void testBuildRequestScopedWebIdentityClient_buildsPerRequestClient() { + void testUpdateWebIdentityToken_refreshesProviderWithoutRebuildingClient() { TestLambdaFunctionHandler handler = new TestLambdaFunctionHandler(webIdentityConfig()); - try (LambdaFunctionHandler.RequestScopedLambdaClient requestClient = - handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-1")) { - assertNotNull(requestClient); - assertNotNull(requestClient.getClient()); - } - + assertTrue(handler.updateWebIdentityToken("token-1")); assertEquals(1, handler.buildLambdaClientCalls); + assertNotNull(handler.currentWebIdentityTokenFingerprint()); + assertNotEquals("token-1", handler.currentWebIdentityTokenFingerprint()); } @Test - void testBuildRequestScopedWebIdentityClient_buildsNewClientForEachToken() { + void testUpdateWebIdentityToken_reusesProviderWhenTokenUnchanged() { TestLambdaFunctionHandler handler = new TestLambdaFunctionHandler(webIdentityConfig()); + assertTrue(handler.updateWebIdentityToken("token-1")); + String fingerprint = handler.currentWebIdentityTokenFingerprint(); - try (LambdaFunctionHandler.RequestScopedLambdaClient first = - handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-1"); - LambdaFunctionHandler.RequestScopedLambdaClient second = - handler.buildRequestScopedWebIdentityClient(webIdentityConfig(), "token-2")) { - assertNotNull(first.getClient()); - assertNotNull(second.getClient()); - assertNotSame(first.getClient(), second.getClient()); - } + assertFalse(handler.updateWebIdentityToken("token-1")); + assertEquals(fingerprint, handler.currentWebIdentityTokenFingerprint()); + assertEquals(1, handler.buildLambdaClientCalls); + } - assertEquals(2, handler.buildLambdaClientCalls); + @Test + void testUpdateWebIdentityToken_refreshesProviderWhenTokenChanges() { + TestLambdaFunctionHandler handler = new TestLambdaFunctionHandler(webIdentityConfig()); + assertTrue(handler.updateWebIdentityToken("token-1")); + String firstFingerprint = handler.currentWebIdentityTokenFingerprint(); + + assertTrue(handler.updateWebIdentityToken("token-2")); + assertNotEquals(firstFingerprint, handler.currentWebIdentityTokenFingerprint()); + assertEquals(1, handler.buildLambdaClientCalls); } } From 347bf8b558296ef7663fd5a695895e444a49b995 Mon Sep 17 00:00:00 2001 From: Steve Hu Date: Sat, 11 Apr 2026 11:34:00 -0400 Subject: [PATCH 11/11] Update lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../com/networknt/aws/lambda/LambdaFunctionHandler.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java index 7733cb6..1fa8825 100644 --- a/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java +++ b/lambda-invoker/src/main/java/com/networknt/aws/lambda/LambdaFunctionHandler.java @@ -423,7 +423,12 @@ static String extractBearerToken(String authorizationHeaderValue) { if (authorizationHeaderValue.length() > BEARER_PREFIX.length() + 1 && authorizationHeaderValue.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length()) && authorizationHeaderValue.charAt(BEARER_PREFIX.length()) == ' ') { - return authorizationHeaderValue.substring(BEARER_PREFIX.length() + 1); + String token = authorizationHeaderValue.substring(BEARER_PREFIX.length() + 1).trim(); + if (token.isEmpty()) { + if(logger.isDebugEnabled()) logger.debug("Authorization header contains a blank Bearer token. STS AssumeRole with Web Identity may fail"); + return null; + } + return token; } if(logger.isDebugEnabled()) logger.debug("Authorization header does not start with Bearer. STS AssumeRole with Web Identity may fail"); return null;