sarutak commented on code in PR #57655:
URL: https://github.com/apache/spark/pull/57655#discussion_r3713182150
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -51,7 +254,102 @@ public Set<String> supportedSchemes() {
@Override
public ServiceCredential resolve(UserContext user, URI target)
throws CredentialResolutionException {
- throw new UnsupportedOperationException(
- "AwsStsCredentialProvider is a stub. Full implementation in
SPARK-57898.");
+ if (user == null) {
+ throw new CredentialResolutionException(
+ "UserContext must not be null when resolving AWS credentials");
+ }
+ if (target == null) {
+ throw new CredentialResolutionException(
+ "Target URI must not be null when resolving AWS credentials");
+ }
+ if (user.getRawToken() == null || user.getRawToken().isBlank()) {
+ throw new CredentialResolutionException(
+ "UserContext raw token must not be null or blank; cannot perform "
+ + "AssumeRoleWithWebIdentity without an identity token");
+ }
+
+ ResolvedConfig cfg = this.config;
+ if (cfg == null) {
+ throw new CredentialResolutionException("resolve() called before
init()");
+ }
+ String sessionName = cfg.roleSessionName;
+ if (sessionName == null || sessionName.isBlank()) {
+ // Derive from principal, sanitizing for STS session name constraints
+ // (alphanumeric, =,.@- only, max 64 chars)
+ String principal = user.getPrincipal();
+ if (principal != null && !principal.isBlank()) {
+ sessionName = sanitizeSessionName(principal);
+ } else {
+ sessionName = DEFAULT_SESSION_NAME;
+ }
+ }
+
+ String rawToken = user.getRawToken();
+ try {
+ AssumeRoleWithWebIdentityRequest.Builder reqBuilder =
+ AssumeRoleWithWebIdentityRequest.builder()
+ .roleArn(cfg.roleArn)
+ .roleSessionName(sessionName)
+ .webIdentityToken(rawToken);
+
+ if (cfg.durationSeconds != null) {
+ reqBuilder.durationSeconds(cfg.durationSeconds);
+ }
+
+ AssumeRoleWithWebIdentityResponse response =
+ cfg.stsClient.assumeRoleWithWebIdentity(reqBuilder.build());
+
+ Credentials creds = response.credentials();
+ if (creds == null || creds.accessKeyId() == null
+ || creds.secretAccessKey() == null || creds.sessionToken() == null) {
+ throw new CredentialResolutionException(
+ "STS returned incomplete credentials for role '" + cfg.roleArn +
"'");
+ }
+
+ Map<String, String> properties = Map.of(
+ "fs.s3a.access.key", creds.accessKeyId(),
+ "fs.s3a.secret.key", creds.secretAccessKey(),
+ "fs.s3a.session.token", creds.sessionToken()
+ );
+
+ Instant expiration = creds.expiration();
+ return new ServiceCredential(properties, expiration);
+ } catch (SdkException e) {
+ // SECURITY: Never include the token in exception messages.
+ // Defensively strip any occurrence of the raw token from the STS error
message
+ // in case the service accidentally echoed it.
+ String errorMsg = e.getMessage();
+ if (errorMsg != null && rawToken != null && errorMsg.contains(rawToken))
{
+ errorMsg = errorMsg.replace(rawToken, "[REDACTED]");
Review Comment:
The outer `CredentialResolutionException` message is correctly sanitized,
but the original `SdkException` (which may contain the raw token in its
message) is passed as the `cause`. Any logging framework that prints full stack
traces, including `getCause().getMessage()`, will expose the token.
For example, if STS returns `"Invalid identity token: <TOKEN>"`, the current
code produces:
```
CredentialResolutionException: Failed to assume role '...' via
AssumeRoleWithWebIdentity: Invalid identity token: [REDACTED]
Caused by: StsException: Invalid identity token: eyJhbGciOi... <-- LEAKED
```
The existing test `testTokenRedactedFromStsErrorMessage` only asserts on
`ex.getMessage()` but not on `ex.getCause().getMessage()`, giving a false sense
of security.
**Suggested fix:**
```java
} catch (SdkException e) {
String errorMsg = e.getMessage();
boolean tokenLeaked = errorMsg != null && rawToken != null
&& errorMsg.contains(rawToken);
if (tokenLeaked) {
errorMsg = errorMsg.replace(rawToken, "[REDACTED]");
}
// SECURITY: If the token appeared in the STS error message, do not pass
// the original exception as-is -- its getMessage() still contains the
token.
// Wrap with a sanitized message to prevent leakage via getCause().
Throwable cause = tokenLeaked
? new
SdkException.Builder().message(errorMsg).cause(e.getCause()).build()
: e;
throw new CredentialResolutionException(
"Failed to assume role '" + cfg.roleArn
+ "' via AssumeRoleWithWebIdentity: " + errorMsg, cause);
}
```
And in the test, please add assertion on the cause:
```java
// In testTokenRedactedFromStsErrorMessage, add:
assertFalse(ex.getCause().getMessage().contains(TEST_TOKEN),
"Raw token must not leak via getCause().getMessage()");
```
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -29,18 +41,209 @@
/**
* :: DeveloperApi ::
- * AWS STS credential provider that exchanges an OIDC identity token for
temporary
- * AWS credentials via {@code AssumeRoleWithWebIdentity}.
+ * A {@link CredentialProvider} that exchanges an OIDC identity token for
temporary
+ * AWS credentials via the STS {@code AssumeRoleWithWebIdentity} API.
+ * <p>
+ * The returned {@link ServiceCredential} contains S3A-compatible Hadoop
configuration
+ * properties ({@code fs.s3a.access.key}, {@code fs.s3a.secret.key},
+ * {@code fs.s3a.session.token}) that can be propagated to executors for
accessing
+ * S3-compatible storage.
+ * <p>
+ * <b>Configuration keys</b> (passed via {@code spark.security.oidc.*}):
+ * <ul>
+ * <li>{@code spark.security.oidc.aws.roleArn} (required) — the ARN of the
IAM role
+ * to assume</li>
+ * <li>{@code spark.security.oidc.aws.sessionName} (optional) — the role
session name;
+ * defaults to a value derived from the user's principal or
"spark-oidc"</li>
+ * <li>{@code spark.security.oidc.aws.durationSeconds} (optional) —
credential duration
+ * in seconds (900–43200); if unset, STS uses the role's default
maximum</li>
+ * <li>{@code spark.security.oidc.aws.region} (optional) — the AWS region
for the STS
+ * endpoint; defaults to us-east-1 when only {@code stsEndpoint} is
set</li>
+ * <li>{@code spark.security.oidc.aws.stsEndpoint} (optional) — a custom STS
endpoint URL
+ * for non-AWS environments (MinIO, Ceph, LocalStack, etc.)</li>
+ * </ul>
+ * <p>
+ * <b>Security note:</b> The OIDC raw token is never included in log messages
or
+ * exception messages. It is passed directly to the STS API call and discarded.
*
* @since 4.3.0
*/
@DeveloperApi
public class AwsStsCredentialProvider implements CredentialProvider {
+ // Configuration key constants
+ static final String CONF_ROLE_ARN = "spark.security.oidc.aws.roleArn";
+ static final String CONF_SESSION_NAME =
"spark.security.oidc.aws.sessionName";
+ static final String CONF_DURATION_SECONDS =
"spark.security.oidc.aws.durationSeconds";
+ static final String CONF_REGION = "spark.security.oidc.aws.region";
+ static final String CONF_STS_ENDPOINT =
"spark.security.oidc.aws.stsEndpoint";
+
+ /** Minimum duration allowed by STS AssumeRoleWithWebIdentity (15 minutes).
*/
+ static final int MIN_DURATION_SECONDS = 900;
+ /** Maximum duration allowed by STS AssumeRoleWithWebIdentity (12 hours). */
+ static final int MAX_DURATION_SECONDS = 43200;
+
+ private static final String DEFAULT_REGION = "us-east-1";
+ private static final String DEFAULT_SESSION_NAME = "spark-oidc";
+
+ /**
+ * Immutable configuration holder that is safely published via the volatile
+ * {@link #config} field. All fields are set once during construction and are
+ * final, ensuring correct visibility across threads after init() completes.
+ */
+ static final class ResolvedConfig {
+ final String roleArn;
+ final String roleSessionName;
+ final Integer durationSeconds;
+ final Region resolvedRegion;
+ final URI endpointOverride;
+ final StsClient stsClient;
+
+ ResolvedConfig(String roleArn, String roleSessionName, Integer
durationSeconds,
+ Region resolvedRegion, URI endpointOverride, StsClient stsClient) {
+ this.roleArn = roleArn;
+ this.roleSessionName = roleSessionName;
+ this.durationSeconds = durationSeconds;
+ this.resolvedRegion = resolvedRegion;
+ this.endpointOverride = endpointOverride;
+ this.stsClient = stsClient;
+ }
+ }
+
+ /** Safely published via volatile write in init(); read in
resolve()/suggestedTtl(). */
+ private volatile ResolvedConfig config;
+
+ /**
+ * Default no-arg constructor used by {@link java.util.ServiceLoader}.
+ */
+ public AwsStsCredentialProvider() {
+ // ServiceLoader requires a public no-arg constructor
+ }
+
+ /**
+ * Package-private constructor for testing with an injected STS client.
+ * <p>
+ * This constructor is visible for testing only; production code must use the
+ * no-arg constructor followed by {@link #init(Map)}.
+ *
+ * @param stsClient the STS client to use (must not be null)
+ * @param roleArn the IAM role ARN (must not be null or blank)
+ * @param roleSessionName the session name (may be null for default)
+ * @param durationSeconds the credential duration in seconds (may be null)
+ */
+ @VisibleForTesting
+ AwsStsCredentialProvider(StsClient stsClient, String roleArn, String
roleSessionName,
+ Integer durationSeconds) {
+ this.config = new ResolvedConfig(roleArn, roleSessionName, durationSeconds,
+ null, null, stsClient);
+ }
+
@Override
public void init(Map<String, String> conf) {
- throw new UnsupportedOperationException(
- "AwsStsCredentialProvider is a stub. Full implementation in
SPARK-57898.");
+ if (this.config != null) {
+ throw new IllegalStateException("AwsStsCredentialProvider is already
initialized");
+ }
+
+ String roleArn = conf.get(CONF_ROLE_ARN);
+ if (roleArn == null || roleArn.isBlank()) {
+ throw new IllegalArgumentException(
+ "Configuration key '" + CONF_ROLE_ARN + "' is required but was not
set. "
+ + "Specify the ARN of the IAM role to assume via
AssumeRoleWithWebIdentity.");
+ }
+
+ String roleSessionName = conf.get(CONF_SESSION_NAME);
+ String regionStr = conf.get(CONF_REGION);
+ String stsEndpoint = conf.get(CONF_STS_ENDPOINT);
+
+ Integer durationSeconds = null;
+ String durationStr = conf.get(CONF_DURATION_SECONDS);
+ if (durationStr != null && !durationStr.isBlank()) {
+ try {
+ durationSeconds = Integer.parseInt(durationStr.trim());
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException(
+ "Configuration key '" + CONF_DURATION_SECONDS
+ + "' must be a valid integer, got: " + durationStr, e);
+ }
+ if (durationSeconds < MIN_DURATION_SECONDS || durationSeconds >
MAX_DURATION_SECONDS) {
+ throw new IllegalArgumentException(
+ "Configuration key '" + CONF_DURATION_SECONDS + "' must be between
"
+ + MIN_DURATION_SECONDS + " and " + MAX_DURATION_SECONDS
+ + " seconds, got: " + durationSeconds);
+ }
+ }
+
+ // Resolve the region and endpoint before building the client
+ Region resolvedRegion = resolveRegion(regionStr, stsEndpoint);
+ URI endpointOverride = resolveEndpoint(stsEndpoint);
+
+ StsClient stsClient = buildStsClient(resolvedRegion, endpointOverride);
+
+ // Single volatile write publishes all configuration atomically
+ this.config = new ResolvedConfig(roleArn, roleSessionName, durationSeconds,
+ resolvedRegion, endpointOverride, stsClient);
+ }
+
+ @Override
+ public void close() {
+ ResolvedConfig cfg = this.config;
+ if (cfg != null && cfg.stsClient != null) {
+ cfg.stsClient.close();
+ }
+ }
+
+ /**
+ * Resolves the AWS region based on explicit configuration and endpoint
presence.
+ * When a custom endpoint is provided without an explicit region, defaults
to us-east-1.
+ */
+ private static Region resolveRegion(String regionStr, String stsEndpoint) {
+ if (regionStr != null && !regionStr.isBlank()) {
+ return Region.of(regionStr);
+ } else if (stsEndpoint != null && !stsEndpoint.isBlank()) {
+ // When a custom endpoint is set but no explicit region, use a default
region.
+ // The region is required by the SDK but not meaningful for non-AWS
endpoints.
+ return Region.of(DEFAULT_REGION);
+ }
+ return null;
+ }
+
+ /**
+ * Resolves the endpoint override URI from configuration.
+ */
+ private static URI resolveEndpoint(String stsEndpoint) {
+ if (stsEndpoint != null && !stsEndpoint.isBlank()) {
+ return URI.create(stsEndpoint);
Review Comment:
If the user provides a malformed endpoint (e.g., `"not a valid uri"`),
`URI.create()` throws a bare `IllegalArgumentException` with a message like
`"Illegal character in scheme name"`. No mention of which config key caused the
issue. All other config validation errors in `init()` include the key name for
debuggability.
Suggested:
```java
private static URI resolveEndpoint(String stsEndpoint) {
if (stsEndpoint != null && !stsEndpoint.isBlank()) {
try {
return URI.create(stsEndpoint.trim());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Configuration key '" + CONF_STS_ENDPOINT
+ "' must be a valid URI, got: " + stsEndpoint, e);
}
}
return null;
}
```
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -29,18 +41,209 @@
/**
* :: DeveloperApi ::
- * AWS STS credential provider that exchanges an OIDC identity token for
temporary
- * AWS credentials via {@code AssumeRoleWithWebIdentity}.
+ * A {@link CredentialProvider} that exchanges an OIDC identity token for
temporary
+ * AWS credentials via the STS {@code AssumeRoleWithWebIdentity} API.
+ * <p>
+ * The returned {@link ServiceCredential} contains S3A-compatible Hadoop
configuration
+ * properties ({@code fs.s3a.access.key}, {@code fs.s3a.secret.key},
+ * {@code fs.s3a.session.token}) that can be propagated to executors for
accessing
+ * S3-compatible storage.
+ * <p>
+ * <b>Configuration keys</b> (passed via {@code spark.security.oidc.*}):
+ * <ul>
+ * <li>{@code spark.security.oidc.aws.roleArn} (required) — the ARN of the
IAM role
+ * to assume</li>
+ * <li>{@code spark.security.oidc.aws.sessionName} (optional) — the role
session name;
+ * defaults to a value derived from the user's principal or
"spark-oidc"</li>
+ * <li>{@code spark.security.oidc.aws.durationSeconds} (optional) —
credential duration
+ * in seconds (900–43200); if unset, STS uses the role's default
maximum</li>
+ * <li>{@code spark.security.oidc.aws.region} (optional) — the AWS region
for the STS
+ * endpoint; defaults to us-east-1 when only {@code stsEndpoint} is
set</li>
+ * <li>{@code spark.security.oidc.aws.stsEndpoint} (optional) — a custom STS
endpoint URL
+ * for non-AWS environments (MinIO, Ceph, LocalStack, etc.)</li>
+ * </ul>
+ * <p>
+ * <b>Security note:</b> The OIDC raw token is never included in log messages
or
+ * exception messages. It is passed directly to the STS API call and discarded.
*
* @since 4.3.0
*/
@DeveloperApi
public class AwsStsCredentialProvider implements CredentialProvider {
+ // Configuration key constants
+ static final String CONF_ROLE_ARN = "spark.security.oidc.aws.roleArn";
+ static final String CONF_SESSION_NAME =
"spark.security.oidc.aws.sessionName";
+ static final String CONF_DURATION_SECONDS =
"spark.security.oidc.aws.durationSeconds";
+ static final String CONF_REGION = "spark.security.oidc.aws.region";
+ static final String CONF_STS_ENDPOINT =
"spark.security.oidc.aws.stsEndpoint";
+
+ /** Minimum duration allowed by STS AssumeRoleWithWebIdentity (15 minutes).
*/
+ static final int MIN_DURATION_SECONDS = 900;
+ /** Maximum duration allowed by STS AssumeRoleWithWebIdentity (12 hours). */
+ static final int MAX_DURATION_SECONDS = 43200;
+
+ private static final String DEFAULT_REGION = "us-east-1";
+ private static final String DEFAULT_SESSION_NAME = "spark-oidc";
+
+ /**
+ * Immutable configuration holder that is safely published via the volatile
+ * {@link #config} field. All fields are set once during construction and are
+ * final, ensuring correct visibility across threads after init() completes.
+ */
+ static final class ResolvedConfig {
+ final String roleArn;
+ final String roleSessionName;
+ final Integer durationSeconds;
+ final Region resolvedRegion;
+ final URI endpointOverride;
+ final StsClient stsClient;
+
+ ResolvedConfig(String roleArn, String roleSessionName, Integer
durationSeconds,
+ Region resolvedRegion, URI endpointOverride, StsClient stsClient) {
+ this.roleArn = roleArn;
+ this.roleSessionName = roleSessionName;
+ this.durationSeconds = durationSeconds;
+ this.resolvedRegion = resolvedRegion;
+ this.endpointOverride = endpointOverride;
+ this.stsClient = stsClient;
+ }
+ }
+
+ /** Safely published via volatile write in init(); read in
resolve()/suggestedTtl(). */
+ private volatile ResolvedConfig config;
+
+ /**
+ * Default no-arg constructor used by {@link java.util.ServiceLoader}.
+ */
+ public AwsStsCredentialProvider() {
+ // ServiceLoader requires a public no-arg constructor
+ }
+
+ /**
+ * Package-private constructor for testing with an injected STS client.
+ * <p>
+ * This constructor is visible for testing only; production code must use the
+ * no-arg constructor followed by {@link #init(Map)}.
+ *
+ * @param stsClient the STS client to use (must not be null)
+ * @param roleArn the IAM role ARN (must not be null or blank)
+ * @param roleSessionName the session name (may be null for default)
+ * @param durationSeconds the credential duration in seconds (may be null)
+ */
+ @VisibleForTesting
+ AwsStsCredentialProvider(StsClient stsClient, String roleArn, String
roleSessionName,
+ Integer durationSeconds) {
+ this.config = new ResolvedConfig(roleArn, roleSessionName, durationSeconds,
+ null, null, stsClient);
+ }
+
@Override
public void init(Map<String, String> conf) {
- throw new UnsupportedOperationException(
- "AwsStsCredentialProvider is a stub. Full implementation in
SPARK-57898.");
+ if (this.config != null) {
+ throw new IllegalStateException("AwsStsCredentialProvider is already
initialized");
+ }
+
+ String roleArn = conf.get(CONF_ROLE_ARN);
+ if (roleArn == null || roleArn.isBlank()) {
+ throw new IllegalArgumentException(
+ "Configuration key '" + CONF_ROLE_ARN + "' is required but was not
set. "
+ + "Specify the ARN of the IAM role to assume via
AssumeRoleWithWebIdentity.");
+ }
+
+ String roleSessionName = conf.get(CONF_SESSION_NAME);
+ String regionStr = conf.get(CONF_REGION);
+ String stsEndpoint = conf.get(CONF_STS_ENDPOINT);
+
+ Integer durationSeconds = null;
+ String durationStr = conf.get(CONF_DURATION_SECONDS);
+ if (durationStr != null && !durationStr.isBlank()) {
+ try {
+ durationSeconds = Integer.parseInt(durationStr.trim());
Review Comment:
`durationStr` is explicitly trimmed before parsing (`durationStr.trim()`),
but `roleArn`, `regionStr`, and `stsEndpoint` are not trimmed. Configuration
values commonly pick up stray whitespace from YAML or properties files, and a
trailing space in a role ARN (e.g., `"arn:aws:iam::123:role/test "`) would pass
the blank check but cause a cryptic STS `MalformedPolicyDocumentException`.
Should we apply consistent trimming to all config values? e.g.:
```java
String roleArn = conf.get(CONF_ROLE_ARN);
if (roleArn != null) roleArn = roleArn.trim();
if (roleArn == null || roleArn.isEmpty()) {
throw new IllegalArgumentException(...);
}
// Similarly for regionStr and stsEndpoint
```
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -29,18 +41,209 @@
/**
* :: DeveloperApi ::
- * AWS STS credential provider that exchanges an OIDC identity token for
temporary
- * AWS credentials via {@code AssumeRoleWithWebIdentity}.
+ * A {@link CredentialProvider} that exchanges an OIDC identity token for
temporary
+ * AWS credentials via the STS {@code AssumeRoleWithWebIdentity} API.
+ * <p>
+ * The returned {@link ServiceCredential} contains S3A-compatible Hadoop
configuration
+ * properties ({@code fs.s3a.access.key}, {@code fs.s3a.secret.key},
+ * {@code fs.s3a.session.token}) that can be propagated to executors for
accessing
+ * S3-compatible storage.
+ * <p>
+ * <b>Configuration keys</b> (passed via {@code spark.security.oidc.*}):
+ * <ul>
+ * <li>{@code spark.security.oidc.aws.roleArn} (required) — the ARN of the
IAM role
Review Comment:
A few documentation nits:
1. The config key descriptions contain non-ASCII characters:
- Em dash (U+2014) in 5 places (e.g., `(required) — the ARN`)
- En dash (U+2013) in `900–43200`
Could you replace them all with `--`?
2. Consider adding a note about the behavior when neither `region` nor
`stsEndpoint` is configured -- the SDK's default region resolution (AWS_REGION
env var, ~/.aws/config, etc.) is used.
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -29,18 +41,209 @@
/**
* :: DeveloperApi ::
- * AWS STS credential provider that exchanges an OIDC identity token for
temporary
- * AWS credentials via {@code AssumeRoleWithWebIdentity}.
+ * A {@link CredentialProvider} that exchanges an OIDC identity token for
temporary
+ * AWS credentials via the STS {@code AssumeRoleWithWebIdentity} API.
+ * <p>
+ * The returned {@link ServiceCredential} contains S3A-compatible Hadoop
configuration
+ * properties ({@code fs.s3a.access.key}, {@code fs.s3a.secret.key},
+ * {@code fs.s3a.session.token}) that can be propagated to executors for
accessing
+ * S3-compatible storage.
+ * <p>
+ * <b>Configuration keys</b> (passed via {@code spark.security.oidc.*}):
+ * <ul>
+ * <li>{@code spark.security.oidc.aws.roleArn} (required) — the ARN of the
IAM role
+ * to assume</li>
+ * <li>{@code spark.security.oidc.aws.sessionName} (optional) — the role
session name;
+ * defaults to a value derived from the user's principal or
"spark-oidc"</li>
+ * <li>{@code spark.security.oidc.aws.durationSeconds} (optional) —
credential duration
+ * in seconds (900–43200); if unset, STS uses the role's default
maximum</li>
+ * <li>{@code spark.security.oidc.aws.region} (optional) — the AWS region
for the STS
+ * endpoint; defaults to us-east-1 when only {@code stsEndpoint} is
set</li>
+ * <li>{@code spark.security.oidc.aws.stsEndpoint} (optional) — a custom STS
endpoint URL
+ * for non-AWS environments (MinIO, Ceph, LocalStack, etc.)</li>
+ * </ul>
+ * <p>
+ * <b>Security note:</b> The OIDC raw token is never included in log messages
or
+ * exception messages. It is passed directly to the STS API call and discarded.
*
* @since 4.3.0
*/
@DeveloperApi
Review Comment:
I know this was present from the scaffolding PR, but since you're touching
this file, could you remove `@DeveloperApi` (and the `:: DeveloperApi ::` in
the Javadoc) because `AwsStsCredentialProvider` is an implementation of
`CredentialProvider` SPI ?
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -51,7 +254,102 @@ public Set<String> supportedSchemes() {
@Override
public ServiceCredential resolve(UserContext user, URI target)
throws CredentialResolutionException {
- throw new UnsupportedOperationException(
- "AwsStsCredentialProvider is a stub. Full implementation in
SPARK-57898.");
+ if (user == null) {
+ throw new CredentialResolutionException(
+ "UserContext must not be null when resolving AWS credentials");
+ }
+ if (target == null) {
+ throw new CredentialResolutionException(
+ "Target URI must not be null when resolving AWS credentials");
+ }
+ if (user.getRawToken() == null || user.getRawToken().isBlank()) {
Review Comment:
nit: `user.getRawToken()` is called twice in the null/blank check before it
is assigned to a local variable. While `UserContext` is immutable,
consolidating to a single call at the top is more defensive and consistent:
```java
String rawToken = user.getRawToken();
if (rawToken == null || rawToken.isBlank()) {
throw new CredentialResolutionException(
"UserContext raw token must not be null or blank; cannot perform "
+ "AssumeRoleWithWebIdentity without an identity token");
}
```
##########
connector/credential-aws/src/main/java/org/apache/spark/security/aws/AwsStsCredentialProvider.java:
##########
@@ -51,7 +254,102 @@ public Set<String> supportedSchemes() {
@Override
public ServiceCredential resolve(UserContext user, URI target)
throws CredentialResolutionException {
- throw new UnsupportedOperationException(
- "AwsStsCredentialProvider is a stub. Full implementation in
SPARK-57898.");
+ if (user == null) {
+ throw new CredentialResolutionException(
+ "UserContext must not be null when resolving AWS credentials");
+ }
+ if (target == null) {
+ throw new CredentialResolutionException(
+ "Target URI must not be null when resolving AWS credentials");
+ }
+ if (user.getRawToken() == null || user.getRawToken().isBlank()) {
+ throw new CredentialResolutionException(
+ "UserContext raw token must not be null or blank; cannot perform "
+ + "AssumeRoleWithWebIdentity without an identity token");
+ }
+
+ ResolvedConfig cfg = this.config;
+ if (cfg == null) {
+ throw new CredentialResolutionException("resolve() called before
init()");
+ }
+ String sessionName = cfg.roleSessionName;
+ if (sessionName == null || sessionName.isBlank()) {
+ // Derive from principal, sanitizing for STS session name constraints
+ // (alphanumeric, =,.@- only, max 64 chars)
+ String principal = user.getPrincipal();
+ if (principal != null && !principal.isBlank()) {
+ sessionName = sanitizeSessionName(principal);
+ } else {
+ sessionName = DEFAULT_SESSION_NAME;
+ }
+ }
+
+ String rawToken = user.getRawToken();
+ try {
+ AssumeRoleWithWebIdentityRequest.Builder reqBuilder =
+ AssumeRoleWithWebIdentityRequest.builder()
+ .roleArn(cfg.roleArn)
+ .roleSessionName(sessionName)
+ .webIdentityToken(rawToken);
+
+ if (cfg.durationSeconds != null) {
+ reqBuilder.durationSeconds(cfg.durationSeconds);
+ }
+
+ AssumeRoleWithWebIdentityResponse response =
+ cfg.stsClient.assumeRoleWithWebIdentity(reqBuilder.build());
+
+ Credentials creds = response.credentials();
+ if (creds == null || creds.accessKeyId() == null
+ || creds.secretAccessKey() == null || creds.sessionToken() == null) {
+ throw new CredentialResolutionException(
+ "STS returned incomplete credentials for role '" + cfg.roleArn +
"'");
+ }
+
+ Map<String, String> properties = Map.of(
+ "fs.s3a.access.key", creds.accessKeyId(),
+ "fs.s3a.secret.key", creds.secretAccessKey(),
+ "fs.s3a.session.token", creds.sessionToken()
+ );
+
+ Instant expiration = creds.expiration();
+ return new ServiceCredential(properties, expiration);
+ } catch (SdkException e) {
+ // SECURITY: Never include the token in exception messages.
+ // Defensively strip any occurrence of the raw token from the STS error
message
+ // in case the service accidentally echoed it.
+ String errorMsg = e.getMessage();
+ if (errorMsg != null && rawToken != null && errorMsg.contains(rawToken))
{
+ errorMsg = errorMsg.replace(rawToken, "[REDACTED]");
+ }
+ throw new CredentialResolutionException(
+ "Failed to assume role '" + cfg.roleArn + "' via
AssumeRoleWithWebIdentity: "
+ + errorMsg, e);
+ }
+ }
+
+ @Override
+ public Duration suggestedTtl() {
+ ResolvedConfig cfg = this.config;
+ if (cfg != null && cfg.durationSeconds != null) {
+ return Duration.ofSeconds(cfg.durationSeconds);
+ }
+ return Duration.ofMinutes(15);
+ }
+
+ /**
+ * Sanitizes a principal string to be valid as an STS role session name.
+ * STS session names must match [\w+=,.@-]{2,64}. We use an explicit
character
+ * class rather than {@code \w} to avoid locale-dependent behavior.
+ */
+ static String sanitizeSessionName(String principal) {
+ String sanitized = principal.replaceAll("[^a-zA-Z0-9_+=,.@\\-]", "-");
Review Comment:
`String.replaceAll()` compiles the regex on every call. Since
`sanitizeSessionName` is called from `resolve()` (which may be invoked
frequently), caching the compiled `Pattern` as a static field avoids repeated
compilation:
```java
private static final Pattern INVALID_SESSION_CHARS =
Pattern.compile("[^a-zA-Z0-9_+=,.@\\-]");
static String sanitizeSessionName(String principal) {
String sanitized =
INVALID_SESSION_CHARS.matcher(principal).replaceAll("-");
...
}
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]