From c1d6cf4af6ac6db86b42cc66002b63b43056a52a Mon Sep 17 00:00:00 2001
From: paf91
Date: Wed, 13 May 2026 22:36:30 +0300
Subject: [PATCH 1/9] HDDS-15273. Add OIDC validation foundation for STS
WebIdentity
Includes:
- STS-focused OIDC/JWKS validator
- OIDC config keys
- AssumeRoleWithWebIdentity authorizer request shape
- fail-closed default authorizer hook
- S3G STS bootstrap auth bypass only for Action=AssumeRoleWithWebIdentity
- design doc
- unit tests
---
.../apache/hadoop/ozone/OzoneConfigKeys.java | 46 ++
.../src/main/resources/ozone-default.xml | 90 ++++
.../oidc-assume-role-with-web-identity.md | 484 ++++++++++++++++++
hadoop-ozone/common/pom.xml | 4 +
.../acl/AssumeRoleWithWebIdentityRequest.java | 185 +++++++
.../ozone/security/acl/IAccessAuthorizer.java | 20 +
.../ozone/security/oidc/AuthCredentials.java | 46 ++
.../security/oidc/CachingJwksProvider.java | 110 ++++
.../ozone/security/oidc/JwksFetcher.java | 31 ++
.../ozone/security/oidc/JwksProvider.java | 29 ++
.../oidc/OidcAuthenticationException.java | 34 ++
.../ozone/security/oidc/OidcConfig.java | 316 ++++++++++++
.../oidc/OidcJwtIdentityProvider.java | 306 +++++++++++
.../ozone/security/oidc/OzoneIdentity.java | 197 +++++++
.../security/oidc/OzoneIdentityProvider.java | 27 +
.../ozone/security/oidc/UrlJwksFetcher.java | 40 ++
.../ozone/security/oidc/package-info.java | 21 +
.../TestAssumeRoleWithWebIdentityRequest.java | 189 +++++++
.../oidc/TestOidcJwtIdentityProvider.java | 402 +++++++++++++++
.../hadoop/ozone/s3/AuthorizationFilter.java | 7 +
.../hadoop/ozone/s3sts/S3STSEndpoint.java | 108 +++-
.../hadoop/ozone/s3sts/S3STSEndpointBase.java | 6 +
.../S3STSWebIdentityAuthBypassFilter.java | 74 +++
.../s3sts/S3STSWebIdentityRequestParser.java | 95 ++++
.../ozone/s3/TestAuthorizationFilter.java | 18 +
.../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 66 +++
.../TestS3STSWebIdentityAuthBypassFilter.java | 137 +++++
27 files changed, 3081 insertions(+), 7 deletions(-)
create mode 100644 hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
create mode 100644 hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
create mode 100644 hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
index c648134d5c92..1f0a0fd99fb5 100644
--- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
+++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
@@ -445,6 +445,52 @@ public final class OzoneConfigKeys {
"ozone.security.enabled";
public static final boolean OZONE_SECURITY_ENABLED_DEFAULT = false;
+ public static final String OZONE_STS_WEB_IDENTITY_ENABLED =
+ "ozone.sts.web.identity.enabled";
+ public static final boolean OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT = false;
+ public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI =
+ "ozone.sts.web.identity.issuer.uri";
+ public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT = "";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI =
+ "ozone.sts.web.identity.jwks.uri";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT = "";
+ public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE =
+ "ozone.sts.web.identity.audience";
+ public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT = "";
+ public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM =
+ "ozone.sts.web.identity.username.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT =
+ "preferred_username";
+ public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM =
+ "ozone.sts.web.identity.subject.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT =
+ "sub";
+ public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM =
+ "ozone.sts.web.identity.groups.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT =
+ "groups";
+ public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM =
+ "ozone.sts.web.identity.roles.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT =
+ "realm_access.roles";
+ public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW =
+ "ozone.sts.web.identity.clock.skew";
+ public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT =
+ "60s";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL =
+ "ozone.sts.web.identity.jwks.refresh.interval";
+ public static final String
+ OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m";
+ public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS =
+ "ozone.sts.web.identity.require.https";
+ public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT =
+ true;
+ public static final String
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS =
+ "ozone.sts.web.identity.allow.insecure.http.for.tests";
+ public static final boolean
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT = false;
+
public static final String OZONE_HTTP_SECURITY_ENABLED_KEY =
"ozone.security.http.kerberos.enabled";
public static final boolean OZONE_HTTP_SECURITY_ENABLED_DEFAULT = false;
diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml
index 2066a99baa47..615ea2ba1aa2 100644
--- a/hadoop-hdds/common/src/main/resources/ozone-default.xml
+++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml
@@ -2142,6 +2142,96 @@
true, hadoop.security.authentication should be Kerberos.
+
+ ozone.sts.web.identity.enabled
+ false
+ OZONE, SECURITY, S3, STS, OIDC
+
+ Enables experimental Ozone STS AssumeRoleWithWebIdentity support.
+ When enabled, OM validates OIDC web identity JWTs and issues temporary
+ S3 credentials through the existing STS token infrastructure.
+
+
+
+ ozone.sts.web.identity.issuer.uri
+
+ OZONE, SECURITY, S3, STS, OIDC
+ Expected OIDC issuer URI for STS web identity tokens.
+
+
+ ozone.sts.web.identity.jwks.uri
+
+ OZONE, SECURITY, S3, STS, OIDC
+
+ JWKS URI used to validate STS web identity JWT signatures. If empty,
+ a later STS integration may derive it from issuer metadata.
+
+
+
+ ozone.sts.web.identity.audience
+
+ OZONE, SECURITY, S3, STS, OIDC
+ Required audience claim for STS web identity tokens.
+
+
+ ozone.sts.web.identity.username.claim
+ preferred_username
+ OZONE, SECURITY, S3, STS, OIDC
+ OIDC claim used as the effective Ozone username.
+
+
+ ozone.sts.web.identity.subject.claim
+ sub
+ OZONE, SECURITY, S3, STS, OIDC
+ OIDC claim used as the immutable external subject.
+
+
+ ozone.sts.web.identity.groups.claim
+ groups
+ OZONE, SECURITY, S3, STS, OIDC
+
+ OIDC claim path containing user groups. Nested claim paths such as
+ resource_access.ozone.groups are supported.
+
+
+
+ ozone.sts.web.identity.roles.claim
+ realm_access.roles
+ OZONE, SECURITY, S3, STS, OIDC
+
+ OIDC claim path containing role attributes. Roles are identity
+ attributes only; Ranger remains the authorization policy engine.
+
+
+
+ ozone.sts.web.identity.clock.skew
+ 60s
+ OZONE, SECURITY, S3, STS, OIDC
+ Allowed clock skew for OIDC exp, nbf, and iat validation.
+
+
+ ozone.sts.web.identity.jwks.refresh.interval
+ 10m
+ OZONE, SECURITY, S3, STS, OIDC
+ Interval for refreshing cached JWKS signing keys.
+
+
+ ozone.sts.web.identity.require.https
+ true
+ OZONE, SECURITY, S3, STS, OIDC
+
+ Require HTTPS issuer and JWKS URIs for STS web identity validation.
+
+
+
+ ozone.sts.web.identity.allow.insecure.http.for.tests
+ false
+ OZONE, SECURITY, S3, STS, OIDC
+
+ Allows HTTP issuer and JWKS URIs only for tests. Do not enable in
+ production.
+
+
ozone.security.http.kerberos.enabled
false
diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
new file mode 100644
index 000000000000..ffbbb6ae2d64
--- /dev/null
+++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
@@ -0,0 +1,484 @@
+---
+title: OIDC AssumeRoleWithWebIdentity for Ozone STS
+summary: Web identity support for Ozone STS using OIDC and Ranger authorization
+date: 2026-05-13
+status: proposed
+---
+
+
+# OIDC AssumeRoleWithWebIdentity for Ozone STS
+
+## Status
+
+Proposed staged implementation.
+
+This document narrows the previous broad OIDC direction to an upstream-friendly
+MVP: extend the Ozone STS temporary S3 credential model with an AWS-compatible
+`AssumeRoleWithWebIdentity` action.
+
+## Problem
+
+Secure Ozone S3 deployments currently depend on Kerberos-backed identities for
+S3 credential issuance. Kubernetes workloads commonly already have OIDC tokens
+from Keycloak or another IdP, but do not have an easy Kerberos bootstrap path.
+
+The target is not a Kerberos-free Ozone cluster. The target is a narrow STS
+exchange:
+
+1. Keycloak authenticates the caller and issues a signed OIDC JWT.
+2. Ozone STS validates the JWT locally using JWKS.
+3. Ranger or the configured Ozone authorizer decides whether the identity may
+ assume the requested role.
+4. Ozone STS issues temporary S3 credentials.
+5. S3 Gateway and OM validate those temporary credentials and authorize object
+ operations using the assumed identity and session context.
+
+Ranger remains the authorization source of truth. Keycloak roles and groups are
+identity attributes only.
+
+## Current STS And S3 Security Path
+
+This design is based on the `origin/HDDS-13323-sts` branch at commit
+`37a224b217`, which contains the STS runtime that was missing from earlier
+base branches.
+
+The current STS runtime contains:
+
+- `/sts` HTTP endpoint in `org.apache.hadoop.ozone.s3sts.S3STSEndpoint`;
+- endpoint authentication setup in `S3STSEndpointBase`;
+- AWS STS `AssumeRole` XML response model in `S3AssumeRoleResponseXml`;
+- S3G to OM client path through `ObjectStore`, `ClientProtocol`, `RpcClient`,
+ `OzoneManagerProtocol`, and
+ `OzoneManagerProtocolClientSideTranslatorPB.assumeRole()`;
+- OM request handling in
+ `org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest`;
+- OM response handling in `S3AssumeRoleResponse`;
+- session token identifier and secret manager in `STSTokenIdentifier`,
+ `STSTokenSecretManager`, and `STSSecurityUtil`;
+- revoked STS token metadata and cleanup through `S3RevokeSTSTokenRequest`,
+ `S3DeleteRevokedSTSTokensRequest`, and
+ `RevokedSTSTokenCleanupService`;
+- authorization extension points in `AssumeRoleRequest`,
+ `IAccessAuthorizer.generateAssumeRoleSessionPolicy()`, and
+ `RequestContext.sessionPolicy`.
+
+The current S3 request authentication path is:
+
+- S3G parses AWS SigV4 in `AuthorizationFilter`,
+ `SignatureProcessor`, `AuthorizationV4HeaderParser`,
+ `AuthorizationV4QueryParser`, and `StringToSignProducer`.
+- `EndpointBase` creates `S3Auth` from the parsed access key, signature, and
+ string-to-sign, then stores it in the `ClientProtocol` thread-local.
+- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into
+ `OMRequest.s3Authentication`.
+- `AWSSignatureProcessor` extracts `x-amz-security-token` for temporary
+ credentials into `SignatureInfo.sessionToken`.
+- `EndpointBase` and `S3STSEndpointBase` propagate the session token into
+ `S3Auth`.
+- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into
+ `OMRequest.s3Authentication`.
+- `S3SecurityUtil.validateS3Credential()` validates either permanent S3
+ credentials or STS temporary credentials.
+- For STS credentials, `STSSecurityUtil` decodes, validates, and decrypts the
+ session token, then OM validates the SigV4 signature with the temporary
+ secret access key.
+- `OmMetadataReader` attaches STS session policy from the OM thread-local
+ `STSTokenIdentifier` to `RequestContext` for subsequent authorization.
+
+The current permanent S3 credential storage path is:
+
+- `S3SecretManager` and `S3SecretManagerImpl`.
+- `S3SecretValue`.
+- OM metadata S3 secret table via `OmMetadataManagerImpl`.
+- `ozone s3 getsecret`, `setsecret`, and `revokesecret` client paths.
+
+## Dependency On Existing Ozone STS Runtime
+
+`AssumeRoleWithWebIdentity` must be an incremental extension of the existing
+`AssumeRole` runtime in `origin/HDDS-13323-sts`. It must not introduce a second
+STS endpoint, a separate S3 authentication system, or local S3G-only temporary
+credential state.
+
+Existing runtime:
+
+```text
+AssumeRole
+ -> S3 SigV4-authenticated /sts request
+ -> OM S3AssumeRoleRequest
+ -> temporary access key / secret / session token
+ -> STSSecurityUtil validation on later S3 requests
+ -> RequestContext.sessionPolicy
+ -> Ranger or configured authorizer
+```
+
+New runtime:
+
+```text
+AssumeRoleWithWebIdentity
+ -> unauthenticated /sts bootstrap request only for this action
+ -> OM validates Keycloak/OIDC JWT
+ -> OM authorizes role assumption through Ranger or configured authorizer
+ -> existing temporary credential issuer / session token path
+ -> existing STSSecurityUtil validation on later S3 requests
+ -> RequestContext.sessionPolicy and assumed identity
+ -> Ranger or configured authorizer
+```
+
+The first implementation slice intentionally stops before the OM runtime path:
+S3G can parse `Action=AssumeRoleWithWebIdentity`, validate the basic STS
+parameters, and return a clear unsupported error once the request reaches the
+placeholder. The next stage must add the OM request/protobuf path and move JWT
+validation, identity mapping, authorization, and credential issuance into OM.
+
+Temporary credentials must not be stored only in S3G memory. S3G can have
+multiple replicas and can restart. The issuing and validation authority must be
+OM-backed, persisted in Ozone metadata, or based on self-contained signed tokens
+whose signing keys are rotation-safe and available to all validating components.
+
+## Endpoint Placement
+
+The existing STS runtime places `/sts` on the S3 Gateway HTTP/HTTPS port.
+WebIdentity follows that placement: S3G exposes the AWS-compatible STS API
+surface, while OM remains authoritative for JWT validation, identity mapping,
+role-assumption authorization, credential issuance, revocation, and later
+temporary credential validation.
+
+Because existing `/sts` `AssumeRole` is protected by the normal S3 SigV4
+`AuthorizationFilter`, `AssumeRoleWithWebIdentity` needs a narrow bootstrap
+exception:
+
+- only for the STS application path;
+- only for `Action=AssumeRoleWithWebIdentity`;
+- only when `ozone.sts.web.identity.enabled=true`;
+- never for normal S3 object APIs;
+- never for existing `AssumeRole` or other STS actions.
+
+This exception must not make S3G a JWT source of truth. S3G may parse and route
+the request, but it must forward the web identity token and request context to
+OM. OM validates the JWT itself and issues the credentials.
+
+## RoleArn Semantics
+
+The current `AssumeRoleRequest` model contains `targetRoleName`, not a full AWS
+IAM role database. No role metadata store or IAM-like role lifecycle was found
+in this tree. `RoleArn` should therefore be treated as the authorization
+resource and request context for Ranger or the configured Ozone authorizer in
+the MVP.
+
+The Web Identity patch must not invent a new IAM role database. If the STS
+runtime already defines role ARN parsing or role-name normalization, Web
+Identity should reuse it. Otherwise, `RoleArn` remains an opaque policy resource
+for the authorizer and for audit/session context.
+
+## New Flow
+
+`AssumeRoleWithWebIdentity` is handled by the Ozone STS endpoint.
+
+Request parameters:
+
+- `Action=AssumeRoleWithWebIdentity`
+- `RoleArn=`
+- `RoleSessionName=`
+- `WebIdentityToken=`
+- `DurationSeconds=`
+- `Policy=`
+- `ProviderId=`
+
+Flow:
+
+1. The client or workload obtains an OIDC access token from Keycloak.
+2. The client calls Ozone STS with `AssumeRoleWithWebIdentity`.
+3. Ozone STS rejects the request unless `ozone.sts.web.identity.enabled=true`.
+4. S3G validates only the STS request shape that is safe to validate at the
+ edge: action, version, role ARN syntax, role session name, duration bounds,
+ and presence of `WebIdentityToken`.
+5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`,
+ `DurationSeconds`, `ProviderId`, and request context to OM.
+6. OM validates the JWT:
+ - token is a signed JWT;
+ - `alg=none` is rejected;
+ - signature validates against the configured JWKS;
+ - `iss` equals `ozone.sts.web.identity.issuer.uri`;
+ - configured audience is present;
+ - `exp`, `nbf`, and `iat` are validated with configured clock skew;
+ - configured username and subject claims are present.
+7. OM maps claims into an Ozone identity:
+ - username;
+ - subject;
+ - issuer;
+ - groups;
+ - roles;
+ - token expiration.
+8. OM builds an assume-role authorization request and calls Ranger or the
+ configured Ozone authorizer before issuing any credential.
+9. If authorized, OM issues temporary S3 credentials:
+ - `Credentials.AccessKeyId`;
+ - `Credentials.SecretAccessKey`;
+ - `Credentials.SessionToken`;
+ - `Credentials.Expiration`;
+ - `SubjectFromWebIdentityToken`;
+ - `AssumedRoleUser`;
+ - `Audience`;
+ - `Provider`.
+10. The client uses those credentials with ordinary AWS SigV4 against S3G.
+11. S3G and OM validate the temporary credential, recover the assumed identity
+ and session policy, and pass them to the authorizer for every S3 operation.
+
+## Configuration
+
+The MVP uses STS-focused configuration keys:
+
+```properties
+ozone.sts.web.identity.enabled=false
+ozone.sts.web.identity.issuer.uri=
+ozone.sts.web.identity.jwks.uri=
+ozone.sts.web.identity.audience=
+ozone.sts.web.identity.username.claim=preferred_username
+ozone.sts.web.identity.subject.claim=sub
+ozone.sts.web.identity.groups.claim=groups
+ozone.sts.web.identity.roles.claim=realm_access.roles
+ozone.sts.web.identity.clock.skew=60s
+ozone.sts.web.identity.jwks.refresh.interval=10m
+ozone.sts.web.identity.require.https=true
+ozone.sts.web.identity.allow.insecure.http.for.tests=false
+```
+
+The feature is opt-in. When disabled, Kerberos, existing S3 SigV4 handling,
+existing S3 secret handling, and non-secure mode behavior are unchanged.
+
+## OIDC Validation
+
+The reusable validation module is intentionally small:
+
+- `AuthCredentials` wraps bearer token material and redacts it in `toString()`.
+- `OzoneIdentity` carries normalized username, subject, issuer, groups, roles,
+ auth method, authentication time, expiration time, and raw claims.
+- `OidcJwtIdentityProvider` validates signed JWTs and maps claims.
+- `JwksProvider`, `JwksFetcher`, `UrlJwksFetcher`, and
+ `CachingJwksProvider` load and cache JWKS with refresh-on-unknown-kid
+ behavior.
+- `OidcAuthenticationException` fails closed without embedding the raw token in
+ exception messages.
+
+The module does not call Keycloak for every S3 request. JWKS validation is local,
+with refresh on cache expiry and unknown key id.
+
+## Ranger Authorization Points
+
+The first authorization point is credential issuance. Before generating
+temporary credentials, Ozone must call the configured authorizer with:
+
+- user: mapped OIDC username;
+- groups: mapped OIDC groups;
+- action: `AssumeRoleWithWebIdentity`;
+- resource: `RoleArn` or normalized Ozone role resource;
+- context: issuer, subject, audience, role session name, provider id,
+ requested duration, and client IP/host if available.
+
+Deny is the default. If Ranger denies or the authorizer cannot decide, Ozone
+returns `AccessDenied` and does not issue credentials.
+
+The common request-shape extension point is
+`AssumeRoleWithWebIdentityRequest`, with
+`IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the
+default authorizer hook. Existing authorizers are not forced to implement this
+immediately because the new method has a fail-closed default implementation.
+
+The second authorization point is every S3 operation made with the temporary
+credentials. OM must recover the assumed identity and session policy from the
+session token and build a `RequestContext` carrying:
+
+- `clientUgi` for the assumed OIDC username and groups;
+- `sessionPolicy` returned by the assume-role authorization step;
+- `s3Action` mapped from the S3 endpoint method;
+- bucket/key resource information.
+
+Ranger evaluates normal resource/action policies using that context.
+
+## Temporary Credential Lifecycle
+
+Temporary credentials must:
+
+- expire no later than the requested `DurationSeconds` and Ozone's configured
+ STS maximum;
+- require `x-amz-security-token` or the equivalent SigV4 query parameter;
+- fail closed if the session token is unknown, expired, revoked, malformed, or
+ fails signature/MAC verification;
+- never log the secret access key, session token, WebIdentityToken, refresh
+ token, or client secret;
+- map back to the assumed OIDC identity and role session context;
+- preserve the authorizer session policy used for subsequent S3 authorization.
+
+The existing STS runtime uses self-contained session tokens containing the
+encrypted secret access key, original identity, role ARN, session policy,
+expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse
+that issuer and validator instead of creating a parallel token format.
+
+In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the
+`originalAccessKeyId` because `AssumeRole` starts from an existing S3 access
+key. `AssumeRoleWithWebIdentity` has no permanent S3 access key. The token
+model must therefore be extended backward-compatibly, for example with an
+`authType` field plus optional WebIdentity fields:
+
+- `authType=ASSUME_ROLE` for existing tokens, with `originalAccessKeyId`
+ preserved;
+- `authType=WEB_IDENTITY` for new tokens, with effective user, groups, issuer,
+ subject, audience, role ARN, role session name, provider id, and session
+ policy;
+- old `AssumeRole` tokens must continue to deserialize and validate exactly as
+ before;
+- `STSSecurityUtil.ensureEssentialFieldsArePresentInToken()` must not require
+ `originalAccessKeyId` for `WEB_IDENTITY` tokens, but must still require all
+ fields needed to validate signatures and authorize later S3 operations.
+
+Revocation should follow the STS design: store revoked session token identifiers
+in OM metadata and fail closed if revocation status cannot be checked.
+
+## AWS-Compatible Response
+
+The XML response should follow the AWS STS shape where practical:
+
+```xml
+
+
+ ...
+ ozone
+ https://keycloak.example.com/realms/ozone
+
+ ...
+ ...
+
+
+ ...
+ ...
+ ...
+ ...
+
+
+
+```
+
+Errors should use STS/S3-compatible codes where possible:
+
+- invalid or expired JWT: `InvalidIdentityToken`;
+- disabled feature: `AccessDenied` or `InvalidAction`;
+- unauthorized role assumption: `AccessDenied`;
+- unsupported optional parameter: `InvalidParameterValue`;
+- internal validation or revocation failures: fail closed.
+
+## Security Model
+
+This feature does not replace Kerberos for daemon authentication or the broader
+Ozone secure cluster model.
+
+Security boundaries:
+
+- Keycloak authenticates the caller by signing JWTs.
+- Ozone STS validates the token and issues short-lived S3 credentials.
+- Ranger or the configured Ozone authorizer decides whether the caller can
+ assume a role and what object-store actions are allowed.
+- Ozone S3G/OM enforce the temporary credential and Ranger decisions.
+
+Required protections:
+
+- TLS is required for production STS and Keycloak endpoints.
+- Unsigned JWTs and `alg=none` are rejected.
+- Incorrect issuer, audience, signature, time claims, or required claims are
+ rejected.
+- JWKS rotation is handled by cache refresh and refresh-on-unknown-kid.
+- Web identity token and temporary credentials are never logged.
+- Direct OM/SCM/DN access is still governed by existing Ozone security. This
+ MVP only adds an STS path for temporary S3 credentials.
+
+## Non-Goals
+
+The MVP explicitly does not include:
+
+- full OIDC-only secure Ozone cluster;
+- replacing Kerberos daemon login;
+- OFS OIDC login;
+- `ozone auth login --oidc`;
+- device-code flow;
+- Keycloak Authorization Services as the object-store PDP;
+- replacing Ranger with Keycloak roles;
+- daemon-to-daemon OIDC authentication.
+
+## Test Strategy
+
+Unit tests:
+
+- valid JWT from a test RSA key validates successfully;
+- expired JWT fails;
+- wrong issuer fails;
+- wrong audience fails;
+- wrong signature fails;
+- `alg=none` fails;
+- manipulated groups claim fails because the signature no longer matches;
+- unknown `kid` triggers JWKS refresh or fails safely;
+- username, subject, groups, and roles claim mapping works;
+- token material is not present in exceptions.
+
+STS authorization tests:
+
+- fake authorizer sees user, groups, action `AssumeRoleWithWebIdentity`, role
+ resource, issuer, subject, audience, and session name;
+- allowed identity receives temporary credentials;
+- denied identity receives `AccessDenied`;
+- no credential is generated before authorization succeeds.
+
+Temporary credential tests:
+
+- credentials require a session token;
+- expired credentials fail;
+- tampered session token fails;
+- unknown/revoked session token fails;
+- allowed bucket operation succeeds with allowed role/session policy;
+- denied bucket operation fails.
+
+Integration tests:
+
+- Keycloak Testcontainers or docker-compose realm `ozone-test`;
+- client `ozone-sts`;
+- users `tomato-user` and `denied-user`;
+- group `ozone-tomato`;
+- `tomato-user` token includes `preferred_username`, `sub`, `groups`,
+ `realm_access.roles`, and `aud=ozone`;
+- real Keycloak JWT validates with Ozone provider;
+- fake/Ranger authorizer allows `tomato-user` to assume a test role and denies
+ `denied-user`.
+
+Full Ranger container testing is optional for the MVP. Unit and mock-layer tests
+must prove request shape and fail-closed behavior.
+
+## Migration And Future Work
+
+Migration path:
+
+1. Existing Kerberos and S3 secret behavior remains unchanged.
+2. Operators enable `ozone.sts.web.identity.enabled=true` for STS only.
+3. Workloads exchange Keycloak JWTs for temporary S3 credentials.
+4. Ranger policies grant role assumption and object access.
+
+Future work:
+
+- reuse the OIDC validation module for OIDC-to-Ozone delegation tokens for
+ OFS/CLI;
+- add daemon authentication without Kerberos via mTLS, SPIFFE, Kubernetes
+ ServiceAccount JWTs, or Keycloak client credentials;
+- add hybrid Kerberos plus OIDC migration mode if broader Ozone authentication
+ is pursued;
+- improve AWS STS API compatibility;
+- add an optional real Ranger integration test profile.
diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml
index 13a53cdc7a88..7e15ac212428 100644
--- a/hadoop-ozone/common/pom.xml
+++ b/hadoop-ozone/common/pom.xml
@@ -47,6 +47,10 @@
com.google.protobuf
protobuf-java
+
+ com.nimbusds
+ nimbus-jose-jwt
+
io.grpc
grpc-api
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
new file mode 100644
index 000000000000..3512356a9b44
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
@@ -0,0 +1,185 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.acl;
+
+import java.net.InetAddress;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Set;
+import net.jcip.annotations.Immutable;
+
+/**
+ * Represents an STS AssumeRoleWithWebIdentity request that has already been
+ * authenticated by validating the web identity token.
+ *
+ * The web identity token itself must not be stored in this object. OM is the
+ * authoritative validator for the token and passes only normalized identity
+ * attributes to the authorizer.
+ */
+@Immutable
+public class AssumeRoleWithWebIdentityRequest {
+
+ public static final String ACTION = "AssumeRoleWithWebIdentity";
+
+ private final String host;
+ private final InetAddress ip;
+ private final String user;
+ private final Set groups;
+ private final Set roles;
+ private final String roleArn;
+ private final String roleSessionName;
+ private final String issuer;
+ private final String subject;
+ private final String audience;
+ private final String providerId;
+ private final Set grants;
+
+ public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip,
+ String user, Set groups, Set roles, String roleArn,
+ String roleSessionName, String issuer, String subject, String audience,
+ String providerId, Set grants) {
+ this.host = host;
+ this.ip = ip;
+ this.user = requireNonBlank(user, "user");
+ this.groups = immutableSet(groups);
+ this.roles = immutableSet(roles);
+ this.roleArn = requireNonBlank(roleArn, "roleArn");
+ this.roleSessionName =
+ requireNonBlank(roleSessionName, "roleSessionName");
+ this.issuer = requireNonBlank(issuer, "issuer");
+ this.subject = requireNonBlank(subject, "subject");
+ this.audience = requireNonBlank(audience, "audience");
+ this.providerId = providerId;
+ this.grants = grants;
+ }
+
+ public String getAction() {
+ return ACTION;
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public InetAddress getIp() {
+ return ip;
+ }
+
+ public String getUser() {
+ return user;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public String getRoleArn() {
+ return roleArn;
+ }
+
+ public String getRoleSessionName() {
+ return roleSessionName;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public String getSubject() {
+ return subject;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getProviderId() {
+ return providerId;
+ }
+
+ public Set getGrants() {
+ return grants;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ } else if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+
+ final AssumeRoleWithWebIdentityRequest that =
+ (AssumeRoleWithWebIdentityRequest) o;
+ return Objects.equals(host, that.host)
+ && Objects.equals(ip, that.ip)
+ && Objects.equals(user, that.user)
+ && Objects.equals(groups, that.groups)
+ && Objects.equals(roles, that.roles)
+ && Objects.equals(roleArn, that.roleArn)
+ && Objects.equals(roleSessionName, that.roleSessionName)
+ && Objects.equals(issuer, that.issuer)
+ && Objects.equals(subject, that.subject)
+ && Objects.equals(audience, that.audience)
+ && Objects.equals(providerId, that.providerId)
+ && Objects.equals(grants, that.grants);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(host, ip, user, groups, roles, roleArn,
+ roleSessionName, issuer, subject, audience, providerId, grants);
+ }
+
+ @Override
+ public String toString() {
+ return "AssumeRoleWithWebIdentityRequest{"
+ + "host='" + host + '\''
+ + ", ip=" + ip
+ + ", user='" + user + '\''
+ + ", groups=" + groups
+ + ", roles=" + roles
+ + ", roleArn='" + roleArn + '\''
+ + ", roleSessionName='" + roleSessionName + '\''
+ + ", issuer='" + issuer + '\''
+ + ", subject='" + subject + '\''
+ + ", audience='" + audience + '\''
+ + ", providerId='" + providerId + '\''
+ + ", grants=" + grants
+ + '}';
+ }
+
+ private static Set immutableSet(Set values) {
+ if (values == null) {
+ return Collections.emptySet();
+ }
+ return Collections.unmodifiableSet(new LinkedHashSet<>(values));
+ }
+
+ private static String requireNonBlank(String value, String name) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be empty");
+ }
+ return value;
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
index 8a07bab606b0..437eb96d94ae 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
@@ -69,6 +69,26 @@ default String generateAssumeRoleSessionPolicy(AssumeRoleRequest assumeRoleReque
throw new OMException("The generateAssumeRoleSessionPolicy call is not supported", NOT_SUPPORTED_OPERATION);
}
+ /**
+ * Attempts to authorize an STS AssumeRoleWithWebIdentity request after OM has
+ * validated the web identity token and mapped it to an Ozone identity.
+ *
+ * Implementations must treat Keycloak/OIDC groups and roles as identity
+ * attributes only. The final role-assumption authorization decision and the
+ * returned session policy must come from this authorizer.
+ *
+ * @param request the web identity role assumption request shape
+ * @return a String representing the permissions granted according to
+ * the authorizer.
+ * @throws OMException if the caller is not authorized or the operation is not
+ * supported.
+ */
+ default String generateAssumeRoleWithWebIdentitySessionPolicy(
+ AssumeRoleWithWebIdentityRequest request) throws OMException {
+ throw new OMException("The generateAssumeRoleWithWebIdentitySessionPolicy"
+ + " call is not supported", NOT_SUPPORTED_OPERATION);
+ }
+
/**
* @return true for Ozone-native authorizer
*/
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
new file mode 100644
index 000000000000..a8f4cf61b4f0
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+/**
+ * Authentication material accepted by Ozone identity providers.
+ */
+public final class AuthCredentials {
+
+ private final String bearerToken;
+
+ private AuthCredentials(String bearerToken) {
+ this.bearerToken = bearerToken;
+ }
+
+ public static AuthCredentials bearerToken(String token) {
+ if (token == null || token.trim().isEmpty()) {
+ throw new IllegalArgumentException("Bearer token must not be empty");
+ }
+ return new AuthCredentials(token);
+ }
+
+ public String getBearerToken() {
+ return bearerToken;
+ }
+
+ @Override
+ public String toString() {
+ return "AuthCredentials{bearerToken=}";
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
new file mode 100644
index 000000000000..007174dc98c8
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import java.io.IOException;
+import java.text.ParseException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Thread-safe JWKS cache with refresh-on-unknown-kid semantics.
+ */
+public final class CachingJwksProvider implements JwksProvider {
+
+ private final JwksFetcher fetcher;
+ private final Duration refreshInterval;
+ private final Clock clock;
+ private volatile JWKSet jwkSet;
+ private volatile Instant loadedAt = Instant.EPOCH;
+
+ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) {
+ this(fetcher, refreshInterval, Clock.systemUTC());
+ }
+
+ CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval,
+ Clock clock) {
+ if (fetcher == null) {
+ throw new IllegalArgumentException("JWKS fetcher must not be null");
+ }
+ if (refreshInterval == null || refreshInterval.isNegative()) {
+ throw new IllegalArgumentException(
+ "JWKS refresh interval must not be negative");
+ }
+ this.fetcher = fetcher;
+ this.refreshInterval = refreshInterval;
+ this.clock = clock;
+ }
+
+ @Override
+ public List getKeys(String keyId) throws OidcAuthenticationException {
+ refreshIfNeeded(false);
+ List keys = findKeys(jwkSet, keyId);
+ if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) {
+ refreshIfNeeded(true);
+ keys = findKeys(jwkSet, keyId);
+ }
+ return keys;
+ }
+
+ private void refreshIfNeeded(boolean force)
+ throws OidcAuthenticationException {
+ Instant now = clock.instant();
+ JWKSet snapshot = jwkSet;
+ if (!force && snapshot != null
+ && now.isBefore(loadedAt.plus(refreshInterval))) {
+ return;
+ }
+
+ synchronized (this) {
+ now = clock.instant();
+ snapshot = jwkSet;
+ if (!force && snapshot != null
+ && now.isBefore(loadedAt.plus(refreshInterval))) {
+ return;
+ }
+ try {
+ jwkSet = fetcher.fetch();
+ loadedAt = now;
+ } catch (IOException | ParseException e) {
+ throw new OidcAuthenticationException(
+ "Unable to refresh OIDC JWKS", e);
+ }
+ }
+ }
+
+ private static List findKeys(JWKSet set, String keyId) {
+ if (set == null) {
+ return Collections.emptyList();
+ }
+ if (keyId == null || keyId.trim().isEmpty()) {
+ return Collections.unmodifiableList(new ArrayList<>(set.getKeys()));
+ }
+ JWK key = set.getKeyByKeyId(keyId);
+ if (key == null) {
+ return Collections.emptyList();
+ }
+ return Collections.singletonList(key);
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
new file mode 100644
index 000000000000..8139076161a2
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import java.io.IOException;
+import java.text.ParseException;
+
+/**
+ * Loads a JSON Web Key Set from an external source.
+ */
+@FunctionalInterface
+public interface JwksFetcher {
+
+ JWKSet fetch() throws IOException, ParseException;
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
new file mode 100644
index 000000000000..4323138c58f0
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import com.nimbusds.jose.jwk.JWK;
+import java.util.List;
+
+/**
+ * Supplies candidate verification keys for a signed OIDC token.
+ */
+public interface JwksProvider {
+
+ List getKeys(String keyId) throws OidcAuthenticationException;
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
new file mode 100644
index 000000000000..faf19282c229
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import java.io.IOException;
+
+/**
+ * Checked exception used when OIDC authentication fails closed.
+ */
+public class OidcAuthenticationException extends IOException {
+
+ public OidcAuthenticationException(String message) {
+ super(message);
+ }
+
+ public OidcAuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
new file mode 100644
index 000000000000..5665c01195e8
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.apache.hadoop.hdds.conf.ConfigurationSource;
+
+/**
+ * Configuration for the experimental OIDC identity provider.
+ */
+public final class OidcConfig {
+
+ private final boolean enabled;
+ private final String issuerUri;
+ private final String jwksUri;
+ private final String audience;
+ private final String usernameClaim;
+ private final String subjectClaim;
+ private final String groupsClaim;
+ private final String rolesClaim;
+ private final Duration clockSkew;
+ private final Duration jwksRefreshInterval;
+ private final boolean requireHttps;
+ private final boolean allowInsecureHttpForTests;
+
+ private OidcConfig(Builder builder) {
+ this.enabled = builder.enabled;
+ this.issuerUri = trimToEmpty(builder.issuerUri);
+ this.jwksUri = trimToEmpty(builder.jwksUri);
+ this.audience = trimToEmpty(builder.audience);
+ this.usernameClaim = requireNonBlank(builder.usernameClaim,
+ OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM);
+ this.subjectClaim = requireNonBlank(builder.subjectClaim,
+ OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM);
+ this.groupsClaim = requireNonBlank(builder.groupsClaim,
+ OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM);
+ this.rolesClaim = requireNonBlank(builder.rolesClaim,
+ OZONE_STS_WEB_IDENTITY_ROLES_CLAIM);
+ this.clockSkew = requireNonNegative(builder.clockSkew,
+ OZONE_STS_WEB_IDENTITY_CLOCK_SKEW);
+ this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval,
+ OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL);
+ this.requireHttps = builder.requireHttps;
+ this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests;
+ }
+
+ public static OidcConfig from(ConfigurationSource conf) {
+ OidcConfig config = newBuilder()
+ .setEnabled(conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED,
+ OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT))
+ .setIssuerUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ISSUER_URI,
+ OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT))
+ .setJwksUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_JWKS_URI,
+ OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT))
+ .setAudience(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_AUDIENCE,
+ OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT))
+ .setUsernameClaim(conf.getTrimmed(
+ OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM,
+ OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT))
+ .setSubjectClaim(conf.getTrimmed(
+ OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM,
+ OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT))
+ .setGroupsClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM,
+ OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT))
+ .setRolesClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ROLES_CLAIM,
+ OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT))
+ .setClockSkew(duration(conf, OZONE_STS_WEB_IDENTITY_CLOCK_SKEW,
+ OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT))
+ .setJwksRefreshInterval(duration(conf,
+ OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL,
+ OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT))
+ .setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS,
+ OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT))
+ .setAllowInsecureHttpForTests(conf.getBoolean(
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT))
+ .build();
+ if (config.isEnabled()) {
+ config.validateForProvider();
+ }
+ return config;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public String getIssuerUri() {
+ return issuerUri;
+ }
+
+ public String getJwksUri() {
+ return jwksUri;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getUsernameClaim() {
+ return usernameClaim;
+ }
+
+ public String getSubjectClaim() {
+ return subjectClaim;
+ }
+
+ public String getGroupsClaim() {
+ return groupsClaim;
+ }
+
+ public String getRolesClaim() {
+ return rolesClaim;
+ }
+
+ public Duration getClockSkew() {
+ return clockSkew;
+ }
+
+ public Duration getJwksRefreshInterval() {
+ return jwksRefreshInterval;
+ }
+
+ public boolean isRequireHttps() {
+ return requireHttps;
+ }
+
+ public boolean isAllowInsecureHttpForTests() {
+ return allowInsecureHttpForTests;
+ }
+
+ void validateForProvider() {
+ requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
+ requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE);
+
+ if (requireHttps && !allowInsecureHttpForTests) {
+ requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
+ if (!jwksUri.isEmpty()) {
+ requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI);
+ }
+ }
+ }
+
+ private static Duration duration(ConfigurationSource conf, String key,
+ String defaultValue) {
+ return Duration.ofMillis(conf.getTimeDuration(key, defaultValue,
+ TimeUnit.MILLISECONDS));
+ }
+
+ private static Duration requireNonNegative(Duration value, String key) {
+ if (value == null || value.isNegative()) {
+ throw new IllegalArgumentException(key + " must not be negative");
+ }
+ return value;
+ }
+
+ private static String requireNonBlank(String value, String key) {
+ String trimmed = trimToEmpty(value);
+ if (trimmed.isEmpty()) {
+ throw new IllegalArgumentException(key + " must not be empty");
+ }
+ return trimmed;
+ }
+
+ private static String trimToEmpty(String value) {
+ return value == null ? "" : value.trim();
+ }
+
+ private static void requireHttpsUri(String value, String key) {
+ URI uri;
+ try {
+ uri = new URI(value);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException(key + " is not a valid URI", e);
+ }
+ if (!"https".equalsIgnoreCase(uri.getScheme())) {
+ throw new IllegalArgumentException(key + " must use https");
+ }
+ }
+
+ /**
+ * Builder for {@link OidcConfig}.
+ */
+ public static final class Builder {
+
+ private boolean enabled = OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
+ private String issuerUri = OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT;
+ private String jwksUri = OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT;
+ private String audience = OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT;
+ private String usernameClaim =
+ OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT;
+ private String subjectClaim =
+ OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT;
+ private String groupsClaim = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT;
+ private String rolesClaim = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT;
+ private Duration clockSkew = Duration.ofSeconds(60);
+ private Duration jwksRefreshInterval = Duration.ofMinutes(10);
+ private boolean requireHttps = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT;
+ private boolean allowInsecureHttpForTests =
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
+
+ private Builder() {
+ }
+
+ public Builder setEnabled(boolean value) {
+ this.enabled = value;
+ return this;
+ }
+
+ public Builder setIssuerUri(String value) {
+ this.issuerUri = value;
+ return this;
+ }
+
+ public Builder setJwksUri(String value) {
+ this.jwksUri = value;
+ return this;
+ }
+
+ public Builder setAudience(String value) {
+ this.audience = value;
+ return this;
+ }
+
+ public Builder setUsernameClaim(String value) {
+ this.usernameClaim = value;
+ return this;
+ }
+
+ public Builder setSubjectClaim(String value) {
+ this.subjectClaim = value;
+ return this;
+ }
+
+ public Builder setGroupsClaim(String value) {
+ this.groupsClaim = value;
+ return this;
+ }
+
+ public Builder setRolesClaim(String value) {
+ this.rolesClaim = value;
+ return this;
+ }
+
+ public Builder setClockSkew(Duration value) {
+ this.clockSkew = value;
+ return this;
+ }
+
+ public Builder setJwksRefreshInterval(Duration value) {
+ this.jwksRefreshInterval = value;
+ return this;
+ }
+
+ public Builder setRequireHttps(boolean value) {
+ this.requireHttps = value;
+ return this;
+ }
+
+ public Builder setAllowInsecureHttpForTests(boolean value) {
+ this.allowInsecureHttpForTests = value;
+ return this;
+ }
+
+ public OidcConfig build() {
+ return new OidcConfig(this);
+ }
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
new file mode 100644
index 000000000000..ce0b0dbafd91
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
@@ -0,0 +1,306 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWT;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.JWTParser;
+import com.nimbusds.jwt.SignedJWT;
+import java.lang.reflect.Array;
+import java.text.ParseException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * OIDC identity provider backed by locally validated signed JWTs.
+ */
+public final class OidcJwtIdentityProvider implements OzoneIdentityProvider {
+
+ private final OidcConfig config;
+ private final JwksProvider jwksProvider;
+ private final Clock clock;
+
+ public OidcJwtIdentityProvider(OidcConfig config,
+ JwksProvider jwksProvider) {
+ this(config, jwksProvider, Clock.systemUTC());
+ }
+
+ OidcJwtIdentityProvider(OidcConfig config, JwksProvider jwksProvider,
+ Clock clock) {
+ if (config == null) {
+ throw new IllegalArgumentException("OIDC config must not be null");
+ }
+ if (jwksProvider == null) {
+ throw new IllegalArgumentException("JWKS provider must not be null");
+ }
+ config.validateForProvider();
+ this.config = config;
+ this.jwksProvider = jwksProvider;
+ this.clock = clock;
+ }
+
+ @Override
+ public OzoneIdentity authenticate(AuthCredentials credentials)
+ throws OidcAuthenticationException {
+ if (credentials == null || credentials.getBearerToken() == null
+ || credentials.getBearerToken().trim().isEmpty()) {
+ throw new OidcAuthenticationException("Missing OIDC bearer token");
+ }
+
+ SignedJWT jwt = parse(credentials.getBearerToken());
+ verifySignature(jwt);
+ JWTClaimsSet claims = claims(jwt);
+ return toIdentity(claims);
+ }
+
+ private SignedJWT parse(String token) throws OidcAuthenticationException {
+ try {
+ JWT jwt = JWTParser.parse(token);
+ if (!(jwt instanceof SignedJWT)) {
+ throw new OidcAuthenticationException("OIDC token must be signed");
+ }
+ return (SignedJWT) jwt;
+ } catch (ParseException e) {
+ throw new OidcAuthenticationException("Unable to parse OIDC token");
+ }
+ }
+
+ private void verifySignature(SignedJWT jwt)
+ throws OidcAuthenticationException {
+ JWSHeader header = jwt.getHeader();
+ JWSAlgorithm algorithm = header.getAlgorithm();
+ if (algorithm == null || "none".equalsIgnoreCase(algorithm.getName())) {
+ throw new OidcAuthenticationException(
+ "OIDC token must use a signed algorithm");
+ }
+ if (!JWSAlgorithm.Family.RSA.contains(algorithm)) {
+ throw new OidcAuthenticationException(
+ "Unsupported OIDC token signing algorithm");
+ }
+
+ List keys = jwksProvider.getKeys(header.getKeyID());
+ if (keys.isEmpty()) {
+ throw new OidcAuthenticationException("OIDC signing key is unknown");
+ }
+ for (JWK key : keys) {
+ if (verifyWithKey(jwt, algorithm, key)) {
+ return;
+ }
+ }
+ throw new OidcAuthenticationException(
+ "OIDC token signature does not match");
+ }
+
+ private boolean verifyWithKey(SignedJWT jwt, JWSAlgorithm algorithm,
+ JWK key) throws OidcAuthenticationException {
+ if (!(key instanceof RSAKey)) {
+ return false;
+ }
+ if (key.getAlgorithm() != null
+ && !algorithm.getName().equals(key.getAlgorithm().getName())) {
+ return false;
+ }
+
+ try {
+ JWSVerifier verifier =
+ new RSASSAVerifier(((RSAKey) key).toRSAPublicKey());
+ return jwt.verify(verifier);
+ } catch (JOSEException e) {
+ throw new OidcAuthenticationException(
+ "Unable to verify OIDC token signature", e);
+ }
+ }
+
+ private JWTClaimsSet claims(SignedJWT jwt)
+ throws OidcAuthenticationException {
+ try {
+ return jwt.getJWTClaimsSet();
+ } catch (ParseException e) {
+ throw new OidcAuthenticationException(
+ "Unable to parse OIDC token claims");
+ }
+ }
+
+ private OzoneIdentity toIdentity(JWTClaimsSet claims)
+ throws OidcAuthenticationException {
+ Instant now = clock.instant();
+ Instant expiresAt = validateExpiration(claims, now);
+ validateNotBefore(claims, now);
+ Instant authenticatedAt = validateIssueTime(claims, now);
+ validateIssuer(claims);
+ validateAudience(claims);
+
+ String username = extractStringClaim(claims, config.getUsernameClaim());
+ String subject = extractStringClaim(claims, config.getSubjectClaim());
+ Set groups = extractStringSet(claims, config.getGroupsClaim());
+ Set roles = extractStringSet(claims, config.getRolesClaim());
+
+ return OzoneIdentity.newBuilder()
+ .setUsername(username)
+ .setSubject(subject)
+ .setIssuer(claims.getIssuer())
+ .setGroups(groups)
+ .setRoles(roles)
+ .setAuthMethod(OzoneIdentity.AUTH_METHOD_OIDC)
+ .setAuthenticatedAt(authenticatedAt)
+ .setExpiresAt(expiresAt)
+ .setRawClaims(claims.getClaims())
+ .build();
+ }
+
+ private Instant validateExpiration(JWTClaimsSet claims, Instant now)
+ throws OidcAuthenticationException {
+ Date expiration = claims.getExpirationTime();
+ if (expiration == null) {
+ throw new OidcAuthenticationException(
+ "OIDC token does not contain expiration");
+ }
+ Instant expiresAt = expiration.toInstant();
+ if (expiresAt.plus(config.getClockSkew()).isBefore(now)) {
+ throw new OidcAuthenticationException("OIDC token is expired");
+ }
+ return expiresAt;
+ }
+
+ private void validateNotBefore(JWTClaimsSet claims, Instant now)
+ throws OidcAuthenticationException {
+ Date notBefore = claims.getNotBeforeTime();
+ if (notBefore != null) {
+ Duration clockSkew = config.getClockSkew();
+ if (notBefore.toInstant().minus(clockSkew).isAfter(now)) {
+ throw new OidcAuthenticationException(
+ "OIDC token is not valid yet");
+ }
+ }
+ }
+
+ private Instant validateIssueTime(JWTClaimsSet claims, Instant now)
+ throws OidcAuthenticationException {
+ Date issueTime = claims.getIssueTime();
+ if (issueTime == null) {
+ throw new OidcAuthenticationException(
+ "OIDC token does not contain issue time");
+ }
+ Instant issuedAt = issueTime.toInstant();
+ if (issuedAt.minus(config.getClockSkew()).isAfter(now)) {
+ throw new OidcAuthenticationException(
+ "OIDC token issue time is in the future");
+ }
+ return issuedAt;
+ }
+
+ private void validateIssuer(JWTClaimsSet claims)
+ throws OidcAuthenticationException {
+ if (!config.getIssuerUri().equals(claims.getIssuer())) {
+ throw new OidcAuthenticationException(
+ "OIDC token issuer does not match");
+ }
+ }
+
+ private void validateAudience(JWTClaimsSet claims)
+ throws OidcAuthenticationException {
+ List audiences = claims.getAudience();
+ if (audiences == null || !audiences.contains(config.getAudience())) {
+ throw new OidcAuthenticationException(
+ "OIDC token audience does not match");
+ }
+ }
+
+ private String extractStringClaim(JWTClaimsSet claims, String claimPath)
+ throws OidcAuthenticationException {
+ Object value = claimValue(claims, claimPath);
+ if (!(value instanceof String) || ((String) value).trim().isEmpty()) {
+ throw new OidcAuthenticationException(
+ "OIDC token is missing required claim");
+ }
+ return ((String) value).trim();
+ }
+
+ private Set extractStringSet(JWTClaimsSet claims, String claimPath)
+ throws OidcAuthenticationException {
+ Object value = claimValue(claims, claimPath);
+ if (value == null) {
+ return Collections.emptySet();
+ }
+
+ Set result = new LinkedHashSet<>();
+ if (value instanceof String) {
+ addStringValue(result, (String) value);
+ } else if (value instanceof Collection) {
+ for (Object item : (Collection>) value) {
+ addStringValue(result, item);
+ }
+ } else if (value.getClass().isArray()) {
+ int length = Array.getLength(value);
+ for (int i = 0; i < length; i++) {
+ addStringValue(result, Array.get(value, i));
+ }
+ } else {
+ throw new OidcAuthenticationException(
+ "OIDC token claim has unsupported type");
+ }
+ return Collections.unmodifiableSet(result);
+ }
+
+ private void addStringValue(Set values, Object value)
+ throws OidcAuthenticationException {
+ if (!(value instanceof String)) {
+ throw new OidcAuthenticationException(
+ "OIDC token claim contains non-string value");
+ }
+ String trimmed = ((String) value).trim();
+ if (!trimmed.isEmpty()) {
+ values.add(trimmed);
+ }
+ }
+
+ private Object claimValue(JWTClaimsSet claims, String claimPath) {
+ Object directValue = claims.getClaim(claimPath);
+ if (directValue != null) {
+ return directValue;
+ }
+
+ String[] parts = claimPath.split("\\.");
+ Object current = claims.getClaim(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ if (!(current instanceof Map)) {
+ return null;
+ }
+ current = ((Map, ?>) current).get(parts[i]);
+ if (current == null) {
+ return null;
+ }
+ }
+ return current;
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
new file mode 100644
index 000000000000..1516bae08bb9
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import java.time.Instant;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Normalized identity produced by an external authentication provider.
+ */
+public final class OzoneIdentity {
+
+ public static final String AUTH_METHOD_OIDC = "oidc";
+
+ private final String username;
+ private final String subject;
+ private final String issuer;
+ private final Set groups;
+ private final Set roles;
+ private final String authMethod;
+ private final Instant authenticatedAt;
+ private final Instant expiresAt;
+ private final Map rawClaims;
+
+ private OzoneIdentity(Builder builder) {
+ this.username = requireNonBlank(builder.username, "username");
+ this.subject = requireNonBlank(builder.subject, "subject");
+ this.issuer = requireNonBlank(builder.issuer, "issuer");
+ this.groups = immutableSet(builder.groups);
+ this.roles = immutableSet(builder.roles);
+ this.authMethod = requireNonBlank(builder.authMethod, "authMethod");
+ this.authenticatedAt = requireNonNull(
+ builder.authenticatedAt, "authenticatedAt");
+ this.expiresAt = requireNonNull(builder.expiresAt, "expiresAt");
+ this.rawClaims = Collections.unmodifiableMap(
+ new LinkedHashMap<>(builder.rawClaims));
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public String getSubject() {
+ return subject;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public String getAuthMethod() {
+ return authMethod;
+ }
+
+ public Instant getAuthenticatedAt() {
+ return authenticatedAt;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public Map getRawClaims() {
+ return rawClaims;
+ }
+
+ @Override
+ public String toString() {
+ return "OzoneIdentity{"
+ + "username='" + username + '\''
+ + ", subject='" + subject + '\''
+ + ", issuer='" + issuer + '\''
+ + ", groups=" + groups
+ + ", roles=" + roles
+ + ", authMethod='" + authMethod + '\''
+ + ", authenticatedAt=" + authenticatedAt
+ + ", expiresAt=" + expiresAt
+ + '}';
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ private static Set immutableSet(Set values) {
+ return Collections.unmodifiableSet(new LinkedHashSet<>(values));
+ }
+
+ private static String requireNonBlank(String value, String name) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be empty");
+ }
+ return value;
+ }
+
+ private static T requireNonNull(T value, String name) {
+ if (value == null) {
+ throw new IllegalArgumentException(name + " must not be null");
+ }
+ return value;
+ }
+
+ /**
+ * Builder for {@link OzoneIdentity}.
+ */
+ public static final class Builder {
+
+ private String username;
+ private String subject;
+ private String issuer;
+ private Set groups = new LinkedHashSet<>();
+ private Set roles = new LinkedHashSet<>();
+ private String authMethod = AUTH_METHOD_OIDC;
+ private Instant authenticatedAt;
+ private Instant expiresAt;
+ private Map rawClaims = new LinkedHashMap<>();
+
+ private Builder() {
+ }
+
+ public Builder setUsername(String value) {
+ this.username = value;
+ return this;
+ }
+
+ public Builder setSubject(String value) {
+ this.subject = value;
+ return this;
+ }
+
+ public Builder setIssuer(String value) {
+ this.issuer = value;
+ return this;
+ }
+
+ public Builder setGroups(Set value) {
+ this.groups = new LinkedHashSet<>(value);
+ return this;
+ }
+
+ public Builder setRoles(Set value) {
+ this.roles = new LinkedHashSet<>(value);
+ return this;
+ }
+
+ public Builder setAuthMethod(String value) {
+ this.authMethod = value;
+ return this;
+ }
+
+ public Builder setAuthenticatedAt(Instant value) {
+ this.authenticatedAt = value;
+ return this;
+ }
+
+ public Builder setExpiresAt(Instant value) {
+ this.expiresAt = value;
+ return this;
+ }
+
+ public Builder setRawClaims(Map value) {
+ this.rawClaims = new LinkedHashMap<>(value);
+ return this;
+ }
+
+ public OzoneIdentity build() {
+ return new OzoneIdentity(this);
+ }
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
new file mode 100644
index 000000000000..550e41a15c46
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+/**
+ * Converts external authentication credentials into an Ozone identity.
+ */
+public interface OzoneIdentityProvider {
+
+ OzoneIdentity authenticate(AuthCredentials credentials)
+ throws OidcAuthenticationException;
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
new file mode 100644
index 000000000000..a2fe2b4dad3f
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import java.io.IOException;
+import java.net.URL;
+import java.text.ParseException;
+
+/**
+ * JWKS fetcher backed by a URL.
+ */
+public final class UrlJwksFetcher implements JwksFetcher {
+
+ private final URL url;
+
+ public UrlJwksFetcher(URL url) {
+ this.url = url;
+ }
+
+ @Override
+ public JWKSet fetch() throws IOException, ParseException {
+ return JWKSet.load(url);
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
new file mode 100644
index 000000000000..fb89e48eb780
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * OIDC identity validation support for Ozone STS web identity flows.
+ */
+package org.apache.hadoop.ozone.security.oidc;
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
new file mode 100644
index 000000000000..19651ba34d9d
--- /dev/null
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.acl;
+
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_DENIED;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.NOT_SUPPORTED_OPERATION;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.net.InetAddress;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link AssumeRoleWithWebIdentityRequest}.
+ */
+public class TestAssumeRoleWithWebIdentityRequest {
+
+ private static final String ROLE_ARN =
+ "arn:aws:iam::123456789012:role/tomato";
+
+ @Test
+ public void testConstructorAndGetters() throws Exception {
+ final Set groups = set("ozone-tomato");
+ final Set roles = set("role:writer");
+ final InetAddress ip = InetAddress.getByName("127.0.0.1");
+
+ final AssumeRoleWithWebIdentityRequest request =
+ new AssumeRoleWithWebIdentityRequest(
+ "s3g.example.com", ip, "tomato-user", groups, roles, ROLE_ARN,
+ "tomato-session", "https://keycloak.example.com/realms/ozone",
+ "subject-1", "ozone", "keycloak", null);
+
+ groups.add("mutated-group");
+ roles.add("mutated-role");
+
+ assertEquals(AssumeRoleWithWebIdentityRequest.ACTION,
+ request.getAction());
+ assertEquals("s3g.example.com", request.getHost());
+ assertEquals(ip, request.getIp());
+ assertEquals("tomato-user", request.getUser());
+ assertEquals(set("ozone-tomato"), request.getGroups());
+ assertEquals(set("role:writer"), request.getRoles());
+ assertEquals(ROLE_ARN, request.getRoleArn());
+ assertEquals("tomato-session", request.getRoleSessionName());
+ assertEquals("https://keycloak.example.com/realms/ozone",
+ request.getIssuer());
+ assertEquals("subject-1", request.getSubject());
+ assertEquals("ozone", request.getAudience());
+ assertEquals("keycloak", request.getProviderId());
+ assertEquals(null, request.getGrants());
+ }
+
+ @Test
+ public void testRequiredIdentityFieldsFailClosed() {
+ assertThrows(IllegalArgumentException.class,
+ () -> requestWithUser(" "));
+ assertThrows(IllegalArgumentException.class,
+ () -> new AssumeRoleWithWebIdentityRequest(
+ null, null, "tomato-user", set("ozone-tomato"), set(),
+ " ", "tomato-session", "issuer", "subject", "audience",
+ null, null));
+ }
+
+ @Test
+ public void testAuthorizerRequestShapeAndAllow() throws Exception {
+ final AssumeRoleWithWebIdentityRequest request = requestWithUser(
+ "tomato-user");
+ final FakeAuthorizer authorizer = FakeAuthorizer.allowing("session-policy");
+
+ final String policy =
+ authorizer.generateAssumeRoleWithWebIdentitySessionPolicy(request);
+
+ assertEquals("session-policy", policy);
+ assertEquals(request, authorizer.getLastRequest());
+ assertEquals("tomato-user", authorizer.getLastRequest().getUser());
+ assertEquals(set("ozone-tomato"), authorizer.getLastRequest().getGroups());
+ assertEquals(AssumeRoleWithWebIdentityRequest.ACTION,
+ authorizer.getLastRequest().getAction());
+ assertEquals(ROLE_ARN, authorizer.getLastRequest().getRoleArn());
+ assertEquals("issuer", authorizer.getLastRequest().getIssuer());
+ assertEquals("subject", authorizer.getLastRequest().getSubject());
+ assertEquals("audience", authorizer.getLastRequest().getAudience());
+ assertEquals("session", authorizer.getLastRequest().getRoleSessionName());
+ assertEquals("provider", authorizer.getLastRequest().getProviderId());
+ }
+
+ @Test
+ public void testAuthorizerDenyByDefault() {
+ final FakeAuthorizer authorizer = new FakeAuthorizer(false, null);
+
+ final OMException ex = assertThrows(OMException.class,
+ () -> authorizer.generateAssumeRoleWithWebIdentitySessionPolicy(
+ requestWithUser("tomato-user")));
+
+ assertEquals(ACCESS_DENIED, ex.getResult());
+ }
+
+ @Test
+ public void testDefaultAuthorizerMethodIsNotSupported() {
+ final IAccessAuthorizer authorizer = new IAccessAuthorizer() {
+ @Override
+ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
+ return false;
+ }
+ };
+
+ final OMException ex = assertThrows(OMException.class,
+ () -> authorizer.generateAssumeRoleWithWebIdentitySessionPolicy(
+ requestWithUser("tomato-user")));
+
+ assertEquals(NOT_SUPPORTED_OPERATION, ex.getResult());
+ }
+
+ @Test
+ public void testRequestDoesNotContainTokenMaterial() {
+ final String token = "eyJhbGciOiJSUzI1NiJ9.sensitive.signature";
+
+ assertThat(requestWithUser("tomato-user").toString())
+ .doesNotContain(token)
+ .doesNotContain("WebIdentityToken");
+ }
+
+ private static AssumeRoleWithWebIdentityRequest requestWithUser(
+ String user) {
+ return new AssumeRoleWithWebIdentityRequest(
+ "host", null, user, set("ozone-tomato"), set("role:writer"),
+ ROLE_ARN, "session", "issuer", "subject", "audience",
+ "provider", null);
+ }
+
+ private static Set set(String... values) {
+ return new LinkedHashSet<>(Arrays.asList(values));
+ }
+
+ private static final class FakeAuthorizer implements IAccessAuthorizer {
+
+ private final boolean allow;
+ private final String sessionPolicy;
+ private AssumeRoleWithWebIdentityRequest lastRequest;
+
+ private FakeAuthorizer(boolean allow, String sessionPolicy) {
+ this.allow = allow;
+ this.sessionPolicy = sessionPolicy;
+ }
+
+ private static FakeAuthorizer allowing(String sessionPolicy) {
+ return new FakeAuthorizer(true, sessionPolicy);
+ }
+
+ @Override
+ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
+ return false;
+ }
+
+ @Override
+ public String generateAssumeRoleWithWebIdentitySessionPolicy(
+ AssumeRoleWithWebIdentityRequest request) throws OMException {
+ lastRequest = request;
+ if (!allow) {
+ throw new OMException("Denied", ACCESS_DENIED);
+ }
+ return sessionPolicy;
+ }
+
+ private AssumeRoleWithWebIdentityRequest getLastRequest() {
+ return lastRequest;
+ }
+ }
+}
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
new file mode 100644
index 000000000000..11ec51cb4173
--- /dev/null
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link OidcJwtIdentityProvider}.
+ */
+public class TestOidcJwtIdentityProvider {
+
+ private static final String ISSUER =
+ "https://keycloak.example.com/realms/ozone";
+ private static final String AUDIENCE = "ozone";
+ private static final Instant NOW = Instant.parse("2026-05-13T00:00:00Z");
+ private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
+
+ private RSAKey primaryKey;
+ private RSAKey rotatedKey;
+ private RSAKey wrongKey;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ primaryKey = rsaKey("kid-primary");
+ rotatedKey = rsaKey("kid-rotated");
+ wrongKey = rsaKey("kid-wrong");
+ }
+
+ @Test
+ public void validJwtPasses() throws Exception {
+ String jwt = token(primaryKey);
+ OzoneIdentity identity = provider(primaryKey)
+ .authenticate(AuthCredentials.bearerToken(jwt));
+
+ assertThat(identity.getUsername()).isEqualTo("tomato-user");
+ assertThat(identity.getSubject()).isEqualTo("subject-tomato");
+ assertThat(identity.getIssuer()).isEqualTo(ISSUER);
+ assertThat(identity.getAuthMethod()).isEqualTo("oidc");
+ assertThat(identity.getGroups()).containsExactly("ozone-tomato");
+ assertThat(identity.getRoles()).containsExactly("writer", "read");
+ assertThat(identity.getExpiresAt()).isEqualTo(NOW.plusSeconds(3600));
+ assertThat(identity.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void expiredJwtFails() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.expirationTime(Date.from(NOW.minusSeconds(120))));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("expired");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void notBeforeInFutureFails() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.notBeforeTime(Date.from(NOW.plusSeconds(120))));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("not valid yet");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void issueTimeInFutureFails() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.issueTime(Date.from(NOW.plusSeconds(120))));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("issue time");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void wrongIssuerFails() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.issuer("https://other.example.com/realms/ozone"));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("issuer");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void wrongAudienceFails() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.audience(Collections.singletonList("other-audience")));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("audience");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void wrongSignatureFails() throws Exception {
+ String jwt = token(wrongKey, primaryKey.getKeyID(), builder -> {
+ });
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("signature");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void manipulatedGroupsClaimFailsSignatureValidation()
+ throws Exception {
+ String jwt = token(primaryKey);
+ String manipulatedJwt = replacePayloadText(jwt, "ozone-tomato",
+ "ozone-admins");
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey)
+ .authenticate(AuthCredentials.bearerToken(manipulatedJwt)));
+
+ assertThat(exception).hasMessageContaining("signature");
+ assertThat(exception.toString()).doesNotContain(manipulatedJwt);
+ }
+
+ @Test
+ public void algNoneFails() {
+ String jwt = new PlainJWT(baseClaims().build()).serialize();
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("signed");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void unknownKidTriggersJwksRefresh() throws Exception {
+ AtomicInteger fetches = new AtomicInteger();
+ CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> {
+ if (fetches.incrementAndGet() == 1) {
+ return jwkSet(primaryKey);
+ }
+ return jwkSet(rotatedKey);
+ }, Duration.ofMinutes(10), CLOCK);
+
+ OzoneIdentity identity = provider(config(), jwksProvider)
+ .authenticate(AuthCredentials.bearerToken(token(rotatedKey)));
+
+ assertThat(identity.getUsername()).isEqualTo("tomato-user");
+ assertThat(fetches).hasValue(2);
+ }
+
+ @Test
+ public void keyRotationWorks() throws Exception {
+ AtomicInteger fetches = new AtomicInteger();
+ CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> {
+ if (fetches.incrementAndGet() == 1) {
+ return jwkSet(primaryKey);
+ }
+ return jwkSet(primaryKey, rotatedKey);
+ }, Duration.ofMinutes(10), CLOCK);
+ OidcJwtIdentityProvider provider = provider(config(), jwksProvider);
+
+ provider.authenticate(AuthCredentials.bearerToken(token(primaryKey)));
+ provider.authenticate(AuthCredentials.bearerToken(token(rotatedKey)));
+ OzoneIdentity identity =
+ provider.authenticate(AuthCredentials.bearerToken(token(primaryKey)));
+
+ assertThat(identity.getUsername()).isEqualTo("tomato-user");
+ assertThat(fetches).hasValue(2);
+ }
+
+ @Test
+ public void usernameClaimMappingWorks() throws Exception {
+ OidcConfig config = configBuilder()
+ .setUsernameClaim("email")
+ .build();
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.claim("email", "tomato@example.com"));
+
+ OzoneIdentity identity = provider(config, jwksProvider(primaryKey))
+ .authenticate(AuthCredentials.bearerToken(jwt));
+
+ assertThat(identity.getUsername()).isEqualTo("tomato@example.com");
+ }
+
+ @Test
+ public void nestedGroupsAndRolesClaimMappingWorks() throws Exception {
+ OidcConfig config = configBuilder()
+ .setGroupsClaim("resource_access.ozone.groups")
+ .setRolesClaim("resource_access.ozone.roles")
+ .build();
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder -> {
+ Map ozone = new LinkedHashMap<>();
+ ozone.put("groups", Arrays.asList("ozone-tomato", "ozone-admins"));
+ ozone.put("roles", Collections.singletonList("writer"));
+ builder.claim("resource_access",
+ Collections.singletonMap("ozone", ozone));
+ });
+
+ OzoneIdentity identity = provider(config, jwksProvider(primaryKey))
+ .authenticate(AuthCredentials.bearerToken(jwt));
+
+ assertThat(identity.getGroups())
+ .containsExactly("ozone-tomato", "ozone-admins");
+ assertThat(identity.getRoles()).containsExactly("writer");
+ }
+
+ @Test
+ public void missingUsernameFailsClosed() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.claim("preferred_username", null));
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception).hasMessageContaining("required claim");
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ @Test
+ public void missingGroupsAreMappedToEmptySet() throws Exception {
+ String jwt = token(primaryKey, primaryKey.getKeyID(), builder ->
+ builder.claim("groups", null));
+
+ OzoneIdentity identity = provider(primaryKey)
+ .authenticate(AuthCredentials.bearerToken(jwt));
+
+ assertThat(identity.getGroups()).isEmpty();
+ }
+
+ @Test
+ public void tokenMaterialIsNotIncludedInParseException() {
+ String jwt = "sensitive-token-material";
+
+ OidcAuthenticationException exception = assertThrows(
+ OidcAuthenticationException.class,
+ () -> provider(primaryKey).authenticate(AuthCredentials.bearerToken(jwt)));
+
+ assertThat(exception.toString()).doesNotContain(jwt);
+ }
+
+ private OidcJwtIdentityProvider provider(RSAKey key) {
+ return provider(config(), jwksProvider(key));
+ }
+
+ private OidcJwtIdentityProvider provider(OidcConfig config,
+ JwksProvider jwksProvider) {
+ return new OidcJwtIdentityProvider(config, jwksProvider, CLOCK);
+ }
+
+ private static OidcConfig config() {
+ return configBuilder().build();
+ }
+
+ private static OidcConfig.Builder configBuilder() {
+ return OidcConfig.newBuilder()
+ .setIssuerUri(ISSUER)
+ .setAudience(AUDIENCE)
+ .setClockSkew(Duration.ofSeconds(60));
+ }
+
+ private static JwksProvider jwksProvider(RSAKey... keys) {
+ JWKSet jwkSet = jwkSet(keys);
+ return keyId -> {
+ if (keyId == null || keyId.trim().isEmpty()) {
+ return new ArrayList<>(jwkSet.getKeys());
+ }
+ JWK key = jwkSet.getKeyByKeyId(keyId);
+ return key == null ? Collections.emptyList()
+ : Collections.singletonList(key);
+ };
+ }
+
+ private static JWKSet jwkSet(RSAKey... keys) {
+ List publicKeys = new ArrayList<>();
+ for (RSAKey key : keys) {
+ publicKeys.add(key.toPublicJWK());
+ }
+ return new JWKSet(publicKeys);
+ }
+
+ private static RSAKey rsaKey(String keyId) throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ KeyPair keyPair = generator.generateKeyPair();
+ return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+ .privateKey((RSAPrivateKey) keyPair.getPrivate())
+ .keyID(keyId)
+ .algorithm(JWSAlgorithm.RS256)
+ .build();
+ }
+
+ private static String token(RSAKey signerKey) throws Exception {
+ return token(signerKey, signerKey.getKeyID(), builder -> {
+ });
+ }
+
+ private static String token(RSAKey signerKey, String keyId,
+ ClaimsCustomizer customizer) throws Exception {
+ JWTClaimsSet.Builder claims = baseClaims();
+ customizer.customize(claims);
+ SignedJWT signedJWT = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keyId).build(),
+ claims.build());
+ signedJWT.sign(new RSASSASigner(signerKey.toRSAPrivateKey()));
+ return signedJWT.serialize();
+ }
+
+ private static String replacePayloadText(String jwt, String from,
+ String to) {
+ String[] parts = jwt.split("\\.");
+ String payload = new String(Base64.getUrlDecoder().decode(parts[1]),
+ StandardCharsets.UTF_8);
+ String manipulatedPayload = payload.replace(from, to);
+ parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ manipulatedPayload.getBytes(StandardCharsets.UTF_8));
+ return String.join(".", parts);
+ }
+
+ private static JWTClaimsSet.Builder baseClaims() {
+ Map realmAccess = new LinkedHashMap<>();
+ realmAccess.put("roles", Arrays.asList("writer", "read"));
+
+ return new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .subject("subject-tomato")
+ .audience(Collections.singletonList(AUDIENCE))
+ .issueTime(Date.from(NOW.minusSeconds(30)))
+ .expirationTime(Date.from(NOW.plusSeconds(3600)))
+ .claim("preferred_username", "tomato-user")
+ .claim("groups", Collections.singletonList("ozone-tomato"))
+ .claim("realm_access", realmAccess);
+ }
+
+ @FunctionalInterface
+ private interface ClaimsCustomizer {
+ void customize(JWTClaimsSet.Builder builder);
+ }
+}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java
index 3db260aef11d..34df811bf20b 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/AuthorizationFilter.java
@@ -50,6 +50,8 @@
@Priority(AuthorizationFilter.PRIORITY)
public class AuthorizationFilter implements ContainerRequestFilter {
public static final int PRIORITY = 50;
+ public static final String SKIP_AWS_AUTH_PROPERTY =
+ AuthorizationFilter.class.getName() + ".skipAwsAuth";
private static final Logger LOG = LoggerFactory.getLogger(
AuthorizationFilter.class);
@@ -63,6 +65,11 @@ public class AuthorizationFilter implements ContainerRequestFilter {
@Override
public void filter(ContainerRequestContext context) throws
IOException {
+ if (Boolean.TRUE.equals(context.getProperty(SKIP_AWS_AUTH_PROPERTY))) {
+ LOG.debug("Skipping AWS SigV4 processing for this request");
+ return;
+ }
+
try {
signatureInfo.initialize(signatureProcessor.parseSignature());
if (signatureInfo.getVersion() == Version.V4) {
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
index 091f8851fa3f..5244989812ed 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
@@ -17,6 +17,8 @@
package org.apache.hadoop.ozone.s3sts;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
import static javax.ws.rs.core.Response.Status.FORBIDDEN;
import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR;
@@ -49,6 +51,7 @@
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator;
import org.apache.hadoop.ozone.om.helpers.S3STSUtils;
+import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder;
import org.apache.hadoop.ozone.s3.RequestIdentifier;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
import org.apache.hadoop.ozone.s3.exception.OSTSException;
@@ -128,9 +131,20 @@ public Response get(
@QueryParam("RoleSessionName") String roleSessionName,
@QueryParam("DurationSeconds") Integer durationSeconds,
@QueryParam("Version") String version,
- @QueryParam("Policy") String awsIamSessionPolicy) throws OS3Exception {
+ @QueryParam("Policy") String awsIamSessionPolicy,
+ @QueryParam("WebIdentityToken") String webIdentityToken,
+ @QueryParam("ProviderId") String providerId) throws OS3Exception {
- return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy);
+ return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds,
+ version, awsIamSessionPolicy, webIdentityToken, providerId);
+ }
+
+ @VisibleForTesting
+ public Response get(String action, String roleArn, String roleSessionName,
+ Integer durationSeconds, String version, String awsIamSessionPolicy)
+ throws OS3Exception {
+ return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds,
+ version, awsIamSessionPolicy, null, null);
}
/**
@@ -152,13 +166,25 @@ public Response post(
@FormParam("RoleSessionName") String roleSessionName,
@FormParam("DurationSeconds") Integer durationSeconds,
@FormParam("Version") String version,
- @FormParam("Policy") String awsIamSessionPolicy) throws OS3Exception {
+ @FormParam("Policy") String awsIamSessionPolicy,
+ @FormParam("WebIdentityToken") String webIdentityToken,
+ @FormParam("ProviderId") String providerId) throws OS3Exception {
- return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds, version, awsIamSessionPolicy);
+ return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds,
+ version, awsIamSessionPolicy, webIdentityToken, providerId);
+ }
+
+ @VisibleForTesting
+ public Response post(String action, String roleArn, String roleSessionName,
+ Integer durationSeconds, String version, String awsIamSessionPolicy)
+ throws OS3Exception {
+ return handleSTSRequest(action, roleArn, roleSessionName, durationSeconds,
+ version, awsIamSessionPolicy, null, null);
}
private Response handleSTSRequest(String action, String roleArn, String roleSessionName,
- Integer durationSeconds, String version, String awsIamSessionPolicy) throws OS3Exception {
+ Integer durationSeconds, String version, String awsIamSessionPolicy,
+ String webIdentityToken, String providerId) throws OS3Exception {
final String requestId = requestIdentifier.getRequestId();
// NOTE: invalid, missing or unsupported actions are not added to the audit log
try {
@@ -173,10 +199,12 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess
switch (action) {
case ASSUME_ROLE_ACTION:
return handleAssumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, version, requestId);
+ case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION:
+ return handleAssumeRoleWithWebIdentity(roleArn, roleSessionName,
+ durationSeconds, webIdentityToken, providerId, version, requestId);
// These operations are not supported yet
case GET_SESSION_TOKEN_ACTION:
case ASSUME_ROLE_WITH_SAML_ACTION:
- case ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION:
case GET_CALLER_IDENTITY_ACTION:
case DECODE_AUTHORIZATION_MESSAGE_ACTION:
case GET_ACCESS_KEY_INFO_ACTION:
@@ -197,6 +225,73 @@ private Response handleSTSRequest(String action, String roleArn, String roleSess
}
}
+ private Response handleAssumeRoleWithWebIdentity(String roleArn,
+ String roleSessionName, Integer durationSeconds, String webIdentityToken,
+ String providerId, String version, String requestId)
+ throws OSTSException {
+ final String action = "AssumeRoleWithWebIdentity";
+ if (!isWebIdentityEnabled()) {
+ throw new OSTSException(
+ INVALID_ACTION, "Operation " + action + " is not supported yet.",
+ NOT_IMPLEMENTED.getStatusCode());
+ }
+
+ if (version == null || !version.equals(EXPECTED_VERSION)) {
+ throw new OSTSException(
+ INVALID_ACTION, "Could not find operation " + action
+ + " for version "
+ + (version == null ? "NO_VERSION_SPECIFIED. Expected version is: "
+ + EXPECTED_VERSION : version), BAD_REQUEST.getStatusCode());
+ }
+
+ final Set validationErrors = new HashSet<>();
+ try {
+ S3STSUtils.validateDuration(durationSeconds);
+ } catch (OMException e) {
+ validationErrors.add(e.getMessage());
+ }
+
+ try {
+ AwsRoleArnValidator.validateAndExtractRoleNameFromArn(roleArn);
+ } catch (OMException e) {
+ validationErrors.add(e.getMessage());
+ }
+
+ try {
+ S3STSUtils.validateRoleSessionName(roleSessionName);
+ } catch (OMException e) {
+ validationErrors.add(e.getMessage());
+ }
+
+ if (StringUtils.isBlank(webIdentityToken)) {
+ validationErrors.add("Value null at 'webIdentityToken' failed to "
+ + "satisfy constraint: Member must not be null");
+ }
+
+ if (!validationErrors.isEmpty()) {
+ final String validationMessage = validationErrors.size()
+ + " validation "
+ + (validationErrors.size() > 1 ? "errors detected: "
+ : "error detected: ")
+ + String.join(";", validationErrors);
+ throw new OSTSException(
+ VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode());
+ }
+
+ // S3G has parsed and routed the request. The OM request path will validate
+ // the JWT and issue credentials; S3G must not trust or log the token here.
+ throw new OSTSException(UNSUPPORTED_OPERATION,
+ "AssumeRoleWithWebIdentity OM runtime is not implemented yet.",
+ NOT_IMPLEMENTED.getStatusCode());
+ }
+
+ private boolean isWebIdentityEnabled() {
+ return OzoneConfigurationHolder.configuration() != null
+ && OzoneConfigurationHolder.configuration().getBoolean(
+ OZONE_STS_WEB_IDENTITY_ENABLED,
+ OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT);
+ }
+
private Response handleAssumeRole(String roleArn, String roleSessionName, Integer durationSeconds,
String awsIamSessionPolicy, String version, String requestId) throws OSTSException {
final String action = "AssumeRole";
@@ -351,4 +446,3 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR
}
}
}
-
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java
index 027784b0edc9..af3caa877085 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpointBase.java
@@ -32,6 +32,7 @@
import org.apache.hadoop.ozone.client.OzoneClient;
import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
import org.apache.hadoop.ozone.om.protocol.S3Auth;
+import org.apache.hadoop.ozone.s3.AuthorizationFilter;
import org.apache.hadoop.ozone.s3.signature.SignatureInfo;
import org.apache.hadoop.ozone.s3.util.AuditUtils;
@@ -63,6 +64,11 @@ public void setAuditLogger(AuditLogger auditLogger) {
@PostConstruct
public void initialization() {
+ if (context != null && Boolean.TRUE.equals(context.getProperty(
+ AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY))) {
+ return;
+ }
+
S3Auth s3Auth = new S3Auth(signatureInfo.getStringToSign(),
signatureInfo.getSignature(),
signatureInfo.getAwsAccessId(), signatureInfo.getAwsAccessId());
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
new file mode 100644
index 000000000000..e406bc0b4af4
--- /dev/null
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3sts;
+
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.io.IOException;
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.PreMatching;
+import javax.ws.rs.ext.Provider;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.s3.AuthorizationFilter;
+import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder;
+
+/**
+ * Allows unauthenticated bootstrap only for STS WebIdentity requests.
+ *
+ * This filter is registered only in the STS JAX-RS application. It does not
+ * validate or trust the JWT; OM remains the authoritative WebIdentity
+ * validator and temporary credential issuer.
+ */
+@Provider
+@PreMatching
+@Priority(AuthorizationFilter.PRIORITY - 1)
+public class S3STSWebIdentityAuthBypassFilter
+ implements ContainerRequestFilter {
+
+ @Inject
+ private OzoneConfiguration ozoneConfiguration;
+
+ @Override
+ public void filter(ContainerRequestContext context) throws IOException {
+ if (isWebIdentityEnabled()
+ && S3STSWebIdentityRequestParser.isAssumeRoleWithWebIdentity(
+ context)) {
+ context.setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY,
+ Boolean.TRUE);
+ }
+ }
+
+ private boolean isWebIdentityEnabled() {
+ OzoneConfiguration conf = ozoneConfiguration;
+ if (conf == null) {
+ conf = OzoneConfigurationHolder.configuration();
+ }
+ return conf != null && conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED,
+ OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT);
+ }
+
+ @VisibleForTesting
+ void setOzoneConfiguration(OzoneConfiguration conf) {
+ this.ozoneConfiguration = conf;
+ }
+}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
new file mode 100644
index 000000000000..7862f076106b
--- /dev/null
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3sts;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.container.ContainerRequestContext;
+
+/**
+ * Parses the STS action without logging request parameters.
+ */
+final class S3STSWebIdentityRequestParser {
+
+ static final String ACTION = "Action";
+ static final String ASSUME_ROLE_WITH_WEB_IDENTITY =
+ "AssumeRoleWithWebIdentity";
+
+ private S3STSWebIdentityRequestParser() {
+ }
+
+ static boolean isAssumeRoleWithWebIdentity(ContainerRequestContext context)
+ throws IOException {
+ String action = getAction(context);
+ return ASSUME_ROLE_WITH_WEB_IDENTITY.equals(action);
+ }
+
+ private static String getAction(ContainerRequestContext context)
+ throws IOException {
+ String method = context.getMethod();
+ if (HttpMethod.GET.equalsIgnoreCase(method)) {
+ return context.getUriInfo().getQueryParameters().getFirst(ACTION);
+ }
+ if (HttpMethod.POST.equalsIgnoreCase(method)) {
+ return getFormAction(context);
+ }
+ return null;
+ }
+
+ private static String getFormAction(ContainerRequestContext context)
+ throws IOException {
+ InputStream stream = context.getEntityStream();
+ if (stream == null) {
+ return null;
+ }
+
+ byte[] body = readFully(stream);
+ context.setEntityStream(new ByteArrayInputStream(body));
+ String form = new String(body, StandardCharsets.UTF_8);
+ for (String pair : form.split("&")) {
+ int equals = pair.indexOf('=');
+ if (equals < 0) {
+ continue;
+ }
+ String name = decode(pair.substring(0, equals));
+ if (ACTION.equals(name)) {
+ return decode(pair.substring(equals + 1));
+ }
+ }
+ return null;
+ }
+
+ private static byte[] readFully(InputStream stream) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[4096];
+ int read;
+ while ((read = stream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ }
+
+ private static String decode(String value) throws IOException {
+ return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
+ }
+}
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java
index 5171138710e0..7ea3a3516a58 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/TestAuthorizationFilter.java
@@ -35,6 +35,7 @@
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
@@ -53,8 +54,10 @@
import javax.ws.rs.core.UriInfo;
import org.apache.hadoop.ozone.s3.signature.AWSSignatureProcessor;
import org.apache.hadoop.ozone.s3.signature.SignatureInfo;
+import org.apache.hadoop.ozone.s3.signature.SignatureProcessor;
import org.apache.hadoop.ozone.s3.signature.StringToSignProducer;
import org.apache.kerby.util.Hex;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -71,6 +74,21 @@ public class TestAuthorizationFilter {
private static final String CURDATE = DATE_FORMATTER.format(LocalDate.now());
+ @Test
+ void skipsAwsAuthWhenExplicitlyRequested() throws Exception {
+ ContainerRequestContext context = mock(ContainerRequestContext.class);
+ SignatureProcessor signatureProcessor = mock(SignatureProcessor.class);
+ SignatureInfo signatureInfo = mock(SignatureInfo.class);
+ when(context.getProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY))
+ .thenReturn(Boolean.TRUE);
+
+ authorizationFilter.setSignatureParser(signatureProcessor);
+ authorizationFilter.setSignatureInfo(signatureInfo);
+ authorizationFilter.filter(context);
+
+ verifyNoInteractions(signatureProcessor, signatureInfo);
+ }
+
private static StreamtestAuthFilterFailuresInput() {
return Stream.of(
arguments(
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
index 059f54e0993c..57bac2281f06 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
@@ -18,6 +18,7 @@
package org.apache.hadoop.ozone.s3sts;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3_ADMINISTRATORS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -77,6 +78,7 @@ public class TestS3STSEndpoint {
@BeforeEach
public void setup() throws Exception {
+ OzoneConfigurationHolder.resetConfiguration();
OzoneConfiguration config = new OzoneConfiguration();
config.set(OZONE_S3_ADMINISTRATORS, "test-user");
OzoneConfigurationHolder.setConfiguration(config);
@@ -360,6 +362,63 @@ public void testStsWhenActionNotImplemented() throws Exception {
"Operation GetSessionToken is not supported yet.");
}
+ @Test
+ public void testStsAssumeRoleWithWebIdentityDisabled() throws Exception {
+ final OSTSException ex = assertThrows(OSTSException.class, () ->
+ endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN,
+ ROLE_SESSION_NAME, 3600, "2011-06-15", null,
+ "sensitive-token-material", "keycloak"));
+
+ assertEquals(501, ex.getHttpCode());
+ verifyNoInteractions(auditLogger);
+ verify(objectStore, never()).assumeRole(anyString(), anyString(),
+ anyInt(), any(), anyString());
+
+ ex.setRequestId(REQUEST_ID);
+ assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction",
+ "Operation AssumeRoleWithWebIdentity is not supported yet.");
+ }
+
+ @Test
+ public void testStsAssumeRoleWithWebIdentityMissingToken()
+ throws Exception {
+ enableWebIdentity();
+
+ final OSTSException ex = assertThrows(OSTSException.class, () ->
+ endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN,
+ ROLE_SESSION_NAME, 3600, "2011-06-15", null, null,
+ "keycloak"));
+
+ assertEquals(400, ex.getHttpCode());
+ verifyNoInteractions(auditLogger);
+ verify(objectStore, never()).assumeRole(anyString(), anyString(),
+ anyInt(), any(), anyString());
+
+ ex.setRequestId(REQUEST_ID);
+ assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError",
+ "webIdentityToken");
+ }
+
+ @Test
+ public void testStsAssumeRoleWithWebIdentityRoutesWhenEnabled()
+ throws Exception {
+ enableWebIdentity();
+
+ final OSTSException ex = assertThrows(OSTSException.class, () ->
+ endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN,
+ ROLE_SESSION_NAME, 3600, "2011-06-15", null,
+ "sensitive-token-material", "keycloak"));
+
+ assertEquals(501, ex.getHttpCode());
+ verifyNoInteractions(auditLogger);
+ verify(objectStore, never()).assumeRole(anyString(), anyString(),
+ anyInt(), any(), anyString());
+
+ ex.setRequestId(REQUEST_ID);
+ assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation",
+ "AssumeRoleWithWebIdentity OM runtime is not implemented yet.");
+ }
+
@Test
public void testStsMissingRoleSessionName() throws Exception {
final OSTSException ex = assertThrows(OSTSException.class, () ->
@@ -570,6 +629,13 @@ private static Document parseXml(String xml) throws Exception {
return documentBuilder.parse(new InputSource(new StringReader(xml)));
}
+ private static void enableWebIdentity() {
+ OzoneConfigurationHolder.resetConfiguration();
+ OzoneConfiguration config = new OzoneConfiguration();
+ config.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true);
+ OzoneConfigurationHolder.setConfiguration(config);
+ }
+
private static void assertStsErrorXml(String xml, String expectedNamespace, String expectedType, String expectedCode,
String expectedMessageContains) throws Exception {
final Document doc = parseXml(xml);
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
new file mode 100644
index 000000000000..8914825ab5ba
--- /dev/null
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3sts;
+
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import javax.ws.rs.HttpMethod;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.UriInfo;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.ozone.s3.AuthorizationFilter;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Tests for the STS-only WebIdentity auth bypass filter.
+ */
+public class TestS3STSWebIdentityAuthBypassFilter {
+
+ @Test
+ public void enabledGetWebIdentityRequestSkipsAwsAuth() throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ ContainerRequestContext context = context(HttpMethod.GET,
+ "AssumeRoleWithWebIdentity", null);
+
+ filter.filter(context);
+
+ verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY,
+ Boolean.TRUE);
+ }
+
+ @Test
+ public void disabledWebIdentityRequestDoesNotSkipAwsAuth()
+ throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(false);
+ ContainerRequestContext context = context(HttpMethod.GET,
+ "AssumeRoleWithWebIdentity", null);
+
+ filter.filter(context);
+
+ verify(context, never()).setProperty(anyString(), any());
+ }
+
+ @Test
+ public void otherStsActionDoesNotSkipAwsAuth() throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ ContainerRequestContext context = context(HttpMethod.GET, "AssumeRole",
+ null);
+
+ filter.filter(context);
+
+ verify(context, never()).setProperty(anyString(), any());
+ }
+
+ @Test
+ public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody()
+ throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ String body = "Action=AssumeRoleWithWebIdentity"
+ + "&WebIdentityToken=sensitive-token-material";
+ ContainerRequestContext context = context(HttpMethod.POST, null, body);
+
+ filter.filter(context);
+
+ verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY,
+ Boolean.TRUE);
+ ArgumentCaptor streamCaptor =
+ ArgumentCaptor.forClass(InputStream.class);
+ verify(context).setEntityStream(streamCaptor.capture());
+ assertEquals(body, read(streamCaptor.getValue()));
+ }
+
+ private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) {
+ OzoneConfiguration conf = new OzoneConfiguration();
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled);
+ S3STSWebIdentityAuthBypassFilter filter =
+ new S3STSWebIdentityAuthBypassFilter();
+ filter.setOzoneConfiguration(conf);
+ return filter;
+ }
+
+ private static ContainerRequestContext context(String method,
+ String queryAction, String body) {
+ ContainerRequestContext context = mock(ContainerRequestContext.class);
+ UriInfo uriInfo = mock(UriInfo.class);
+ MultivaluedHashMap queryParams =
+ new MultivaluedHashMap<>();
+ if (queryAction != null) {
+ queryParams.putSingle("Action", queryAction);
+ }
+ when(context.getMethod()).thenReturn(method);
+ when(context.getUriInfo()).thenReturn(uriInfo);
+ when(uriInfo.getQueryParameters()).thenReturn(queryParams);
+ if (body != null) {
+ when(context.getEntityStream()).thenReturn(new ByteArrayInputStream(
+ body.getBytes(StandardCharsets.UTF_8)));
+ }
+ return context;
+ }
+
+ private static String read(InputStream stream) throws Exception {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[256];
+ int read;
+ while ((read = stream.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+}
From f7e139f3d62612d65dc91b40f23ea4900c91c906 Mon Sep 17 00:00:00 2001
From: paf91
Date: Wed, 13 May 2026 23:44:41 +0300
Subject: [PATCH 2/9] HDDS-15273. Implement OM-authoritative
AssumeRoleWithWebIdentity runtime
Includes:
- OM protocol/client path for AssumeRoleWithWebIdentity
- OM-side JWT validation in preExecute()
- sanitized Ratis request without raw WebIdentityToken
- WebIdentity-backed STS token identity model
- backward-compatible STSTokenIdentifier authType
- reuse of existing STS validation path for subsequent S3 requests
- S3G STS XML response/routing for WebIdentity
- tests proving no raw JWT persistence and replay determinism
---
.../oidc-assume-role-with-web-identity.md | 44 ++-
.../hadoop/ozone/client/ObjectStore.java | 12 +
.../ozone/client/protocol/ClientProtocol.java | 18 +
.../hadoop/ozone/client/rpc/RpcClient.java | 11 +
.../java/org/apache/hadoop/ozone/OmUtils.java | 2 +
.../om/helpers/AssumeRoleResponseInfo.java | 4 +-
...AssumeRoleWithWebIdentityResponseInfo.java | 151 ++++++++
.../om/protocol/OzoneManagerProtocol.java | 21 ++
...ManagerProtocolClientSideTranslatorPB.java | 29 ++
.../helpers/TestAssumeRoleResponseInfo.java | 14 +-
.../src/main/proto/OmClientProtocol.proto | 65 ++++
.../apache/hadoop/ozone/audit/OMAction.java | 1 +
.../apache/hadoop/ozone/om/OzoneManager.java | 9 +
.../ozone/om/helpers/OMAuditLogger.java | 2 +
.../ratis/utils/OzoneManagerRatisUtils.java | 4 +
.../s3/security/S3AssumeRoleRequest.java | 27 +-
.../S3AssumeRoleWithWebIdentityRequest.java | 351 +++++++++++++++++
.../s3/security/S3RevokeSTSTokenRequest.java | 2 +-
.../S3AssumeRoleWithWebIdentityResponse.java | 43 +++
.../hadoop/ozone/security/S3SecurityUtil.java | 10 +-
.../ozone/security/STSSecurityUtil.java | 23 +-
.../ozone/security/STSTokenIdentifier.java | 206 +++++++++-
.../ozone/security/STSTokenSecretManager.java | 44 ++-
...estS3AssumeRoleWithWebIdentityRequest.java | 352 ++++++++++++++++++
.../om/response/TestCleanupTableInfo.java | 2 +
.../ozone/security/TestSTSSecurityUtil.java | 105 ++++++
.../security/TestSTSTokenIdentifier.java | 89 ++++-
...3AssumeRoleWithWebIdentityResponseXml.java | 159 ++++++++
.../hadoop/ozone/s3sts/S3STSEndpoint.java | 106 +++++-
.../hadoop/ozone/s3sts/TestS3STSEndpoint.java | 53 ++-
30 files changed, 1897 insertions(+), 62 deletions(-)
create mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java
create mode 100644 hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
create mode 100644 hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
index ffbbb6ae2d64..b61233376642 100644
--- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
+++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
@@ -136,11 +136,24 @@ AssumeRoleWithWebIdentity
-> Ranger or configured authorizer
```
-The first implementation slice intentionally stops before the OM runtime path:
-S3G can parse `Action=AssumeRoleWithWebIdentity`, validate the basic STS
-parameters, and return a clear unsupported error once the request reaches the
-placeholder. The next stage must add the OM request/protobuf path and move JWT
-validation, identity mapping, authorization, and credential issuance into OM.
+The OM runtime slice adds the WebIdentity request/protobuf path while preserving
+the existing `AssumeRole` flow. S3G parses and routes
+`Action=AssumeRoleWithWebIdentity`, but OM remains the authoritative validator
+and issuer.
+
+The raw `WebIdentityToken` is accepted only in the external OM RPC request. The
+OM leader validates it in `S3AssumeRoleWithWebIdentityRequest.preExecute()`,
+maps claims into a sanitized identity/session request, authorizes role
+assumption, generates temporary credential material using the existing STS
+helpers, and returns an `UpdateAssumeRoleWithWebIdentityRequest` for Ratis
+replication. The replicated request must not contain the raw JWT.
+
+`validateAndUpdateCache()` consumes only the sanitized update request. It must
+not call Keycloak, refresh JWKS, revalidate JWTs, or otherwise depend on current
+external IdP state during Ratis apply or replay. Credential expiration is
+computed by the leader before replication and stored as
+`credentialExpirationEpochSeconds` so replay does not depend on the apply-time
+clock.
Temporary credentials must not be stored only in S3G memory. S3G can have
multiple replicas and can restart. The issuing and validation authority must be
@@ -206,7 +219,8 @@ Flow:
edge: action, version, role ARN syntax, role session name, duration bounds,
and presence of `WebIdentityToken`.
5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`,
- `DurationSeconds`, `ProviderId`, and request context to OM.
+ `DurationSeconds`, `ProviderId`, and request context to OM in the external
+ RPC request only.
6. OM validates the JWT:
- token is a signed JWT;
- `alg=none` is rejected;
@@ -224,7 +238,19 @@ Flow:
- token expiration.
8. OM builds an assume-role authorization request and calls Ranger or the
configured Ozone authorizer before issuing any credential.
-9. If authorized, OM issues temporary S3 credentials:
+9. OM strips the raw JWT before Ratis replication and submits only sanitized
+ identity/session fields:
+ - role ARN and role session name;
+ - provider id;
+ - effective user;
+ - subject, issuer, audience;
+ - groups and roles;
+ - web identity token expiration;
+ - token fingerprint;
+ - requested/effective duration;
+ - credential expiration;
+ - derived session policy.
+10. If authorized, OM issues temporary S3 credentials:
- `Credentials.AccessKeyId`;
- `Credentials.SecretAccessKey`;
- `Credentials.SessionToken`;
@@ -233,8 +259,8 @@ Flow:
- `AssumedRoleUser`;
- `Audience`;
- `Provider`.
-10. The client uses those credentials with ordinary AWS SigV4 against S3G.
-11. S3G and OM validate the temporary credential, recover the assumed identity
+11. The client uses those credentials with ordinary AWS SigV4 against S3G.
+12. S3G and OM validate the temporary credential, recover the assumed identity
and session policy, and pass them to the authorizer for every S3 operation.
## Configuration
diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java
index f423979bde2d..a01839f12027 100644
--- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java
+++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/ObjectStore.java
@@ -37,6 +37,7 @@
import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
import org.apache.hadoop.ozone.om.helpers.DeleteTenantState;
import org.apache.hadoop.ozone.om.helpers.OmVolumeArgs;
@@ -767,6 +768,17 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName,
return proxy.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId);
}
+ /**
+ * Process the AssumeRoleWithWebIdentity operation.
+ */
+ public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException {
+ return proxy.assumeRoleWithWebIdentity(roleArn, roleSessionName,
+ durationSeconds, webIdentityToken, providerId, requestId);
+ }
+
/**
* Revokes an STS token.
* @param sessionToken The STS sessionToken
diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
index 1877d6bbed8a..07b890a6cc83 100644
--- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
+++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/protocol/ClientProtocol.java
@@ -47,6 +47,7 @@
import org.apache.hadoop.ozone.om.OMConfigKeys;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.DeleteTenantState;
import org.apache.hadoop.ozone.om.helpers.ErrorInfo;
import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo;
@@ -1387,6 +1388,23 @@ void deleteObjectTagging(String volumeName, String bucketName, String keyName)
AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName, int durationSeconds,
String awsIamSessionPolicy, String requestId) throws IOException;
+ /**
+ * Process the AssumeRoleWithWebIdentity operation.
+ *
+ * @param roleArn The ARN of the role to assume
+ * @param roleSessionName The session name for this operation
+ * @param durationSeconds The requested token validity in seconds
+ * @param webIdentityToken The OIDC web identity token
+ * @param providerId Optional provider id
+ * @param requestId The requestId from the STS endpoint
+ * @return response information containing temporary credentials
+ * @throws IOException if an error occurs during the operation
+ */
+ AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException;
+
/**
* Revokes an STS token.
* @param sessionToken The STS sessionToken
diff --git a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
index a9075320dbee..c8b3768e852b 100644
--- a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
+++ b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/rpc/RpcClient.java
@@ -130,6 +130,7 @@
import org.apache.hadoop.ozone.om.OmConfig;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo;
import org.apache.hadoop.ozone.om.helpers.BucketEncryptionKeyInfo;
import org.apache.hadoop.ozone.om.helpers.BucketLayout;
@@ -2787,6 +2788,16 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName,
return ozoneManagerClient.assumeRole(roleArn, roleSessionName, durationSeconds, awsIamSessionPolicy, requestId);
}
+ @Override
+ public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException {
+ return ozoneManagerClient.assumeRoleWithWebIdentity(roleArn,
+ roleSessionName, durationSeconds, webIdentityToken, providerId,
+ requestId);
+ }
+
@Override
public void revokeSTSToken(String sessionToken) throws IOException {
ozoneManagerClient.revokeSTSToken(sessionToken);
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java
index 74848c0ff6a1..8caadd428c99 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/OmUtils.java
@@ -280,6 +280,7 @@ public static boolean isReadOnly(OMRequest omRequest) {
case AbortMultiPartUpload:
case GetS3Secret:
case AssumeRole:
+ case AssumeRoleWithWebIdentity:
case GetDelegationToken:
case RenewDelegationToken:
case CancelDelegationToken:
@@ -440,6 +441,7 @@ public static boolean shouldSendToFollower(OMRequest omRequest) {
case PutObjectTagging:
case DeleteObjectTagging:
case AssumeRole:
+ case AssumeRoleWithWebIdentity:
case RevokeSTSToken:
case DeleteRevokedSTSTokens:
case ServiceList: // OM leader should have the most up-to-date OM service list info
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java
index 5f21abb3cbd6..232f2bda50c1 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleResponseInfo.java
@@ -80,8 +80,8 @@ public AssumeRoleResponse getProtobuf() {
@Override
public String toString() {
- return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey='" + secretAccessKey +
- "', sessionToken='" + sessionToken + "', expirationEpochSeconds=" + expirationEpochSeconds +
+ return "AssumeRoleResponseInfo{" + "accessKeyId='" + accessKeyId + "', secretAccessKey=" +
+ ", sessionToken=, expirationEpochSeconds=" + expirationEpochSeconds +
", assumedRoleId='" + assumedRoleId + "'}";
}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
new file mode 100644
index 000000000000..77654ac8a50d
--- /dev/null
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.helpers;
+
+import java.util.Objects;
+import net.jcip.annotations.Immutable;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse;
+
+/**
+ * Utility class to handle AssumeRoleWithWebIdentityResponse protobuf message.
+ */
+@Immutable
+public class AssumeRoleWithWebIdentityResponseInfo {
+
+ private final String accessKeyId;
+ private final String secretAccessKey;
+ private final String sessionToken;
+ private final long expirationEpochSeconds;
+ private final String assumedRoleId;
+ private final String subjectFromWebIdentityToken;
+ private final String audience;
+ private final String provider;
+
+ public AssumeRoleWithWebIdentityResponseInfo(String accessKeyId,
+ String secretAccessKey, String sessionToken, long expirationEpochSeconds,
+ String assumedRoleId, String subjectFromWebIdentityToken, String audience,
+ String provider) {
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.sessionToken = sessionToken;
+ this.expirationEpochSeconds = expirationEpochSeconds;
+ this.assumedRoleId = assumedRoleId;
+ this.subjectFromWebIdentityToken = subjectFromWebIdentityToken;
+ this.audience = audience;
+ this.provider = provider;
+ }
+
+ public String getAccessKeyId() {
+ return accessKeyId;
+ }
+
+ public String getSecretAccessKey() {
+ return secretAccessKey;
+ }
+
+ public String getSessionToken() {
+ return sessionToken;
+ }
+
+ public long getExpirationEpochSeconds() {
+ return expirationEpochSeconds;
+ }
+
+ public String getAssumedRoleId() {
+ return assumedRoleId;
+ }
+
+ public String getSubjectFromWebIdentityToken() {
+ return subjectFromWebIdentityToken;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getProvider() {
+ return provider;
+ }
+
+ public static AssumeRoleWithWebIdentityResponseInfo fromProtobuf(
+ AssumeRoleWithWebIdentityResponse response) {
+ return new AssumeRoleWithWebIdentityResponseInfo(
+ response.getAccessKeyId(), response.getSecretAccessKey(),
+ response.getSessionToken(), response.getExpirationEpochSeconds(),
+ response.getAssumedRoleId(),
+ response.getSubjectFromWebIdentityToken(), response.getAudience(),
+ response.hasProvider() ? response.getProvider() : null);
+ }
+
+ public AssumeRoleWithWebIdentityResponse getProtobuf() {
+ AssumeRoleWithWebIdentityResponse.Builder builder =
+ AssumeRoleWithWebIdentityResponse.newBuilder()
+ .setAccessKeyId(accessKeyId)
+ .setSecretAccessKey(secretAccessKey)
+ .setSessionToken(sessionToken)
+ .setExpirationEpochSeconds(expirationEpochSeconds)
+ .setAssumedRoleId(assumedRoleId)
+ .setSubjectFromWebIdentityToken(subjectFromWebIdentityToken)
+ .setAudience(audience);
+ if (provider != null && !provider.isEmpty()) {
+ builder.setProvider(provider);
+ }
+ return builder.build();
+ }
+
+ @Override
+ public String toString() {
+ return "AssumeRoleWithWebIdentityResponseInfo{"
+ + "accessKeyId='" + accessKeyId + '\''
+ + ", secretAccessKey="
+ + ", sessionToken="
+ + ", expirationEpochSeconds=" + expirationEpochSeconds
+ + ", assumedRoleId='" + assumedRoleId + '\''
+ + ", subjectFromWebIdentityToken='" + subjectFromWebIdentityToken + '\''
+ + ", audience='" + audience + '\''
+ + ", provider='" + provider + '\''
+ + '}';
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ } else if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ AssumeRoleWithWebIdentityResponseInfo that =
+ (AssumeRoleWithWebIdentityResponseInfo) o;
+ return expirationEpochSeconds == that.expirationEpochSeconds
+ && Objects.equals(accessKeyId, that.accessKeyId)
+ && Objects.equals(secretAccessKey, that.secretAccessKey)
+ && Objects.equals(sessionToken, that.sessionToken)
+ && Objects.equals(assumedRoleId, that.assumedRoleId)
+ && Objects.equals(subjectFromWebIdentityToken,
+ that.subjectFromWebIdentityToken)
+ && Objects.equals(audience, that.audience)
+ && Objects.equals(provider, that.provider);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(accessKeyId, secretAccessKey, sessionToken,
+ expirationEpochSeconds, assumedRoleId, subjectFromWebIdentityToken,
+ audience, provider);
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java
index 5e4dafb925bc..cd80780ac4f7 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocol/OzoneManagerProtocol.java
@@ -30,6 +30,7 @@
import org.apache.hadoop.ozone.om.OMConfigKeys;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.DBUpdates;
import org.apache.hadoop.ozone.om.helpers.DeleteTenantState;
import org.apache.hadoop.ozone.om.helpers.ErrorInfo;
@@ -1193,6 +1194,26 @@ default AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName
throw new UnsupportedOperationException("OzoneManager does not require this to be implemented");
}
+ /**
+ * Process the AssumeRoleWithWebIdentity operation.
+ *
+ * @param roleArn The ARN of the role to assume
+ * @param roleSessionName The session name for this operation
+ * @param durationSeconds The requested token validity in seconds
+ * @param webIdentityToken The OIDC web identity token
+ * @param providerId Optional provider id
+ * @param requestId The requestId from the STS endpoint
+ * @return response information containing temporary credentials
+ * @throws IOException if an error occurs during the operation
+ */
+ default AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException {
+ throw new UnsupportedOperationException(
+ "OzoneManager does not require this to be implemented");
+ }
+
/**
* Revokes an STS token.
* @param sessionToken The STS sessionToken
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
index b42b0e6ddf87..ecf4dbebd2a5 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/protocolPB/OzoneManagerProtocolClientSideTranslatorPB.java
@@ -55,6 +55,7 @@
import org.apache.hadoop.ozone.OzoneAcl;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.BasicOmKeyInfo;
import org.apache.hadoop.ozone.om.helpers.DBUpdates;
import org.apache.hadoop.ozone.om.helpers.DeleteTenantState;
@@ -2685,6 +2686,34 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName,
handleError(submitRequest(omRequest)).getAssumeRoleResponse());
}
+ @Override
+ public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException {
+ final OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest.Builder
+ request =
+ OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest
+ .newBuilder()
+ .setRoleArn(roleArn)
+ .setRoleSessionName(roleSessionName)
+ .setDurationSeconds(durationSeconds)
+ .setWebIdentityToken(webIdentityToken)
+ .setRequestId(requestId);
+ if (providerId != null && !providerId.isEmpty()) {
+ request.setProviderId(providerId);
+ }
+
+ final OMRequest omRequest =
+ createOMRequest(Type.AssumeRoleWithWebIdentity)
+ .setAssumeRoleWithWebIdentityRequest(request)
+ .build();
+
+ return AssumeRoleWithWebIdentityResponseInfo.fromProtobuf(
+ handleError(submitRequest(omRequest))
+ .getAssumeRoleWithWebIdentityResponse());
+ }
+
@Override
public void revokeSTSToken(String sessionToken) throws IOException {
final OzoneManagerProtocolProtos.RevokeSTSTokenRequest request =
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java
index 38c74dc1f261..8cb556f5bc76 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/om/helpers/TestAssumeRoleResponseInfo.java
@@ -18,8 +18,10 @@
package org.apache.hadoop.ozone.om.helpers;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleResponse;
import org.junit.jupiter.api.Test;
@@ -186,12 +188,14 @@ public void testToString() {
ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, EXPIRATION_EPOCH_SECONDS, ASSUMED_ROLE_ID);
final String toString = response.toString();
- final String expectedString = "AssumeRoleResponseInfo{" + "accessKeyId='" + ACCESS_KEY_ID +
- "', secretAccessKey='" + SECRET_ACCESS_KEY + "', sessionToken='" + SESSION_TOKEN +
- "', expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS + ", assumedRoleId='" + ASSUMED_ROLE_ID + "'}";
assertNotNull(toString);
- assertEquals(expectedString, toString);
+ assertTrue(toString.contains("accessKeyId='" + ACCESS_KEY_ID + "'"));
+ assertTrue(toString.contains("secretAccessKey="));
+ assertTrue(toString.contains("sessionToken="));
+ assertTrue(toString.contains("expirationEpochSeconds=" + EXPIRATION_EPOCH_SECONDS));
+ assertTrue(toString.contains("assumedRoleId='" + ASSUMED_ROLE_ID + "'"));
+ assertFalse(toString.contains(SECRET_ACCESS_KEY));
+ assertFalse(toString.contains(SESSION_TOKEN));
}
}
-
diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
index 9bb0d801ee7b..536c064eac59 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -159,6 +159,7 @@ enum Type {
AssumeRole = 143;
RevokeSTSToken = 144;
DeleteRevokedSTSTokens = 145;
+ AssumeRoleWithWebIdentity = 146;
}
enum SafeMode {
@@ -313,6 +314,8 @@ message OMRequest {
optional RevokeSTSTokenRequest revokeSTSTokenRequest = 145;
optional DeleteRevokedSTSTokensRequest deleteRevokedSTSTokensRequest = 146;
optional UpdateAssumeRoleRequest updateAssumeRoleRequest = 147;
+ optional AssumeRoleWithWebIdentityRequest assumeRoleWithWebIdentityRequest = 148;
+ optional UpdateAssumeRoleWithWebIdentityRequest updateAssumeRoleWithWebIdentityRequest = 149;
}
message OMResponse {
@@ -449,6 +452,7 @@ message OMResponse {
optional AssumeRoleResponse assumeRoleResponse = 143;
optional RevokeSTSTokenResponse revokeSTSTokenResponse = 144;
optional DeleteRevokedSTSTokensResponse deleteRevokedSTSTokensResponse = 145;
+ optional AssumeRoleWithWebIdentityResponse assumeRoleWithWebIdentityResponse = 146;
}
enum Status {
@@ -1537,6 +1541,20 @@ message OMTokenProto {
optional string originalAccessKeyId = 18;
optional string secretAccessKey = 19;
optional string sessionPolicy = 20;
+ enum STSAuthType {
+ ASSUME_ROLE = 1;
+ WEB_IDENTITY = 2;
+ };
+ optional STSAuthType stsAuthType = 21 [default = ASSUME_ROLE];
+ optional string effectiveUser = 22;
+ optional string issuer = 23;
+ optional string subject = 24;
+ optional string audience = 25;
+ repeated string groups = 26;
+ repeated string roles = 27;
+ optional string roleSessionName = 28;
+ optional string providerId = 29;
+ optional string tokenFingerprint = 30;
}
message SecretKeyProto {
@@ -2434,6 +2452,53 @@ message UpdateAssumeRoleRequest {
required string roleId = 8;
}
+message AssumeRoleWithWebIdentityRequest {
+ required string roleArn = 1;
+ required string roleSessionName = 2;
+ optional int32 durationSeconds = 3 [default = 3600];
+ required string webIdentityToken = 4;
+ optional string providerId = 5;
+ required string requestId = 6;
+}
+
+message AssumeRoleWithWebIdentityResponse {
+ required string accessKeyId = 1;
+ required string secretAccessKey = 2;
+ required string sessionToken = 3;
+ required uint64 expirationEpochSeconds = 4;
+ required string assumedRoleId = 5;
+ required string subjectFromWebIdentityToken = 6;
+ required string audience = 7;
+ optional string provider = 8;
+}
+
+/**
+ This request is created by the OM leader after validating the incoming
+ WebIdentityToken. It is the only WebIdentity request form that may be
+ replicated through Ratis; it must never contain the raw JWT.
+*/
+message UpdateAssumeRoleWithWebIdentityRequest {
+ required string roleArn = 1;
+ required string roleSessionName = 2;
+ required int32 durationSeconds = 3;
+ optional string providerId = 4;
+ required string requestId = 5;
+ required string tempAccessKeyId = 6;
+ required string secretAccessKey = 7;
+ required string roleId = 8;
+ required string effectiveUser = 9;
+ required string subject = 10;
+ required string issuer = 11;
+ required string audience = 12;
+ repeated string groups = 13;
+ repeated string roles = 14;
+ required uint64 webIdentityTokenExpiresAt = 15;
+ required uint64 authenticatedAt = 16;
+ required string tokenFingerprint = 17;
+ required string sessionPolicy = 18;
+ required uint64 credentialExpirationEpochSeconds = 19;
+}
+
message RevokeSTSTokenRequest {
required string sessionToken = 1;
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java
index f07c4494619a..ccb318fb6b58 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/audit/OMAction.java
@@ -85,6 +85,7 @@ public enum OMAction implements AuditAction {
// STS Actions
S3_ASSUME_ROLE,
+ S3_ASSUME_ROLE_WITH_WEB_IDENTITY,
REVOKE_STS_TOKEN,
CREATE_TENANT,
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
index d1f27c6132ce..d2334199d7ab 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
@@ -899,6 +899,15 @@ public static String getS3AuthEffectiveAccessId() throws OMException {
throw new OMException(
"OMClientRequest has session token but no token identifier in OzoneManager", INVALID_REQUEST);
}
+ if (stsTokenIdentifier.isWebIdentity()) {
+ final String effectiveUser = stsTokenIdentifier.getEffectiveUser();
+ if (effectiveUser != null && !effectiveUser.isEmpty()) {
+ return effectiveUser;
+ }
+ throw new OMException(
+ "Invalid STS Token format - could not find effectiveUser",
+ INVALID_REQUEST);
+ }
final String originalAccessKeyId = stsTokenIdentifier.getOriginalAccessKeyId();
if (originalAccessKeyId != null && !originalAccessKeyId.isEmpty()) {
return originalAccessKeyId;
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java
index 2c17d2335475..a381a5b79fe6 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/helpers/OMAuditLogger.java
@@ -96,6 +96,8 @@ private static void init() {
CMD_AUDIT_ACTION_MAP.put(Type.PutObjectTagging, OMAction.PUT_OBJECT_TAGGING);
CMD_AUDIT_ACTION_MAP.put(Type.DeleteObjectTagging, OMAction.DELETE_OBJECT_TAGGING);
CMD_AUDIT_ACTION_MAP.put(Type.AssumeRole, OMAction.S3_ASSUME_ROLE);
+ CMD_AUDIT_ACTION_MAP.put(Type.AssumeRoleWithWebIdentity,
+ OMAction.S3_ASSUME_ROLE_WITH_WEB_IDENTITY);
CMD_AUDIT_ACTION_MAP.put(Type.RevokeSTSToken, OMAction.REVOKE_STS_TOKEN);
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java
index d405654585c8..685dc51bc032 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/ratis/utils/OzoneManagerRatisUtils.java
@@ -68,6 +68,7 @@
import org.apache.hadoop.ozone.om.request.s3.multipart.S3ExpiredMultipartUploadsAbortRequest;
import org.apache.hadoop.ozone.om.request.s3.security.OMSetSecretRequest;
import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest;
+import org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleWithWebIdentityRequest;
import org.apache.hadoop.ozone.om.request.s3.security.S3DeleteRevokedSTSTokensRequest;
import org.apache.hadoop.ozone.om.request.s3.security.S3GetSecretRequest;
import org.apache.hadoop.ozone.om.request.s3.security.S3RevokeSTSTokenRequest;
@@ -203,6 +204,9 @@ public static OMClientRequest createClientRequest(OMRequest omRequest,
case AssumeRole:
ozoneManager.checkS3STSEnabled();
return new S3AssumeRoleRequest(omRequest, CLOCK);
+ case AssumeRoleWithWebIdentity:
+ ozoneManager.checkS3STSEnabled();
+ return new S3AssumeRoleWithWebIdentityRequest(omRequest, CLOCK);
case RevokeSTSToken:
return new S3RevokeSTSTokenRequest(omRequest);
case DeleteRevokedSTSTokens:
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java
index a87f2de54dde..ecde295dda28 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleRequest.java
@@ -101,12 +101,9 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
// HA mode therefore will have identical audit logs with the same tempAccessKeyId.
// Generate temporary AWS credentials using cryptographically strong SecureRandom
- final String tempAccessKeyId = STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars(
- CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ACCESS_KEY_ID_LENGTH);
- final String secretAccessKey = generateSecureRandomStringUsingChars(
- CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH, STS_SECRET_ACCESS_KEY_LENGTH);
- final String roleId = ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars(
- CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH, STS_ROLE_ID_LENGTH);
+ final String tempAccessKeyId = generateTempAccessKeyId();
+ final String secretAccessKey = generateSecretAccessKey();
+ final String roleId = generateRoleId();
// Build UpdateAssumeRoleRequest with leader-generated credentials
final UpdateAssumeRoleRequest.Builder updateAssumeRoleRequestBuilder =
@@ -302,4 +299,22 @@ static String generateSecureRandomStringUsingChars(String chars, int charsLength
}
return sb.toString();
}
+
+ static String generateTempAccessKeyId() {
+ return STS_TOKEN_PREFIX + generateSecureRandomStringUsingChars(
+ CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH,
+ STS_ACCESS_KEY_ID_LENGTH);
+ }
+
+ static String generateSecretAccessKey() {
+ return generateSecureRandomStringUsingChars(
+ CHARS_FOR_SECRET_ACCESS_KEYS, CHARS_FOR_SECRET_ACCESS_KEYS_LENGTH,
+ STS_SECRET_ACCESS_KEY_LENGTH);
+ }
+
+ static String generateRoleId() {
+ return ASSUME_ROLE_ID_PREFIX + generateSecureRandomStringUsingChars(
+ CHARS_FOR_ACCESS_KEY_IDS, CHARS_FOR_ACCESS_KEY_IDS_LENGTH,
+ STS_ROLE_ID_LENGTH);
+ }
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
new file mode 100644
index 000000000000..282aec1d1cd4
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
@@ -0,0 +1,351 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.request.s3.security;
+
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_DENIED;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import org.apache.hadoop.ipc_.ProtobufRpcEngine;
+import org.apache.hadoop.ozone.audit.AuditLogger;
+import org.apache.hadoop.ozone.audit.OMAction;
+import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
+import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator;
+import org.apache.hadoop.ozone.om.helpers.S3STSUtils;
+import org.apache.hadoop.ozone.om.request.OMClientRequest;
+import org.apache.hadoop.ozone.om.request.util.OmResponseUtil;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleWithWebIdentityResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleWithWebIdentityRequest;
+import org.apache.hadoop.ozone.security.oidc.AuthCredentials;
+import org.apache.hadoop.ozone.security.oidc.CachingJwksProvider;
+import org.apache.hadoop.ozone.security.oidc.OidcAuthenticationException;
+import org.apache.hadoop.ozone.security.oidc.OidcConfig;
+import org.apache.hadoop.ozone.security.oidc.OidcJwtIdentityProvider;
+import org.apache.hadoop.ozone.security.oidc.OzoneIdentity;
+import org.apache.hadoop.ozone.security.oidc.OzoneIdentityProvider;
+import org.apache.hadoop.ozone.security.oidc.UrlJwksFetcher;
+
+/**
+ * Handles STS AssumeRoleWithWebIdentity requests.
+ *
+ * Raw WebIdentityToken is accepted only in the external OM RPC request. The
+ * leader validates it in {@link #preExecute(OzoneManager)} and returns a
+ * sanitized OMRequest for Ratis replication.
+ */
+public class S3AssumeRoleWithWebIdentityRequest extends OMClientRequest {
+
+ private final Clock clock;
+ private final OzoneIdentityProvider testIdentityProvider;
+
+ public S3AssumeRoleWithWebIdentityRequest(OMRequest omRequest, Clock clock) {
+ this(omRequest, clock, null);
+ }
+
+ @VisibleForTesting
+ S3AssumeRoleWithWebIdentityRequest(OMRequest omRequest, Clock clock,
+ OzoneIdentityProvider identityProvider) {
+ super(omRequest);
+ this.clock = clock;
+ this.testIdentityProvider = identityProvider;
+ }
+
+ @Override
+ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
+ final AssumeRoleWithWebIdentityRequest request =
+ getOmRequest().getAssumeRoleWithWebIdentityRequest();
+ final OidcConfig oidcConfig;
+ try {
+ oidcConfig = OidcConfig.from(ozoneManager.getConfiguration());
+ } catch (IllegalArgumentException e) {
+ throw new OMException("Invalid STS WebIdentity OIDC configuration",
+ e, INVALID_REQUEST);
+ }
+ if (!oidcConfig.isEnabled()) {
+ throw new OMException("STS WebIdentity is not enabled. Please set "
+ + "ozone.sts.web.identity.enabled to true and restart all OMs.",
+ FEATURE_NOT_ENABLED);
+ }
+
+ final int requestedDuration =
+ S3STSUtils.validateDuration(request.getDurationSeconds());
+ S3STSUtils.validateRoleSessionName(request.getRoleSessionName());
+ AwsRoleArnValidator.validateAndExtractRoleNameFromArn(request.getRoleArn());
+
+ final OzoneIdentity identity = authenticate(oidcConfig,
+ request.getWebIdentityToken());
+ final Instant issuedAt = clock.instant();
+ final int effectiveDuration =
+ clampDurationToTokenLifetime(requestedDuration, identity, issuedAt);
+ final long credentialExpirationEpochSeconds =
+ issuedAt.plusSeconds(effectiveDuration).getEpochSecond();
+ final String tokenFingerprint =
+ sha256Hex(request.getWebIdentityToken());
+
+ final String sessionPolicy = generateSessionPolicy(ozoneManager,
+ identity, request, oidcConfig.getAudience());
+ if (Strings.isNullOrEmpty(sessionPolicy)) {
+ throw new OMException("AssumeRoleWithWebIdentity was denied because "
+ + "the authorizer returned no session policy", ACCESS_DENIED);
+ }
+
+ final UpdateAssumeRoleWithWebIdentityRequest updateRequest =
+ UpdateAssumeRoleWithWebIdentityRequest.newBuilder()
+ .setRoleArn(request.getRoleArn())
+ .setRoleSessionName(request.getRoleSessionName())
+ .setDurationSeconds(effectiveDuration)
+ .setProviderId(request.hasProviderId() ? request.getProviderId() : "")
+ .setRequestId(request.getRequestId())
+ .setTempAccessKeyId(S3AssumeRoleRequest.generateTempAccessKeyId())
+ .setSecretAccessKey(S3AssumeRoleRequest.generateSecretAccessKey())
+ .setRoleId(S3AssumeRoleRequest.generateRoleId())
+ .setEffectiveUser(identity.getUsername())
+ .setSubject(identity.getSubject())
+ .setIssuer(identity.getIssuer())
+ .setAudience(oidcConfig.getAudience())
+ .addAllGroups(identity.getGroups())
+ .addAllRoles(identity.getRoles())
+ .setWebIdentityTokenExpiresAt(identity.getExpiresAt().toEpochMilli())
+ .setAuthenticatedAt(identity.getAuthenticatedAt().toEpochMilli())
+ .setTokenFingerprint(tokenFingerprint)
+ .setSessionPolicy(sessionPolicy)
+ .setCredentialExpirationEpochSeconds(
+ credentialExpirationEpochSeconds)
+ .build();
+
+ final OMRequest.Builder omRequest = OMRequest.newBuilder()
+ .setUserInfo(getUserInfo().toBuilder()
+ .setUserName(identity.getUsername()))
+ .setCmdType(getOmRequest().getCmdType())
+ .setClientId(getOmRequest().getClientId())
+ .setUpdateAssumeRoleWithWebIdentityRequest(updateRequest);
+
+ if (getOmRequest().hasTraceID()) {
+ omRequest.setTraceID(getOmRequest().getTraceID());
+ }
+
+ return omRequest.build();
+ }
+
+ @Override
+ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager,
+ ExecutionContext context) {
+ final OMRequest omRequest = getOmRequest();
+ final UpdateAssumeRoleWithWebIdentityRequest updateRequest =
+ omRequest.getUpdateAssumeRoleWithWebIdentityRequest();
+
+ final Map auditMap = new HashMap<>();
+ final AuditLogger auditLogger = ozoneManager.getAuditLogger();
+ addAuditParams(auditMap, updateRequest);
+
+ Exception exception = null;
+ OMClientResponse omClientResponse;
+ try {
+ S3STSUtils.validateDuration(updateRequest.getDurationSeconds());
+ S3STSUtils.validateRoleSessionName(updateRequest.getRoleSessionName());
+ AwsRoleArnValidator.validateAndExtractRoleNameFromArn(
+ updateRequest.getRoleArn());
+ if (Strings.isNullOrEmpty(updateRequest.getSessionPolicy())) {
+ throw new OMException("Missing WebIdentity session policy",
+ ACCESS_DENIED);
+ }
+
+ final String sessionToken = ozoneManager.getSTSTokenSecretManager()
+ .createWebIdentitySTSTokenString(
+ updateRequest.getTempAccessKeyId(),
+ updateRequest.getRoleArn(),
+ Instant.ofEpochSecond(
+ updateRequest.getCredentialExpirationEpochSeconds()),
+ updateRequest.getSecretAccessKey(),
+ updateRequest.getSessionPolicy(),
+ updateRequest.getEffectiveUser(),
+ updateRequest.getIssuer(),
+ updateRequest.getSubject(),
+ updateRequest.getAudience(),
+ new LinkedHashSet<>(updateRequest.getGroupsList()),
+ new LinkedHashSet<>(updateRequest.getRolesList()),
+ updateRequest.getRoleSessionName(),
+ updateRequest.getProviderId(),
+ updateRequest.getTokenFingerprint());
+
+ final String assumedRoleId =
+ updateRequest.getRoleId() + ":" + updateRequest.getRoleSessionName();
+ final long expirationEpochSeconds =
+ updateRequest.getCredentialExpirationEpochSeconds();
+
+ final AssumeRoleWithWebIdentityResponse.Builder responseBuilder =
+ AssumeRoleWithWebIdentityResponse.newBuilder()
+ .setAccessKeyId(updateRequest.getTempAccessKeyId())
+ .setSecretAccessKey(updateRequest.getSecretAccessKey())
+ .setSessionToken(sessionToken)
+ .setExpirationEpochSeconds(expirationEpochSeconds)
+ .setAssumedRoleId(assumedRoleId)
+ .setSubjectFromWebIdentityToken(updateRequest.getSubject())
+ .setAudience(updateRequest.getAudience());
+ if (!Strings.isNullOrEmpty(updateRequest.getProviderId())) {
+ responseBuilder.setProvider(updateRequest.getProviderId());
+ }
+
+ omClientResponse = new S3AssumeRoleWithWebIdentityResponse(
+ OmResponseUtil.getOMResponseBuilder(omRequest)
+ .setAssumeRoleWithWebIdentityResponse(responseBuilder.build())
+ .build());
+ } catch (OMException e) {
+ exception = e;
+ omClientResponse = new S3AssumeRoleWithWebIdentityResponse(
+ createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
+ e));
+ } catch (IOException e) {
+ final OMException omException = new OMException(
+ "Failed to generate STS WebIdentity token", e, INTERNAL_ERROR);
+ exception = omException;
+ omClientResponse = new S3AssumeRoleWithWebIdentityResponse(
+ createErrorOMResponse(OmResponseUtil.getOMResponseBuilder(omRequest),
+ omException));
+ }
+
+ markForAudit(auditLogger, buildAuditMessage(
+ OMAction.S3_ASSUME_ROLE_WITH_WEB_IDENTITY, auditMap, exception,
+ omRequest.getUserInfo()));
+ return omClientResponse;
+ }
+
+ private OzoneIdentity authenticate(OidcConfig oidcConfig,
+ String webIdentityToken) throws OMException {
+ try {
+ return identityProvider(oidcConfig)
+ .authenticate(AuthCredentials.bearerToken(webIdentityToken));
+ } catch (OidcAuthenticationException | IllegalArgumentException e) {
+ throw new OMException("Invalid WebIdentityToken", e, INVALID_TOKEN);
+ }
+ }
+
+ private OzoneIdentityProvider identityProvider(OidcConfig oidcConfig)
+ throws OMException {
+ if (testIdentityProvider != null) {
+ return testIdentityProvider;
+ }
+ if (Strings.isNullOrEmpty(oidcConfig.getJwksUri())) {
+ throw new OMException(OZONE_STS_WEB_IDENTITY_JWKS_URI
+ + " must be configured when STS WebIdentity is enabled",
+ INVALID_REQUEST);
+ }
+ try {
+ URL jwksUrl = new URL(oidcConfig.getJwksUri());
+ return new OidcJwtIdentityProvider(oidcConfig,
+ new CachingJwksProvider(new UrlJwksFetcher(jwksUrl),
+ oidcConfig.getJwksRefreshInterval()));
+ } catch (MalformedURLException e) {
+ throw new OMException(OZONE_STS_WEB_IDENTITY_JWKS_URI
+ + " is not a valid URL", e, INVALID_REQUEST);
+ }
+ }
+
+ private int clampDurationToTokenLifetime(int requestedDuration,
+ OzoneIdentity identity, Instant now) throws OMException {
+ long jwtRemainingSeconds =
+ Duration.between(now, identity.getExpiresAt()).getSeconds();
+ if (jwtRemainingSeconds < S3STSUtils.MIN_DURATION_SECONDS) {
+ throw new OMException("WebIdentityToken expires before the minimum STS "
+ + "credential duration can be issued", TOKEN_EXPIRED);
+ }
+ return (int) Math.min(requestedDuration, Math.min(jwtRemainingSeconds,
+ S3STSUtils.MAX_DURATION_SECONDS));
+ }
+
+ private String generateSessionPolicy(OzoneManager ozoneManager,
+ OzoneIdentity identity, AssumeRoleWithWebIdentityRequest request,
+ String audience) throws OMException {
+ InetAddress remoteIp = ProtobufRpcEngine.Server.getRemoteIp();
+ if (remoteIp == null) {
+ remoteIp = ozoneManager.getOmRpcServerAddr().getAddress();
+ }
+ final String hostName = remoteIp != null ? remoteIp.getHostName()
+ : ozoneManager.getOmRpcServerAddr().getHostName();
+
+ return ozoneManager.getAccessAuthorizer()
+ .generateAssumeRoleWithWebIdentitySessionPolicy(
+ new org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest(
+ hostName,
+ remoteIp,
+ identity.getUsername(),
+ identity.getGroups(),
+ identity.getRoles(),
+ request.getRoleArn(),
+ request.getRoleSessionName(),
+ identity.getIssuer(),
+ identity.getSubject(),
+ audience,
+ request.hasProviderId() ? request.getProviderId() : null,
+ null));
+ }
+
+ private static void addAuditParams(Map auditMap,
+ UpdateAssumeRoleWithWebIdentityRequest request) {
+ auditMap.put("action", "AssumeRoleWithWebIdentity");
+ auditMap.put("roleArn", request.getRoleArn());
+ auditMap.put("roleSessionName", request.getRoleSessionName());
+ auditMap.put("duration", String.valueOf(request.getDurationSeconds()));
+ auditMap.put("providerId", request.getProviderId());
+ auditMap.put("effectiveUser", request.getEffectiveUser());
+ auditMap.put("issuer", request.getIssuer());
+ auditMap.put("subject", request.getSubject());
+ auditMap.put("audience", request.getAudience());
+ auditMap.put("tokenFingerprint", request.getTokenFingerprint());
+ auditMap.put("requestId", request.getRequestId());
+ }
+
+ private static String sha256Hex(String value) throws OMException {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
+ StringBuilder builder = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ builder.append(String.format("%02x", b));
+ }
+ return builder.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new OMException("SHA-256 digest is unavailable", e,
+ INTERNAL_ERROR);
+ }
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java
index 94c2f8d50831..7f7d8c06b515 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3RevokeSTSTokenRequest.java
@@ -112,7 +112,7 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager, Execut
ozoneManager.getMetadataManager().getS3RevokedStsTokenTable().addCacheEntry(
new CacheKey<>(sessionToken), CacheValue.get(context.getIndex(), CLOCK.millis()));
- LOG.info("Marked STS session token '{}' as revoked.", sessionToken);
+ LOG.info("Marked STS session token as revoked.");
return omClientResponse;
}
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java
new file mode 100644
index 000000000000..92d6d510a0fe
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/response/s3/security/S3AssumeRoleWithWebIdentityResponse.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.response.s3.security;
+
+import java.io.IOException;
+import org.apache.hadoop.hdds.utils.db.BatchOperation;
+import org.apache.hadoop.ozone.om.OMMetadataManager;
+import org.apache.hadoop.ozone.om.response.CleanupTableInfo;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+
+/**
+ * Response for AssumeRoleWithWebIdentity request.
+ * This is a stateless operation that doesn't modify any database tables.
+ */
+@CleanupTableInfo()
+public class S3AssumeRoleWithWebIdentityResponse extends OMClientResponse {
+
+ public S3AssumeRoleWithWebIdentityResponse(OMResponse omResponse) {
+ super(omResponse);
+ }
+
+ @Override
+ public void addToDBBatch(OMMetadataManager omMetadataManager,
+ BatchOperation batchOperation) throws IOException {
+ // No database changes for assume role with web identity.
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java
index 923bf9d9a78f..9846074bc53a 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/S3SecurityUtil.java
@@ -75,12 +75,14 @@ public static void validateS3Credential(OMRequest omRequest,
// Ensure the token is not revoked
if (isRevokedStsToken(token, ozoneManager)) {
- LOG.info("Session token has been revoked: {}, {}", stsTokenIdentifier.getTempAccessKeyId(), token);
+ LOG.info("Session token has been revoked for tempAccessKeyId: {}",
+ stsTokenIdentifier.getTempAccessKeyId());
throw new OMException("STS token has been revoked", REVOKED_TOKEN);
}
// Ensure the principal that created the STS token (originalAccessKeyId) has not been revoked
- if (isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) {
+ if (!stsTokenIdentifier.isWebIdentity() &&
+ isOriginalAccessKeyIdRevoked(stsTokenIdentifier, ozoneManager)) {
LOG.info("OriginalAccessKeyId for session token has been revoked: {}, {}",
stsTokenIdentifier.getOriginalAccessKeyId(), stsTokenIdentifier.getTempAccessKeyId());
throw new OMException("STS token no longer valid: OriginalAccessKeyId principal revoked", REVOKED_TOKEN);
@@ -151,8 +153,8 @@ private static void validateSTSTokenAwsSignature(STSTokenIdentifier stsTokenIden
s3Authentication.getStringToSign(), s3Authentication.getSignature(), secretAccessKey)) {
return;
}
- throw new OMException(
- "STS token validation failed for token: " + omRequest.getS3Authentication().getSessionToken(), INVALID_TOKEN);
+ throw new OMException("STS token signature validation failed",
+ INVALID_TOKEN);
}
/**
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
index c414708cebee..24ec8fa17b84 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
@@ -172,12 +172,29 @@ static void ensureEssentialFieldsArePresentInToken(STSTokenIdentifier stsTokenId
if (StringUtils.isEmpty(stsTokenIdentifier.getRoleArn())) {
throw new SecretManager.InvalidToken("Invalid STS token - roleArn is null/empty");
}
- if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) {
- throw new SecretManager.InvalidToken("Invalid STS token - originalAccessKeyId is null/empty");
+ if (stsTokenIdentifier.isWebIdentity()) {
+ if (StringUtils.isEmpty(stsTokenIdentifier.getEffectiveUser())) {
+ throw new SecretManager.InvalidToken(
+ "Invalid STS token - effectiveUser is null/empty");
+ }
+ if (StringUtils.isEmpty(stsTokenIdentifier.getIssuer())) {
+ throw new SecretManager.InvalidToken(
+ "Invalid STS token - issuer is null/empty");
+ }
+ if (StringUtils.isEmpty(stsTokenIdentifier.getSubject())) {
+ throw new SecretManager.InvalidToken(
+ "Invalid STS token - subject is null/empty");
+ }
+ if (StringUtils.isEmpty(stsTokenIdentifier.getAudience())) {
+ throw new SecretManager.InvalidToken(
+ "Invalid STS token - audience is null/empty");
+ }
+ } else if (StringUtils.isEmpty(stsTokenIdentifier.getOriginalAccessKeyId())) {
+ throw new SecretManager.InvalidToken(
+ "Invalid STS token - originalAccessKeyId is null/empty");
}
if (StringUtils.isEmpty(stsTokenIdentifier.getSecretAccessKey())) {
throw new SecretManager.InvalidToken("Invalid STS token - secretAccessKey is null/empty");
}
}
}
-
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
index 1ba4b7186f2c..48250da74f75 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
@@ -25,7 +25,10 @@
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
+import java.util.Collections;
+import java.util.LinkedHashSet;
import java.util.Objects;
+import java.util.Set;
import java.util.UUID;
import org.apache.hadoop.hdds.annotation.InterfaceAudience;
import org.apache.hadoop.hdds.annotation.InterfaceStability;
@@ -41,11 +44,29 @@
public class STSTokenIdentifier extends ShortLivedTokenIdentifier {
public static final Text KIND_NAME = new Text("STSToken");
+ /**
+ * Identifies the authentication path that produced an STS token.
+ */
+ public enum AuthType {
+ ASSUME_ROLE,
+ WEB_IDENTITY
+ }
+
// STS-specific fields
+ private AuthType authType = AuthType.ASSUME_ROLE;
private String roleArn;
private String originalAccessKeyId;
private String secretAccessKey;
private String sessionPolicy;
+ private String effectiveUser;
+ private String issuer;
+ private String subject;
+ private String audience;
+ private Set groups = Collections.emptySet();
+ private Set roles = Collections.emptySet();
+ private String roleSessionName;
+ private String providerId;
+ private String tokenFingerprint;
// Encryption key derived from ManagedSecretKey for this token
private transient byte[] encryptionKey;
@@ -75,6 +96,7 @@ public STSTokenIdentifier() {
public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, String roleArn, Instant expiry,
String secretAccessKey, String sessionPolicy, byte[] encryptionKey) {
super(tempAccessKeyId, expiry);
+ this.authType = AuthType.ASSUME_ROLE;
this.originalAccessKeyId = originalAccessKeyId;
this.roleArn = roleArn;
this.secretAccessKey = secretAccessKey;
@@ -82,6 +104,31 @@ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, St
this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null;
}
+ /**
+ * Create a new WebIdentity-backed STS token identifier.
+ */
+ public STSTokenIdentifier(String tempAccessKeyId, String roleArn,
+ Instant expiry, String secretAccessKey, String sessionPolicy,
+ String effectiveUser, String issuer, String subject, String audience,
+ Set groups, Set roles, String roleSessionName,
+ String providerId, String tokenFingerprint, byte[] encryptionKey) {
+ super(tempAccessKeyId, expiry);
+ this.authType = AuthType.WEB_IDENTITY;
+ this.roleArn = roleArn;
+ this.secretAccessKey = secretAccessKey;
+ this.sessionPolicy = sessionPolicy;
+ this.effectiveUser = effectiveUser;
+ this.issuer = issuer;
+ this.subject = subject;
+ this.audience = audience;
+ this.groups = immutableSet(groups);
+ this.roles = immutableSet(roles);
+ this.roleSessionName = roleSessionName;
+ this.providerId = providerId;
+ this.tokenFingerprint = tokenFingerprint;
+ this.encryptionKey = encryptionKey != null ? encryptionKey.clone() : null;
+ }
+
@Override
public Text getKind() {
return KIND_NAME;
@@ -126,10 +173,25 @@ public OMTokenProto toProtoBuf() {
.setMaxDate(getExpiry().toEpochMilli())
.setOwner(getOwnerId() != null ? getOwnerId() : "")
.setAccessKeyId(getOwnerId() != null ? getOwnerId() : "")
- .setOriginalAccessKeyId(originalAccessKeyId != null ? originalAccessKeyId : "")
- .setRoleArn(roleArn != null ? roleArn : "")
- .setSecretAccessKey(secretAccessKey != null ? encryptSensitiveField(secretAccessKey) : "")
- .setSessionPolicy(sessionPolicy != null ? sessionPolicy : "");
+ .setStsAuthType(toProtoAuthType(authType));
+
+ setIfNotEmpty(builder::setOriginalAccessKeyId, originalAccessKeyId);
+ setIfNotEmpty(builder::setRoleArn, roleArn);
+ if (secretAccessKey != null) {
+ builder.setSecretAccessKey(encryptSensitiveField(secretAccessKey));
+ }
+ if (sessionPolicy != null) {
+ builder.setSessionPolicy(sessionPolicy);
+ }
+ setIfNotEmpty(builder::setEffectiveUser, effectiveUser);
+ setIfNotEmpty(builder::setIssuer, issuer);
+ setIfNotEmpty(builder::setSubject, subject);
+ setIfNotEmpty(builder::setAudience, audience);
+ builder.addAllGroups(groups);
+ builder.addAllRoles(roles);
+ setIfNotEmpty(builder::setRoleSessionName, roleSessionName);
+ setIfNotEmpty(builder::setProviderId, providerId);
+ setIfNotEmpty(builder::setTokenFingerprint, tokenFingerprint);
return builder.build();
}
@@ -145,12 +207,13 @@ public void fromProtoBuf(OMTokenProto token) throws IOException {
setOwnerId(token.getOwner());
setExpiry(Instant.ofEpochMilli(token.getMaxDate()));
+ this.authType = fromProtoAuthType(token.getStsAuthType());
if (token.hasOriginalAccessKeyId()) {
- this.originalAccessKeyId = token.getOriginalAccessKeyId();
+ this.originalAccessKeyId = emptyToNull(token.getOriginalAccessKeyId());
}
if (token.hasRoleArn()) {
- this.roleArn = token.getRoleArn();
+ this.roleArn = emptyToNull(token.getRoleArn());
}
if (token.hasSecretKeyId()) {
try {
@@ -168,6 +231,31 @@ public void fromProtoBuf(OMTokenProto token) throws IOException {
if (token.hasSessionPolicy()) {
this.sessionPolicy = token.getSessionPolicy();
+ } else {
+ this.sessionPolicy = "";
+ }
+ if (token.hasEffectiveUser()) {
+ this.effectiveUser = emptyToNull(token.getEffectiveUser());
+ }
+ if (token.hasIssuer()) {
+ this.issuer = emptyToNull(token.getIssuer());
+ }
+ if (token.hasSubject()) {
+ this.subject = emptyToNull(token.getSubject());
+ }
+ if (token.hasAudience()) {
+ this.audience = emptyToNull(token.getAudience());
+ }
+ this.groups = immutableSet(token.getGroupsList());
+ this.roles = immutableSet(token.getRolesList());
+ if (token.hasRoleSessionName()) {
+ this.roleSessionName = emptyToNull(token.getRoleSessionName());
+ }
+ if (token.hasProviderId()) {
+ this.providerId = emptyToNull(token.getProviderId());
+ }
+ if (token.hasTokenFingerprint()) {
+ this.tokenFingerprint = emptyToNull(token.getTokenFingerprint());
}
}
@@ -222,6 +310,14 @@ public String getRoleArn() {
return roleArn;
}
+ public AuthType getAuthType() {
+ return authType;
+ }
+
+ public boolean isWebIdentity() {
+ return authType == AuthType.WEB_IDENTITY;
+ }
+
public String getSecretAccessKey() {
return secretAccessKey;
}
@@ -230,6 +326,42 @@ public String getOriginalAccessKeyId() {
return originalAccessKeyId;
}
+ public String getEffectiveUser() {
+ return effectiveUser;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public String getSubject() {
+ return subject;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public Set getGroups() {
+ return groups;
+ }
+
+ public Set getRoles() {
+ return roles;
+ }
+
+ public String getRoleSessionName() {
+ return roleSessionName;
+ }
+
+ public String getProviderId() {
+ return providerId;
+ }
+
+ public String getTokenFingerprint() {
+ return tokenFingerprint;
+ }
+
/**
* Get the temporary access key ID (same as owner).
*/
@@ -263,23 +395,79 @@ public boolean equals(Object o) {
}
final STSTokenIdentifier that = (STSTokenIdentifier) o;
- return Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) &&
+ return authType == that.authType &&
+ Objects.equals(roleArn, that.roleArn) && Objects.equals(secretAccessKey, that.secretAccessKey) &&
Objects.equals(originalAccessKeyId, that.originalAccessKeyId) &&
- Objects.equals(sessionPolicy, that.sessionPolicy);
+ Objects.equals(sessionPolicy, that.sessionPolicy) &&
+ Objects.equals(effectiveUser, that.effectiveUser) &&
+ Objects.equals(issuer, that.issuer) &&
+ Objects.equals(subject, that.subject) &&
+ Objects.equals(audience, that.audience) &&
+ Objects.equals(groups, that.groups) &&
+ Objects.equals(roles, that.roles) &&
+ Objects.equals(roleSessionName, that.roleSessionName) &&
+ Objects.equals(providerId, that.providerId) &&
+ Objects.equals(tokenFingerprint, that.tokenFingerprint);
}
@Override
public int hashCode() {
return Objects.hash(
- super.hashCode(), roleArn, secretAccessKey, originalAccessKeyId, sessionPolicy);
+ super.hashCode(), authType, roleArn, secretAccessKey, originalAccessKeyId,
+ sessionPolicy, effectiveUser, issuer, subject, audience, groups, roles,
+ roleSessionName, providerId, tokenFingerprint);
}
@Override
public String toString() {
// Intentionally left off secretAccessKey
return "STSTokenIdentifier{" + "tempAccessKeyId='" + getOwnerId() + "'" +
+ ", authType=" + authType +
", originalAccessKeyId='" + originalAccessKeyId + "', roleArn='" + roleArn + "'" +
+ ", effectiveUser='" + effectiveUser + "', issuer='" + issuer + "'" +
+ ", subject='" + subject + "', audience='" + audience + "'" +
+ ", groups=" + groups + ", roles=" + roles +
+ ", roleSessionName='" + roleSessionName + "', providerId='" + providerId + "'" +
+ ", tokenFingerprint='" + tokenFingerprint + "'" +
", expiry='" + getExpiry() + "', secretKeyId='" + getSecretKeyId() + "'" +
", sessionPolicy='" + sessionPolicy + "'}";
}
+
+ private static OMTokenProto.STSAuthType toProtoAuthType(AuthType value) {
+ return value == AuthType.WEB_IDENTITY
+ ? OMTokenProto.STSAuthType.WEB_IDENTITY
+ : OMTokenProto.STSAuthType.ASSUME_ROLE;
+ }
+
+ private static AuthType fromProtoAuthType(OMTokenProto.STSAuthType value) {
+ return value == OMTokenProto.STSAuthType.WEB_IDENTITY
+ ? AuthType.WEB_IDENTITY : AuthType.ASSUME_ROLE;
+ }
+
+ private static Set immutableSet(Iterable values) {
+ if (values == null) {
+ return Collections.emptySet();
+ }
+ LinkedHashSet set = new LinkedHashSet<>();
+ for (String value : values) {
+ if (value != null && !value.isEmpty()) {
+ set.add(value);
+ }
+ }
+ return Collections.unmodifiableSet(set);
+ }
+
+ private static void setIfNotEmpty(StringSetter setter, String value) {
+ if (value != null && !value.isEmpty()) {
+ setter.set(value);
+ }
+ }
+
+ private static String emptyToNull(String value) {
+ return value == null || value.isEmpty() ? null : value;
+ }
+
+ private interface StringSetter {
+ void set(String value);
+ }
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
index f72b1892de85..e39fe57c368b 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
@@ -20,6 +20,7 @@
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
+import java.util.Set;
import org.apache.hadoop.hdds.annotation.InterfaceAudience;
import org.apache.hadoop.hdds.annotation.InterfaceStability;
import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
@@ -100,6 +101,47 @@ public String createSTSTokenString(String tempAccessKeyId, String originalAccess
final Token token = generateToken(identifier);
return token.encodeToUrlString();
}
-}
+ /**
+ * Create a WebIdentity-backed STS token and return it as an encoded string.
+ */
+ public String createWebIdentitySTSTokenString(String tempAccessKeyId,
+ String roleArn, int durationSeconds, String secretAccessKey,
+ String sessionPolicy, String effectiveUser, String issuer, String subject,
+ String audience, Set groups, Set roles,
+ String roleSessionName, String providerId, String tokenFingerprint,
+ Clock clock) throws IOException {
+ final Instant expiration = clock.instant().plusSeconds(durationSeconds);
+ return createWebIdentitySTSTokenString(tempAccessKeyId, roleArn, expiration,
+ secretAccessKey, sessionPolicy, effectiveUser, issuer, subject,
+ audience, groups, roles, roleSessionName, providerId,
+ tokenFingerprint);
+ }
+ /**
+ * Create a WebIdentity-backed STS token with a precomputed expiration.
+ *
+ * This is used by OM Ratis apply/replay paths where the leader has already
+ * validated the WebIdentityToken and replicated only sanitized deterministic
+ * session data.
+ */
+ public String createWebIdentitySTSTokenString(String tempAccessKeyId,
+ String roleArn, Instant expiration, String secretAccessKey,
+ String sessionPolicy, String effectiveUser, String issuer, String subject,
+ String audience, Set groups, Set roles,
+ String roleSessionName, String providerId, String tokenFingerprint)
+ throws IOException {
+ final ManagedSecretKey currentSecretKey =
+ secretKeyClient.getCurrentSecretKey();
+ final byte[] encryptionKey =
+ currentSecretKey.getSecretKey().getEncoded();
+
+ final STSTokenIdentifier identifier = new STSTokenIdentifier(
+ tempAccessKeyId, roleArn, expiration, secretAccessKey, sessionPolicy,
+ effectiveUser, issuer, subject, audience, groups, roles,
+ roleSessionName, providerId, tokenFingerprint, encryptionKey);
+
+ final Token token = generateToken(identifier);
+ return token.encodeToUrlString();
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
new file mode 100644
index 000000000000..c14805be4c62
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.om.request.s3.security;
+
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.UUID;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
+import org.apache.hadoop.hdds.security.symmetric.SecretKeySignerClient;
+import org.apache.hadoop.ozone.audit.AuditLogger;
+import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.om.execution.flowcontrol.ExecutionContext;
+import org.apache.hadoop.ozone.om.response.OMClientResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMRequest;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMResponse;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Status;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type;
+import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleWithWebIdentityRequest;
+import org.apache.hadoop.ozone.security.STSTokenSecretManager;
+import org.apache.hadoop.ozone.security.oidc.AuthCredentials;
+import org.apache.hadoop.ozone.security.oidc.OidcAuthenticationException;
+import org.apache.hadoop.ozone.security.oidc.OzoneIdentity;
+import org.apache.hadoop.ozone.security.oidc.OzoneIdentityProvider;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
+import org.apache.hadoop.security.token.TokenIdentifier;
+import org.apache.ozone.test.TestClock;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+/**
+ * Unit tests for OM-authoritative AssumeRoleWithWebIdentity.
+ */
+public class TestS3AssumeRoleWithWebIdentityRequest {
+
+ private static final String ROLE_ARN =
+ "arn:aws:iam::123456789012:role/tomato";
+ private static final String ROLE_SESSION_NAME = "tomato-session";
+ private static final String PROVIDER_ID = "keycloak";
+ private static final String REQUEST_ID = UUID.randomUUID().toString();
+ private static final String RAW_JWT =
+ "eyJhbGciOiJSUzI1NiJ9.sensitive-web-identity-token.signature";
+ private static final String SESSION_POLICY = "session-policy";
+ private static final String ISSUER =
+ "https://keycloak.example.com/realms/ozone";
+ private static final String AUDIENCE = "ozone";
+ private static final String SUBJECT = "subject-123";
+ private static final String USER = "tomato-user";
+ private static final TestClock CLOCK =
+ new TestClock(Instant.ofEpochSecond(1764819000), ZoneOffset.UTC);
+
+ private OzoneManager ozoneManager;
+ private OzoneConfiguration configuration;
+ private IAccessAuthorizer accessAuthorizer;
+ private ExecutionContext context;
+
+ @BeforeEach
+ public void setup() throws IOException {
+ ozoneManager = mock(OzoneManager.class);
+ configuration = new OzoneConfiguration();
+ configuration.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true);
+ configuration.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, ISSUER);
+ configuration.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE);
+ when(ozoneManager.getConfiguration()).thenReturn(configuration);
+ when(ozoneManager.getOmRpcServerAddr()).thenReturn(
+ new InetSocketAddress("localhost", 9876));
+ when(ozoneManager.getAuditLogger()).thenReturn(mock(AuditLogger.class));
+
+ accessAuthorizer = mock(IAccessAuthorizer.class);
+ when(accessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy(
+ any(org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest.class)))
+ .thenReturn(SESSION_POLICY);
+ when(ozoneManager.getAccessAuthorizer()).thenReturn(accessAuthorizer);
+
+ final STSTokenSecretManager stsTokenSecretManager =
+ createTokenSecretManager();
+ when(ozoneManager.getSTSTokenSecretManager()).thenReturn(
+ stsTokenSecretManager);
+ context = ExecutionContext.of(1L, null);
+ }
+
+ @Test
+ public void testPreExecuteValidatesJwtAndStripsRawToken() throws Exception {
+ final CapturingIdentityProvider identityProvider =
+ new CapturingIdentityProvider(identity(3600));
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ identityProvider);
+
+ final OMRequest preExecuted = request.preExecute(ozoneManager);
+
+ assertThat(identityProvider.getCapturedToken()).isEqualTo(RAW_JWT);
+ assertThat(preExecuted.hasAssumeRoleWithWebIdentityRequest()).isFalse();
+ assertThat(preExecuted.hasUpdateAssumeRoleWithWebIdentityRequest())
+ .isTrue();
+ assertThat(preExecuted.toString()).doesNotContain(RAW_JWT);
+ assertThat(new String(preExecuted.toByteArray(), StandardCharsets.ISO_8859_1))
+ .doesNotContain(RAW_JWT);
+
+ final UpdateAssumeRoleWithWebIdentityRequest update =
+ preExecuted.getUpdateAssumeRoleWithWebIdentityRequest();
+ assertThat(update.getRoleArn()).isEqualTo(ROLE_ARN);
+ assertThat(update.getRoleSessionName()).isEqualTo(ROLE_SESSION_NAME);
+ assertThat(update.getDurationSeconds()).isEqualTo(3600);
+ assertThat(update.getProviderId()).isEqualTo(PROVIDER_ID);
+ assertThat(update.getRequestId()).isEqualTo(REQUEST_ID);
+ assertThat(update.getEffectiveUser()).isEqualTo(USER);
+ assertThat(update.getSubject()).isEqualTo(SUBJECT);
+ assertThat(update.getIssuer()).isEqualTo(ISSUER);
+ assertThat(update.getAudience()).isEqualTo(AUDIENCE);
+ assertThat(update.getGroupsList()).containsExactly("ozone-tomato");
+ assertThat(update.getRolesList()).containsExactly("role:writer");
+ assertThat(update.getSessionPolicy()).isEqualTo(SESSION_POLICY);
+ assertThat(update.getTempAccessKeyId()).startsWith("ASIA");
+ assertThat(update.getSecretAccessKey()).hasSize(40);
+ assertThat(update.getTokenFingerprint()).hasSize(64);
+ assertThat(update.getCredentialExpirationEpochSeconds())
+ .isEqualTo(CLOCK.instant().plusSeconds(3600).getEpochSecond());
+ }
+
+ @Test
+ public void testAuthorizerReceivesMappedIdentityAndContext()
+ throws Exception {
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ new CapturingIdentityProvider(identity(3600)));
+
+ request.preExecute(ozoneManager);
+
+ final ArgumentCaptor captor =
+ ArgumentCaptor.forClass(org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest.class);
+ verify(accessAuthorizer)
+ .generateAssumeRoleWithWebIdentitySessionPolicy(captor.capture());
+
+ final org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest captured = captor.getValue();
+ assertThat(captured.getUser()).isEqualTo(USER);
+ assertThat(captured.getGroups()).containsExactly("ozone-tomato");
+ assertThat(captured.getRoles()).containsExactly("role:writer");
+ assertThat(captured.getAction()).isEqualTo(
+ org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest.ACTION);
+ assertThat(captured.getRoleArn()).isEqualTo(ROLE_ARN);
+ assertThat(captured.getRoleSessionName()).isEqualTo(ROLE_SESSION_NAME);
+ assertThat(captured.getIssuer()).isEqualTo(ISSUER);
+ assertThat(captured.getSubject()).isEqualTo(SUBJECT);
+ assertThat(captured.getAudience()).isEqualTo(AUDIENCE);
+ assertThat(captured.getProviderId()).isEqualTo(PROVIDER_ID);
+ }
+
+ @Test
+ public void testValidateAndUpdateCacheUsesOnlySanitizedRequest()
+ throws Exception {
+ final CapturingIdentityProvider identityProvider =
+ new CapturingIdentityProvider(identity(3600));
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ identityProvider);
+ final OMRequest preExecuted = request.preExecute(ozoneManager);
+
+ final OzoneIdentityProvider replayProvider =
+ credentials -> {
+ throw new AssertionError("OIDC provider must not be called during "
+ + "Ratis apply/replay");
+ };
+ final S3AssumeRoleWithWebIdentityRequest replayRequest =
+ new S3AssumeRoleWithWebIdentityRequest(preExecuted,
+ new TestClock(CLOCK.instant().plusSeconds(86400), ZoneOffset.UTC),
+ replayProvider);
+
+ final OMClientResponse clientResponse =
+ replayRequest.validateAndUpdateCache(ozoneManager, context);
+ final OMResponse omResponse = clientResponse.getOMResponse();
+
+ assertThat(omResponse.getStatus()).isEqualTo(Status.OK);
+ assertThat(omResponse.hasAssumeRoleWithWebIdentityResponse()).isTrue();
+ final AssumeRoleWithWebIdentityResponse response =
+ omResponse.getAssumeRoleWithWebIdentityResponse();
+ assertThat(response.getAccessKeyId()).startsWith("ASIA");
+ assertThat(response.getSecretAccessKey()).hasSize(40);
+ assertThat(response.getSessionToken()).isNotBlank();
+ assertThat(response.getExpirationEpochSeconds())
+ .isEqualTo(CLOCK.instant().plusSeconds(3600).getEpochSecond());
+ assertThat(response.getAssumedRoleId()).contains(":" + ROLE_SESSION_NAME);
+ assertThat(response.getSubjectFromWebIdentityToken()).isEqualTo(SUBJECT);
+ assertThat(response.getAudience()).isEqualTo(AUDIENCE);
+ assertThat(response.getProvider()).isEqualTo(PROVIDER_ID);
+
+ verify(accessAuthorizer)
+ .generateAssumeRoleWithWebIdentitySessionPolicy(any());
+ verifyNoMoreInteractions(accessAuthorizer);
+ }
+
+ @Test
+ public void testDurationIsClampedToWebIdentityTokenLifetime()
+ throws Exception {
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ new CapturingIdentityProvider(identity(1200)));
+
+ final OMRequest preExecuted = request.preExecute(ozoneManager);
+
+ assertThat(preExecuted.getUpdateAssumeRoleWithWebIdentityRequest()
+ .getDurationSeconds()).isEqualTo(1200);
+ assertThat(preExecuted.getUpdateAssumeRoleWithWebIdentityRequest()
+ .getCredentialExpirationEpochSeconds())
+ .isEqualTo(CLOCK.instant().plusSeconds(1200).getEpochSecond());
+ }
+
+ @Test
+ public void testTokenExpiringBeforeMinimumDurationFailsClosed() {
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ new CapturingIdentityProvider(identity(899)));
+
+ assertThatThrownBy(() -> request.preExecute(ozoneManager))
+ .isInstanceOf(OMException.class)
+ .satisfies(e -> assertThat(((OMException) e).getResult())
+ .isEqualTo(TOKEN_EXPIRED))
+ .hasMessageContaining("expires before the minimum STS credential");
+ }
+
+ @Test
+ public void testDisabledFeatureFailsBeforeTokenValidation() {
+ configuration.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, false);
+ final CapturingIdentityProvider identityProvider =
+ new CapturingIdentityProvider(identity(3600));
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ identityProvider);
+
+ assertThatThrownBy(() -> request.preExecute(ozoneManager))
+ .isInstanceOf(OMException.class)
+ .satisfies(e -> assertThat(((OMException) e).getResult())
+ .isEqualTo(FEATURE_NOT_ENABLED));
+ assertThat(identityProvider.getCapturedToken()).isNull();
+ }
+
+ private static OMRequest externalRequest(int durationSeconds) {
+ return OMRequest.newBuilder()
+ .setCmdType(Type.AssumeRoleWithWebIdentity)
+ .setClientId("client-1")
+ .setAssumeRoleWithWebIdentityRequest(
+ org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos
+ .AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setRoleArn(ROLE_ARN)
+ .setRoleSessionName(ROLE_SESSION_NAME)
+ .setDurationSeconds(durationSeconds)
+ .setProviderId(PROVIDER_ID)
+ .setRequestId(REQUEST_ID)
+ .setWebIdentityToken(RAW_JWT)
+ .build())
+ .build();
+ }
+
+ private static OzoneIdentity identity(long expiresInSeconds) {
+ return OzoneIdentity.newBuilder()
+ .setUsername(USER)
+ .setSubject(SUBJECT)
+ .setIssuer(ISSUER)
+ .setGroups(set("ozone-tomato"))
+ .setRoles(set("role:writer"))
+ .setAuthenticatedAt(CLOCK.instant())
+ .setExpiresAt(CLOCK.instant().plusSeconds(expiresInSeconds))
+ .build();
+ }
+
+ private static Set set(String... values) {
+ return new LinkedHashSet<>(Arrays.asList(values));
+ }
+
+ private static STSTokenSecretManager createTokenSecretManager()
+ throws IOException {
+ final SecretKeySignerClient secretKeyClient =
+ mock(SecretKeySignerClient.class);
+ final ManagedSecretKey managedSecretKey = mock(ManagedSecretKey.class);
+ final SecretKey secretKey = new SecretKeySpec(
+ "testSecretKey".getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ final UUID secretKeyId = UUID.randomUUID();
+
+ when(secretKeyClient.getCurrentSecretKey()).thenReturn(managedSecretKey);
+ when(managedSecretKey.getSecretKey()).thenReturn(secretKey);
+ when(managedSecretKey.getId()).thenReturn(secretKeyId);
+ when(managedSecretKey.sign(any(TokenIdentifier.class))).thenReturn(
+ "signature".getBytes(StandardCharsets.UTF_8));
+ return new STSTokenSecretManager(secretKeyClient);
+ }
+
+ private static final class CapturingIdentityProvider
+ implements OzoneIdentityProvider {
+
+ private final OzoneIdentity identity;
+ private String capturedToken;
+
+ private CapturingIdentityProvider(OzoneIdentity identity) {
+ this.identity = identity;
+ }
+
+ @Override
+ public OzoneIdentity authenticate(AuthCredentials credentials)
+ throws OidcAuthenticationException {
+ capturedToken = credentials.getBearerToken();
+ return identity;
+ }
+
+ private String getCapturedToken() {
+ return capturedToken;
+ }
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
index 6d424a66602d..8ec66e2cc5a0 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/response/TestCleanupTableInfo.java
@@ -63,6 +63,7 @@
import org.apache.hadoop.ozone.om.response.file.OMFileCreateResponse;
import org.apache.hadoop.ozone.om.response.key.OMKeyCreateResponse;
import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleResponse;
+import org.apache.hadoop.ozone.om.response.s3.security.S3AssumeRoleWithWebIdentityResponse;
import org.apache.hadoop.ozone.om.response.util.OMEchoRPCWriteResponse;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateFileRequest;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.CreateKeyRequest;
@@ -140,6 +141,7 @@ public void checkAnnotationAndTableName() {
subTypes.remove(OMEchoRPCWriteResponse.class);
subTypes.remove(DummyOMClientResponse.class);
subTypes.remove(S3AssumeRoleResponse.class);
+ subTypes.remove(S3AssumeRoleWithWebIdentityResponse.class);
subTypes.forEach(aClass -> {
if (Modifier.isAbstract(aClass.getModifiers())) {
assertFalse(aClass.isAnnotationPresent(CleanupTableInfo.class),
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
index c93df8a49009..f7c26df24d99 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSSecurityUtil.java
@@ -28,6 +28,9 @@
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.hadoop.hdds.security.exception.SCMSecurityException;
@@ -100,6 +103,38 @@ public void testConstructValidateAndDecryptSTSTokenSuccess() throws IOException
assertThat(expirationEpochMillis).isEqualTo(clock.millis() + (DURATION_SECONDS * 1000));
}
+ @Test
+ public void testConstructValidateAndDecryptWebIdentitySTSTokenSuccess()
+ throws IOException {
+ final String tokenString =
+ tokenSecretManager.createWebIdentitySTSTokenString(
+ TEMP_ACCESS_KEY, ROLE_ARN, DURATION_SECONDS, SECRET_ACCESS_KEY,
+ SESSION_POLICY, "tomato-user",
+ "https://keycloak.example.com/realms/ozone", "subject-123",
+ "ozone", set("ozone-tomato"), set("role:writer"),
+ "tomato-session", "keycloak", "fingerprint", clock);
+
+ final STSTokenIdentifier result =
+ STSSecurityUtil.constructValidateAndDecryptSTSToken(tokenString,
+ secretKeyClient, clock);
+
+ assertThat(result.isWebIdentity()).isTrue();
+ assertThat(result.getOwnerId()).isEqualTo(TEMP_ACCESS_KEY);
+ assertThat(result.getOriginalAccessKeyId()).isNull();
+ assertThat(result.getEffectiveUser()).isEqualTo("tomato-user");
+ assertThat(result.getIssuer()).isEqualTo(
+ "https://keycloak.example.com/realms/ozone");
+ assertThat(result.getSubject()).isEqualTo("subject-123");
+ assertThat(result.getAudience()).isEqualTo("ozone");
+ assertThat(result.getGroups()).containsExactly("ozone-tomato");
+ assertThat(result.getRoles()).containsExactly("role:writer");
+ assertThat(result.getRoleSessionName()).isEqualTo("tomato-session");
+ assertThat(result.getProviderId()).isEqualTo("keycloak");
+ assertThat(result.getTokenFingerprint()).isEqualTo("fingerprint");
+ assertThat(result.getSecretAccessKey()).isEqualTo(SECRET_ACCESS_KEY);
+ assertThat(result.getSessionPolicy()).isEqualTo(SESSION_POLICY);
+ }
+
@Test
public void testConstructValidateAndDecryptSTSTokenSuccessWithNullSessionPolicy() throws Exception {
// Create a valid token with null session policy
@@ -365,6 +400,63 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingOriginalAccessKeyId
.hasMessage("Invalid STS token - originalAccessKeyId is null/empty");
}
+ @Test
+ public void testEnsureEssentialFieldsAllowsWebIdentityWithoutOriginalAccessKeyId()
+ throws Exception {
+ final STSTokenIdentifier tokenIdentifier = webIdentityToken(
+ "tomato-user", "issuer", "subject", "audience");
+
+ STSSecurityUtil.ensureEssentialFieldsArePresentInToken(tokenIdentifier);
+ }
+
+ @Test
+ public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingEffectiveUser() {
+ final STSTokenIdentifier tokenIdentifier = webIdentityToken(
+ null, "issuer", "subject", "audience");
+
+ assertThatThrownBy(() ->
+ STSSecurityUtil.ensureEssentialFieldsArePresentInToken(
+ tokenIdentifier))
+ .isInstanceOf(SecretManager.InvalidToken.class)
+ .hasMessage("Invalid STS token - effectiveUser is null/empty");
+ }
+
+ @Test
+ public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingIssuer() {
+ final STSTokenIdentifier tokenIdentifier = webIdentityToken(
+ "tomato-user", null, "subject", "audience");
+
+ assertThatThrownBy(() ->
+ STSSecurityUtil.ensureEssentialFieldsArePresentInToken(
+ tokenIdentifier))
+ .isInstanceOf(SecretManager.InvalidToken.class)
+ .hasMessage("Invalid STS token - issuer is null/empty");
+ }
+
+ @Test
+ public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingSubject() {
+ final STSTokenIdentifier tokenIdentifier = webIdentityToken(
+ "tomato-user", "issuer", null, "audience");
+
+ assertThatThrownBy(() ->
+ STSSecurityUtil.ensureEssentialFieldsArePresentInToken(
+ tokenIdentifier))
+ .isInstanceOf(SecretManager.InvalidToken.class)
+ .hasMessage("Invalid STS token - subject is null/empty");
+ }
+
+ @Test
+ public void testEnsureEssentialFieldsArePresentInWebIdentityTokenMissingAudience() {
+ final STSTokenIdentifier tokenIdentifier = webIdentityToken(
+ "tomato-user", "issuer", "subject", null);
+
+ assertThatThrownBy(() ->
+ STSSecurityUtil.ensureEssentialFieldsArePresentInToken(
+ tokenIdentifier))
+ .isInstanceOf(SecretManager.InvalidToken.class)
+ .hasMessage("Invalid STS token - audience is null/empty");
+ }
+
@Test
public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() {
final STSTokenIdentifier tokenIdentifier = new STSTokenIdentifier(
@@ -374,4 +466,17 @@ public void testEnsureEssentialFieldsArePresentInTokenMissingSecretAccessKey() {
.isInstanceOf(SecretManager.InvalidToken.class)
.hasMessage("Invalid STS token - secretAccessKey is null/empty");
}
+
+ private static STSTokenIdentifier webIdentityToken(String effectiveUser,
+ String issuer, String subject, String audience) {
+ return new STSTokenIdentifier(
+ TEMP_ACCESS_KEY, ROLE_ARN, Instant.now(), SECRET_ACCESS_KEY,
+ SESSION_POLICY, effectiveUser, issuer, subject, audience,
+ set("ozone-tomato"), set("role:writer"), "session", "provider",
+ "fingerprint", ENCRYPTION_KEY);
+ }
+
+ private static Set set(String... values) {
+ return new LinkedHashSet<>(Arrays.asList(values));
+ }
}
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java
index 09a786faaea3..eb74f7380b62 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/TestSTSTokenIdentifier.java
@@ -27,6 +27,9 @@
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.OMTokenProto;
@@ -74,11 +77,16 @@ public void testProtoBufRoundTrip() throws IOException {
assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey"); // must be encrypted
assertThat(proto.getSessionPolicy()).isEqualTo("sessionPolicy");
assertThat(proto.getSecretKeyId()).isEqualTo(secretKeyId.toString());
+ assertThat(proto.getStsAuthType()).isEqualTo(
+ OMTokenProto.STSAuthType.ASSUME_ROLE);
final STSTokenIdentifier parsedTokenIdentifier = new STSTokenIdentifier();
parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY);
parsedTokenIdentifier.fromProtoBuf(proto);
+ assertThat(parsedTokenIdentifier.getAuthType()).isEqualTo(
+ STSTokenIdentifier.AuthType.ASSUME_ROLE);
+ assertThat(parsedTokenIdentifier.isWebIdentity()).isFalse();
assertThat(parsedTokenIdentifier.getOwnerId()).isEqualTo("tempAccess");
assertThat(parsedTokenIdentifier.getExpiry()).isEqualTo(expiry);
assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isEqualTo("origAccess");
@@ -90,6 +98,68 @@ public void testProtoBufRoundTrip() throws IOException {
assertThat(parsedTokenIdentifier.hashCode()).isEqualTo(originalTokenIdentifier.hashCode());
}
+ @Test
+ public void testWebIdentityProtoBufRoundTrip() throws IOException {
+ final Instant expiry =
+ Instant.now().plusSeconds(7200).truncatedTo(ChronoUnit.MILLIS);
+ final STSTokenIdentifier originalTokenIdentifier =
+ new STSTokenIdentifier(
+ "tempAccess", "arn:aws:iam::123456789012:role/WebRole",
+ expiry, "secretKey", "sessionPolicy", "tomato-user",
+ "https://keycloak.example.com/realms/ozone", "subject-123",
+ "ozone", set("ozone-tomato"), set("role:writer"),
+ "tomato-session", "keycloak", "fingerprint", ENCRYPTION_KEY);
+ final UUID secretKeyId = UUID.randomUUID();
+ originalTokenIdentifier.setSecretKeyId(secretKeyId);
+
+ final OMTokenProto proto = originalTokenIdentifier.toProtoBuf();
+ assertThat(proto.getStsAuthType()).isEqualTo(
+ OMTokenProto.STSAuthType.WEB_IDENTITY);
+ assertThat(proto.getOwner()).isEqualTo("tempAccess");
+ assertThat(proto.getOriginalAccessKeyId()).isEmpty();
+ assertThat(proto.getRoleArn()).isEqualTo(
+ "arn:aws:iam::123456789012:role/WebRole");
+ assertThat(proto.getSecretAccessKey()).isNotEqualTo("secretKey");
+ assertThat(proto.getEffectiveUser()).isEqualTo("tomato-user");
+ assertThat(proto.getIssuer()).isEqualTo(
+ "https://keycloak.example.com/realms/ozone");
+ assertThat(proto.getSubject()).isEqualTo("subject-123");
+ assertThat(proto.getAudience()).isEqualTo("ozone");
+ assertThat(proto.getGroupsList()).containsExactly("ozone-tomato");
+ assertThat(proto.getRolesList()).containsExactly("role:writer");
+ assertThat(proto.getRoleSessionName()).isEqualTo("tomato-session");
+ assertThat(proto.getProviderId()).isEqualTo("keycloak");
+ assertThat(proto.getTokenFingerprint()).isEqualTo("fingerprint");
+
+ final STSTokenIdentifier parsedTokenIdentifier =
+ new STSTokenIdentifier();
+ parsedTokenIdentifier.setEncryptionKey(ENCRYPTION_KEY);
+ parsedTokenIdentifier.fromProtoBuf(proto);
+
+ assertThat(parsedTokenIdentifier.getAuthType()).isEqualTo(
+ STSTokenIdentifier.AuthType.WEB_IDENTITY);
+ assertThat(parsedTokenIdentifier.isWebIdentity()).isTrue();
+ assertThat(parsedTokenIdentifier.getOriginalAccessKeyId()).isNull();
+ assertThat(parsedTokenIdentifier.getEffectiveUser())
+ .isEqualTo("tomato-user");
+ assertThat(parsedTokenIdentifier.getIssuer()).isEqualTo(
+ "https://keycloak.example.com/realms/ozone");
+ assertThat(parsedTokenIdentifier.getSubject()).isEqualTo("subject-123");
+ assertThat(parsedTokenIdentifier.getAudience()).isEqualTo("ozone");
+ assertThat(parsedTokenIdentifier.getGroups()).containsExactly(
+ "ozone-tomato");
+ assertThat(parsedTokenIdentifier.getRoles()).containsExactly(
+ "role:writer");
+ assertThat(parsedTokenIdentifier.getRoleSessionName())
+ .isEqualTo("tomato-session");
+ assertThat(parsedTokenIdentifier.getProviderId()).isEqualTo("keycloak");
+ assertThat(parsedTokenIdentifier.getTokenFingerprint())
+ .isEqualTo("fingerprint");
+ assertThat(parsedTokenIdentifier.getSecretAccessKey())
+ .isEqualTo("secretKey");
+ assertThat(parsedTokenIdentifier).isEqualTo(originalTokenIdentifier);
+ }
+
@Test
public void testFromProtoBufInvalidSecretKeyId() {
final OMTokenProto invalid = OMTokenProto.newBuilder()
@@ -409,11 +479,16 @@ public void testToString() {
stsTokenIdentifier.setSecretKeyId(uuid);
final String stsTokenIdentifierStr = stsTokenIdentifier.toString();
- final String expectedString = "STSTokenIdentifier{" + "tempAccessKeyId='tempAccessKeyId'" +
- ", originalAccessKeyId='originalAccessKeyId'" + ", roleArn='roleArn'" + ", expiry='" + expiry +
- "', secretKeyId='" + uuid + "', sessionPolicy='sessionPolicy'" + '}';
- assertEquals(expectedString, stsTokenIdentifierStr);
+ assertThat(stsTokenIdentifierStr)
+ .contains("tempAccessKeyId='tempAccessKeyId'")
+ .contains("authType=ASSUME_ROLE")
+ .contains("originalAccessKeyId='originalAccessKeyId'")
+ .contains("roleArn='roleArn'")
+ .contains("expiry='" + expiry + "'")
+ .contains("secretKeyId='" + uuid + "'")
+ .contains("sessionPolicy='sessionPolicy'")
+ .doesNotContain("secretAccessKey");
}
@Test
@@ -449,6 +524,8 @@ public void testEqualsWithDifferentEncryptionKeys() {
assertThat(stsTokenIdentifier).isEqualTo(stsTokenIdentifier2);
assertThat(stsTokenIdentifier.hashCode()).isEqualTo(stsTokenIdentifier2.hashCode());
}
-}
-
+ private static Set set(String... values) {
+ return new LinkedHashSet<>(Arrays.asList(values));
+ }
+}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
new file mode 100644
index 000000000000..f8d4bacbbf05
--- /dev/null
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3sts;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.XmlType;
+
+/**
+ * JAXB model for AWS-compatible AssumeRoleWithWebIdentity response XML.
+ */
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement(name = "AssumeRoleWithWebIdentityResponse",
+ namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
+public class S3AssumeRoleWithWebIdentityResponseXml {
+
+ @XmlElement(name = "AssumeRoleWithWebIdentityResult")
+ private AssumeRoleWithWebIdentityResult result;
+
+ @XmlElement(name = "ResponseMetadata")
+ private ResponseMetadata responseMetadata;
+
+ public AssumeRoleWithWebIdentityResult getResult() {
+ return result;
+ }
+
+ public void setResult(AssumeRoleWithWebIdentityResult result) {
+ this.result = result;
+ }
+
+ public ResponseMetadata getResponseMetadata() {
+ return responseMetadata;
+ }
+
+ public void setResponseMetadata(ResponseMetadata responseMetadata) {
+ this.responseMetadata = responseMetadata;
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "AssumeRoleWithWebIdentityResultType",
+ namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
+ public static class AssumeRoleWithWebIdentityResult {
+ @XmlElement(name = "Credentials")
+ private Credentials credentials;
+
+ @XmlElement(name = "SubjectFromWebIdentityToken")
+ private String subjectFromWebIdentityToken;
+
+ @XmlElement(name = "AssumedRoleUser")
+ private AssumedRoleUser assumedRoleUser;
+
+ @XmlElement(name = "Audience")
+ private String audience;
+
+ @XmlElement(name = "Provider")
+ private String provider;
+
+ public void setCredentials(Credentials credentials) {
+ this.credentials = credentials;
+ }
+
+ public void setSubjectFromWebIdentityToken(String value) {
+ this.subjectFromWebIdentityToken = value;
+ }
+
+ public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) {
+ this.assumedRoleUser = assumedRoleUser;
+ }
+
+ public void setAudience(String audience) {
+ this.audience = audience;
+ }
+
+ public void setProvider(String provider) {
+ this.provider = provider;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "AssumeRoleWithWebIdentityCredentialsType",
+ namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
+ public static class Credentials {
+ @XmlElement(name = "AccessKeyId")
+ private String accessKeyId;
+
+ @XmlElement(name = "SecretAccessKey")
+ private String secretAccessKey;
+
+ @XmlElement(name = "SessionToken")
+ private String sessionToken;
+
+ @XmlElement(name = "Expiration")
+ private String expiration;
+
+ public void setAccessKeyId(String accessKeyId) {
+ this.accessKeyId = accessKeyId;
+ }
+
+ public void setSecretAccessKey(String secretAccessKey) {
+ this.secretAccessKey = secretAccessKey;
+ }
+
+ public void setSessionToken(String sessionToken) {
+ this.sessionToken = sessionToken;
+ }
+
+ public void setExpiration(String expiration) {
+ this.expiration = expiration;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "AssumeRoleWithWebIdentityAssumedRoleUserType",
+ namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
+ public static class AssumedRoleUser {
+ @XmlElement(name = "AssumedRoleId")
+ private String assumedRoleId;
+
+ @XmlElement(name = "Arn")
+ private String arn;
+
+ public void setAssumedRoleId(String assumedRoleId) {
+ this.assumedRoleId = assumedRoleId;
+ }
+
+ public void setArn(String arn) {
+ this.arn = arn;
+ }
+ }
+
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @XmlType(name = "AssumeRoleWithWebIdentityResponseMetadataType",
+ namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
+ public static class ResponseMetadata {
+ @XmlElement(name = "RequestId")
+ private String requestId;
+
+ public void setRequestId(String requestId) {
+ this.requestId = requestId;
+ }
+ }
+}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
index 5244989812ed..068977d8ba46 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
@@ -49,6 +49,7 @@
import org.apache.hadoop.ozone.audit.S3GAction;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.AwsRoleArnValidator;
import org.apache.hadoop.ozone.om.helpers.S3STSUtils;
import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder;
@@ -98,7 +99,8 @@ public class S3STSEndpoint extends S3STSEndpointBase {
static {
try {
- JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class);
+ JAXB_CONTEXT = JAXBContext.newInstance(S3AssumeRoleResponseXml.class,
+ S3AssumeRoleWithWebIdentityResponseXml.class);
} catch (JAXBException e) {
throw new RuntimeException("Failed to initialize JAXBContext: " + e, e);
}
@@ -278,11 +280,53 @@ private Response handleAssumeRoleWithWebIdentity(String roleArn,
VALIDATION_ERROR, validationMessage, BAD_REQUEST.getStatusCode());
}
- // S3G has parsed and routed the request. The OM request path will validate
- // the JWT and issue credentials; S3G must not trust or log the token here.
- throw new OSTSException(UNSUPPORTED_OPERATION,
- "AssumeRoleWithWebIdentity OM runtime is not implemented yet.",
- NOT_IMPLEMENTED.getStatusCode());
+ final int duration = durationSeconds == null
+ ? S3STSUtils.DEFAULT_DURATION_SECONDS : durationSeconds;
+ final String assumedRoleUserArn =
+ S3STSUtils.toAssumedRoleUserArn(roleArn, roleSessionName);
+ try {
+ final AssumeRoleWithWebIdentityResponseInfo responseInfo = getClient()
+ .getObjectStore()
+ .assumeRoleWithWebIdentity(roleArn, roleSessionName, duration,
+ webIdentityToken, providerId, requestId);
+ final String responseXml = generateAssumeRoleWithWebIdentityResponse(
+ assumedRoleUserArn, responseInfo, requestId);
+ return Response.ok(responseXml)
+ .header("Content-Type", "text/xml")
+ .build();
+ } catch (IOException e) {
+ LOG.error("Error during AssumeRoleWithWebIdentity processing", e);
+ if (e instanceof OMException) {
+ final OMException omException = (OMException) e;
+ if (omException.getResult() == OMException.ResultCodes.ACCESS_DENIED ||
+ omException.getResult() == OMException.ResultCodes.PERMISSION_DENIED ||
+ omException.getResult() == OMException.ResultCodes.TOKEN_EXPIRED) {
+ throw new OSTSException(
+ ACCESS_DENIED,
+ "User is not authorized to perform: "
+ + "sts:AssumeRoleWithWebIdentity on resource: " + roleArn,
+ FORBIDDEN.getStatusCode());
+ }
+ if (omException.getResult() == OMException.ResultCodes.INVALID_TOKEN) {
+ throw new OSTSException(
+ INVALID_CLIENT_TOKEN_ID,
+ "The web identity token included in the request is invalid.",
+ FORBIDDEN.getStatusCode());
+ }
+ if (omException.getResult() == OMException.ResultCodes.NOT_SUPPORTED_OPERATION ||
+ omException.getResult() == OMException.ResultCodes.FEATURE_NOT_ENABLED) {
+ throw new OSTSException(UNSUPPORTED_OPERATION,
+ omException.getMessage(), NOT_IMPLEMENTED.getStatusCode());
+ }
+ if (omException.getResult() == OMException.ResultCodes.INVALID_REQUEST) {
+ throw new OSTSException(VALIDATION_ERROR, omException.getMessage(),
+ BAD_REQUEST.getStatusCode());
+ }
+ }
+ throw new OSTSException(
+ INTERNAL_FAILURE, "An internal error has occurred.",
+ INTERNAL_SERVER_ERROR.getStatusCode(), "Receiver");
+ }
}
private boolean isWebIdentityEnabled() {
@@ -445,4 +489,54 @@ private String generateAssumeRoleResponse(String assumedRoleUserArn, AssumeRoleR
throw new IOException("Failed to marshal AssumeRole response", e);
}
}
+
+ private String generateAssumeRoleWithWebIdentityResponse(
+ String assumedRoleUserArn,
+ AssumeRoleWithWebIdentityResponseInfo responseInfo, String requestId)
+ throws IOException {
+ final String expiration = DateTimeFormatter.ISO_INSTANT.format(
+ Instant.ofEpochSecond(responseInfo.getExpirationEpochSeconds())
+ .atOffset(ZoneOffset.UTC).toInstant());
+
+ try {
+ final S3AssumeRoleWithWebIdentityResponseXml response =
+ new S3AssumeRoleWithWebIdentityResponseXml();
+ final S3AssumeRoleWithWebIdentityResponseXml
+ .AssumeRoleWithWebIdentityResult result =
+ new S3AssumeRoleWithWebIdentityResponseXml
+ .AssumeRoleWithWebIdentityResult();
+ final S3AssumeRoleWithWebIdentityResponseXml.Credentials credentials =
+ new S3AssumeRoleWithWebIdentityResponseXml.Credentials();
+ credentials.setAccessKeyId(responseInfo.getAccessKeyId());
+ credentials.setSecretAccessKey(responseInfo.getSecretAccessKey());
+ credentials.setSessionToken(responseInfo.getSessionToken());
+ credentials.setExpiration(expiration);
+ result.setCredentials(credentials);
+ result.setSubjectFromWebIdentityToken(
+ responseInfo.getSubjectFromWebIdentityToken());
+ result.setAudience(responseInfo.getAudience());
+ if (StringUtils.isNotBlank(responseInfo.getProvider())) {
+ result.setProvider(responseInfo.getProvider());
+ }
+ final S3AssumeRoleWithWebIdentityResponseXml.AssumedRoleUser user =
+ new S3AssumeRoleWithWebIdentityResponseXml.AssumedRoleUser();
+ user.setAssumedRoleId(responseInfo.getAssumedRoleId());
+ user.setArn(assumedRoleUserArn);
+ result.setAssumedRoleUser(user);
+ response.setResult(result);
+ final S3AssumeRoleWithWebIdentityResponseXml.ResponseMetadata meta =
+ new S3AssumeRoleWithWebIdentityResponseXml.ResponseMetadata();
+ meta.setRequestId(requestId);
+ response.setResponseMetadata(meta);
+
+ final Marshaller marshaller = JAXB_CONTEXT.createMarshaller();
+ marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE);
+ final StringWriter stringWriter = new StringWriter();
+ marshaller.marshal(response, stringWriter);
+ return stringWriter.toString();
+ } catch (JAXBException e) {
+ throw new IOException(
+ "Failed to marshal AssumeRoleWithWebIdentity response", e);
+ }
+ }
}
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
index 57bac2281f06..bd92c3669c41 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSEndpoint.java
@@ -51,6 +51,7 @@
import org.apache.hadoop.ozone.client.OzoneClientStub;
import org.apache.hadoop.ozone.om.exceptions.OMException;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.s3.OzoneConfigurationHolder;
import org.apache.hadoop.ozone.s3.RequestIdentifier;
import org.apache.hadoop.ozone.s3.exception.OSTSException;
@@ -100,6 +101,17 @@ public void setup() throws Exception {
"session-token",
Instant.now().plusSeconds(3600).getEpochSecond(),
"AROA1234567890123456:test-session"));
+ when(objectStore.assumeRoleWithWebIdentity(anyString(), anyString(),
+ anyInt(), anyString(), any(), anyString()))
+ .thenReturn(new AssumeRoleWithWebIdentityResponseInfo(
+ "ASIAWEBIDENTITY123456",
+ "webIdentitySecretAccessKey",
+ "web-identity-session-token",
+ Instant.now().plusSeconds(3600).getEpochSecond(),
+ "AROA1234567890123456:test-session",
+ "subject-123",
+ "ozone",
+ "keycloak"));
when(clientStub.getObjectStore()).thenReturn(objectStore);
endpoint = new S3STSEndpoint();
@@ -373,6 +385,8 @@ public void testStsAssumeRoleWithWebIdentityDisabled() throws Exception {
verifyNoInteractions(auditLogger);
verify(objectStore, never()).assumeRole(anyString(), anyString(),
anyInt(), any(), anyString());
+ verify(objectStore, never()).assumeRoleWithWebIdentity(
+ anyString(), anyString(), anyInt(), anyString(), any(), anyString());
ex.setRequestId(REQUEST_ID);
assertStsErrorXml(ex.toXml(), AWS_FAULT_NS, "Sender", "InvalidAction",
@@ -393,6 +407,8 @@ public void testStsAssumeRoleWithWebIdentityMissingToken()
verifyNoInteractions(auditLogger);
verify(objectStore, never()).assumeRole(anyString(), anyString(),
anyInt(), any(), anyString());
+ verify(objectStore, never()).assumeRoleWithWebIdentity(
+ anyString(), anyString(), anyInt(), anyString(), any(), anyString());
ex.setRequestId(REQUEST_ID);
assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "ValidationError",
@@ -404,19 +420,36 @@ public void testStsAssumeRoleWithWebIdentityRoutesWhenEnabled()
throws Exception {
enableWebIdentity();
- final OSTSException ex = assertThrows(OSTSException.class, () ->
- endpoint.get("AssumeRoleWithWebIdentity", ROLE_ARN,
- ROLE_SESSION_NAME, 3600, "2011-06-15", null,
- "sensitive-token-material", "keycloak"));
+ final Response response = endpoint.get("AssumeRoleWithWebIdentity",
+ ROLE_ARN, ROLE_SESSION_NAME, 3600, "2011-06-15", null,
+ "sensitive-token-material", "keycloak");
- assertEquals(501, ex.getHttpCode());
+ assertEquals(200, response.getStatus());
verifyNoInteractions(auditLogger);
- verify(objectStore, never()).assumeRole(anyString(), anyString(),
- anyInt(), any(), anyString());
+ verify(objectStore).assumeRoleWithWebIdentity(ROLE_ARN,
+ ROLE_SESSION_NAME, 3600, "sensitive-token-material", "keycloak",
+ REQUEST_ID);
- ex.setRequestId(REQUEST_ID);
- assertStsErrorXml(ex.toXml(), STS_NS, "Sender", "UnsupportedOperation",
- "AssumeRoleWithWebIdentity OM runtime is not implemented yet.");
+ final Document doc = parseXml((String) response.getEntity());
+ final Element root = doc.getDocumentElement();
+ assertEquals("AssumeRoleWithWebIdentityResponse", root.getLocalName());
+ assertEquals(STS_NS, root.getNamespaceURI());
+ assertNotNull(doc.getElementsByTagNameNS(
+ STS_NS, "AssumeRoleWithWebIdentityResult").item(0));
+ assertEquals("ASIAWEBIDENTITY123456",
+ doc.getElementsByTagName("AccessKeyId").item(0).getTextContent());
+ assertEquals("webIdentitySecretAccessKey",
+ doc.getElementsByTagName("SecretAccessKey").item(0)
+ .getTextContent());
+ assertEquals("web-identity-session-token",
+ doc.getElementsByTagName("SessionToken").item(0).getTextContent());
+ assertEquals("subject-123",
+ doc.getElementsByTagName("SubjectFromWebIdentityToken").item(0)
+ .getTextContent());
+ assertEquals("ozone",
+ doc.getElementsByTagName("Audience").item(0).getTextContent());
+ assertEquals("keycloak",
+ doc.getElementsByTagName("Provider").item(0).getTextContent());
}
@Test
From 2834204f080e9b93c02b22124f639a2a92f37da6 Mon Sep 17 00:00:00 2001
From: paf91
Date: Thu, 14 May 2026 00:37:28 +0300
Subject: [PATCH 3/9] HDDS-15273. Add end-to-end WebIdentity STS S3 credential
test
---
...TestAssumeRoleWithWebIdentityEndToEnd.java | 562 ++++++++++++++++++
.../ozone/security/STSSecurityUtil.java | 4 +
.../ozone/security/STSTokenSecretManager.java | 8 +-
3 files changed, 573 insertions(+), 1 deletion(-)
create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
new file mode 100644
index 000000000000..4a224fd86ee3
--- /dev/null
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
@@ -0,0 +1,562 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.awssdk;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.hadoop.hdds.security.SecurityConfig.OZONE_TEST_AUTHORIZATION_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_AUTHORIZER_CLASS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
+import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY;
+import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY;
+import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
+import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY;
+import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY;
+import static org.apache.ozone.test.GenericTestUtils.PortAllocator.localhostWithFreePort;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.PlainJWT;
+import com.nimbusds.jwt.SignedJWT;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.crypto.KeyGenerator;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.apache.commons.io.IOUtils;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
+import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.s3.S3GatewayService;
+import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
+import org.apache.hadoop.ozone.security.acl.IOzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObj;
+import org.apache.hadoop.ozone.security.acl.RequestContext;
+import org.apache.ozone.test.ClusterForTests;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * End-to-end coverage for the WebIdentity STS bootstrap path and the existing
+ * S3 SigV4 temporary credential validation path.
+ */
+class TestAssumeRoleWithWebIdentityEndToEnd
+ extends ClusterForTests {
+
+ private static final String ISSUER = "http://keycloak.test/realms/ozone";
+ private static final String AUDIENCE = "ozone";
+ private static final String ALLOWED_BUCKET = "tomato-files";
+ private static final String DENIED_BUCKET = "denied-files";
+ private static final String ROLE_ARN =
+ "arn:aws:iam::123456789012:role/tomato-role";
+ private static final String SESSION_NAME = "tomato-session";
+
+ private final TestJwtIssuer jwtIssuer = new TestJwtIssuer();
+ private S3GatewayService s3GatewayService;
+
+ @Override
+ protected OzoneConfiguration createOzoneConfig() {
+ OzoneConfiguration conf = super.createOzoneConfig();
+ conf.setBoolean(OZONE_TEST_AUTHORIZATION_ENABLED, true);
+ conf.setBoolean(OZONE_ACL_ENABLED, true);
+ conf.set(OZONE_ACL_AUTHORIZER_CLASS,
+ WebIdentityEndToEndAuthorizer.class.getName());
+ conf.setBoolean(OZONE_S3G_STS_HTTP_ENABLED_KEY, true);
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true);
+ conf.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, ISSUER);
+ conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE);
+ conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwtIssuer.jwksUri());
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false);
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
+ true);
+ conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY, "RATIS");
+ conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "ONE");
+ conf.set(OZONE_S3G_STS_HTTP_ADDRESS_KEY, localhostWithFreePort());
+ conf.set(OZONE_S3G_STS_HTTPS_ADDRESS_KEY, localhostWithFreePort());
+ return conf;
+ }
+
+ @Override
+ protected MiniOzoneCluster createCluster() throws Exception {
+ OzoneManager.setTestSecureOmFlag(true);
+ WebIdentityEndToEndAuthorizer.reset();
+ s3GatewayService = new S3GatewayService();
+ return newClusterBuilder()
+ .setNumDatanodes(1)
+ .setSecretKeyClient(new InMemorySecretKeyClient())
+ .addService(s3GatewayService)
+ .build();
+ }
+
+ @Override
+ protected void onClusterReady() throws Exception {
+ try (OzoneClient client = getCluster().newClient()) {
+ client.getObjectStore().createS3Bucket(ALLOWED_BUCKET);
+ client.getObjectStore().createS3Bucket(DENIED_BUCKET);
+ }
+ }
+
+ @AfterAll
+ void resetTestSecurityFlag() {
+ OzoneManager.setTestSecureOmFlag(false);
+ }
+
+ @Test
+ void webIdentityTemporaryCredentialsAuthorizeS3Operations()
+ throws Exception {
+ StsCredentials credentials = assumeRoleWithWebIdentity(
+ jwtIssuer.token("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), AUDIENCE,
+ Instant.now().plus(Duration.ofHours(1))));
+
+ AssumeRoleWithWebIdentityRequest request =
+ WebIdentityEndToEndAuthorizer.lastAssumeRoleRequest;
+ assertThat(request).isNotNull();
+ assertEquals("tomato-user", request.getUser());
+ assertThat(request.getGroups()).containsExactly("ozone-tomato");
+ assertEquals(ROLE_ARN, request.getRoleArn());
+ assertEquals(SESSION_NAME, request.getRoleSessionName());
+ assertEquals(ISSUER, request.getIssuer());
+ assertEquals(AUDIENCE, request.getAudience());
+
+ try (S3Client s3 = s3Client(credentials)) {
+ String key = "allowed.txt";
+ s3.putObject(PutObjectRequest.builder()
+ .bucket(ALLOWED_BUCKET)
+ .key(key)
+ .build(),
+ RequestBody.fromBytes("web identity data".getBytes(UTF_8)));
+
+ ResponseBytes object =
+ s3.getObjectAsBytes(b -> b.bucket(ALLOWED_BUCKET).key(key));
+ assertEquals("web identity data", object.asUtf8String());
+
+ assertThat(s3.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)).contents())
+ .anyMatch(item -> key.equals(item.key()));
+
+ assertThrows(S3Exception.class, () ->
+ s3.putObject(PutObjectRequest.builder()
+ .bucket(DENIED_BUCKET)
+ .key("denied.txt")
+ .build(),
+ RequestBody.fromBytes("denied".getBytes(UTF_8))));
+ }
+
+ assertThat(WebIdentityEndToEndAuthorizer.accessChecksWithSessionPolicy)
+ .hasPositiveValue();
+ }
+
+ @Test
+ void invalidWebIdentityTokensFailBeforeCredentialsAreIssued()
+ throws Exception {
+ HttpResponse missingToken = postSts(null);
+ assertThat(missingToken.code).isGreaterThanOrEqualTo(400);
+
+ assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), "wrong-audience",
+ Instant.now().plus(Duration.ofHours(1))), 403);
+
+ assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), AUDIENCE,
+ Instant.now().minus(Duration.ofMinutes(5))), 403);
+
+ assertStsFails(jwtIssuer.algNoneToken(), 403);
+
+ assertStsFails(jwtIssuer.tamperedGroupsToken(), 403);
+
+ assertStsFails(jwtIssuer.token("denied-user", "subject-denied",
+ Collections.singletonList("ozone-denied"), AUDIENCE,
+ Instant.now().plus(Duration.ofHours(1))), 403);
+ }
+
+ @Test
+ void normalS3RequestWithoutSigV4IsStillDenied() throws Exception {
+ URL url = URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)
+ + "/" + ALLOWED_BUCKET).toURL();
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("GET");
+ assertThat(connection.getResponseCode()).isEqualTo(403);
+ }
+
+ @Test
+ void temporaryCredentialsRequireCorrectSecretAndSessionToken()
+ throws Exception {
+ StsCredentials credentials = assumeRoleWithWebIdentity(
+ jwtIssuer.token("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), AUDIENCE,
+ Instant.now().plus(Duration.ofHours(1))));
+
+ try (S3Client missingSessionToken =
+ s3Client(credentials.accessKeyId, credentials.secretAccessKey,
+ null)) {
+ assertThrows(S3Exception.class, () ->
+ missingSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
+ }
+
+ try (S3Client wrongSessionToken =
+ s3Client(credentials.accessKeyId, credentials.secretAccessKey,
+ credentials.sessionToken + "wrong")) {
+ assertThrows(S3Exception.class, () ->
+ wrongSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
+ }
+
+ try (S3Client wrongSecret =
+ s3Client(credentials.accessKeyId,
+ credentials.secretAccessKey + "wrong",
+ credentials.sessionToken)) {
+ assertThrows(S3Exception.class, () ->
+ wrongSecret.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
+ }
+ }
+
+ private StsCredentials assumeRoleWithWebIdentity(String token)
+ throws Exception {
+ HttpResponse response = postSts(token);
+ assertEquals(200, response.code, response.body);
+ Document document = parseXml(response.body);
+ StsCredentials credentials = new StsCredentials(
+ xmlText(document, "AccessKeyId"),
+ xmlText(document, "SecretAccessKey"),
+ xmlText(document, "SessionToken"));
+ assertThat(credentials.accessKeyId).startsWith("ASIA");
+ assertThat(credentials.secretAccessKey).isNotBlank();
+ assertThat(credentials.sessionToken).isNotBlank();
+ assertThat(xmlText(document, "SubjectFromWebIdentityToken"))
+ .isEqualTo("subject-tomato");
+ assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE);
+ assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME);
+ return credentials;
+ }
+
+ private void assertStsFails(String token, int expectedStatus)
+ throws Exception {
+ HttpResponse response = postSts(token);
+ assertEquals(expectedStatus, response.code, response.body);
+ assertThat(response.body).doesNotContain(token);
+ }
+
+ private HttpResponse postSts(String token) throws IOException {
+ URL url = URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_STS_HTTP_ADDRESS_KEY)
+ + "/sts").toURL();
+ String body = "Action=AssumeRoleWithWebIdentity"
+ + "&Version=2011-06-15"
+ + "&RoleArn=" + encode(ROLE_ARN)
+ + "&RoleSessionName=" + encode(SESSION_NAME)
+ + "&DurationSeconds=900";
+ if (token != null) {
+ body += "&WebIdentityToken=" + encode(token);
+ }
+
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type",
+ "application/x-www-form-urlencoded");
+ try (OutputStream output = connection.getOutputStream()) {
+ output.write(body.getBytes(UTF_8));
+ }
+
+ int code = connection.getResponseCode();
+ String responseBody;
+ if (code >= 400) {
+ responseBody = IOUtils.toString(connection.getErrorStream(), UTF_8);
+ } else {
+ responseBody = IOUtils.toString(connection.getInputStream(), UTF_8);
+ }
+ return new HttpResponse(code, responseBody);
+ }
+
+ private S3Client s3Client(StsCredentials credentials) {
+ return s3Client(credentials.accessKeyId, credentials.secretAccessKey,
+ credentials.sessionToken);
+ }
+
+ private S3Client s3Client(String accessKeyId, String secretAccessKey,
+ String sessionToken) {
+ StaticCredentialsProvider credentialsProvider;
+ if (sessionToken == null) {
+ credentialsProvider = StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKeyId, secretAccessKey));
+ } else {
+ credentialsProvider = StaticCredentialsProvider.create(
+ AwsSessionCredentials.create(accessKeyId, secretAccessKey,
+ sessionToken));
+ }
+ return S3Client.builder()
+ .region(Region.US_EAST_1)
+ .endpointOverride(URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)))
+ .credentialsProvider(credentialsProvider)
+ .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
+ .forcePathStyle(true)
+ .build();
+ }
+
+ private static String encode(String value) throws IOException {
+ return URLEncoder.encode(value, UTF_8.name());
+ }
+
+ private static Document parseXml(String xml) throws Exception {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ return factory.newDocumentBuilder()
+ .parse(IOUtils.toInputStream(xml, UTF_8));
+ }
+
+ private static String xmlText(Document document, String localName) {
+ return document.getElementsByTagNameNS("*", localName)
+ .item(0).getTextContent();
+ }
+
+ private static final class HttpResponse {
+ private final int code;
+ private final String body;
+
+ private HttpResponse(int code, String body) {
+ this.code = code;
+ this.body = body;
+ }
+ }
+
+ private static final class StsCredentials {
+ private final String accessKeyId;
+ private final String secretAccessKey;
+ private final String sessionToken;
+
+ private StsCredentials(String accessKeyId, String secretAccessKey,
+ String sessionToken) {
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.sessionToken = sessionToken;
+ }
+ }
+
+ public static final class WebIdentityEndToEndAuthorizer
+ implements IAccessAuthorizer {
+ private static final AtomicInteger accessChecksWithSessionPolicy =
+ new AtomicInteger();
+ private static volatile AssumeRoleWithWebIdentityRequest
+ lastAssumeRoleRequest;
+
+ public static void reset() {
+ accessChecksWithSessionPolicy.set(0);
+ lastAssumeRoleRequest = null;
+ }
+
+ @Override
+ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
+ if (context.getSessionPolicy() == null) {
+ return true;
+ }
+ accessChecksWithSessionPolicy.incrementAndGet();
+ if (!(ozoneObject instanceof OzoneObj)) {
+ return false;
+ }
+ OzoneObj obj = (OzoneObj) ozoneObject;
+ return obj.getBucketName() == null
+ || ALLOWED_BUCKET.equals(obj.getBucketName());
+ }
+
+ @Override
+ public String generateAssumeRoleWithWebIdentitySessionPolicy(
+ AssumeRoleWithWebIdentityRequest request) throws OMException {
+ lastAssumeRoleRequest = request;
+ if (!"tomato-user".equals(request.getUser())
+ || !request.getGroups().contains("ozone-tomato")
+ || !ROLE_ARN.equals(request.getRoleArn())) {
+ throw new OMException("WebIdentity role assumption denied",
+ OMException.ResultCodes.ACCESS_DENIED);
+ }
+ return "{"
+ + "\"Version\":\"2012-10-17\","
+ + "\"Statement\":[{"
+ + "\"Effect\":\"Allow\","
+ + "\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:ListBucket\"],"
+ + "\"Resource\":[\"arn:aws:s3:::" + ALLOWED_BUCKET
+ + "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]"
+ + "}]}";
+ }
+ }
+
+ private static final class InMemorySecretKeyClient
+ implements SecretKeyClient {
+ private final Map keys = new LinkedHashMap<>();
+ private final ManagedSecretKey current;
+
+ private InMemorySecretKeyClient() {
+ this.current = newKey();
+ keys.put(current.getId(), current);
+ }
+
+ @Override
+ public ManagedSecretKey getCurrentSecretKey() {
+ return current;
+ }
+
+ @Override
+ public ManagedSecretKey getSecretKey(UUID id) {
+ return keys.get(id);
+ }
+
+ private static ManagedSecretKey newKey() {
+ try {
+ KeyGenerator generator = KeyGenerator.getInstance("HmacSHA256");
+ return new ManagedSecretKey(UUID.randomUUID(), Instant.now(),
+ Instant.now().plus(Duration.ofHours(4)), generator.generateKey());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("HmacSHA256 is unavailable", e);
+ }
+ }
+ }
+
+ private static final class TestJwtIssuer {
+ private final RSAKey key;
+ private final Path jwksFile;
+
+ private TestJwtIssuer() {
+ try {
+ this.key = rsaKey("kid-primary");
+ this.jwksFile = Files.createTempFile(
+ "ozone-webidentity-jwks", ".json");
+ JWKSet jwkSet = new JWKSet(Collections.singletonList(
+ key.toPublicJWK()));
+ Files.write(jwksFile, jwkSet.toString().getBytes(UTF_8));
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to create test JWKS", e);
+ }
+ }
+
+ private String jwksUri() {
+ return jwksFile.toUri().toString();
+ }
+
+ private String token(String username, String subject,
+ Iterable groups, String audience, Instant expiresAt)
+ throws Exception {
+ return signedToken(key, key.getKeyID(), username, subject, groups,
+ audience, expiresAt);
+ }
+
+ private String tamperedGroupsToken() throws Exception {
+ String token = token("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), AUDIENCE,
+ Instant.now().plus(Duration.ofHours(1)));
+ String[] parts = token.split("\\.");
+ String payload = new String(java.util.Base64.getUrlDecoder()
+ .decode(parts[1]), UTF_8);
+ parts[1] = java.util.Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(payload.replace("ozone-tomato", "ozone-admins")
+ .getBytes(UTF_8));
+ return String.join(".", parts);
+ }
+
+ private String algNoneToken() {
+ return new PlainJWT(claims("tomato-user", "subject-tomato",
+ Collections.singletonList("ozone-tomato"), AUDIENCE,
+ Instant.now().plus(Duration.ofHours(1))).build()).serialize();
+ }
+
+ private static String signedToken(RSAKey signerKey, String keyId,
+ String username, String subject, Iterable groups,
+ String audience, Instant expiresAt) throws Exception {
+ SignedJWT jwt = new SignedJWT(
+ new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(keyId).build(),
+ claims(username, subject, groups, audience, expiresAt).build());
+ jwt.sign(new RSASSASigner(signerKey.toRSAPrivateKey()));
+ return jwt.serialize();
+ }
+
+ private static JWTClaimsSet.Builder claims(String username, String subject,
+ Iterable groups, String audience, Instant expiresAt) {
+ Map realmAccess = new LinkedHashMap<>();
+ realmAccess.put("roles", Arrays.asList("writer", "read"));
+ Instant now = Instant.now();
+ return new JWTClaimsSet.Builder()
+ .issuer(ISSUER)
+ .subject(subject)
+ .audience(Collections.singletonList(audience))
+ .issueTime(Date.from(now.minusSeconds(30)))
+ .expirationTime(Date.from(expiresAt))
+ .claim("preferred_username", username)
+ .claim("groups", groups)
+ .claim("realm_access", realmAccess);
+ }
+
+ private static RSAKey rsaKey(String keyId) throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ KeyPair keyPair = generator.generateKeyPair();
+ return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
+ .privateKey((RSAPrivateKey) keyPair.getPrivate())
+ .keyID(keyId)
+ .algorithm(JWSAlgorithm.RS256)
+ .build();
+ }
+ }
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
index 24ec8fa17b84..90f660724e15 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSSecurityUtil.java
@@ -154,6 +154,10 @@ private static Token decodeTokenFromString(String encodedTok
final Token token = new Token<>();
try {
token.decodeFromUrlString(encodedToken);
+ if (!encodedToken.equals(token.encodeToUrlString())) {
+ throw new SecretManager.InvalidToken(
+ "Failed to decode STS token string: non-canonical token encoding");
+ }
return token;
} catch (IOException e) {
throw new SecretManager.InvalidToken("Failed to decode STS token string: " + e);
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
index e39fe57c368b..aaa4f14dd681 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
@@ -40,7 +40,7 @@ public class STSTokenSecretManager extends ShortLivedTokenSecretManager
Date: Thu, 14 May 2026 03:09:02 +0300
Subject: [PATCH 4/9] HDDS-15273. Add Keycloak IT and hardening for WebIdentity
STS
---
.../apache/hadoop/ozone/OzoneConfigKeys.java | 12 +
.../src/main/resources/ozone-default.xml | 23 +-
.../oidc-assume-role-with-web-identity.md | 20 +-
.../OzoneSTSWebIdentityKeycloakRanger.md | 300 ++++++++++++
...AssumeRoleWithWebIdentityResponseInfo.java | 1 +
.../acl/AssumeRoleWithWebIdentityRequest.java | 1 +
.../ozone/security/acl/IAccessAuthorizer.java | 5 +
.../security/oidc/CachingJwksProvider.java | 44 +-
.../ozone/security/oidc/OidcConfig.java | 79 ++++
.../ozone/security/oidc/UrlJwksFetcher.java | 35 +-
.../oidc/TestOidcJwtIdentityProvider.java | 31 ++
hadoop-ozone/integration-test-s3/pom.xml | 5 +
...stractAssumeRoleWithWebIdentityS3Test.java | 426 ++++++++++++++++++
...TestAssumeRoleWithWebIdentityEndToEnd.java | 370 +--------------
...stAssumeRoleWithWebIdentityKeycloakIT.java | 211 +++++++++
.../resources/keycloak/ozone-test-realm.json | 137 ++++++
.../apache/hadoop/ozone/om/OzoneManager.java | 18 +
.../S3AssumeRoleWithWebIdentityRequest.java | 5 +-
.../ozone/security/STSTokenIdentifier.java | 1 +
.../ozone/security/STSTokenSecretManager.java | 2 +
...estS3AssumeRoleWithWebIdentityRequest.java | 39 +-
...3AssumeRoleWithWebIdentityResponseXml.java | 4 +
.../hadoop/ozone/s3sts/S3STSEndpoint.java | 7 +-
.../s3sts/S3STSWebIdentityRequestParser.java | 16 +-
.../TestS3STSWebIdentityAuthBypassFilter.java | 46 ++
pom.xml | 6 +
26 files changed, 1486 insertions(+), 358 deletions(-)
create mode 100644 hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
create mode 100644 hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
create mode 100644 hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json
diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
index 1f0a0fd99fb5..e798e2f8271b 100644
--- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
+++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
@@ -481,6 +481,18 @@ public final class OzoneConfigKeys {
"ozone.sts.web.identity.jwks.refresh.interval";
public static final String
OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT =
+ "ozone.sts.web.identity.jwks.connect.timeout";
+ public static final String
+ OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT = "5s";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT =
+ "ozone.sts.web.identity.jwks.read.timeout";
+ public static final String
+ OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT = "5s";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT =
+ "ozone.sts.web.identity.jwks.size.limit";
+ public static final String
+ OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT = "1MB";
public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS =
"ozone.sts.web.identity.require.https";
public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT =
diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml
index 615ea2ba1aa2..19bd629fec49 100644
--- a/hadoop-hdds/common/src/main/resources/ozone-default.xml
+++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml
@@ -2213,7 +2213,28 @@
ozone.sts.web.identity.jwks.refresh.interval
10m
OZONE, SECURITY, S3, STS, OIDC
- Interval for refreshing cached JWKS signing keys.
+
+ Interval for refreshing cached JWKS signing keys. Unknown key IDs may
+ trigger an earlier refresh, but repeated unknown key IDs are debounced.
+
+
+
+ ozone.sts.web.identity.jwks.connect.timeout
+ 5s
+ OZONE, SECURITY, S3, STS, OIDC
+ Connection timeout for fetching OIDC JWKS signing keys.
+
+
+ ozone.sts.web.identity.jwks.read.timeout
+ 5s
+ OZONE, SECURITY, S3, STS, OIDC
+ Read timeout for fetching OIDC JWKS signing keys.
+
+
+ ozone.sts.web.identity.jwks.size.limit
+ 1MB
+ OZONE, SECURITY, S3, STS, OIDC
+ Maximum JWKS response size accepted by Ozone STS.
ozone.sts.web.identity.require.https
diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
index b61233376642..34f0ef995c77 100644
--- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
+++ b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
@@ -278,6 +278,9 @@ ozone.sts.web.identity.groups.claim=groups
ozone.sts.web.identity.roles.claim=realm_access.roles
ozone.sts.web.identity.clock.skew=60s
ozone.sts.web.identity.jwks.refresh.interval=10m
+ozone.sts.web.identity.jwks.connect.timeout=5s
+ozone.sts.web.identity.jwks.read.timeout=5s
+ozone.sts.web.identity.jwks.size.limit=1MB
ozone.sts.web.identity.require.https=true
ozone.sts.web.identity.allow.insecure.http.for.tests=false
```
@@ -300,7 +303,11 @@ The reusable validation module is intentionally small:
exception messages.
The module does not call Keycloak for every S3 request. JWKS validation is local,
-with refresh on cache expiry and unknown key id.
+with refresh on cache expiry and unknown key id. The default JWKS refresh
+interval is 10 minutes. Unknown key ids may trigger an earlier refresh, but
+repeated unknown kids are debounced to avoid refresh storms from attacker
+supplied token headers. JWKS fetches use bounded connect/read timeouts and a
+bounded response size.
## Ranger Authorization Points
@@ -322,6 +329,12 @@ The common request-shape extension point is
`IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the
default authorizer hook. Existing authorizers are not forced to implement this
immediately because the new method has a fail-closed default implementation.
+For production Ranger deployments, the external Ranger Ozone plugin must add a
+companion override for this method. The `RangerOzoneAuthorizer` class is
+provided by Apache Ranger, not this Ozone repository. Without a WebIdentity
+capable Ranger/Ozone authorizer, the default hook returns
+`NOT_SUPPORTED_OPERATION`, Ozone fails closed, and no WebIdentity temporary
+credentials are issued.
The second authorization point is every S3 operation made with the temporary
credentials. OM must recover the assumed identity and session policy from the
@@ -352,6 +365,11 @@ The existing STS runtime uses self-contained session tokens containing the
encrypted secret access key, original identity, role ARN, session policy,
expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse
that issuer and validator instead of creating a parallel token format.
+The sanitized replicated OM request still carries temporary credential material
+through Ratis in the same way as the existing `AssumeRole` implementation.
+Operators must protect OM metadata, Ratis logs, snapshots, and backups as
+sensitive security material. The raw WebIdentity JWT must not be replicated or
+stored in OM metadata.
In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the
`originalAccessKeyId` because `AssumeRole` starts from an existing S3 access
diff --git a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
new file mode 100644
index 000000000000..1846e793552a
--- /dev/null
+++ b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
@@ -0,0 +1,300 @@
+---
+title: "Using Ozone STS AssumeRoleWithWebIdentity with Keycloak and Ranger"
+date: "2026-05-14"
+summary: Exchange Keycloak/OIDC web identity tokens for temporary Ozone S3 credentials through Ozone STS.
+weight: 6
+menu:
+ main:
+ parent: Security
+icon: key
+---
+
+
+Ozone STS can exchange an OIDC web identity token for short-lived S3
+credentials using an AWS-compatible `AssumeRoleWithWebIdentity` request. This
+is intended for deployments where workloads already authenticate to Keycloak or
+another OIDC provider and need temporary credentials for Ozone S3.
+
+This feature is disabled by default.
+
+## Architecture
+
+The Web Identity flow has three separate responsibilities:
+
+1. Keycloak authenticates the caller and issues a signed OIDC JWT.
+2. Ozone STS validates the JWT and issues temporary S3 credentials.
+3. Ranger or the configured Ozone authorizer authorizes role assumption and
+ subsequent S3 access.
+
+Keycloak groups and roles are identity attributes only. They are not the final
+bucket or object policy engine. Ranger policies or the configured Ozone
+authorizer remain the policy decision point (PDP) and source of authorization
+decisions.
+
+The request path is:
+
+```text
+Client or workload
+ -> Keycloak access token
+ -> Ozone STS AssumeRoleWithWebIdentity
+ -> temporary AccessKeyId, SecretAccessKey, SessionToken
+ -> normal S3 SigV4 request with x-amz-security-token
+ -> OM STS token validation
+ -> Ranger or Ozone authorizer
+```
+
+## What This Adds
+
+This feature adds `AssumeRoleWithWebIdentity` to the existing Ozone STS
+temporary credential model. Ozone STS validates the configured issuer,
+audience, JWT signature, expiry, not-before time, issued-at time, username
+claim, subject claim, and configured group and role claims.
+
+The returned credentials use the existing STS session-token validation path for
+later S3 operations. S3 clients must sign normal S3 requests with AWS Signature
+Version 4 and include the returned session token as `x-amz-security-token`.
+
+## What This Does Not Add
+
+This feature does not replace Kerberos daemon authentication, does not add OFS
+OIDC login, does not add CLI device-code login, does not make non-secure Ozone
+fully secure, and does not use Keycloak Authorization Services as the Ozone
+bucket or object policy decision point.
+
+## Ozone Configuration
+
+Enable Web Identity support only on clusters where STS and the S3 Gateway are
+configured and the authorization provider can authorize STS role assumption and
+subsequent S3 access.
+
+```xml
+
+ ozone.sts.web.identity.enabled
+ true
+
+
+ ozone.sts.web.identity.issuer.uri
+ https://keycloak.example.com/realms/ozone
+
+
+ ozone.sts.web.identity.jwks.uri
+ https://keycloak.example.com/realms/ozone/protocol/openid-connect/certs
+
+
+ ozone.sts.web.identity.audience
+ ozone
+
+
+ ozone.sts.web.identity.username.claim
+ preferred_username
+
+
+ ozone.sts.web.identity.subject.claim
+ sub
+
+
+ ozone.sts.web.identity.groups.claim
+ groups
+
+
+ ozone.sts.web.identity.roles.claim
+ realm_access.roles
+
+
+ ozone.sts.web.identity.require.https
+ true
+
+
+ ozone.sts.web.identity.jwks.refresh.interval
+ 10m
+
+
+ ozone.sts.web.identity.jwks.connect.timeout
+ 5s
+
+
+ ozone.sts.web.identity.jwks.read.timeout
+ 5s
+
+
+ ozone.sts.web.identity.jwks.size.limit
+ 1MB
+
+```
+
+For local tests only, HTTP issuer and JWKS URLs can be enabled explicitly:
+
+```xml
+
+ ozone.sts.web.identity.require.https
+ false
+
+
+ ozone.sts.web.identity.allow.insecure.http.for.tests
+ true
+
+```
+
+Production deployments should use HTTPS for both Keycloak and Ozone endpoints.
+
+JWKS keys are cached. The default refresh interval is 10 minutes. A token with
+an unknown `kid` can trigger an earlier JWKS refresh, but repeated unknown
+`kid` values are debounced to avoid refresh storms. During Keycloak signing key
+rotation, new tokens may fail until Ozone refreshes JWKS or sees the first
+unknown `kid` after the debounce window. Operators should publish old and new
+keys concurrently for at least the maximum token lifetime plus the JWKS refresh
+interval.
+
+## Keycloak Setup
+
+A minimal Keycloak setup contains:
+
+- Realm: `ozone`
+- Client: `ozone-sts`
+- Audience mapper: include `ozone` in access tokens
+- Group membership mapper: include groups in the `groups` claim
+- Users and groups used by Ranger policies, for example user `tomato-user` in
+ group `ozone-tomato`
+
+The token presented to Ozone STS must contain claims compatible with the Ozone
+configuration:
+
+```json
+{
+ "iss": "https://keycloak.example.com/realms/ozone",
+ "aud": "ozone",
+ "sub": "ce3f0b9b-...",
+ "preferred_username": "tomato-user",
+ "groups": ["ozone-tomato"],
+ "realm_access": {
+ "roles": ["offline_access"]
+ }
+}
+```
+
+## Ranger Policy Model
+
+The authorizer must allow the mapped OIDC identity to perform
+`AssumeRoleWithWebIdentity` on the requested role ARN before Ozone issues any
+temporary credential. The role ARN is treated as an authorization resource for
+this MVP. This patch does not add an IAM role database.
+
+Production Ranger deployments need a WebIdentity-capable Ozone authorizer. In
+this source tree, `IAccessAuthorizer` provides the
+`generateAssumeRoleWithWebIdentitySessionPolicy(...)` extension point and its
+default implementation fails closed with `NOT_SUPPORTED_OPERATION`. The
+`org.apache.ranger.authorization.ozone.authorizer.RangerOzoneAuthorizer`
+implementation is supplied by the external Ranger Ozone plugin, not by this
+repository. Until that plugin overrides the WebIdentity method, deployments
+using it will fail closed and will not issue temporary credentials for
+`AssumeRoleWithWebIdentity`.
+
+The authorizer must also allow the later S3 operations. In a Ranger deployment,
+the recommended shape is:
+
+- STS assumption policy: allow group `ozone-tomato` to assume the configured
+ role ARN.
+- S3 resource policy: allow group `ozone-tomato` to access the intended
+ volume, bucket, key, or prefix.
+- Deny by default for identities and buckets not covered by policy.
+
+## STS Request
+
+The client posts a form-encoded request to the Ozone STS endpoint:
+
+```bash
+curl -sS -X POST "https://s3g.example.com:9881/sts" \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "Action=AssumeRoleWithWebIdentity" \
+ --data-urlencode "RoleArn=arn:aws:iam::123456789012:role/tomato-role" \
+ --data-urlencode "RoleSessionName=tomato-session" \
+ --data-urlencode "WebIdentityToken=${KEYCLOAK_ACCESS_TOKEN}" \
+ --data-urlencode "DurationSeconds=3600"
+```
+
+The bootstrap request does not use S3 SigV4. The unauthenticated bypass is
+limited to `/sts`, only for `Action=AssumeRoleWithWebIdentity`, and only when
+`ozone.sts.web.identity.enabled=true`. OM still validates the JWT itself before
+issuing credentials.
+
+## STS Response
+
+The response follows the AWS STS shape where practical:
+
+```xml
+
+
+
+ ...
+ ...
+ ...
+ 2026-05-14T12:00:00Z
+
+ ...
+
+ arn:aws:sts::123456789012:assumed-role/tomato-role/tomato-session
+ ...
+
+ ozone
+ https://keycloak.example.com/realms/ozone
+
+
+```
+
+## Using Temporary Credentials
+
+AWS-compatible clients must use all three returned credential fields:
+
+```bash
+export AWS_ACCESS_KEY_ID="returned-access-key"
+export AWS_SECRET_ACCESS_KEY="returned-secret-key"
+export AWS_SESSION_TOKEN="returned-session-token"
+export AWS_DEFAULT_REGION="us-east-1"
+
+aws --endpoint-url https://s3g.example.com:9878 s3 cp ./file.txt s3://tomato-files/file.txt
+aws --endpoint-url https://s3g.example.com:9878 s3 ls s3://tomato-files/
+aws --endpoint-url https://s3g.example.com:9878 s3 cp s3://tomato-files/file.txt ./file.txt
+```
+
+If `AWS_SESSION_TOKEN` is missing, wrong, expired, or encoded in a
+non-canonical form, STS temporary credential validation fails closed.
+
+## Security Notes
+
+The raw web identity JWT is validated by OM before Ratis replication and is
+stripped before the replicated request is written. The replicated request and
+STS token identifier contain sanitized identity and session fields, not the raw
+JWT.
+
+The temporary `SecretAccessKey` and returned session token are protected by the
+existing STS token path. The sanitized replicated OM request still carries
+temporary credential material through Ratis similarly to the existing
+`AssumeRole` implementation, because the credentials are generated before the
+state-machine apply path. Operators must protect OM metadata directories, Ratis
+logs, snapshots, and backups as sensitive security material.
+
+Do not log bearer tokens, session tokens, temporary secrets, client secrets, or
+Authorization headers. Ozone errors should not include token material.
+
+Temporary credentials expire. The effective expiration is constrained by the
+STS duration limits and the web identity token lifetime.
+
+Protect Ozone endpoints according to the deployment model. This feature does
+not make direct unauthenticated OM, SCM, DataNode, OFS, or internal RPC access
+safe. It only adds an OIDC-to-temporary-S3-credentials exchange path for Ozone
+STS.
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
index 77654ac8a50d..82ef3079caba 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/om/helpers/AssumeRoleWithWebIdentityResponseInfo.java
@@ -36,6 +36,7 @@ public class AssumeRoleWithWebIdentityResponseInfo {
private final String audience;
private final String provider;
+ @SuppressWarnings("checkstyle:ParameterNumber")
public AssumeRoleWithWebIdentityResponseInfo(String accessKeyId,
String secretAccessKey, String sessionToken, long expirationEpochSeconds,
String assumedRoleId, String subjectFromWebIdentityToken, String audience,
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
index 3512356a9b44..d365557d46c4 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
@@ -50,6 +50,7 @@ public class AssumeRoleWithWebIdentityRequest {
private final String providerId;
private final Set grants;
+ @SuppressWarnings("checkstyle:ParameterNumber")
public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip,
String user, Set groups, Set roles, String roleArn,
String roleSessionName, String issuer, String subject, String audience,
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
index 437eb96d94ae..569f6b41bb2b 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/IAccessAuthorizer.java
@@ -77,6 +77,11 @@ default String generateAssumeRoleSessionPolicy(AssumeRoleRequest assumeRoleReque
* attributes only. The final role-assumption authorization decision and the
* returned session policy must come from this authorizer.
*
+ * Deployments using an external Ranger Ozone plugin need a companion
+ * plugin implementation of this method. Without it, this default method
+ * fails closed with {@link OMException.ResultCodes#NOT_SUPPORTED_OPERATION}
+ * and Ozone STS will not issue WebIdentity temporary credentials.
+ *
* @param request the web identity role assumption request shape
* @return a String representing the permissions granted according to
* the authorizer.
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
index 007174dc98c8..bff570ccf780 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
@@ -33,11 +33,16 @@
*/
public final class CachingJwksProvider implements JwksProvider {
+ private static final Duration DEFAULT_UNKNOWN_KID_REFRESH_DEBOUNCE =
+ Duration.ofSeconds(5);
+
private final JwksFetcher fetcher;
private final Duration refreshInterval;
+ private final Duration unknownKidRefreshDebounce;
private final Clock clock;
private volatile JWKSet jwkSet;
private volatile Instant loadedAt = Instant.EPOCH;
+ private volatile Instant lastUnknownKidRefreshAt = Instant.EPOCH;
public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) {
this(fetcher, refreshInterval, Clock.systemUTC());
@@ -45,6 +50,12 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) {
CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval,
Clock clock) {
+ this(fetcher, refreshInterval, DEFAULT_UNKNOWN_KID_REFRESH_DEBOUNCE,
+ clock);
+ }
+
+ CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval,
+ Duration unknownKidRefreshDebounce, Clock clock) {
if (fetcher == null) {
throw new IllegalArgumentException("JWKS fetcher must not be null");
}
@@ -52,8 +63,14 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) {
throw new IllegalArgumentException(
"JWKS refresh interval must not be negative");
}
+ if (unknownKidRefreshDebounce == null
+ || unknownKidRefreshDebounce.isNegative()) {
+ throw new IllegalArgumentException(
+ "Unknown kid refresh debounce must not be negative");
+ }
this.fetcher = fetcher;
this.refreshInterval = refreshInterval;
+ this.unknownKidRefreshDebounce = unknownKidRefreshDebounce;
this.clock = clock;
}
@@ -62,7 +79,7 @@ public List getKeys(String keyId) throws OidcAuthenticationException {
refreshIfNeeded(false);
List keys = findKeys(jwkSet, keyId);
if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) {
- refreshIfNeeded(true);
+ refreshForUnknownKidIfNeeded();
keys = findKeys(jwkSet, keyId);
}
return keys;
@@ -94,6 +111,31 @@ private void refreshIfNeeded(boolean force)
}
}
+ private void refreshForUnknownKidIfNeeded()
+ throws OidcAuthenticationException {
+ Instant now = clock.instant();
+ if (now.isBefore(lastUnknownKidRefreshAt.plus(
+ unknownKidRefreshDebounce))) {
+ return;
+ }
+
+ synchronized (this) {
+ now = clock.instant();
+ if (now.isBefore(lastUnknownKidRefreshAt.plus(
+ unknownKidRefreshDebounce))) {
+ return;
+ }
+ try {
+ jwkSet = fetcher.fetch();
+ loadedAt = now;
+ lastUnknownKidRefreshAt = now;
+ } catch (IOException | ParseException e) {
+ throw new OidcAuthenticationException(
+ "Unable to refresh OIDC JWKS", e);
+ }
+ }
+ }
+
private static List findKeys(JWKSet set, String keyId) {
if (set == null) {
return Collections.emptyList();
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
index 5665c01195e8..53a91995a445 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
@@ -29,8 +29,14 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
@@ -47,6 +53,7 @@
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import org.apache.hadoop.hdds.conf.ConfigurationSource;
+import org.apache.hadoop.hdds.conf.StorageUnit;
/**
* Configuration for the experimental OIDC identity provider.
@@ -63,6 +70,9 @@ public final class OidcConfig {
private final String rolesClaim;
private final Duration clockSkew;
private final Duration jwksRefreshInterval;
+ private final Duration jwksConnectTimeout;
+ private final Duration jwksReadTimeout;
+ private final int jwksSizeLimit;
private final boolean requireHttps;
private final boolean allowInsecureHttpForTests;
@@ -83,6 +93,12 @@ private OidcConfig(Builder builder) {
OZONE_STS_WEB_IDENTITY_CLOCK_SKEW);
this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval,
OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL);
+ this.jwksConnectTimeout = requirePositive(builder.jwksConnectTimeout,
+ OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT);
+ this.jwksReadTimeout = requirePositive(builder.jwksReadTimeout,
+ OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT);
+ this.jwksSizeLimit = requirePositive(builder.jwksSizeLimit,
+ OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT);
this.requireHttps = builder.requireHttps;
this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests;
}
@@ -112,6 +128,15 @@ public static OidcConfig from(ConfigurationSource conf) {
.setJwksRefreshInterval(duration(conf,
OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL,
OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT))
+ .setJwksConnectTimeout(duration(conf,
+ OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT,
+ OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT))
+ .setJwksReadTimeout(duration(conf,
+ OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT,
+ OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT))
+ .setJwksSizeLimit(storageSize(conf,
+ OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT,
+ OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT))
.setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS,
OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT))
.setAllowInsecureHttpForTests(conf.getBoolean(
@@ -168,6 +193,18 @@ public Duration getJwksRefreshInterval() {
return jwksRefreshInterval;
}
+ public Duration getJwksConnectTimeout() {
+ return jwksConnectTimeout;
+ }
+
+ public Duration getJwksReadTimeout() {
+ return jwksReadTimeout;
+ }
+
+ public int getJwksSizeLimit() {
+ return jwksSizeLimit;
+ }
+
public boolean isRequireHttps() {
return requireHttps;
}
@@ -194,6 +231,16 @@ private static Duration duration(ConfigurationSource conf, String key,
TimeUnit.MILLISECONDS));
}
+ private static int storageSize(ConfigurationSource conf, String key,
+ String defaultValue) {
+ double bytes = conf.getStorageSize(key, defaultValue, StorageUnit.BYTES);
+ if (bytes > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException(key + " must not exceed "
+ + Integer.MAX_VALUE + " bytes");
+ }
+ return (int) bytes;
+ }
+
private static Duration requireNonNegative(Duration value, String key) {
if (value == null || value.isNegative()) {
throw new IllegalArgumentException(key + " must not be negative");
@@ -201,6 +248,20 @@ private static Duration requireNonNegative(Duration value, String key) {
return value;
}
+ private static Duration requirePositive(Duration value, String key) {
+ if (value == null || value.isZero() || value.isNegative()) {
+ throw new IllegalArgumentException(key + " must be positive");
+ }
+ return value;
+ }
+
+ private static int requirePositive(int value, String key) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(key + " must be positive");
+ }
+ return value;
+ }
+
private static String requireNonBlank(String value, String key) {
String trimmed = trimToEmpty(value);
if (trimmed.isEmpty()) {
@@ -242,6 +303,9 @@ public static final class Builder {
private String rolesClaim = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT;
private Duration clockSkew = Duration.ofSeconds(60);
private Duration jwksRefreshInterval = Duration.ofMinutes(10);
+ private Duration jwksConnectTimeout = Duration.ofSeconds(5);
+ private Duration jwksReadTimeout = Duration.ofSeconds(5);
+ private int jwksSizeLimit = 1024 * 1024;
private boolean requireHttps = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT;
private boolean allowInsecureHttpForTests =
OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
@@ -299,6 +363,21 @@ public Builder setJwksRefreshInterval(Duration value) {
return this;
}
+ public Builder setJwksConnectTimeout(Duration value) {
+ this.jwksConnectTimeout = value;
+ return this;
+ }
+
+ public Builder setJwksReadTimeout(Duration value) {
+ this.jwksReadTimeout = value;
+ return this;
+ }
+
+ public Builder setJwksSizeLimit(int value) {
+ this.jwksSizeLimit = value;
+ return this;
+ }
+
public Builder setRequireHttps(boolean value) {
this.requireHttps = value;
return this;
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
index a2fe2b4dad3f..483ccf21342e 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
@@ -21,6 +21,7 @@
import java.io.IOException;
import java.net.URL;
import java.text.ParseException;
+import java.time.Duration;
/**
* JWKS fetcher backed by a URL.
@@ -28,13 +29,45 @@
public final class UrlJwksFetcher implements JwksFetcher {
private final URL url;
+ private final int connectTimeoutMillis;
+ private final int readTimeoutMillis;
+ private final int sizeLimitBytes;
public UrlJwksFetcher(URL url) {
+ this(url, Duration.ofSeconds(5), Duration.ofSeconds(5), 1024 * 1024);
+ }
+
+ public UrlJwksFetcher(URL url, Duration connectTimeout,
+ Duration readTimeout, int sizeLimitBytes) {
+ if (url == null) {
+ throw new IllegalArgumentException("JWKS URL must not be null");
+ }
this.url = url;
+ this.connectTimeoutMillis = toMillisInt(connectTimeout,
+ "JWKS connect timeout");
+ this.readTimeoutMillis = toMillisInt(readTimeout, "JWKS read timeout");
+ if (sizeLimitBytes <= 0) {
+ throw new IllegalArgumentException(
+ "JWKS size limit must be positive");
+ }
+ this.sizeLimitBytes = sizeLimitBytes;
}
@Override
public JWKSet fetch() throws IOException, ParseException {
- return JWKSet.load(url);
+ return JWKSet.load(url, connectTimeoutMillis, readTimeoutMillis,
+ sizeLimitBytes);
+ }
+
+ private static int toMillisInt(Duration value, String name) {
+ if (value == null || value.isZero() || value.isNegative()) {
+ throw new IllegalArgumentException(name + " must be positive");
+ }
+ long millis = value.toMillis();
+ if (millis <= 0 || millis > Integer.MAX_VALUE) {
+ throw new IllegalArgumentException(name
+ + " must be between 1ms and " + Integer.MAX_VALUE + "ms");
+ }
+ return (int) millis;
}
}
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
index 11ec51cb4173..1480afc4a47e 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
@@ -29,7 +29,10 @@
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.PlainJWT;
import com.nimbusds.jwt.SignedJWT;
+import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
@@ -211,6 +214,23 @@ public void unknownKidTriggersJwksRefresh() throws Exception {
assertThat(fetches).hasValue(2);
}
+ @Test
+ public void unknownKidRefreshIsDebounced() throws Exception {
+ AtomicInteger fetches = new AtomicInteger();
+ CachingJwksProvider jwksProvider = new CachingJwksProvider(() -> {
+ fetches.incrementAndGet();
+ return jwkSet(primaryKey);
+ }, Duration.ofMinutes(10), Duration.ofSeconds(5), CLOCK);
+ OidcJwtIdentityProvider provider = provider(config(), jwksProvider);
+
+ assertThrows(OidcAuthenticationException.class, () ->
+ provider.authenticate(AuthCredentials.bearerToken(token(rotatedKey))));
+ assertThrows(OidcAuthenticationException.class, () ->
+ provider.authenticate(AuthCredentials.bearerToken(token(wrongKey))));
+
+ assertThat(fetches).hasValue(2);
+ }
+
@Test
public void keyRotationWorks() throws Exception {
AtomicInteger fetches = new AtomicInteger();
@@ -291,6 +311,17 @@ public void missingGroupsAreMappedToEmptySet() throws Exception {
assertThat(identity.getGroups()).isEmpty();
}
+ @Test
+ public void jwksFetcherEnforcesSizeLimit() throws Exception {
+ Path jwksFile = Files.createTempFile("ozone-test-jwks", ".json");
+ Files.write(jwksFile,
+ jwkSet(primaryKey).toString().getBytes(StandardCharsets.UTF_8));
+ UrlJwksFetcher fetcher = new UrlJwksFetcher(jwksFile.toUri().toURL(),
+ Duration.ofSeconds(5), Duration.ofSeconds(5), 8);
+
+ assertThrows(IOException.class, fetcher::fetch);
+ }
+
@Test
public void tokenMaterialIsNotIncludedInParseException() {
String jwt = "sensitive-token-material";
diff --git a/hadoop-ozone/integration-test-s3/pom.xml b/hadoop-ozone/integration-test-s3/pom.xml
index 8ee8d5794576..7c1742f65d83 100644
--- a/hadoop-ozone/integration-test-s3/pom.xml
+++ b/hadoop-ozone/integration-test-s3/pom.xml
@@ -157,6 +157,11 @@
slf4j-api
test
+
+ org.testcontainers
+ testcontainers
+ test
+
software.amazon.awssdk
apache-client
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
new file mode 100644
index 000000000000..fa600074b743
--- /dev/null
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
@@ -0,0 +1,426 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.awssdk;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.hadoop.hdds.security.SecurityConfig.OZONE_TEST_AUTHORIZATION_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_AUTHORIZER_CLASS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
+import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY;
+import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY;
+import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
+import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY;
+import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY;
+import static org.apache.ozone.test.GenericTestUtils.PortAllocator.localhostWithFreePort;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.crypto.KeyGenerator;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.apache.commons.io.IOUtils;
+import org.apache.hadoop.hdds.conf.OzoneConfiguration;
+import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
+import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient;
+import org.apache.hadoop.ozone.MiniOzoneCluster;
+import org.apache.hadoop.ozone.client.OzoneClient;
+import org.apache.hadoop.ozone.om.OzoneManager;
+import org.apache.hadoop.ozone.om.exceptions.OMException;
+import org.apache.hadoop.ozone.s3.S3GatewayService;
+import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
+import org.apache.hadoop.ozone.security.acl.IOzoneObj;
+import org.apache.hadoop.ozone.security.acl.OzoneObj;
+import org.apache.hadoop.ozone.security.acl.RequestContext;
+import org.apache.ozone.test.ClusterForTests;
+import org.junit.jupiter.api.AfterAll;
+import org.w3c.dom.Document;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+
+/**
+ * Shared mini-cluster harness for WebIdentity STS bootstrap tests.
+ */
+abstract class AbstractAssumeRoleWithWebIdentityS3Test
+ extends ClusterForTests {
+
+ protected static final String AUDIENCE = "ozone";
+ protected static final String ALLOWED_BUCKET = "tomato-files";
+ protected static final String DENIED_BUCKET = "denied-files";
+ protected static final String ROLE_ARN =
+ "arn:aws:iam::123456789012:role/tomato-role";
+ protected static final String SESSION_NAME = "tomato-session";
+ protected static final String PROVIDER_ID = "keycloak";
+
+ private S3GatewayService s3GatewayService;
+
+ protected abstract String issuerUri();
+
+ protected abstract String jwksUri();
+
+ @Override
+ protected OzoneConfiguration createOzoneConfig() {
+ OzoneConfiguration conf = super.createOzoneConfig();
+ conf.setBoolean(OZONE_TEST_AUTHORIZATION_ENABLED, true);
+ conf.setBoolean(OZONE_ACL_ENABLED, true);
+ conf.set(OZONE_ACL_AUTHORIZER_CLASS,
+ WebIdentityTestAuthorizer.class.getName());
+ conf.setBoolean(OZONE_S3G_STS_HTTP_ENABLED_KEY, true);
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true);
+ conf.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, issuerUri());
+ conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE);
+ conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwksUri());
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false);
+ conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
+ true);
+ conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY, "RATIS");
+ conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "ONE");
+ conf.set(OZONE_S3G_STS_HTTP_ADDRESS_KEY, localhostWithFreePort());
+ conf.set(OZONE_S3G_STS_HTTPS_ADDRESS_KEY, localhostWithFreePort());
+ return conf;
+ }
+
+ @Override
+ protected MiniOzoneCluster createCluster() throws Exception {
+ OzoneManager.setTestSecureOmFlag(true);
+ WebIdentityTestAuthorizer.reset();
+ s3GatewayService = new S3GatewayService();
+ return newClusterBuilder()
+ .setNumDatanodes(1)
+ .setSecretKeyClient(new InMemorySecretKeyClient())
+ .addService(s3GatewayService)
+ .build();
+ }
+
+ @Override
+ protected void onClusterReady() throws Exception {
+ try (OzoneClient client = getCluster().newClient()) {
+ client.getObjectStore().createS3Bucket(ALLOWED_BUCKET);
+ client.getObjectStore().createS3Bucket(DENIED_BUCKET);
+ }
+ }
+
+ @AfterAll
+ void resetTestSecurityFlag() {
+ OzoneManager.setTestSecureOmFlag(false);
+ }
+
+ protected StsCredentials assumeRoleWithWebIdentity(String token,
+ String expectedSubject) throws Exception {
+ HttpResponse response = postSts(token);
+ assertEquals(200, response.getCode(), response.getBody());
+ Document document = parseXml(response.getBody());
+ StsCredentials credentials = new StsCredentials(
+ xmlText(document, "AccessKeyId"),
+ xmlText(document, "SecretAccessKey"),
+ xmlText(document, "SessionToken"));
+ assertThat(credentials.getAccessKeyId()).startsWith("ASIA");
+ assertThat(credentials.getSecretAccessKey()).isNotBlank();
+ assertThat(credentials.getSessionToken()).isNotBlank();
+ if (expectedSubject != null) {
+ assertThat(xmlText(document, "SubjectFromWebIdentityToken"))
+ .isEqualTo(expectedSubject);
+ }
+ assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE);
+ assertThat(xmlText(document, "Provider")).isEqualTo(PROVIDER_ID);
+ assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME);
+ return credentials;
+ }
+
+ protected void assertTemporaryCredentialsAuthorizeS3Operations(
+ StsCredentials credentials) {
+ try (S3Client s3 = s3Client(credentials)) {
+ String key = "allowed.txt";
+ s3.putObject(PutObjectRequest.builder()
+ .bucket(ALLOWED_BUCKET)
+ .key(key)
+ .build(),
+ RequestBody.fromBytes("web identity data".getBytes(UTF_8)));
+
+ ResponseBytes object =
+ s3.getObjectAsBytes(b -> b.bucket(ALLOWED_BUCKET).key(key));
+ assertEquals("web identity data", object.asUtf8String());
+
+ assertThat(s3.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)).contents())
+ .anyMatch(item -> key.equals(item.key()));
+
+ S3Exception denied = assertThrows(S3Exception.class, () ->
+ s3.putObject(PutObjectRequest.builder()
+ .bucket(DENIED_BUCKET)
+ .key("denied.txt")
+ .build(),
+ RequestBody.fromBytes("denied".getBytes(UTF_8))));
+ assertThat(denied.statusCode()).isEqualTo(403);
+ }
+ }
+
+ protected void assertNormalS3RequestWithoutSigV4IsDenied()
+ throws Exception {
+ URL url = URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)
+ + "/" + ALLOWED_BUCKET).toURL();
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("GET");
+ assertThat(connection.getResponseCode()).isEqualTo(403);
+ }
+
+ protected void assertStsFails(String token, int expectedStatus)
+ throws Exception {
+ HttpResponse response = postSts(token);
+ assertEquals(expectedStatus, response.getCode(), response.getBody());
+ if (token != null) {
+ assertThat(response.getBody()).doesNotContain(token);
+ }
+ }
+
+ protected HttpResponse postSts(String token) throws IOException {
+ URL url = URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_STS_HTTP_ADDRESS_KEY)
+ + "/sts").toURL();
+ String body = "Action=AssumeRoleWithWebIdentity"
+ + "&Version=2011-06-15"
+ + "&RoleArn=" + encode(ROLE_ARN)
+ + "&RoleSessionName=" + encode(SESSION_NAME)
+ + "&ProviderId=" + encode(PROVIDER_ID)
+ + "&DurationSeconds=900";
+ if (token != null) {
+ body += "&WebIdentityToken=" + encode(token);
+ }
+
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type",
+ "application/x-www-form-urlencoded");
+ try (OutputStream output = connection.getOutputStream()) {
+ output.write(body.getBytes(UTF_8));
+ }
+
+ int code = connection.getResponseCode();
+ String responseBody;
+ if (code >= 400) {
+ responseBody = IOUtils.toString(connection.getErrorStream(), UTF_8);
+ } else {
+ responseBody = IOUtils.toString(connection.getInputStream(), UTF_8);
+ }
+ return new HttpResponse(code, responseBody);
+ }
+
+ protected S3Client s3Client(StsCredentials credentials) {
+ return s3Client(credentials.getAccessKeyId(), credentials.getSecretAccessKey(),
+ credentials.getSessionToken());
+ }
+
+ protected S3Client s3Client(String accessKeyId, String secretAccessKey,
+ String sessionToken) {
+ StaticCredentialsProvider credentialsProvider;
+ if (sessionToken == null) {
+ credentialsProvider = StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(accessKeyId, secretAccessKey));
+ } else {
+ credentialsProvider = StaticCredentialsProvider.create(
+ AwsSessionCredentials.create(accessKeyId, secretAccessKey,
+ sessionToken));
+ }
+ return S3Client.builder()
+ .region(Region.US_EAST_1)
+ .endpointOverride(URI.create("http://"
+ + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)))
+ .credentialsProvider(credentialsProvider)
+ .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
+ .forcePathStyle(true)
+ .build();
+ }
+
+ protected AssumeRoleWithWebIdentityRequest lastAssumeRoleRequest() {
+ return WebIdentityTestAuthorizer.lastAssumeRoleRequest;
+ }
+
+ protected int accessChecksWithSessionPolicy() {
+ return WebIdentityTestAuthorizer.ACCESS_CHECKS_WITH_SESSION_POLICY.get();
+ }
+
+ private static String encode(String value) throws IOException {
+ return URLEncoder.encode(value, UTF_8.name());
+ }
+
+ private static Document parseXml(String xml) throws Exception {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ return factory.newDocumentBuilder()
+ .parse(IOUtils.toInputStream(xml, UTF_8));
+ }
+
+ private static String xmlText(Document document, String localName) {
+ return document.getElementsByTagNameNS("*", localName)
+ .item(0).getTextContent();
+ }
+
+ protected static final class HttpResponse {
+ private final int code;
+ private final String body;
+
+ private HttpResponse(int code, String body) {
+ this.code = code;
+ this.body = body;
+ }
+
+ int getCode() {
+ return code;
+ }
+
+ String getBody() {
+ return body;
+ }
+ }
+
+ protected static final class StsCredentials {
+ private final String accessKeyId;
+ private final String secretAccessKey;
+ private final String sessionToken;
+
+ private StsCredentials(String accessKeyId, String secretAccessKey,
+ String sessionToken) {
+ this.accessKeyId = accessKeyId;
+ this.secretAccessKey = secretAccessKey;
+ this.sessionToken = sessionToken;
+ }
+
+ String getAccessKeyId() {
+ return accessKeyId;
+ }
+
+ String getSecretAccessKey() {
+ return secretAccessKey;
+ }
+
+ String getSessionToken() {
+ return sessionToken;
+ }
+ }
+
+ public static final class WebIdentityTestAuthorizer
+ implements IAccessAuthorizer {
+ private static final AtomicInteger ACCESS_CHECKS_WITH_SESSION_POLICY =
+ new AtomicInteger();
+ private static volatile AssumeRoleWithWebIdentityRequest
+ lastAssumeRoleRequest;
+
+ public static void reset() {
+ ACCESS_CHECKS_WITH_SESSION_POLICY.set(0);
+ lastAssumeRoleRequest = null;
+ }
+
+ @Override
+ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
+ if (context.getSessionPolicy() == null) {
+ return true;
+ }
+ ACCESS_CHECKS_WITH_SESSION_POLICY.incrementAndGet();
+ if (!(ozoneObject instanceof OzoneObj)) {
+ return false;
+ }
+ OzoneObj obj = (OzoneObj) ozoneObject;
+ return obj.getBucketName() == null
+ || ALLOWED_BUCKET.equals(obj.getBucketName());
+ }
+
+ @Override
+ public String generateAssumeRoleWithWebIdentitySessionPolicy(
+ AssumeRoleWithWebIdentityRequest request) throws OMException {
+ lastAssumeRoleRequest = request;
+ if (!"tomato-user".equals(request.getUser())
+ || !request.getGroups().contains("ozone-tomato")
+ || !ROLE_ARN.equals(request.getRoleArn())
+ || !PROVIDER_ID.equals(request.getProviderId())
+ || request.getSubject() == null
+ || request.getSubject().trim().isEmpty()) {
+ throw new OMException("WebIdentity role assumption denied",
+ OMException.ResultCodes.ACCESS_DENIED);
+ }
+ return "{"
+ + "\"Version\":\"2012-10-17\","
+ + "\"Statement\":[{"
+ + "\"Effect\":\"Allow\","
+ + "\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:ListBucket\"],"
+ + "\"Resource\":[\"arn:aws:s3:::" + ALLOWED_BUCKET
+ + "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]"
+ + "}]}";
+ }
+ }
+
+ private static final class InMemorySecretKeyClient
+ implements SecretKeyClient {
+ private final Map keys = new LinkedHashMap<>();
+ private final ManagedSecretKey current;
+
+ private InMemorySecretKeyClient() {
+ this.current = newKey();
+ keys.put(current.getId(), current);
+ }
+
+ @Override
+ public ManagedSecretKey getCurrentSecretKey() {
+ return current;
+ }
+
+ @Override
+ public ManagedSecretKey getSecretKey(UUID id) {
+ return keys.get(id);
+ }
+
+ private static ManagedSecretKey newKey() {
+ try {
+ KeyGenerator generator = KeyGenerator.getInstance("HmacSHA256");
+ return new ManagedSecretKey(UUID.randomUUID(), Instant.now(),
+ Instant.now().plus(Duration.ofHours(4)), generator.generateKey());
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("HmacSHA256 is unavailable", e);
+ }
+ }
+ }
+}
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
index 4a224fd86ee3..06dcbf449494 100644
--- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityEndToEnd.java
@@ -18,22 +18,6 @@
package org.apache.hadoop.ozone.s3.awssdk;
import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.apache.hadoop.hdds.security.SecurityConfig.OZONE_TEST_AUTHORIZATION_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_AUTHORIZER_CLASS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
-import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY;
-import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY;
-import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
-import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY;
-import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY;
-import static org.apache.ozone.test.GenericTestUtils.PortAllocator.localhostWithFreePort;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -46,17 +30,10 @@
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.PlainJWT;
import com.nimbusds.jwt.SignedJWT;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.net.HttpURLConnection;
-import java.net.URI;
-import java.net.URL;
-import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
-import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
@@ -66,103 +43,30 @@
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
-import java.util.UUID;
-import java.util.concurrent.atomic.AtomicInteger;
-import javax.crypto.KeyGenerator;
-import javax.xml.parsers.DocumentBuilderFactory;
-import org.apache.commons.io.IOUtils;
-import org.apache.hadoop.hdds.conf.OzoneConfiguration;
-import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
-import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient;
-import org.apache.hadoop.ozone.MiniOzoneCluster;
-import org.apache.hadoop.ozone.client.OzoneClient;
-import org.apache.hadoop.ozone.om.OzoneManager;
-import org.apache.hadoop.ozone.om.exceptions.OMException;
-import org.apache.hadoop.ozone.s3.S3GatewayService;
import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest;
-import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
-import org.apache.hadoop.ozone.security.acl.IOzoneObj;
-import org.apache.hadoop.ozone.security.acl.OzoneObj;
-import org.apache.hadoop.ozone.security.acl.RequestContext;
-import org.apache.ozone.test.ClusterForTests;
-import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
-import org.w3c.dom.Document;
-import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
-import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
-import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
-import software.amazon.awssdk.core.ResponseBytes;
-import software.amazon.awssdk.core.checksums.RequestChecksumCalculation;
-import software.amazon.awssdk.core.sync.RequestBody;
-import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
-import software.amazon.awssdk.services.s3.model.GetObjectResponse;
-import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;
/**
- * End-to-end coverage for the WebIdentity STS bootstrap path and the existing
- * S3 SigV4 temporary credential validation path.
+ * End-to-end coverage for the WebIdentity STS bootstrap path using a generated
+ * JWT and local JWKS file.
*/
class TestAssumeRoleWithWebIdentityEndToEnd
- extends ClusterForTests {
+ extends AbstractAssumeRoleWithWebIdentityS3Test {
private static final String ISSUER = "http://keycloak.test/realms/ozone";
- private static final String AUDIENCE = "ozone";
- private static final String ALLOWED_BUCKET = "tomato-files";
- private static final String DENIED_BUCKET = "denied-files";
- private static final String ROLE_ARN =
- "arn:aws:iam::123456789012:role/tomato-role";
- private static final String SESSION_NAME = "tomato-session";
private final TestJwtIssuer jwtIssuer = new TestJwtIssuer();
- private S3GatewayService s3GatewayService;
@Override
- protected OzoneConfiguration createOzoneConfig() {
- OzoneConfiguration conf = super.createOzoneConfig();
- conf.setBoolean(OZONE_TEST_AUTHORIZATION_ENABLED, true);
- conf.setBoolean(OZONE_ACL_ENABLED, true);
- conf.set(OZONE_ACL_AUTHORIZER_CLASS,
- WebIdentityEndToEndAuthorizer.class.getName());
- conf.setBoolean(OZONE_S3G_STS_HTTP_ENABLED_KEY, true);
- conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, true);
- conf.set(OZONE_STS_WEB_IDENTITY_ISSUER_URI, ISSUER);
- conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE);
- conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwtIssuer.jwksUri());
- conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false);
- conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
- true);
- conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY, "RATIS");
- conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "ONE");
- conf.set(OZONE_S3G_STS_HTTP_ADDRESS_KEY, localhostWithFreePort());
- conf.set(OZONE_S3G_STS_HTTPS_ADDRESS_KEY, localhostWithFreePort());
- return conf;
+ protected String issuerUri() {
+ return ISSUER;
}
@Override
- protected MiniOzoneCluster createCluster() throws Exception {
- OzoneManager.setTestSecureOmFlag(true);
- WebIdentityEndToEndAuthorizer.reset();
- s3GatewayService = new S3GatewayService();
- return newClusterBuilder()
- .setNumDatanodes(1)
- .setSecretKeyClient(new InMemorySecretKeyClient())
- .addService(s3GatewayService)
- .build();
- }
-
- @Override
- protected void onClusterReady() throws Exception {
- try (OzoneClient client = getCluster().newClient()) {
- client.getObjectStore().createS3Bucket(ALLOWED_BUCKET);
- client.getObjectStore().createS3Bucket(DENIED_BUCKET);
- }
- }
-
- @AfterAll
- void resetTestSecurityFlag() {
- OzoneManager.setTestSecureOmFlag(false);
+ protected String jwksUri() {
+ return jwtIssuer.jwksUri();
}
@Test
@@ -171,50 +75,28 @@ void webIdentityTemporaryCredentialsAuthorizeS3Operations()
StsCredentials credentials = assumeRoleWithWebIdentity(
jwtIssuer.token("tomato-user", "subject-tomato",
Collections.singletonList("ozone-tomato"), AUDIENCE,
- Instant.now().plus(Duration.ofHours(1))));
+ Instant.now().plus(Duration.ofHours(1))), "subject-tomato");
- AssumeRoleWithWebIdentityRequest request =
- WebIdentityEndToEndAuthorizer.lastAssumeRoleRequest;
+ AssumeRoleWithWebIdentityRequest request = lastAssumeRoleRequest();
assertThat(request).isNotNull();
assertEquals("tomato-user", request.getUser());
assertThat(request.getGroups()).containsExactly("ozone-tomato");
assertEquals(ROLE_ARN, request.getRoleArn());
assertEquals(SESSION_NAME, request.getRoleSessionName());
assertEquals(ISSUER, request.getIssuer());
+ assertEquals("subject-tomato", request.getSubject());
assertEquals(AUDIENCE, request.getAudience());
+ assertEquals(PROVIDER_ID, request.getProviderId());
- try (S3Client s3 = s3Client(credentials)) {
- String key = "allowed.txt";
- s3.putObject(PutObjectRequest.builder()
- .bucket(ALLOWED_BUCKET)
- .key(key)
- .build(),
- RequestBody.fromBytes("web identity data".getBytes(UTF_8)));
-
- ResponseBytes object =
- s3.getObjectAsBytes(b -> b.bucket(ALLOWED_BUCKET).key(key));
- assertEquals("web identity data", object.asUtf8String());
-
- assertThat(s3.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)).contents())
- .anyMatch(item -> key.equals(item.key()));
-
- assertThrows(S3Exception.class, () ->
- s3.putObject(PutObjectRequest.builder()
- .bucket(DENIED_BUCKET)
- .key("denied.txt")
- .build(),
- RequestBody.fromBytes("denied".getBytes(UTF_8))));
- }
-
- assertThat(WebIdentityEndToEndAuthorizer.accessChecksWithSessionPolicy)
- .hasPositiveValue();
+ assertTemporaryCredentialsAuthorizeS3Operations(credentials);
+ assertThat(accessChecksWithSessionPolicy()).isPositive();
}
@Test
void invalidWebIdentityTokensFailBeforeCredentialsAreIssued()
throws Exception {
HttpResponse missingToken = postSts(null);
- assertThat(missingToken.code).isGreaterThanOrEqualTo(400);
+ assertThat(missingToken.getCode()).isGreaterThanOrEqualTo(400);
assertStsFails(jwtIssuer.token("tomato-user", "subject-tomato",
Collections.singletonList("ozone-tomato"), "wrong-audience",
@@ -235,12 +117,7 @@ void invalidWebIdentityTokensFailBeforeCredentialsAreIssued()
@Test
void normalS3RequestWithoutSigV4IsStillDenied() throws Exception {
- URL url = URI.create("http://"
- + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)
- + "/" + ALLOWED_BUCKET).toURL();
- HttpURLConnection connection = (HttpURLConnection) url.openConnection();
- connection.setRequestMethod("GET");
- assertThat(connection.getResponseCode()).isEqualTo(403);
+ assertNormalS3RequestWithoutSigV4IsDenied();
}
@Test
@@ -249,232 +126,33 @@ void temporaryCredentialsRequireCorrectSecretAndSessionToken()
StsCredentials credentials = assumeRoleWithWebIdentity(
jwtIssuer.token("tomato-user", "subject-tomato",
Collections.singletonList("ozone-tomato"), AUDIENCE,
- Instant.now().plus(Duration.ofHours(1))));
+ Instant.now().plus(Duration.ofHours(1))), "subject-tomato");
try (S3Client missingSessionToken =
- s3Client(credentials.accessKeyId, credentials.secretAccessKey,
+ s3Client(credentials.getAccessKeyId(),
+ credentials.getSecretAccessKey(),
null)) {
assertThrows(S3Exception.class, () ->
missingSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
}
try (S3Client wrongSessionToken =
- s3Client(credentials.accessKeyId, credentials.secretAccessKey,
- credentials.sessionToken + "wrong")) {
+ s3Client(credentials.getAccessKeyId(),
+ credentials.getSecretAccessKey(),
+ credentials.getSessionToken() + "wrong")) {
assertThrows(S3Exception.class, () ->
wrongSessionToken.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
}
try (S3Client wrongSecret =
- s3Client(credentials.accessKeyId,
- credentials.secretAccessKey + "wrong",
- credentials.sessionToken)) {
+ s3Client(credentials.getAccessKeyId(),
+ credentials.getSecretAccessKey() + "wrong",
+ credentials.getSessionToken())) {
assertThrows(S3Exception.class, () ->
wrongSecret.listObjectsV2(b -> b.bucket(ALLOWED_BUCKET)));
}
}
- private StsCredentials assumeRoleWithWebIdentity(String token)
- throws Exception {
- HttpResponse response = postSts(token);
- assertEquals(200, response.code, response.body);
- Document document = parseXml(response.body);
- StsCredentials credentials = new StsCredentials(
- xmlText(document, "AccessKeyId"),
- xmlText(document, "SecretAccessKey"),
- xmlText(document, "SessionToken"));
- assertThat(credentials.accessKeyId).startsWith("ASIA");
- assertThat(credentials.secretAccessKey).isNotBlank();
- assertThat(credentials.sessionToken).isNotBlank();
- assertThat(xmlText(document, "SubjectFromWebIdentityToken"))
- .isEqualTo("subject-tomato");
- assertThat(xmlText(document, "Audience")).isEqualTo(AUDIENCE);
- assertThat(xmlText(document, "AssumedRoleId")).contains(SESSION_NAME);
- return credentials;
- }
-
- private void assertStsFails(String token, int expectedStatus)
- throws Exception {
- HttpResponse response = postSts(token);
- assertEquals(expectedStatus, response.code, response.body);
- assertThat(response.body).doesNotContain(token);
- }
-
- private HttpResponse postSts(String token) throws IOException {
- URL url = URI.create("http://"
- + s3GatewayService.getConf().get(OZONE_S3G_STS_HTTP_ADDRESS_KEY)
- + "/sts").toURL();
- String body = "Action=AssumeRoleWithWebIdentity"
- + "&Version=2011-06-15"
- + "&RoleArn=" + encode(ROLE_ARN)
- + "&RoleSessionName=" + encode(SESSION_NAME)
- + "&DurationSeconds=900";
- if (token != null) {
- body += "&WebIdentityToken=" + encode(token);
- }
-
- HttpURLConnection connection = (HttpURLConnection) url.openConnection();
- connection.setRequestMethod("POST");
- connection.setDoOutput(true);
- connection.setRequestProperty("Content-Type",
- "application/x-www-form-urlencoded");
- try (OutputStream output = connection.getOutputStream()) {
- output.write(body.getBytes(UTF_8));
- }
-
- int code = connection.getResponseCode();
- String responseBody;
- if (code >= 400) {
- responseBody = IOUtils.toString(connection.getErrorStream(), UTF_8);
- } else {
- responseBody = IOUtils.toString(connection.getInputStream(), UTF_8);
- }
- return new HttpResponse(code, responseBody);
- }
-
- private S3Client s3Client(StsCredentials credentials) {
- return s3Client(credentials.accessKeyId, credentials.secretAccessKey,
- credentials.sessionToken);
- }
-
- private S3Client s3Client(String accessKeyId, String secretAccessKey,
- String sessionToken) {
- StaticCredentialsProvider credentialsProvider;
- if (sessionToken == null) {
- credentialsProvider = StaticCredentialsProvider.create(
- AwsBasicCredentials.create(accessKeyId, secretAccessKey));
- } else {
- credentialsProvider = StaticCredentialsProvider.create(
- AwsSessionCredentials.create(accessKeyId, secretAccessKey,
- sessionToken));
- }
- return S3Client.builder()
- .region(Region.US_EAST_1)
- .endpointOverride(URI.create("http://"
- + s3GatewayService.getConf().get(OZONE_S3G_HTTP_ADDRESS_KEY)))
- .credentialsProvider(credentialsProvider)
- .requestChecksumCalculation(RequestChecksumCalculation.WHEN_REQUIRED)
- .forcePathStyle(true)
- .build();
- }
-
- private static String encode(String value) throws IOException {
- return URLEncoder.encode(value, UTF_8.name());
- }
-
- private static Document parseXml(String xml) throws Exception {
- DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
- factory.setNamespaceAware(true);
- return factory.newDocumentBuilder()
- .parse(IOUtils.toInputStream(xml, UTF_8));
- }
-
- private static String xmlText(Document document, String localName) {
- return document.getElementsByTagNameNS("*", localName)
- .item(0).getTextContent();
- }
-
- private static final class HttpResponse {
- private final int code;
- private final String body;
-
- private HttpResponse(int code, String body) {
- this.code = code;
- this.body = body;
- }
- }
-
- private static final class StsCredentials {
- private final String accessKeyId;
- private final String secretAccessKey;
- private final String sessionToken;
-
- private StsCredentials(String accessKeyId, String secretAccessKey,
- String sessionToken) {
- this.accessKeyId = accessKeyId;
- this.secretAccessKey = secretAccessKey;
- this.sessionToken = sessionToken;
- }
- }
-
- public static final class WebIdentityEndToEndAuthorizer
- implements IAccessAuthorizer {
- private static final AtomicInteger accessChecksWithSessionPolicy =
- new AtomicInteger();
- private static volatile AssumeRoleWithWebIdentityRequest
- lastAssumeRoleRequest;
-
- public static void reset() {
- accessChecksWithSessionPolicy.set(0);
- lastAssumeRoleRequest = null;
- }
-
- @Override
- public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
- if (context.getSessionPolicy() == null) {
- return true;
- }
- accessChecksWithSessionPolicy.incrementAndGet();
- if (!(ozoneObject instanceof OzoneObj)) {
- return false;
- }
- OzoneObj obj = (OzoneObj) ozoneObject;
- return obj.getBucketName() == null
- || ALLOWED_BUCKET.equals(obj.getBucketName());
- }
-
- @Override
- public String generateAssumeRoleWithWebIdentitySessionPolicy(
- AssumeRoleWithWebIdentityRequest request) throws OMException {
- lastAssumeRoleRequest = request;
- if (!"tomato-user".equals(request.getUser())
- || !request.getGroups().contains("ozone-tomato")
- || !ROLE_ARN.equals(request.getRoleArn())) {
- throw new OMException("WebIdentity role assumption denied",
- OMException.ResultCodes.ACCESS_DENIED);
- }
- return "{"
- + "\"Version\":\"2012-10-17\","
- + "\"Statement\":[{"
- + "\"Effect\":\"Allow\","
- + "\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:ListBucket\"],"
- + "\"Resource\":[\"arn:aws:s3:::" + ALLOWED_BUCKET
- + "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]"
- + "}]}";
- }
- }
-
- private static final class InMemorySecretKeyClient
- implements SecretKeyClient {
- private final Map keys = new LinkedHashMap<>();
- private final ManagedSecretKey current;
-
- private InMemorySecretKeyClient() {
- this.current = newKey();
- keys.put(current.getId(), current);
- }
-
- @Override
- public ManagedSecretKey getCurrentSecretKey() {
- return current;
- }
-
- @Override
- public ManagedSecretKey getSecretKey(UUID id) {
- return keys.get(id);
- }
-
- private static ManagedSecretKey newKey() {
- try {
- KeyGenerator generator = KeyGenerator.getInstance("HmacSHA256");
- return new ManagedSecretKey(UUID.randomUUID(), Instant.now(),
- Instant.now().plus(Duration.ofHours(4)), generator.generateKey());
- } catch (NoSuchAlgorithmException e) {
- throw new IllegalStateException("HmacSHA256 is unavailable", e);
- }
- }
- }
-
private static final class TestJwtIssuer {
private final RSAKey key;
private final Path jwksFile;
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
new file mode 100644
index 000000000000..6b81190d6d96
--- /dev/null
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
@@ -0,0 +1,211 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.s3.awssdk;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLEncoder;
+import java.util.Base64;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.commons.io.IOUtils;
+import org.apache.hadoop.ozone.security.acl.AssumeRoleWithWebIdentityRequest;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.utility.DockerImageName;
+import org.testcontainers.utility.MountableFile;
+
+/**
+ * Integration coverage for a real Keycloak-issued JWT with Ozone STS
+ * AssumeRoleWithWebIdentity.
+ */
+class TestAssumeRoleWithWebIdentityKeycloakIT
+ extends AbstractAssumeRoleWithWebIdentityS3Test {
+
+ private static final DockerImageName KEYCLOAK_IMAGE =
+ DockerImageName.parse("quay.io/keycloak/keycloak:26.0.7");
+ private static final Pattern ACCESS_TOKEN_PATTERN =
+ Pattern.compile("\"access_token\"\\s*:\\s*\"([^\"]+)\"");
+ private static GenericContainer> keycloak;
+
+ @Override
+ protected String issuerUri() {
+ return keycloakIssuerUri();
+ }
+
+ @Override
+ protected String jwksUri() {
+ return keycloakIssuerUri() + "/protocol/openid-connect/certs";
+ }
+
+ @AfterAll
+ static void stopKeycloak() {
+ if (keycloak != null) {
+ keycloak.stop();
+ keycloak = null;
+ }
+ }
+
+ @Test
+ void keycloakTokenCanAssumeRoleAndUseTemporaryS3Credentials()
+ throws Exception {
+ String token = fetchToken("ozone-sts", "tomato-user", "tomato-password");
+
+ StsCredentials credentials =
+ assumeRoleWithWebIdentity(token, keycloakSubject(token));
+
+ AssumeRoleWithWebIdentityRequest request = lastAssumeRoleRequest();
+ assertThat(request).isNotNull();
+ assertThat(request.getUser()).isEqualTo("tomato-user");
+ assertThat(request.getGroups()).contains("ozone-tomato");
+ assertThat(request.getIssuer()).isEqualTo(keycloakIssuerUri());
+ assertThat(request.getAudience()).isEqualTo(AUDIENCE);
+ assertThat(request.getRoleArn()).isEqualTo(ROLE_ARN);
+ assertThat(request.getRoleSessionName()).isEqualTo(SESSION_NAME);
+ assertThat(request.getProviderId()).isEqualTo(PROVIDER_ID);
+
+ assertTemporaryCredentialsAuthorizeS3Operations(credentials);
+ assertThat(accessChecksWithSessionPolicy()).isPositive();
+ }
+
+ @Test
+ void deniedKeycloakUserCannotAssumeRole() throws Exception {
+ String token = fetchToken("ozone-sts", "denied-user", "denied-password");
+
+ assertStsFails(token, 403);
+ }
+
+ @Test
+ void wrongAudienceKeycloakTokenFails() throws Exception {
+ String token = fetchToken("wrong-audience-client", "tomato-user",
+ "tomato-password");
+
+ assertStsFails(token, 403);
+ }
+
+ @Test
+ void tamperedKeycloakTokenFails() throws Exception {
+ String token = fetchToken("ozone-sts", "tomato-user", "tomato-password");
+
+ assertStsFails(rewriteJwtPayload(token, "ozone-tomato", "ozone-admins"),
+ 403);
+ }
+
+ @Test
+ void missingWebIdentityTokenFails() throws Exception {
+ HttpResponse response = postSts(null);
+
+ assertThat(response.getCode()).isGreaterThanOrEqualTo(400);
+ }
+
+ private static synchronized String keycloakIssuerUri() {
+ GenericContainer> container = keycloak();
+ return "http://" + container.getHost() + ":"
+ + container.getMappedPort(8080) + "/realms/ozone-test";
+ }
+
+ private static synchronized GenericContainer> keycloak() {
+ if (keycloak == null) {
+ keycloak = new GenericContainer<>(KEYCLOAK_IMAGE)
+ .withExposedPorts(8080)
+ .withEnv("KEYCLOAK_ADMIN", "admin")
+ .withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+ .withEnv("KC_BOOTSTRAP_ADMIN_USERNAME", "admin")
+ .withEnv("KC_BOOTSTRAP_ADMIN_PASSWORD", "admin")
+ .withCopyFileToContainer(
+ MountableFile.forClasspathResource(
+ "keycloak/ozone-test-realm.json"),
+ "/opt/keycloak/data/import/ozone-test-realm.json")
+ .withCommand("start-dev", "--import-realm",
+ "--hostname-strict=false")
+ .waitingFor(Wait.forHttp(
+ "/realms/ozone-test/.well-known/openid-configuration")
+ .forStatusCode(200));
+ keycloak.start();
+ }
+ return keycloak;
+ }
+
+ private static String fetchToken(String clientId, String username,
+ String password) throws IOException {
+ URL url = URI.create(keycloakIssuerUri()
+ + "/protocol/openid-connect/token").toURL();
+ String body = "grant_type=password"
+ + "&client_id=" + encode(clientId)
+ + "&username=" + encode(username)
+ + "&password=" + encode(password);
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type",
+ "application/x-www-form-urlencoded");
+ try (OutputStream output = connection.getOutputStream()) {
+ output.write(body.getBytes(UTF_8));
+ }
+ int code = connection.getResponseCode();
+ String response = code >= 400
+ ? IOUtils.toString(connection.getErrorStream(), UTF_8)
+ : IOUtils.toString(connection.getInputStream(), UTF_8);
+ assertThat(code).as("Keycloak token endpoint response code")
+ .isEqualTo(200);
+ Matcher matcher = ACCESS_TOKEN_PATTERN.matcher(response);
+ assertThat(matcher.find()).as("Keycloak access token is present")
+ .isTrue();
+ return matcher.group(1);
+ }
+
+ private static String keycloakSubject(String jwt) {
+ return jsonValue(jwtPayload(jwt), "sub");
+ }
+
+ private static String rewriteJwtPayload(String jwt, String from, String to) {
+ String[] parts = jwt.split("\\.");
+ assertThat(parts).hasSize(3);
+ String payload = jwtPayload(jwt);
+ assertThat(payload).contains(from);
+ parts[1] = Base64.getUrlEncoder().withoutPadding()
+ .encodeToString(payload.replace(from, to).getBytes(UTF_8));
+ return String.join(".", parts);
+ }
+
+ private static String jwtPayload(String jwt) {
+ String[] parts = jwt.split("\\.");
+ assertThat(parts).hasSize(3);
+ return new String(Base64.getUrlDecoder().decode(parts[1]), UTF_8);
+ }
+
+ private static String jsonValue(String json, String field) {
+ Pattern pattern = Pattern.compile("\"" + Pattern.quote(field)
+ + "\"\\s*:\\s*\"([^\"]+)\"");
+ Matcher matcher = pattern.matcher(json);
+ assertThat(matcher.find()).as("JWT claim %s is present", field).isTrue();
+ return matcher.group(1);
+ }
+
+ private static String encode(String value) throws IOException {
+ return URLEncoder.encode(value, UTF_8.name());
+ }
+}
diff --git a/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json b/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json
new file mode 100644
index 000000000000..5c8f4204187f
--- /dev/null
+++ b/hadoop-ozone/integration-test-s3/src/test/resources/keycloak/ozone-test-realm.json
@@ -0,0 +1,137 @@
+{
+ "realm": "ozone-test",
+ "enabled": true,
+ "accessTokenLifespan": 3600,
+ "groups": [
+ {
+ "name": "ozone-tomato"
+ },
+ {
+ "name": "ozone-denied"
+ }
+ ],
+ "clients": [
+ {
+ "clientId": "ozone-sts",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "redirectUris": [
+ "*"
+ ],
+ "webOrigins": [
+ "*"
+ ],
+ "protocolMappers": [
+ {
+ "name": "audience-ozone",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.custom.audience": "ozone",
+ "id.token.claim": "false",
+ "access.token.claim": "true"
+ }
+ },
+ {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-group-membership-mapper",
+ "consentRequired": false,
+ "config": {
+ "claim.name": "groups",
+ "full.path": "false",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "wrong-audience-client",
+ "enabled": true,
+ "protocol": "openid-connect",
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "serviceAccountsEnabled": false,
+ "redirectUris": [
+ "*"
+ ],
+ "webOrigins": [
+ "*"
+ ],
+ "protocolMappers": [
+ {
+ "name": "audience-not-ozone",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "consentRequired": false,
+ "config": {
+ "included.custom.audience": "not-ozone",
+ "id.token.claim": "false",
+ "access.token.claim": "true"
+ }
+ },
+ {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-group-membership-mapper",
+ "consentRequired": false,
+ "config": {
+ "claim.name": "groups",
+ "full.path": "false",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ }
+ ]
+ }
+ ],
+ "users": [
+ {
+ "username": "tomato-user",
+ "enabled": true,
+ "email": "tomato-user@example.com",
+ "emailVerified": true,
+ "firstName": "Tomato",
+ "lastName": "User",
+ "requiredActions": [],
+ "groups": [
+ "/ozone-tomato"
+ ],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "tomato-password",
+ "temporary": false
+ }
+ ]
+ },
+ {
+ "username": "denied-user",
+ "enabled": true,
+ "email": "denied-user@example.com",
+ "emailVerified": true,
+ "firstName": "Denied",
+ "lastName": "User",
+ "requiredActions": [],
+ "groups": [
+ "/ozone-denied"
+ ],
+ "credentials": [
+ {
+ "type": "password",
+ "value": "denied-password",
+ "temporary": false
+ }
+ ]
+ }
+ ]
+}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
index d2334199d7ab..f75939aa1429 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
@@ -39,6 +39,10 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_READONLY_ADMINISTRATORS;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConsts.DB_TRANSIENT_MARKER;
import static org.apache.hadoop.ozone.OzoneConsts.DEFAULT_OM_UPDATE_ID;
import static org.apache.hadoop.ozone.OzoneConsts.LAYOUT_VERSION_KEY;
@@ -1914,6 +1918,7 @@ public DeletingServiceMetrics getDeletionMetrics() {
public void start() throws IOException {
Map auditMap = new HashMap();
auditMap.put("OmState", omState.name());
+ logInsecureWebIdentityHttpWarning();
if (omState == State.BOOTSTRAPPING) {
if (isBootstrapping) {
auditMap.put("Bootstrap", "normal");
@@ -2022,6 +2027,19 @@ public void start() throws IOException {
SYSTEMAUDIT.logWriteSuccess(buildAuditMessageForSuccess(OMSystemAction.STARTUP, auditMap));
}
+ private void logInsecureWebIdentityHttpWarning() {
+ if (configuration.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED,
+ OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT)
+ && configuration.getBoolean(
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) {
+ LOG.warn("STS WebIdentity is configured with {}=true. This permits "
+ + "insecure HTTP issuer/JWKS URIs for tests only and is unsafe "
+ + "for production deployments.",
+ OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS);
+ }
+ }
+
/**
* Restarts the service. This method re-initializes the rpc server.
*/
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
index 282aec1d1cd4..8d4e6eed7725 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
@@ -271,7 +271,10 @@ private OzoneIdentityProvider identityProvider(OidcConfig oidcConfig)
try {
URL jwksUrl = new URL(oidcConfig.getJwksUri());
return new OidcJwtIdentityProvider(oidcConfig,
- new CachingJwksProvider(new UrlJwksFetcher(jwksUrl),
+ new CachingJwksProvider(new UrlJwksFetcher(jwksUrl,
+ oidcConfig.getJwksConnectTimeout(),
+ oidcConfig.getJwksReadTimeout(),
+ oidcConfig.getJwksSizeLimit()),
oidcConfig.getJwksRefreshInterval()));
} catch (MalformedURLException e) {
throw new OMException(OZONE_STS_WEB_IDENTITY_JWKS_URI
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
index 48250da74f75..8b1dbe25b488 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
@@ -107,6 +107,7 @@ public STSTokenIdentifier(String tempAccessKeyId, String originalAccessKeyId, St
/**
* Create a new WebIdentity-backed STS token identifier.
*/
+ @SuppressWarnings("checkstyle:ParameterNumber")
public STSTokenIdentifier(String tempAccessKeyId, String roleArn,
Instant expiry, String secretAccessKey, String sessionPolicy,
String effectiveUser, String issuer, String subject, String audience,
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
index aaa4f14dd681..d321851a76fb 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenSecretManager.java
@@ -111,6 +111,7 @@ public String createSTSTokenString(String tempAccessKeyId, String originalAccess
/**
* Create a WebIdentity-backed STS token and return it as an encoded string.
*/
+ @SuppressWarnings("checkstyle:ParameterNumber")
public String createWebIdentitySTSTokenString(String tempAccessKeyId,
String roleArn, int durationSeconds, String secretAccessKey,
String sessionPolicy, String effectiveUser, String issuer, String subject,
@@ -131,6 +132,7 @@ public String createWebIdentitySTSTokenString(String tempAccessKeyId,
* validated the WebIdentityToken and replicated only sanitized deterministic
* session data.
*/
+ @SuppressWarnings("checkstyle:ParameterNumber")
public String createWebIdentitySTSTokenString(String tempAccessKeyId,
String roleArn, Instant expiration, String secretAccessKey,
String sessionPolicy, String effectiveUser, String issuer, String subject,
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
index c14805be4c62..fb96f37aac18 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
@@ -20,6 +20,7 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED;
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,11 +57,11 @@
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.Type;
import org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos.UpdateAssumeRoleWithWebIdentityRequest;
import org.apache.hadoop.ozone.security.STSTokenSecretManager;
+import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
import org.apache.hadoop.ozone.security.oidc.AuthCredentials;
import org.apache.hadoop.ozone.security.oidc.OidcAuthenticationException;
import org.apache.hadoop.ozone.security.oidc.OzoneIdentity;
import org.apache.hadoop.ozone.security.oidc.OzoneIdentityProvider;
-import org.apache.hadoop.ozone.security.acl.IAccessAuthorizer;
import org.apache.hadoop.security.token.TokenIdentifier;
import org.apache.ozone.test.TestClock;
import org.junit.jupiter.api.BeforeEach;
@@ -278,7 +279,29 @@ public void testDisabledFeatureFailsBeforeTokenValidation() {
assertThat(identityProvider.getCapturedToken()).isNull();
}
+ @Test
+ public void testExceptionCauseChainDoesNotExposeTokenMaterial() {
+ final String sensitiveToken = "raw.jwt.SecretAccessKey.SessionToken."
+ + "AuthorizationHeader";
+ configuration.set(OZONE_STS_WEB_IDENTITY_JWKS_URI,
+ "https://keycloak.example.com/realms/ozone/certs");
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(
+ externalRequest(3600, sensitiveToken), CLOCK);
+
+ assertThatThrownBy(() -> request.preExecute(ozoneManager))
+ .isInstanceOf(OMException.class)
+ .satisfies(e -> assertThrowableChainDoesNotContain(e,
+ sensitiveToken, "SecretAccessKey", "SessionToken",
+ "AuthorizationHeader"));
+ }
+
private static OMRequest externalRequest(int durationSeconds) {
+ return externalRequest(durationSeconds, RAW_JWT);
+ }
+
+ private static OMRequest externalRequest(int durationSeconds,
+ String webIdentityToken) {
return OMRequest.newBuilder()
.setCmdType(Type.AssumeRoleWithWebIdentity)
.setClientId("client-1")
@@ -290,11 +313,23 @@ private static OMRequest externalRequest(int durationSeconds) {
.setDurationSeconds(durationSeconds)
.setProviderId(PROVIDER_ID)
.setRequestId(REQUEST_ID)
- .setWebIdentityToken(RAW_JWT)
+ .setWebIdentityToken(webIdentityToken)
.build())
.build();
}
+ private static void assertThrowableChainDoesNotContain(Throwable throwable,
+ String... sensitiveValues) {
+ Throwable current = throwable;
+ while (current != null) {
+ String text = String.valueOf(current);
+ for (String sensitiveValue : sensitiveValues) {
+ assertThat(text).doesNotContain(sensitiveValue);
+ }
+ current = current.getCause();
+ }
+ }
+
private static OzoneIdentity identity(long expiresInSeconds) {
return OzoneIdentity.newBuilder()
.setUsername(USER)
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
index f8d4bacbbf05..7afa41ba7694 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
@@ -53,6 +53,7 @@ public void setResponseMetadata(ResponseMetadata responseMetadata) {
this.responseMetadata = responseMetadata;
}
+ /** XML model for the AssumeRoleWithWebIdentityResult element. */
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "AssumeRoleWithWebIdentityResultType",
namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
@@ -93,6 +94,7 @@ public void setProvider(String provider) {
}
}
+ /** XML model for temporary STS credentials. */
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "AssumeRoleWithWebIdentityCredentialsType",
namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
@@ -126,6 +128,7 @@ public void setExpiration(String expiration) {
}
}
+ /** XML model for the assumed role user. */
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "AssumeRoleWithWebIdentityAssumedRoleUserType",
namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
@@ -145,6 +148,7 @@ public void setArn(String arn) {
}
}
+ /** XML model for response metadata. */
@XmlAccessorType(XmlAccessType.FIELD)
@XmlType(name = "AssumeRoleWithWebIdentityResponseMetadataType",
namespace = "https://sts.amazonaws.com/doc/2011-06-15/")
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
index 068977d8ba46..56af2134f5f1 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSEndpoint.java
@@ -17,12 +17,12 @@
package org.apache.hadoop.ozone.s3sts;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
import static javax.ws.rs.core.Response.Status.BAD_REQUEST;
import static javax.ws.rs.core.Response.Status.FORBIDDEN;
import static javax.ws.rs.core.Response.Status.INTERNAL_SERVER_ERROR;
import static javax.ws.rs.core.Response.Status.NOT_IMPLEMENTED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
+import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
@@ -127,6 +127,7 @@ public void setRequestIdentifier(RequestIdentifier requestIdentifier) {
*/
@GET
@Produces(MediaType.APPLICATION_XML)
+ @SuppressWarnings("checkstyle:ParameterNumber")
public Response get(
@QueryParam("Action") String action,
@QueryParam("RoleArn") String roleArn,
@@ -162,6 +163,7 @@ public Response get(String action, String roleArn, String roleSessionName,
*/
@POST
@Produces(MediaType.APPLICATION_XML)
+ @SuppressWarnings("checkstyle:ParameterNumber")
public Response post(
@FormParam("Action") String action,
@FormParam("RoleArn") String roleArn,
@@ -184,6 +186,7 @@ public Response post(String action, String roleArn, String roleSessionName,
version, awsIamSessionPolicy, null, null);
}
+ @SuppressWarnings("checkstyle:ParameterNumber")
private Response handleSTSRequest(String action, String roleArn, String roleSessionName,
Integer durationSeconds, String version, String awsIamSessionPolicy,
String webIdentityToken, String providerId) throws OS3Exception {
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
index 7862f076106b..7489b1b58777 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
@@ -23,6 +23,7 @@
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
+import java.util.List;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.container.ContainerRequestContext;
@@ -48,7 +49,8 @@ private static String getAction(ContainerRequestContext context)
throws IOException {
String method = context.getMethod();
if (HttpMethod.GET.equalsIgnoreCase(method)) {
- return context.getUriInfo().getQueryParameters().getFirst(ACTION);
+ return singleAction(context.getUriInfo().getQueryParameters()
+ .get(ACTION));
}
if (HttpMethod.POST.equalsIgnoreCase(method)) {
return getFormAction(context);
@@ -66,6 +68,7 @@ private static String getFormAction(ContainerRequestContext context)
byte[] body = readFully(stream);
context.setEntityStream(new ByteArrayInputStream(body));
String form = new String(body, StandardCharsets.UTF_8);
+ String action = null;
for (String pair : form.split("&")) {
int equals = pair.indexOf('=');
if (equals < 0) {
@@ -73,10 +76,13 @@ private static String getFormAction(ContainerRequestContext context)
}
String name = decode(pair.substring(0, equals));
if (ACTION.equals(name)) {
- return decode(pair.substring(equals + 1));
+ if (action != null) {
+ return null;
+ }
+ action = decode(pair.substring(equals + 1));
}
}
- return null;
+ return action;
}
private static byte[] readFully(InputStream stream) throws IOException {
@@ -92,4 +98,8 @@ private static byte[] readFully(InputStream stream) throws IOException {
private static String decode(String value) throws IOException {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
}
+
+ private static String singleAction(List values) {
+ return values != null && values.size() == 1 ? values.get(0) : null;
+ }
}
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
index 8914825ab5ba..1d3ea0ecaa4d 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
@@ -30,6 +30,7 @@
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.MultivaluedHashMap;
@@ -97,6 +98,29 @@ public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody()
assertEquals(body, read(streamCaptor.getValue()));
}
+ @Test
+ public void adversarialActionValuesDoNotSkipAwsAuth() throws Exception {
+ assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity%20");
+ assertDoesNotSkipPost("action=assumerolewithwebidentity");
+ assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity%00");
+ assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity"
+ + "&Action=AssumeRole");
+ assertDoesNotSkipPost("{\"Action\":\"AssumeRoleWithWebIdentity\"}");
+ assertDoesNotSkipPost("Version=2011-06-15");
+ assertDoesNotSkipPost("Action=");
+ }
+
+ @Test
+ public void duplicateQueryActionDoesNotSkipAwsAuth() throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ ContainerRequestContext context = contextWithQueryActions(
+ "AssumeRoleWithWebIdentity", "AssumeRole");
+
+ filter.filter(context);
+
+ verify(context, never()).setProperty(anyString(), any());
+ }
+
private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) {
OzoneConfiguration conf = new OzoneConfiguration();
conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled);
@@ -106,6 +130,15 @@ private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) {
return filter;
}
+ private static void assertDoesNotSkipPost(String body) throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ ContainerRequestContext context = context(HttpMethod.POST, null, body);
+
+ filter.filter(context);
+
+ verify(context, never()).setProperty(anyString(), any());
+ }
+
private static ContainerRequestContext context(String method,
String queryAction, String body) {
ContainerRequestContext context = mock(ContainerRequestContext.class);
@@ -125,6 +158,19 @@ private static ContainerRequestContext context(String method,
return context;
}
+ private static ContainerRequestContext contextWithQueryActions(
+ String... queryActions) {
+ ContainerRequestContext context = mock(ContainerRequestContext.class);
+ UriInfo uriInfo = mock(UriInfo.class);
+ MultivaluedHashMap queryParams =
+ new MultivaluedHashMap<>();
+ queryParams.put("Action", Arrays.asList(queryActions));
+ when(context.getMethod()).thenReturn(HttpMethod.GET);
+ when(context.getUriInfo()).thenReturn(uriInfo);
+ when(uriInfo.getQueryParameters()).thenReturn(queryParams);
+ return context;
+ }
+
private static String read(InputStream stream) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[256];
diff --git a/pom.xml b/pom.xml
index 696eb499952b..e5a94101c6eb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -224,6 +224,7 @@
1.5.4
${test.build.dir}
${project.build.directory}/test-dir
+ 1.21.3
4
@@ -1570,6 +1571,11 @@
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+
org.vafer
jdeb
From 727e9332f1e96b41f865439d2cc7cdf6d00a8d6f Mon Sep 17 00:00:00 2001
From: paf91
Date: Thu, 14 May 2026 14:10:10 +0300
Subject: [PATCH 5/9] HDDS-15273. Fix WebIdentity STS compile checks
---
hadoop-ozone/integration-test-s3/pom.xml | 10 ++++++++++
.../apache/hadoop/ozone/client/ClientProtocolStub.java | 9 +++++++++
2 files changed, 19 insertions(+)
diff --git a/hadoop-ozone/integration-test-s3/pom.xml b/hadoop-ozone/integration-test-s3/pom.xml
index 7c1742f65d83..b6244430a6c2 100644
--- a/hadoop-ozone/integration-test-s3/pom.xml
+++ b/hadoop-ozone/integration-test-s3/pom.xml
@@ -41,6 +41,11 @@
guava
test
+
+ com.nimbusds
+ nimbus-jose-jwt
+ test
+
commons-io
commons-io
@@ -112,6 +117,11 @@
test-jar
test
+
+ org.apache.ozone
+ ozone-manager
+ test
+
org.apache.ozone
ozone-mini-cluster
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
index 5159f6214128..b4c41f51faba 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/client/ClientProtocolStub.java
@@ -39,6 +39,7 @@
import org.apache.hadoop.ozone.client.protocol.ClientProtocol;
import org.apache.hadoop.ozone.client.protocol.ListStatusLightOptions;
import org.apache.hadoop.ozone.om.helpers.AssumeRoleResponseInfo;
+import org.apache.hadoop.ozone.om.helpers.AssumeRoleWithWebIdentityResponseInfo;
import org.apache.hadoop.ozone.om.helpers.DeleteTenantState;
import org.apache.hadoop.ozone.om.helpers.ErrorInfo;
import org.apache.hadoop.ozone.om.helpers.LeaseKeyInfo;
@@ -809,6 +810,14 @@ public AssumeRoleResponseInfo assumeRole(String roleArn, String roleSessionName,
return null;
}
+ @Override
+ public AssumeRoleWithWebIdentityResponseInfo assumeRoleWithWebIdentity(
+ String roleArn, String roleSessionName, int durationSeconds,
+ String webIdentityToken, String providerId, String requestId)
+ throws IOException {
+ return null;
+ }
+
@Override
public void revokeSTSToken(String sessionToken) throws IOException {
}
From f048827ae479783c05aa62c57541112198a131cc Mon Sep 17 00:00:00 2001
From: paf91
Date: Thu, 14 May 2026 16:58:03 +0300
Subject: [PATCH 6/9] HDDS-15273. Fix WebIdentity STS static analysis issues
---
.../acl/AssumeRoleWithWebIdentityRequest.java | 3 +-
.../ozone/security/oidc/AuthCredentials.java | 4 +-
.../security/oidc/CachingJwksProvider.java | 5 +-
.../oidc/OidcJwtIdentityProvider.java | 5 +-
.../ozone/security/oidc/OzoneIdentity.java | 3 +-
.../oidc/TestOidcJwtIdentityProvider.java | 3 +-
...stractAssumeRoleWithWebIdentityS3Test.java | 17 +++++--
...stAssumeRoleWithWebIdentityKeycloakIT.java | 9 ++--
.../ozone/security/STSTokenIdentifier.java | 18 +++----
...3AssumeRoleWithWebIdentityResponseXml.java | 48 +++++++++++++++++++
10 files changed, 89 insertions(+), 26 deletions(-)
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
index d365557d46c4..6d393ba47081 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
@@ -23,6 +23,7 @@
import java.util.Objects;
import java.util.Set;
import net.jcip.annotations.Immutable;
+import org.apache.commons.lang3.StringUtils;
/**
* Represents an STS AssumeRoleWithWebIdentity request that has already been
@@ -178,7 +179,7 @@ private static Set immutableSet(Set values) {
}
private static String requireNonBlank(String value, String name) {
- if (value == null || value.trim().isEmpty()) {
+ if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException(name + " must not be empty");
}
return value;
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
index a8f4cf61b4f0..2b4ca3b06b37 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
@@ -17,6 +17,8 @@
package org.apache.hadoop.ozone.security.oidc;
+import org.apache.commons.lang3.StringUtils;
+
/**
* Authentication material accepted by Ozone identity providers.
*/
@@ -29,7 +31,7 @@ private AuthCredentials(String bearerToken) {
}
public static AuthCredentials bearerToken(String token) {
- if (token == null || token.trim().isEmpty()) {
+ if (StringUtils.isBlank(token)) {
throw new IllegalArgumentException("Bearer token must not be empty");
}
return new AuthCredentials(token);
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
index bff570ccf780..625337c5d623 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
@@ -27,6 +27,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import org.apache.commons.lang3.StringUtils;
/**
* Thread-safe JWKS cache with refresh-on-unknown-kid semantics.
@@ -78,7 +79,7 @@ public CachingJwksProvider(JwksFetcher fetcher, Duration refreshInterval) {
public List getKeys(String keyId) throws OidcAuthenticationException {
refreshIfNeeded(false);
List keys = findKeys(jwkSet, keyId);
- if (keys.isEmpty() && keyId != null && !keyId.trim().isEmpty()) {
+ if (keys.isEmpty() && StringUtils.isNotBlank(keyId)) {
refreshForUnknownKidIfNeeded();
keys = findKeys(jwkSet, keyId);
}
@@ -140,7 +141,7 @@ private static List findKeys(JWKSet set, String keyId) {
if (set == null) {
return Collections.emptyList();
}
- if (keyId == null || keyId.trim().isEmpty()) {
+ if (StringUtils.isBlank(keyId)) {
return Collections.unmodifiableList(new ArrayList<>(set.getKeys()));
}
JWK key = set.getKeyByKeyId(keyId);
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
index ce0b0dbafd91..590cbb97ea08 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
@@ -40,6 +40,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
/**
* OIDC identity provider backed by locally validated signed JWTs.
@@ -73,7 +74,7 @@ public OidcJwtIdentityProvider(OidcConfig config,
public OzoneIdentity authenticate(AuthCredentials credentials)
throws OidcAuthenticationException {
if (credentials == null || credentials.getBearerToken() == null
- || credentials.getBearerToken().trim().isEmpty()) {
+ || StringUtils.isBlank(credentials.getBearerToken())) {
throw new OidcAuthenticationException("Missing OIDC bearer token");
}
@@ -239,7 +240,7 @@ private void validateAudience(JWTClaimsSet claims)
private String extractStringClaim(JWTClaimsSet claims, String claimPath)
throws OidcAuthenticationException {
Object value = claimValue(claims, claimPath);
- if (!(value instanceof String) || ((String) value).trim().isEmpty()) {
+ if (!(value instanceof String) || StringUtils.isBlank((String) value)) {
throw new OidcAuthenticationException(
"OIDC token is missing required claim");
}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
index 1516bae08bb9..bb39dc51265d 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
@@ -23,6 +23,7 @@
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
/**
* Normalized identity produced by an external authentication provider.
@@ -114,7 +115,7 @@ private static Set immutableSet(Set values) {
}
private static String requireNonBlank(String value, String name) {
- if (value == null || value.trim().isEmpty()) {
+ if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException(name + " must not be empty");
}
return value;
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
index 1480afc4a47e..850c9b3774fc 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
@@ -50,6 +50,7 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -356,7 +357,7 @@ private static OidcConfig.Builder configBuilder() {
private static JwksProvider jwksProvider(RSAKey... keys) {
JWKSet jwkSet = jwkSet(keys);
return keyId -> {
- if (keyId == null || keyId.trim().isEmpty()) {
+ if (StringUtils.isBlank(keyId)) {
return new ArrayList<>(jwkSet.getKeys());
}
JWK key = jwkSet.getKeyByKeyId(keyId);
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
index fa600074b743..1bcf2252d2e0 100644
--- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
@@ -49,11 +49,13 @@
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import javax.crypto.KeyGenerator;
import javax.xml.parsers.DocumentBuilderFactory;
import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.hdds.conf.OzoneConfiguration;
import org.apache.hadoop.hdds.security.symmetric.ManagedSecretKey;
import org.apache.hadoop.hdds.security.symmetric.SecretKeyClient;
@@ -138,7 +140,8 @@ protected MiniOzoneCluster createCluster() throws Exception {
@Override
protected void onClusterReady() throws Exception {
- try (OzoneClient client = getCluster().newClient()) {
+ try (OzoneClient client =
+ Objects.requireNonNull(getCluster().newClient())) {
client.getObjectStore().createS3Bucket(ALLOWED_BUCKET);
client.getObjectStore().createS3Bucket(DENIED_BUCKET);
}
@@ -173,7 +176,7 @@ protected StsCredentials assumeRoleWithWebIdentity(String token,
protected void assertTemporaryCredentialsAuthorizeS3Operations(
StsCredentials credentials) {
- try (S3Client s3 = s3Client(credentials)) {
+ try (S3Client s3 = Objects.requireNonNull(s3Client(credentials))) {
String key = "allowed.txt";
s3.putObject(PutObjectRequest.builder()
.bucket(ALLOWED_BUCKET)
@@ -372,13 +375,12 @@ public boolean checkAccess(IOzoneObj ozoneObject, RequestContext context) {
@Override
public String generateAssumeRoleWithWebIdentitySessionPolicy(
AssumeRoleWithWebIdentityRequest request) throws OMException {
- lastAssumeRoleRequest = request;
+ recordAssumeRoleRequest(request);
if (!"tomato-user".equals(request.getUser())
|| !request.getGroups().contains("ozone-tomato")
|| !ROLE_ARN.equals(request.getRoleArn())
|| !PROVIDER_ID.equals(request.getProviderId())
- || request.getSubject() == null
- || request.getSubject().trim().isEmpty()) {
+ || StringUtils.isBlank(request.getSubject())) {
throw new OMException("WebIdentity role assumption denied",
OMException.ResultCodes.ACCESS_DENIED);
}
@@ -391,6 +393,11 @@ public String generateAssumeRoleWithWebIdentitySessionPolicy(
+ "\",\"arn:aws:s3:::" + ALLOWED_BUCKET + "/*\"]"
+ "}]}";
}
+
+ private static void recordAssumeRoleRequest(
+ AssumeRoleWithWebIdentityRequest request) {
+ lastAssumeRoleRequest = request;
+ }
}
private static final class InMemorySecretKeyClient
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
index 6b81190d6d96..e970a3ce8124 100644
--- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/TestAssumeRoleWithWebIdentityKeycloakIT.java
@@ -62,10 +62,11 @@ protected String jwksUri() {
}
@AfterAll
- static void stopKeycloak() {
- if (keycloak != null) {
- keycloak.stop();
- keycloak = null;
+ static synchronized void stopKeycloak() {
+ GenericContainer> container = keycloak;
+ keycloak = null;
+ if (container != null) {
+ container.stop();
}
}
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
index 8b1dbe25b488..596e743616ac 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/STSTokenIdentifier.java
@@ -44,13 +44,8 @@
public class STSTokenIdentifier extends ShortLivedTokenIdentifier {
public static final Text KIND_NAME = new Text("STSToken");
- /**
- * Identifies the authentication path that produced an STS token.
- */
- public enum AuthType {
- ASSUME_ROLE,
- WEB_IDENTITY
- }
+ // Service name for STS tokens
+ public static final String STS_SERVICE = "STS";
// STS-specific fields
private AuthType authType = AuthType.ASSUME_ROLE;
@@ -71,8 +66,13 @@ public enum AuthType {
// Encryption key derived from ManagedSecretKey for this token
private transient byte[] encryptionKey;
- // Service name for STS tokens
- public static final String STS_SERVICE = "STS";
+ /**
+ * Identifies the authentication path that produced an STS token.
+ */
+ public enum AuthType {
+ ASSUME_ROLE,
+ WEB_IDENTITY
+ }
/**
* Create an empty STS token identifier.
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
index 7afa41ba7694..6b844e1f00b6 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3AssumeRoleWithWebIdentityResponseXml.java
@@ -73,22 +73,42 @@ public static class AssumeRoleWithWebIdentityResult {
@XmlElement(name = "Provider")
private String provider;
+ public Credentials getCredentials() {
+ return credentials;
+ }
+
public void setCredentials(Credentials credentials) {
this.credentials = credentials;
}
+ public String getSubjectFromWebIdentityToken() {
+ return subjectFromWebIdentityToken;
+ }
+
public void setSubjectFromWebIdentityToken(String value) {
this.subjectFromWebIdentityToken = value;
}
+ public AssumedRoleUser getAssumedRoleUser() {
+ return assumedRoleUser;
+ }
+
public void setAssumedRoleUser(AssumedRoleUser assumedRoleUser) {
this.assumedRoleUser = assumedRoleUser;
}
+ public String getAudience() {
+ return audience;
+ }
+
public void setAudience(String audience) {
this.audience = audience;
}
+ public String getProvider() {
+ return provider;
+ }
+
public void setProvider(String provider) {
this.provider = provider;
}
@@ -111,18 +131,34 @@ public static class Credentials {
@XmlElement(name = "Expiration")
private String expiration;
+ public String getAccessKeyId() {
+ return accessKeyId;
+ }
+
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
+ public String getSecretAccessKey() {
+ return secretAccessKey;
+ }
+
public void setSecretAccessKey(String secretAccessKey) {
this.secretAccessKey = secretAccessKey;
}
+ public String getSessionToken() {
+ return sessionToken;
+ }
+
public void setSessionToken(String sessionToken) {
this.sessionToken = sessionToken;
}
+ public String getExpiration() {
+ return expiration;
+ }
+
public void setExpiration(String expiration) {
this.expiration = expiration;
}
@@ -139,10 +175,18 @@ public static class AssumedRoleUser {
@XmlElement(name = "Arn")
private String arn;
+ public String getAssumedRoleId() {
+ return assumedRoleId;
+ }
+
public void setAssumedRoleId(String assumedRoleId) {
this.assumedRoleId = assumedRoleId;
}
+ public String getArn() {
+ return arn;
+ }
+
public void setArn(String arn) {
this.arn = arn;
}
@@ -156,6 +200,10 @@ public static class ResponseMetadata {
@XmlElement(name = "RequestId")
private String requestId;
+ public String getRequestId() {
+ return requestId;
+ }
+
public void setRequestId(String requestId) {
this.requestId = requestId;
}
From 8062f0cc2ec371237d307f0f3cc8ec02b84bc7c7 Mon Sep 17 00:00:00 2001
From: paf91
Date: Sat, 23 May 2026 04:18:00 +0300
Subject: [PATCH 7/9] HDDS-15273. Remove split design doc from WebIdentity
implementation PR
---
.../oidc-assume-role-with-web-identity.md | 528 ------------------
1 file changed, 528 deletions(-)
delete mode 100644 hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
diff --git a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md b/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
deleted file mode 100644
index 34f0ef995c77..000000000000
--- a/hadoop-hdds/docs/content/design/oidc-assume-role-with-web-identity.md
+++ /dev/null
@@ -1,528 +0,0 @@
----
-title: OIDC AssumeRoleWithWebIdentity for Ozone STS
-summary: Web identity support for Ozone STS using OIDC and Ranger authorization
-date: 2026-05-13
-status: proposed
----
-
-
-# OIDC AssumeRoleWithWebIdentity for Ozone STS
-
-## Status
-
-Proposed staged implementation.
-
-This document narrows the previous broad OIDC direction to an upstream-friendly
-MVP: extend the Ozone STS temporary S3 credential model with an AWS-compatible
-`AssumeRoleWithWebIdentity` action.
-
-## Problem
-
-Secure Ozone S3 deployments currently depend on Kerberos-backed identities for
-S3 credential issuance. Kubernetes workloads commonly already have OIDC tokens
-from Keycloak or another IdP, but do not have an easy Kerberos bootstrap path.
-
-The target is not a Kerberos-free Ozone cluster. The target is a narrow STS
-exchange:
-
-1. Keycloak authenticates the caller and issues a signed OIDC JWT.
-2. Ozone STS validates the JWT locally using JWKS.
-3. Ranger or the configured Ozone authorizer decides whether the identity may
- assume the requested role.
-4. Ozone STS issues temporary S3 credentials.
-5. S3 Gateway and OM validate those temporary credentials and authorize object
- operations using the assumed identity and session context.
-
-Ranger remains the authorization source of truth. Keycloak roles and groups are
-identity attributes only.
-
-## Current STS And S3 Security Path
-
-This design is based on the `origin/HDDS-13323-sts` branch at commit
-`37a224b217`, which contains the STS runtime that was missing from earlier
-base branches.
-
-The current STS runtime contains:
-
-- `/sts` HTTP endpoint in `org.apache.hadoop.ozone.s3sts.S3STSEndpoint`;
-- endpoint authentication setup in `S3STSEndpointBase`;
-- AWS STS `AssumeRole` XML response model in `S3AssumeRoleResponseXml`;
-- S3G to OM client path through `ObjectStore`, `ClientProtocol`, `RpcClient`,
- `OzoneManagerProtocol`, and
- `OzoneManagerProtocolClientSideTranslatorPB.assumeRole()`;
-- OM request handling in
- `org.apache.hadoop.ozone.om.request.s3.security.S3AssumeRoleRequest`;
-- OM response handling in `S3AssumeRoleResponse`;
-- session token identifier and secret manager in `STSTokenIdentifier`,
- `STSTokenSecretManager`, and `STSSecurityUtil`;
-- revoked STS token metadata and cleanup through `S3RevokeSTSTokenRequest`,
- `S3DeleteRevokedSTSTokensRequest`, and
- `RevokedSTSTokenCleanupService`;
-- authorization extension points in `AssumeRoleRequest`,
- `IAccessAuthorizer.generateAssumeRoleSessionPolicy()`, and
- `RequestContext.sessionPolicy`.
-
-The current S3 request authentication path is:
-
-- S3G parses AWS SigV4 in `AuthorizationFilter`,
- `SignatureProcessor`, `AuthorizationV4HeaderParser`,
- `AuthorizationV4QueryParser`, and `StringToSignProducer`.
-- `EndpointBase` creates `S3Auth` from the parsed access key, signature, and
- string-to-sign, then stores it in the `ClientProtocol` thread-local.
-- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into
- `OMRequest.s3Authentication`.
-- `AWSSignatureProcessor` extracts `x-amz-security-token` for temporary
- credentials into `SignatureInfo.sessionToken`.
-- `EndpointBase` and `S3STSEndpointBase` propagate the session token into
- `S3Auth`.
-- `OzoneManagerProtocolClientSideTranslatorPB` copies `S3Auth` into
- `OMRequest.s3Authentication`.
-- `S3SecurityUtil.validateS3Credential()` validates either permanent S3
- credentials or STS temporary credentials.
-- For STS credentials, `STSSecurityUtil` decodes, validates, and decrypts the
- session token, then OM validates the SigV4 signature with the temporary
- secret access key.
-- `OmMetadataReader` attaches STS session policy from the OM thread-local
- `STSTokenIdentifier` to `RequestContext` for subsequent authorization.
-
-The current permanent S3 credential storage path is:
-
-- `S3SecretManager` and `S3SecretManagerImpl`.
-- `S3SecretValue`.
-- OM metadata S3 secret table via `OmMetadataManagerImpl`.
-- `ozone s3 getsecret`, `setsecret`, and `revokesecret` client paths.
-
-## Dependency On Existing Ozone STS Runtime
-
-`AssumeRoleWithWebIdentity` must be an incremental extension of the existing
-`AssumeRole` runtime in `origin/HDDS-13323-sts`. It must not introduce a second
-STS endpoint, a separate S3 authentication system, or local S3G-only temporary
-credential state.
-
-Existing runtime:
-
-```text
-AssumeRole
- -> S3 SigV4-authenticated /sts request
- -> OM S3AssumeRoleRequest
- -> temporary access key / secret / session token
- -> STSSecurityUtil validation on later S3 requests
- -> RequestContext.sessionPolicy
- -> Ranger or configured authorizer
-```
-
-New runtime:
-
-```text
-AssumeRoleWithWebIdentity
- -> unauthenticated /sts bootstrap request only for this action
- -> OM validates Keycloak/OIDC JWT
- -> OM authorizes role assumption through Ranger or configured authorizer
- -> existing temporary credential issuer / session token path
- -> existing STSSecurityUtil validation on later S3 requests
- -> RequestContext.sessionPolicy and assumed identity
- -> Ranger or configured authorizer
-```
-
-The OM runtime slice adds the WebIdentity request/protobuf path while preserving
-the existing `AssumeRole` flow. S3G parses and routes
-`Action=AssumeRoleWithWebIdentity`, but OM remains the authoritative validator
-and issuer.
-
-The raw `WebIdentityToken` is accepted only in the external OM RPC request. The
-OM leader validates it in `S3AssumeRoleWithWebIdentityRequest.preExecute()`,
-maps claims into a sanitized identity/session request, authorizes role
-assumption, generates temporary credential material using the existing STS
-helpers, and returns an `UpdateAssumeRoleWithWebIdentityRequest` for Ratis
-replication. The replicated request must not contain the raw JWT.
-
-`validateAndUpdateCache()` consumes only the sanitized update request. It must
-not call Keycloak, refresh JWKS, revalidate JWTs, or otherwise depend on current
-external IdP state during Ratis apply or replay. Credential expiration is
-computed by the leader before replication and stored as
-`credentialExpirationEpochSeconds` so replay does not depend on the apply-time
-clock.
-
-Temporary credentials must not be stored only in S3G memory. S3G can have
-multiple replicas and can restart. The issuing and validation authority must be
-OM-backed, persisted in Ozone metadata, or based on self-contained signed tokens
-whose signing keys are rotation-safe and available to all validating components.
-
-## Endpoint Placement
-
-The existing STS runtime places `/sts` on the S3 Gateway HTTP/HTTPS port.
-WebIdentity follows that placement: S3G exposes the AWS-compatible STS API
-surface, while OM remains authoritative for JWT validation, identity mapping,
-role-assumption authorization, credential issuance, revocation, and later
-temporary credential validation.
-
-Because existing `/sts` `AssumeRole` is protected by the normal S3 SigV4
-`AuthorizationFilter`, `AssumeRoleWithWebIdentity` needs a narrow bootstrap
-exception:
-
-- only for the STS application path;
-- only for `Action=AssumeRoleWithWebIdentity`;
-- only when `ozone.sts.web.identity.enabled=true`;
-- never for normal S3 object APIs;
-- never for existing `AssumeRole` or other STS actions.
-
-This exception must not make S3G a JWT source of truth. S3G may parse and route
-the request, but it must forward the web identity token and request context to
-OM. OM validates the JWT itself and issues the credentials.
-
-## RoleArn Semantics
-
-The current `AssumeRoleRequest` model contains `targetRoleName`, not a full AWS
-IAM role database. No role metadata store or IAM-like role lifecycle was found
-in this tree. `RoleArn` should therefore be treated as the authorization
-resource and request context for Ranger or the configured Ozone authorizer in
-the MVP.
-
-The Web Identity patch must not invent a new IAM role database. If the STS
-runtime already defines role ARN parsing or role-name normalization, Web
-Identity should reuse it. Otherwise, `RoleArn` remains an opaque policy resource
-for the authorizer and for audit/session context.
-
-## New Flow
-
-`AssumeRoleWithWebIdentity` is handled by the Ozone STS endpoint.
-
-Request parameters:
-
-- `Action=AssumeRoleWithWebIdentity`
-- `RoleArn=`
-- `RoleSessionName=`
-- `WebIdentityToken=`
-- `DurationSeconds=`
-- `Policy=`
-- `ProviderId=`
-
-Flow:
-
-1. The client or workload obtains an OIDC access token from Keycloak.
-2. The client calls Ozone STS with `AssumeRoleWithWebIdentity`.
-3. Ozone STS rejects the request unless `ozone.sts.web.identity.enabled=true`.
-4. S3G validates only the STS request shape that is safe to validate at the
- edge: action, version, role ARN syntax, role session name, duration bounds,
- and presence of `WebIdentityToken`.
-5. S3G forwards `RoleArn`, `RoleSessionName`, `WebIdentityToken`,
- `DurationSeconds`, `ProviderId`, and request context to OM in the external
- RPC request only.
-6. OM validates the JWT:
- - token is a signed JWT;
- - `alg=none` is rejected;
- - signature validates against the configured JWKS;
- - `iss` equals `ozone.sts.web.identity.issuer.uri`;
- - configured audience is present;
- - `exp`, `nbf`, and `iat` are validated with configured clock skew;
- - configured username and subject claims are present.
-7. OM maps claims into an Ozone identity:
- - username;
- - subject;
- - issuer;
- - groups;
- - roles;
- - token expiration.
-8. OM builds an assume-role authorization request and calls Ranger or the
- configured Ozone authorizer before issuing any credential.
-9. OM strips the raw JWT before Ratis replication and submits only sanitized
- identity/session fields:
- - role ARN and role session name;
- - provider id;
- - effective user;
- - subject, issuer, audience;
- - groups and roles;
- - web identity token expiration;
- - token fingerprint;
- - requested/effective duration;
- - credential expiration;
- - derived session policy.
-10. If authorized, OM issues temporary S3 credentials:
- - `Credentials.AccessKeyId`;
- - `Credentials.SecretAccessKey`;
- - `Credentials.SessionToken`;
- - `Credentials.Expiration`;
- - `SubjectFromWebIdentityToken`;
- - `AssumedRoleUser`;
- - `Audience`;
- - `Provider`.
-11. The client uses those credentials with ordinary AWS SigV4 against S3G.
-12. S3G and OM validate the temporary credential, recover the assumed identity
- and session policy, and pass them to the authorizer for every S3 operation.
-
-## Configuration
-
-The MVP uses STS-focused configuration keys:
-
-```properties
-ozone.sts.web.identity.enabled=false
-ozone.sts.web.identity.issuer.uri=
-ozone.sts.web.identity.jwks.uri=
-ozone.sts.web.identity.audience=
-ozone.sts.web.identity.username.claim=preferred_username
-ozone.sts.web.identity.subject.claim=sub
-ozone.sts.web.identity.groups.claim=groups
-ozone.sts.web.identity.roles.claim=realm_access.roles
-ozone.sts.web.identity.clock.skew=60s
-ozone.sts.web.identity.jwks.refresh.interval=10m
-ozone.sts.web.identity.jwks.connect.timeout=5s
-ozone.sts.web.identity.jwks.read.timeout=5s
-ozone.sts.web.identity.jwks.size.limit=1MB
-ozone.sts.web.identity.require.https=true
-ozone.sts.web.identity.allow.insecure.http.for.tests=false
-```
-
-The feature is opt-in. When disabled, Kerberos, existing S3 SigV4 handling,
-existing S3 secret handling, and non-secure mode behavior are unchanged.
-
-## OIDC Validation
-
-The reusable validation module is intentionally small:
-
-- `AuthCredentials` wraps bearer token material and redacts it in `toString()`.
-- `OzoneIdentity` carries normalized username, subject, issuer, groups, roles,
- auth method, authentication time, expiration time, and raw claims.
-- `OidcJwtIdentityProvider` validates signed JWTs and maps claims.
-- `JwksProvider`, `JwksFetcher`, `UrlJwksFetcher`, and
- `CachingJwksProvider` load and cache JWKS with refresh-on-unknown-kid
- behavior.
-- `OidcAuthenticationException` fails closed without embedding the raw token in
- exception messages.
-
-The module does not call Keycloak for every S3 request. JWKS validation is local,
-with refresh on cache expiry and unknown key id. The default JWKS refresh
-interval is 10 minutes. Unknown key ids may trigger an earlier refresh, but
-repeated unknown kids are debounced to avoid refresh storms from attacker
-supplied token headers. JWKS fetches use bounded connect/read timeouts and a
-bounded response size.
-
-## Ranger Authorization Points
-
-The first authorization point is credential issuance. Before generating
-temporary credentials, Ozone must call the configured authorizer with:
-
-- user: mapped OIDC username;
-- groups: mapped OIDC groups;
-- action: `AssumeRoleWithWebIdentity`;
-- resource: `RoleArn` or normalized Ozone role resource;
-- context: issuer, subject, audience, role session name, provider id,
- requested duration, and client IP/host if available.
-
-Deny is the default. If Ranger denies or the authorizer cannot decide, Ozone
-returns `AccessDenied` and does not issue credentials.
-
-The common request-shape extension point is
-`AssumeRoleWithWebIdentityRequest`, with
-`IAccessAuthorizer.generateAssumeRoleWithWebIdentitySessionPolicy()` as the
-default authorizer hook. Existing authorizers are not forced to implement this
-immediately because the new method has a fail-closed default implementation.
-For production Ranger deployments, the external Ranger Ozone plugin must add a
-companion override for this method. The `RangerOzoneAuthorizer` class is
-provided by Apache Ranger, not this Ozone repository. Without a WebIdentity
-capable Ranger/Ozone authorizer, the default hook returns
-`NOT_SUPPORTED_OPERATION`, Ozone fails closed, and no WebIdentity temporary
-credentials are issued.
-
-The second authorization point is every S3 operation made with the temporary
-credentials. OM must recover the assumed identity and session policy from the
-session token and build a `RequestContext` carrying:
-
-- `clientUgi` for the assumed OIDC username and groups;
-- `sessionPolicy` returned by the assume-role authorization step;
-- `s3Action` mapped from the S3 endpoint method;
-- bucket/key resource information.
-
-Ranger evaluates normal resource/action policies using that context.
-
-## Temporary Credential Lifecycle
-
-Temporary credentials must:
-
-- expire no later than the requested `DurationSeconds` and Ozone's configured
- STS maximum;
-- require `x-amz-security-token` or the equivalent SigV4 query parameter;
-- fail closed if the session token is unknown, expired, revoked, malformed, or
- fails signature/MAC verification;
-- never log the secret access key, session token, WebIdentityToken, refresh
- token, or client secret;
-- map back to the assumed OIDC identity and role session context;
-- preserve the authorizer session policy used for subsequent S3 authorization.
-
-The existing STS runtime uses self-contained session tokens containing the
-encrypted secret access key, original identity, role ARN, session policy,
-expiration, signing key id, and MAC. `AssumeRoleWithWebIdentity` should reuse
-that issuer and validator instead of creating a parallel token format.
-The sanitized replicated OM request still carries temporary credential material
-through Ratis in the same way as the existing `AssumeRole` implementation.
-Operators must protect OM metadata, Ratis logs, snapshots, and backups as
-sensitive security material. The raw WebIdentity JWT must not be replicated or
-stored in OM metadata.
-
-In `origin/HDDS-13323-sts`, `STSTokenIdentifier` stores the
-`originalAccessKeyId` because `AssumeRole` starts from an existing S3 access
-key. `AssumeRoleWithWebIdentity` has no permanent S3 access key. The token
-model must therefore be extended backward-compatibly, for example with an
-`authType` field plus optional WebIdentity fields:
-
-- `authType=ASSUME_ROLE` for existing tokens, with `originalAccessKeyId`
- preserved;
-- `authType=WEB_IDENTITY` for new tokens, with effective user, groups, issuer,
- subject, audience, role ARN, role session name, provider id, and session
- policy;
-- old `AssumeRole` tokens must continue to deserialize and validate exactly as
- before;
-- `STSSecurityUtil.ensureEssentialFieldsArePresentInToken()` must not require
- `originalAccessKeyId` for `WEB_IDENTITY` tokens, but must still require all
- fields needed to validate signatures and authorize later S3 operations.
-
-Revocation should follow the STS design: store revoked session token identifiers
-in OM metadata and fail closed if revocation status cannot be checked.
-
-## AWS-Compatible Response
-
-The XML response should follow the AWS STS shape where practical:
-
-```xml
-
-
- ...
- ozone
- https://keycloak.example.com/realms/ozone
-
- ...
- ...
-
-
- ...
- ...
- ...
- ...
-
-
-
-```
-
-Errors should use STS/S3-compatible codes where possible:
-
-- invalid or expired JWT: `InvalidIdentityToken`;
-- disabled feature: `AccessDenied` or `InvalidAction`;
-- unauthorized role assumption: `AccessDenied`;
-- unsupported optional parameter: `InvalidParameterValue`;
-- internal validation or revocation failures: fail closed.
-
-## Security Model
-
-This feature does not replace Kerberos for daemon authentication or the broader
-Ozone secure cluster model.
-
-Security boundaries:
-
-- Keycloak authenticates the caller by signing JWTs.
-- Ozone STS validates the token and issues short-lived S3 credentials.
-- Ranger or the configured Ozone authorizer decides whether the caller can
- assume a role and what object-store actions are allowed.
-- Ozone S3G/OM enforce the temporary credential and Ranger decisions.
-
-Required protections:
-
-- TLS is required for production STS and Keycloak endpoints.
-- Unsigned JWTs and `alg=none` are rejected.
-- Incorrect issuer, audience, signature, time claims, or required claims are
- rejected.
-- JWKS rotation is handled by cache refresh and refresh-on-unknown-kid.
-- Web identity token and temporary credentials are never logged.
-- Direct OM/SCM/DN access is still governed by existing Ozone security. This
- MVP only adds an STS path for temporary S3 credentials.
-
-## Non-Goals
-
-The MVP explicitly does not include:
-
-- full OIDC-only secure Ozone cluster;
-- replacing Kerberos daemon login;
-- OFS OIDC login;
-- `ozone auth login --oidc`;
-- device-code flow;
-- Keycloak Authorization Services as the object-store PDP;
-- replacing Ranger with Keycloak roles;
-- daemon-to-daemon OIDC authentication.
-
-## Test Strategy
-
-Unit tests:
-
-- valid JWT from a test RSA key validates successfully;
-- expired JWT fails;
-- wrong issuer fails;
-- wrong audience fails;
-- wrong signature fails;
-- `alg=none` fails;
-- manipulated groups claim fails because the signature no longer matches;
-- unknown `kid` triggers JWKS refresh or fails safely;
-- username, subject, groups, and roles claim mapping works;
-- token material is not present in exceptions.
-
-STS authorization tests:
-
-- fake authorizer sees user, groups, action `AssumeRoleWithWebIdentity`, role
- resource, issuer, subject, audience, and session name;
-- allowed identity receives temporary credentials;
-- denied identity receives `AccessDenied`;
-- no credential is generated before authorization succeeds.
-
-Temporary credential tests:
-
-- credentials require a session token;
-- expired credentials fail;
-- tampered session token fails;
-- unknown/revoked session token fails;
-- allowed bucket operation succeeds with allowed role/session policy;
-- denied bucket operation fails.
-
-Integration tests:
-
-- Keycloak Testcontainers or docker-compose realm `ozone-test`;
-- client `ozone-sts`;
-- users `tomato-user` and `denied-user`;
-- group `ozone-tomato`;
-- `tomato-user` token includes `preferred_username`, `sub`, `groups`,
- `realm_access.roles`, and `aud=ozone`;
-- real Keycloak JWT validates with Ozone provider;
-- fake/Ranger authorizer allows `tomato-user` to assume a test role and denies
- `denied-user`.
-
-Full Ranger container testing is optional for the MVP. Unit and mock-layer tests
-must prove request shape and fail-closed behavior.
-
-## Migration And Future Work
-
-Migration path:
-
-1. Existing Kerberos and S3 secret behavior remains unchanged.
-2. Operators enable `ozone.sts.web.identity.enabled=true` for STS only.
-3. Workloads exchange Keycloak JWTs for temporary S3 credentials.
-4. Ranger policies grant role assumption and object access.
-
-Future work:
-
-- reuse the OIDC validation module for OIDC-to-Ozone delegation tokens for
- OFS/CLI;
-- add daemon authentication without Kerberos via mTLS, SPIFFE, Kubernetes
- ServiceAccount JWTs, or Keycloak client credentials;
-- add hybrid Kerberos plus OIDC migration mode if broader Ozone authentication
- is pursued;
-- improve AWS STS API compatibility;
-- add an optional real Ranger integration test profile.
From 12b1ef2662ab00a0d800654df2e82778ada8baf6 Mon Sep 17 00:00:00 2001
From: paf91
Date: Sun, 24 May 2026 20:53:55 +0300
Subject: [PATCH 8/9] HDDS-15273. Address WebIdentity STS review comments
---
.../apache/hadoop/ozone/OzoneConfigKeys.java | 54 --
.../src/main/resources/ozone-default.xml | 111 -----
.../OzoneSTSWebIdentityKeycloakRanger.md | 7 +-
hadoop-ozone/common/pom.xml | 4 -
.../acl/AssumeRoleWithWebIdentityRequest.java | 121 ++++-
.../ozone/security/oidc/OidcConfig.java | 395 ---------------
.../TestAssumeRoleWithWebIdentityRequest.java | 47 +-
...stractAssumeRoleWithWebIdentityS3Test.java | 11 +-
.../src/main/proto/OmClientProtocol.proto | 54 +-
hadoop-ozone/ozone-manager/pom.xml | 4 +
.../apache/hadoop/ozone/om/OzoneManager.java | 18 -
.../S3AssumeRoleWithWebIdentityRequest.java | 111 ++++-
.../ozone/security/oidc/AuthCredentials.java | 0
.../security/oidc/CachingJwksProvider.java | 0
.../ozone/security/oidc/JwksFetcher.java | 0
.../ozone/security/oidc/JwksProvider.java | 0
.../oidc/OidcAuthenticationException.java | 0
.../ozone/security/oidc/OidcConfig.java | 461 ++++++++++++++++++
.../oidc/OidcJwtIdentityProvider.java | 0
.../ozone/security/oidc/OzoneIdentity.java | 0
.../security/oidc/OzoneIdentityProvider.java | 0
.../ozone/security/oidc/UrlJwksFetcher.java | 0
.../ozone/security/oidc/package-info.java | 0
...estS3AssumeRoleWithWebIdentityRequest.java | 79 ++-
.../oidc/TestOidcJwtIdentityProvider.java | 0
.../S3STSWebIdentityAuthBypassFilter.java | 15 +-
.../s3sts/S3STSWebIdentityRequestParser.java | 14 +-
.../TestS3STSWebIdentityAuthBypassFilter.java | 15 +-
pom.xml | 2 +-
29 files changed, 804 insertions(+), 719 deletions(-)
delete mode 100644 hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java (100%)
create mode 100644 hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java (100%)
rename hadoop-ozone/{common => ozone-manager}/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java (100%)
diff --git a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
index e798e2f8271b..2a72b3c65f00 100644
--- a/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
+++ b/hadoop-hdds/common/src/main/java/org/apache/hadoop/ozone/OzoneConfigKeys.java
@@ -448,60 +448,6 @@ public final class OzoneConfigKeys {
public static final String OZONE_STS_WEB_IDENTITY_ENABLED =
"ozone.sts.web.identity.enabled";
public static final boolean OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT = false;
- public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI =
- "ozone.sts.web.identity.issuer.uri";
- public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT = "";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI =
- "ozone.sts.web.identity.jwks.uri";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT = "";
- public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE =
- "ozone.sts.web.identity.audience";
- public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT = "";
- public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM =
- "ozone.sts.web.identity.username.claim";
- public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT =
- "preferred_username";
- public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM =
- "ozone.sts.web.identity.subject.claim";
- public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT =
- "sub";
- public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM =
- "ozone.sts.web.identity.groups.claim";
- public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT =
- "groups";
- public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM =
- "ozone.sts.web.identity.roles.claim";
- public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT =
- "realm_access.roles";
- public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW =
- "ozone.sts.web.identity.clock.skew";
- public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT =
- "60s";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL =
- "ozone.sts.web.identity.jwks.refresh.interval";
- public static final String
- OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT = "10m";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT =
- "ozone.sts.web.identity.jwks.connect.timeout";
- public static final String
- OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT = "5s";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT =
- "ozone.sts.web.identity.jwks.read.timeout";
- public static final String
- OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT = "5s";
- public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT =
- "ozone.sts.web.identity.jwks.size.limit";
- public static final String
- OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT = "1MB";
- public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS =
- "ozone.sts.web.identity.require.https";
- public static final boolean OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT =
- true;
- public static final String
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS =
- "ozone.sts.web.identity.allow.insecure.http.for.tests";
- public static final boolean
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT = false;
public static final String OZONE_HTTP_SECURITY_ENABLED_KEY =
"ozone.security.http.kerberos.enabled";
diff --git a/hadoop-hdds/common/src/main/resources/ozone-default.xml b/hadoop-hdds/common/src/main/resources/ozone-default.xml
index 19bd629fec49..2066a99baa47 100644
--- a/hadoop-hdds/common/src/main/resources/ozone-default.xml
+++ b/hadoop-hdds/common/src/main/resources/ozone-default.xml
@@ -2142,117 +2142,6 @@
true, hadoop.security.authentication should be Kerberos.
-
- ozone.sts.web.identity.enabled
- false
- OZONE, SECURITY, S3, STS, OIDC
-
- Enables experimental Ozone STS AssumeRoleWithWebIdentity support.
- When enabled, OM validates OIDC web identity JWTs and issues temporary
- S3 credentials through the existing STS token infrastructure.
-
-
-
- ozone.sts.web.identity.issuer.uri
-
- OZONE, SECURITY, S3, STS, OIDC
- Expected OIDC issuer URI for STS web identity tokens.
-
-
- ozone.sts.web.identity.jwks.uri
-
- OZONE, SECURITY, S3, STS, OIDC
-
- JWKS URI used to validate STS web identity JWT signatures. If empty,
- a later STS integration may derive it from issuer metadata.
-
-
-
- ozone.sts.web.identity.audience
-
- OZONE, SECURITY, S3, STS, OIDC
- Required audience claim for STS web identity tokens.
-
-
- ozone.sts.web.identity.username.claim
- preferred_username
- OZONE, SECURITY, S3, STS, OIDC
- OIDC claim used as the effective Ozone username.
-
-
- ozone.sts.web.identity.subject.claim
- sub
- OZONE, SECURITY, S3, STS, OIDC
- OIDC claim used as the immutable external subject.
-
-
- ozone.sts.web.identity.groups.claim
- groups
- OZONE, SECURITY, S3, STS, OIDC
-
- OIDC claim path containing user groups. Nested claim paths such as
- resource_access.ozone.groups are supported.
-
-
-
- ozone.sts.web.identity.roles.claim
- realm_access.roles
- OZONE, SECURITY, S3, STS, OIDC
-
- OIDC claim path containing role attributes. Roles are identity
- attributes only; Ranger remains the authorization policy engine.
-
-
-
- ozone.sts.web.identity.clock.skew
- 60s
- OZONE, SECURITY, S3, STS, OIDC
- Allowed clock skew for OIDC exp, nbf, and iat validation.
-
-
- ozone.sts.web.identity.jwks.refresh.interval
- 10m
- OZONE, SECURITY, S3, STS, OIDC
-
- Interval for refreshing cached JWKS signing keys. Unknown key IDs may
- trigger an earlier refresh, but repeated unknown key IDs are debounced.
-
-
-
- ozone.sts.web.identity.jwks.connect.timeout
- 5s
- OZONE, SECURITY, S3, STS, OIDC
- Connection timeout for fetching OIDC JWKS signing keys.
-
-
- ozone.sts.web.identity.jwks.read.timeout
- 5s
- OZONE, SECURITY, S3, STS, OIDC
- Read timeout for fetching OIDC JWKS signing keys.
-
-
- ozone.sts.web.identity.jwks.size.limit
- 1MB
- OZONE, SECURITY, S3, STS, OIDC
- Maximum JWKS response size accepted by Ozone STS.
-
-
- ozone.sts.web.identity.require.https
- true
- OZONE, SECURITY, S3, STS, OIDC
-
- Require HTTPS issuer and JWKS URIs for STS web identity validation.
-
-
-
- ozone.sts.web.identity.allow.insecure.http.for.tests
- false
- OZONE, SECURITY, S3, STS, OIDC
-
- Allows HTTP issuer and JWKS URIs only for tests. Do not enable in
- production.
-
-
ozone.security.http.kerberos.enabled
false
diff --git a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
index 1846e793552a..d8b96d7e3ec9 100644
--- a/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
+++ b/hadoop-hdds/docs/content/security/OzoneSTSWebIdentityKeycloakRanger.md
@@ -137,17 +137,14 @@ subsequent S3 access.
```
-For local tests only, HTTP issuer and JWKS URLs can be enabled explicitly:
+For local tests only, HTTP issuer and JWKS URLs can be enabled explicitly by
+turning off the HTTPS requirement:
```xml
ozone.sts.web.identity.require.https
false
-
- ozone.sts.web.identity.allow.insecure.http.for.tests
- true
-
```
Production deployments should use HTTPS for both Keycloak and Ozone endpoints.
diff --git a/hadoop-ozone/common/pom.xml b/hadoop-ozone/common/pom.xml
index 7e15ac212428..13a53cdc7a88 100644
--- a/hadoop-ozone/common/pom.xml
+++ b/hadoop-ozone/common/pom.xml
@@ -47,10 +47,6 @@
com.google.protobuf
protobuf-java
-
- com.nimbusds
- nimbus-jose-jwt
-
io.grpc
grpc-api
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
index 6d393ba47081..cda41199be9e 100644
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/acl/AssumeRoleWithWebIdentityRequest.java
@@ -34,7 +34,7 @@
* attributes to the authorizer.
*/
@Immutable
-public class AssumeRoleWithWebIdentityRequest {
+public final class AssumeRoleWithWebIdentityRequest {
public static final String ACTION = "AssumeRoleWithWebIdentity";
@@ -51,24 +51,24 @@ public class AssumeRoleWithWebIdentityRequest {
private final String providerId;
private final Set grants;
- @SuppressWarnings("checkstyle:ParameterNumber")
- public AssumeRoleWithWebIdentityRequest(String host, InetAddress ip,
- String user, Set groups, Set roles, String roleArn,
- String roleSessionName, String issuer, String subject, String audience,
- String providerId, Set grants) {
- this.host = host;
- this.ip = ip;
- this.user = requireNonBlank(user, "user");
- this.groups = immutableSet(groups);
- this.roles = immutableSet(roles);
- this.roleArn = requireNonBlank(roleArn, "roleArn");
+ private AssumeRoleWithWebIdentityRequest(Builder builder) {
+ this.host = builder.host;
+ this.ip = builder.ip;
+ this.user = requireNonBlank(builder.user, "user");
+ this.groups = immutableSet(builder.groups);
+ this.roles = immutableSet(builder.roles);
+ this.roleArn = requireNonBlank(builder.roleArn, "roleArn");
this.roleSessionName =
- requireNonBlank(roleSessionName, "roleSessionName");
- this.issuer = requireNonBlank(issuer, "issuer");
- this.subject = requireNonBlank(subject, "subject");
- this.audience = requireNonBlank(audience, "audience");
- this.providerId = providerId;
- this.grants = grants;
+ requireNonBlank(builder.roleSessionName, "roleSessionName");
+ this.issuer = requireNonBlank(builder.issuer, "issuer");
+ this.subject = requireNonBlank(builder.subject, "subject");
+ this.audience = requireNonBlank(builder.audience, "audience");
+ this.providerId = builder.providerId;
+ this.grants = builder.grants;
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
}
public String getAction() {
@@ -184,4 +184,89 @@ private static String requireNonBlank(String value, String name) {
}
return value;
}
+
+ /**
+ * Builder for {@link AssumeRoleWithWebIdentityRequest}.
+ */
+ public static final class Builder {
+ private String host;
+ private InetAddress ip;
+ private String user;
+ private Set groups;
+ private Set roles;
+ private String roleArn;
+ private String roleSessionName;
+ private String issuer;
+ private String subject;
+ private String audience;
+ private String providerId;
+ private Set grants;
+
+ private Builder() {
+ }
+
+ public Builder setHost(String value) {
+ this.host = value;
+ return this;
+ }
+
+ public Builder setIp(InetAddress value) {
+ this.ip = value;
+ return this;
+ }
+
+ public Builder setUser(String value) {
+ this.user = value;
+ return this;
+ }
+
+ public Builder setGroups(Set value) {
+ this.groups = value;
+ return this;
+ }
+
+ public Builder setRoles(Set value) {
+ this.roles = value;
+ return this;
+ }
+
+ public Builder setRoleArn(String value) {
+ this.roleArn = value;
+ return this;
+ }
+
+ public Builder setRoleSessionName(String value) {
+ this.roleSessionName = value;
+ return this;
+ }
+
+ public Builder setIssuer(String value) {
+ this.issuer = value;
+ return this;
+ }
+
+ public Builder setSubject(String value) {
+ this.subject = value;
+ return this;
+ }
+
+ public Builder setAudience(String value) {
+ this.audience = value;
+ return this;
+ }
+
+ public Builder setProviderId(String value) {
+ this.providerId = value;
+ return this;
+ }
+
+ public Builder setGrants(Set value) {
+ this.grants = value;
+ return this;
+ }
+
+ public AssumeRoleWithWebIdentityRequest build() {
+ return new AssumeRoleWithWebIdentityRequest(this);
+ }
+ }
}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
deleted file mode 100644
index 53a91995a445..000000000000
--- a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
+++ /dev/null
@@ -1,395 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.apache.hadoop.ozone.security.oidc;
-
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT;
-
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.time.Duration;
-import java.util.concurrent.TimeUnit;
-import org.apache.hadoop.hdds.conf.ConfigurationSource;
-import org.apache.hadoop.hdds.conf.StorageUnit;
-
-/**
- * Configuration for the experimental OIDC identity provider.
- */
-public final class OidcConfig {
-
- private final boolean enabled;
- private final String issuerUri;
- private final String jwksUri;
- private final String audience;
- private final String usernameClaim;
- private final String subjectClaim;
- private final String groupsClaim;
- private final String rolesClaim;
- private final Duration clockSkew;
- private final Duration jwksRefreshInterval;
- private final Duration jwksConnectTimeout;
- private final Duration jwksReadTimeout;
- private final int jwksSizeLimit;
- private final boolean requireHttps;
- private final boolean allowInsecureHttpForTests;
-
- private OidcConfig(Builder builder) {
- this.enabled = builder.enabled;
- this.issuerUri = trimToEmpty(builder.issuerUri);
- this.jwksUri = trimToEmpty(builder.jwksUri);
- this.audience = trimToEmpty(builder.audience);
- this.usernameClaim = requireNonBlank(builder.usernameClaim,
- OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM);
- this.subjectClaim = requireNonBlank(builder.subjectClaim,
- OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM);
- this.groupsClaim = requireNonBlank(builder.groupsClaim,
- OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM);
- this.rolesClaim = requireNonBlank(builder.rolesClaim,
- OZONE_STS_WEB_IDENTITY_ROLES_CLAIM);
- this.clockSkew = requireNonNegative(builder.clockSkew,
- OZONE_STS_WEB_IDENTITY_CLOCK_SKEW);
- this.jwksRefreshInterval = requireNonNegative(builder.jwksRefreshInterval,
- OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL);
- this.jwksConnectTimeout = requirePositive(builder.jwksConnectTimeout,
- OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT);
- this.jwksReadTimeout = requirePositive(builder.jwksReadTimeout,
- OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT);
- this.jwksSizeLimit = requirePositive(builder.jwksSizeLimit,
- OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT);
- this.requireHttps = builder.requireHttps;
- this.allowInsecureHttpForTests = builder.allowInsecureHttpForTests;
- }
-
- public static OidcConfig from(ConfigurationSource conf) {
- OidcConfig config = newBuilder()
- .setEnabled(conf.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED,
- OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT))
- .setIssuerUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ISSUER_URI,
- OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT))
- .setJwksUri(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_JWKS_URI,
- OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT))
- .setAudience(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_AUDIENCE,
- OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT))
- .setUsernameClaim(conf.getTrimmed(
- OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM,
- OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT))
- .setSubjectClaim(conf.getTrimmed(
- OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM,
- OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT))
- .setGroupsClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM,
- OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT))
- .setRolesClaim(conf.getTrimmed(OZONE_STS_WEB_IDENTITY_ROLES_CLAIM,
- OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT))
- .setClockSkew(duration(conf, OZONE_STS_WEB_IDENTITY_CLOCK_SKEW,
- OZONE_STS_WEB_IDENTITY_CLOCK_SKEW_DEFAULT))
- .setJwksRefreshInterval(duration(conf,
- OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL,
- OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL_DEFAULT))
- .setJwksConnectTimeout(duration(conf,
- OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT,
- OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT_DEFAULT))
- .setJwksReadTimeout(duration(conf,
- OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT,
- OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT_DEFAULT))
- .setJwksSizeLimit(storageSize(conf,
- OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT,
- OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT_DEFAULT))
- .setRequireHttps(conf.getBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS,
- OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT))
- .setAllowInsecureHttpForTests(conf.getBoolean(
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT))
- .build();
- if (config.isEnabled()) {
- config.validateForProvider();
- }
- return config;
- }
-
- public static Builder newBuilder() {
- return new Builder();
- }
-
- public boolean isEnabled() {
- return enabled;
- }
-
- public String getIssuerUri() {
- return issuerUri;
- }
-
- public String getJwksUri() {
- return jwksUri;
- }
-
- public String getAudience() {
- return audience;
- }
-
- public String getUsernameClaim() {
- return usernameClaim;
- }
-
- public String getSubjectClaim() {
- return subjectClaim;
- }
-
- public String getGroupsClaim() {
- return groupsClaim;
- }
-
- public String getRolesClaim() {
- return rolesClaim;
- }
-
- public Duration getClockSkew() {
- return clockSkew;
- }
-
- public Duration getJwksRefreshInterval() {
- return jwksRefreshInterval;
- }
-
- public Duration getJwksConnectTimeout() {
- return jwksConnectTimeout;
- }
-
- public Duration getJwksReadTimeout() {
- return jwksReadTimeout;
- }
-
- public int getJwksSizeLimit() {
- return jwksSizeLimit;
- }
-
- public boolean isRequireHttps() {
- return requireHttps;
- }
-
- public boolean isAllowInsecureHttpForTests() {
- return allowInsecureHttpForTests;
- }
-
- void validateForProvider() {
- requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
- requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE);
-
- if (requireHttps && !allowInsecureHttpForTests) {
- requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
- if (!jwksUri.isEmpty()) {
- requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI);
- }
- }
- }
-
- private static Duration duration(ConfigurationSource conf, String key,
- String defaultValue) {
- return Duration.ofMillis(conf.getTimeDuration(key, defaultValue,
- TimeUnit.MILLISECONDS));
- }
-
- private static int storageSize(ConfigurationSource conf, String key,
- String defaultValue) {
- double bytes = conf.getStorageSize(key, defaultValue, StorageUnit.BYTES);
- if (bytes > Integer.MAX_VALUE) {
- throw new IllegalArgumentException(key + " must not exceed "
- + Integer.MAX_VALUE + " bytes");
- }
- return (int) bytes;
- }
-
- private static Duration requireNonNegative(Duration value, String key) {
- if (value == null || value.isNegative()) {
- throw new IllegalArgumentException(key + " must not be negative");
- }
- return value;
- }
-
- private static Duration requirePositive(Duration value, String key) {
- if (value == null || value.isZero() || value.isNegative()) {
- throw new IllegalArgumentException(key + " must be positive");
- }
- return value;
- }
-
- private static int requirePositive(int value, String key) {
- if (value <= 0) {
- throw new IllegalArgumentException(key + " must be positive");
- }
- return value;
- }
-
- private static String requireNonBlank(String value, String key) {
- String trimmed = trimToEmpty(value);
- if (trimmed.isEmpty()) {
- throw new IllegalArgumentException(key + " must not be empty");
- }
- return trimmed;
- }
-
- private static String trimToEmpty(String value) {
- return value == null ? "" : value.trim();
- }
-
- private static void requireHttpsUri(String value, String key) {
- URI uri;
- try {
- uri = new URI(value);
- } catch (URISyntaxException e) {
- throw new IllegalArgumentException(key + " is not a valid URI", e);
- }
- if (!"https".equalsIgnoreCase(uri.getScheme())) {
- throw new IllegalArgumentException(key + " must use https");
- }
- }
-
- /**
- * Builder for {@link OidcConfig}.
- */
- public static final class Builder {
-
- private boolean enabled = OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
- private String issuerUri = OZONE_STS_WEB_IDENTITY_ISSUER_URI_DEFAULT;
- private String jwksUri = OZONE_STS_WEB_IDENTITY_JWKS_URI_DEFAULT;
- private String audience = OZONE_STS_WEB_IDENTITY_AUDIENCE_DEFAULT;
- private String usernameClaim =
- OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM_DEFAULT;
- private String subjectClaim =
- OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM_DEFAULT;
- private String groupsClaim = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM_DEFAULT;
- private String rolesClaim = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM_DEFAULT;
- private Duration clockSkew = Duration.ofSeconds(60);
- private Duration jwksRefreshInterval = Duration.ofMinutes(10);
- private Duration jwksConnectTimeout = Duration.ofSeconds(5);
- private Duration jwksReadTimeout = Duration.ofSeconds(5);
- private int jwksSizeLimit = 1024 * 1024;
- private boolean requireHttps = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS_DEFAULT;
- private boolean allowInsecureHttpForTests =
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
-
- private Builder() {
- }
-
- public Builder setEnabled(boolean value) {
- this.enabled = value;
- return this;
- }
-
- public Builder setIssuerUri(String value) {
- this.issuerUri = value;
- return this;
- }
-
- public Builder setJwksUri(String value) {
- this.jwksUri = value;
- return this;
- }
-
- public Builder setAudience(String value) {
- this.audience = value;
- return this;
- }
-
- public Builder setUsernameClaim(String value) {
- this.usernameClaim = value;
- return this;
- }
-
- public Builder setSubjectClaim(String value) {
- this.subjectClaim = value;
- return this;
- }
-
- public Builder setGroupsClaim(String value) {
- this.groupsClaim = value;
- return this;
- }
-
- public Builder setRolesClaim(String value) {
- this.rolesClaim = value;
- return this;
- }
-
- public Builder setClockSkew(Duration value) {
- this.clockSkew = value;
- return this;
- }
-
- public Builder setJwksRefreshInterval(Duration value) {
- this.jwksRefreshInterval = value;
- return this;
- }
-
- public Builder setJwksConnectTimeout(Duration value) {
- this.jwksConnectTimeout = value;
- return this;
- }
-
- public Builder setJwksReadTimeout(Duration value) {
- this.jwksReadTimeout = value;
- return this;
- }
-
- public Builder setJwksSizeLimit(int value) {
- this.jwksSizeLimit = value;
- return this;
- }
-
- public Builder setRequireHttps(boolean value) {
- this.requireHttps = value;
- return this;
- }
-
- public Builder setAllowInsecureHttpForTests(boolean value) {
- this.allowInsecureHttpForTests = value;
- return this;
- }
-
- public OidcConfig build() {
- return new OidcConfig(this);
- }
- }
-}
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
index 19651ba34d9d..f4a40d25b8fb 100644
--- a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/acl/TestAssumeRoleWithWebIdentityRequest.java
@@ -45,10 +45,19 @@ public void testConstructorAndGetters() throws Exception {
final InetAddress ip = InetAddress.getByName("127.0.0.1");
final AssumeRoleWithWebIdentityRequest request =
- new AssumeRoleWithWebIdentityRequest(
- "s3g.example.com", ip, "tomato-user", groups, roles, ROLE_ARN,
- "tomato-session", "https://keycloak.example.com/realms/ozone",
- "subject-1", "ozone", "keycloak", null);
+ AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setHost("s3g.example.com")
+ .setIp(ip)
+ .setUser("tomato-user")
+ .setGroups(groups)
+ .setRoles(roles)
+ .setRoleArn(ROLE_ARN)
+ .setRoleSessionName("tomato-session")
+ .setIssuer("https://keycloak.example.com/realms/ozone")
+ .setSubject("subject-1")
+ .setAudience("ozone")
+ .setProviderId("keycloak")
+ .build();
groups.add("mutated-group");
roles.add("mutated-role");
@@ -75,10 +84,16 @@ public void testRequiredIdentityFieldsFailClosed() {
assertThrows(IllegalArgumentException.class,
() -> requestWithUser(" "));
assertThrows(IllegalArgumentException.class,
- () -> new AssumeRoleWithWebIdentityRequest(
- null, null, "tomato-user", set("ozone-tomato"), set(),
- " ", "tomato-session", "issuer", "subject", "audience",
- null, null));
+ () -> AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setUser("tomato-user")
+ .setGroups(set("ozone-tomato"))
+ .setRoles(set())
+ .setRoleArn(" ")
+ .setRoleSessionName("tomato-session")
+ .setIssuer("issuer")
+ .setSubject("subject")
+ .setAudience("audience")
+ .build());
}
@Test
@@ -142,10 +157,18 @@ public void testRequestDoesNotContainTokenMaterial() {
private static AssumeRoleWithWebIdentityRequest requestWithUser(
String user) {
- return new AssumeRoleWithWebIdentityRequest(
- "host", null, user, set("ozone-tomato"), set("role:writer"),
- ROLE_ARN, "session", "issuer", "subject", "audience",
- "provider", null);
+ return AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setHost("host")
+ .setUser(user)
+ .setGroups(set("ozone-tomato"))
+ .setRoles(set("role:writer"))
+ .setRoleArn(ROLE_ARN)
+ .setRoleSessionName("session")
+ .setIssuer("issuer")
+ .setSubject("subject")
+ .setAudience("audience")
+ .setProviderId("provider")
+ .build();
}
private static Set set(String... values) {
diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
index 1bcf2252d2e0..9ecadea55865 100644
--- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
+++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/AbstractAssumeRoleWithWebIdentityS3Test.java
@@ -22,17 +22,16 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_AUTHORIZER_CLASS;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ACL_ENABLED;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_S3G_STS_HTTP_ENABLED_KEY;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_KEY;
import static org.apache.hadoop.ozone.om.OMConfigKeys.OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY;
import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_HTTP_ADDRESS_KEY;
import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTPS_ADDRESS_KEY;
import static org.apache.hadoop.ozone.s3sts.S3STSConfigKeys.OZONE_S3G_STS_HTTP_ADDRESS_KEY;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS;
import static org.apache.ozone.test.GenericTestUtils.PortAllocator.localhostWithFreePort;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -117,8 +116,6 @@ protected OzoneConfiguration createOzoneConfig() {
conf.set(OZONE_STS_WEB_IDENTITY_AUDIENCE, AUDIENCE);
conf.set(OZONE_STS_WEB_IDENTITY_JWKS_URI, jwksUri());
conf.setBoolean(OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS, false);
- conf.setBoolean(OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
- true);
conf.set(OZONE_SERVER_DEFAULT_REPLICATION_TYPE_KEY, "RATIS");
conf.set(OZONE_SERVER_DEFAULT_REPLICATION_KEY, "ONE");
conf.set(OZONE_S3G_STS_HTTP_ADDRESS_KEY, localhostWithFreePort());
diff --git a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
index 536c064eac59..9d4aa22f9b67 100644
--- a/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
+++ b/hadoop-ozone/interface-client/src/main/proto/OmClientProtocol.proto
@@ -2453,22 +2453,22 @@ message UpdateAssumeRoleRequest {
}
message AssumeRoleWithWebIdentityRequest {
- required string roleArn = 1;
- required string roleSessionName = 2;
+ optional string roleArn = 1;
+ optional string roleSessionName = 2;
optional int32 durationSeconds = 3 [default = 3600];
- required string webIdentityToken = 4;
+ optional string webIdentityToken = 4;
optional string providerId = 5;
- required string requestId = 6;
+ optional string requestId = 6;
}
message AssumeRoleWithWebIdentityResponse {
- required string accessKeyId = 1;
- required string secretAccessKey = 2;
- required string sessionToken = 3;
- required uint64 expirationEpochSeconds = 4;
- required string assumedRoleId = 5;
- required string subjectFromWebIdentityToken = 6;
- required string audience = 7;
+ optional string accessKeyId = 1;
+ optional string secretAccessKey = 2;
+ optional string sessionToken = 3;
+ optional uint64 expirationEpochSeconds = 4;
+ optional string assumedRoleId = 5;
+ optional string subjectFromWebIdentityToken = 6;
+ optional string audience = 7;
optional string provider = 8;
}
@@ -2478,25 +2478,25 @@ message AssumeRoleWithWebIdentityResponse {
replicated through Ratis; it must never contain the raw JWT.
*/
message UpdateAssumeRoleWithWebIdentityRequest {
- required string roleArn = 1;
- required string roleSessionName = 2;
- required int32 durationSeconds = 3;
+ optional string roleArn = 1;
+ optional string roleSessionName = 2;
+ optional int32 durationSeconds = 3;
optional string providerId = 4;
- required string requestId = 5;
- required string tempAccessKeyId = 6;
- required string secretAccessKey = 7;
- required string roleId = 8;
- required string effectiveUser = 9;
- required string subject = 10;
- required string issuer = 11;
- required string audience = 12;
+ optional string requestId = 5;
+ optional string tempAccessKeyId = 6;
+ optional string secretAccessKey = 7;
+ optional string roleId = 8;
+ optional string effectiveUser = 9;
+ optional string subject = 10;
+ optional string issuer = 11;
+ optional string audience = 12;
repeated string groups = 13;
repeated string roles = 14;
- required uint64 webIdentityTokenExpiresAt = 15;
- required uint64 authenticatedAt = 16;
- required string tokenFingerprint = 17;
- required string sessionPolicy = 18;
- required uint64 credentialExpirationEpochSeconds = 19;
+ optional uint64 webIdentityTokenExpiresAt = 15;
+ optional uint64 authenticatedAt = 16;
+ optional string tokenFingerprint = 17;
+ optional string sessionPolicy = 18;
+ optional uint64 credentialExpirationEpochSeconds = 19;
}
message RevokeSTSTokenRequest {
diff --git a/hadoop-ozone/ozone-manager/pom.xml b/hadoop-ozone/ozone-manager/pom.xml
index 2dd6dfdc10db..e4de0086620f 100644
--- a/hadoop-ozone/ozone-manager/pom.xml
+++ b/hadoop-ozone/ozone-manager/pom.xml
@@ -46,6 +46,10 @@
com.google.protobuf
protobuf-java
+
+ com.nimbusds
+ nimbus-jose-jwt
+
commons-codec
commons-codec
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
index f75939aa1429..d2334199d7ab 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/OzoneManager.java
@@ -39,10 +39,6 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_READONLY_ADMINISTRATORS;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SCM_BLOCK_SIZE_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConsts.DB_TRANSIENT_MARKER;
import static org.apache.hadoop.ozone.OzoneConsts.DEFAULT_OM_UPDATE_ID;
import static org.apache.hadoop.ozone.OzoneConsts.LAYOUT_VERSION_KEY;
@@ -1918,7 +1914,6 @@ public DeletingServiceMetrics getDeletionMetrics() {
public void start() throws IOException {
Map auditMap = new HashMap();
auditMap.put("OmState", omState.name());
- logInsecureWebIdentityHttpWarning();
if (omState == State.BOOTSTRAPPING) {
if (isBootstrapping) {
auditMap.put("Bootstrap", "normal");
@@ -2027,19 +2022,6 @@ public void start() throws IOException {
SYSTEMAUDIT.logWriteSuccess(buildAuditMessageForSuccess(OMSystemAction.STARTUP, auditMap));
}
- private void logInsecureWebIdentityHttpWarning() {
- if (configuration.getBoolean(OZONE_STS_WEB_IDENTITY_ENABLED,
- OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT)
- && configuration.getBoolean(
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS,
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS_DEFAULT)) {
- LOG.warn("STS WebIdentity is configured with {}=true. This permits "
- + "insecure HTTP issuer/JWKS URIs for tests only and is unsafe "
- + "for production deployments.",
- OZONE_STS_WEB_IDENTITY_ALLOW_INSECURE_HTTP_FOR_TESTS);
- }
- }
-
/**
* Restarts the service. This method re-initializes the rpc server.
*/
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
index 8d4e6eed7725..8e6afa68f81d 100644
--- a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/om/request/s3/security/S3AssumeRoleWithWebIdentityRequest.java
@@ -17,13 +17,13 @@
package org.apache.hadoop.ozone.om.request.s3.security;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.ACCESS_DENIED;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INTERNAL_ERROR;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_TOKEN;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
@@ -105,10 +105,12 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
FEATURE_NOT_ENABLED);
}
+ validateExternalRequest(request);
final int requestedDuration =
S3STSUtils.validateDuration(request.getDurationSeconds());
S3STSUtils.validateRoleSessionName(request.getRoleSessionName());
- AwsRoleArnValidator.validateAndExtractRoleNameFromArn(request.getRoleArn());
+ AwsRoleArnValidator.validateAndExtractRoleNameFromArn(
+ request.getRoleArn());
final OzoneIdentity identity = authenticate(oidcConfig,
request.getWebIdentityToken());
@@ -132,7 +134,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
.setRoleArn(request.getRoleArn())
.setRoleSessionName(request.getRoleSessionName())
.setDurationSeconds(effectiveDuration)
- .setProviderId(request.hasProviderId() ? request.getProviderId() : "")
+ .setProviderId(request.hasProviderId()
+ ? request.getProviderId() : "")
.setRequestId(request.getRequestId())
.setTempAccessKeyId(S3AssumeRoleRequest.generateTempAccessKeyId())
.setSecretAccessKey(S3AssumeRoleRequest.generateSecretAccessKey())
@@ -143,7 +146,8 @@ public OMRequest preExecute(OzoneManager ozoneManager) throws IOException {
.setAudience(oidcConfig.getAudience())
.addAllGroups(identity.getGroups())
.addAllRoles(identity.getRoles())
- .setWebIdentityTokenExpiresAt(identity.getExpiresAt().toEpochMilli())
+ .setWebIdentityTokenExpiresAt(
+ identity.getExpiresAt().toEpochMilli())
.setAuthenticatedAt(identity.getAuthenticatedAt().toEpochMilli())
.setTokenFingerprint(tokenFingerprint)
.setSessionPolicy(sessionPolicy)
@@ -179,14 +183,11 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager,
Exception exception = null;
OMClientResponse omClientResponse;
try {
+ validateUpdateRequest(updateRequest);
S3STSUtils.validateDuration(updateRequest.getDurationSeconds());
S3STSUtils.validateRoleSessionName(updateRequest.getRoleSessionName());
AwsRoleArnValidator.validateAndExtractRoleNameFromArn(
updateRequest.getRoleArn());
- if (Strings.isNullOrEmpty(updateRequest.getSessionPolicy())) {
- throw new OMException("Missing WebIdentity session policy",
- ACCESS_DENIED);
- }
final String sessionToken = ozoneManager.getSTSTokenSecretManager()
.createWebIdentitySTSTokenString(
@@ -206,8 +207,8 @@ public OMClientResponse validateAndUpdateCache(OzoneManager ozoneManager,
updateRequest.getProviderId(),
updateRequest.getTokenFingerprint());
- final String assumedRoleId =
- updateRequest.getRoleId() + ":" + updateRequest.getRoleSessionName();
+ final String assumedRoleId = updateRequest.getRoleId()
+ + ":" + updateRequest.getRoleSessionName();
final long expirationEpochSeconds =
updateRequest.getCredentialExpirationEpochSeconds();
@@ -306,20 +307,82 @@ private String generateSessionPolicy(OzoneManager ozoneManager,
return ozoneManager.getAccessAuthorizer()
.generateAssumeRoleWithWebIdentitySessionPolicy(
- new org.apache.hadoop.ozone.security.acl
- .AssumeRoleWithWebIdentityRequest(
- hostName,
- remoteIp,
- identity.getUsername(),
- identity.getGroups(),
- identity.getRoles(),
- request.getRoleArn(),
- request.getRoleSessionName(),
- identity.getIssuer(),
- identity.getSubject(),
- audience,
- request.hasProviderId() ? request.getProviderId() : null,
- null));
+ org.apache.hadoop.ozone.security.acl
+ .AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setHost(hostName)
+ .setIp(remoteIp)
+ .setUser(identity.getUsername())
+ .setGroups(identity.getGroups())
+ .setRoles(identity.getRoles())
+ .setRoleArn(request.getRoleArn())
+ .setRoleSessionName(request.getRoleSessionName())
+ .setIssuer(identity.getIssuer())
+ .setSubject(identity.getSubject())
+ .setAudience(audience)
+ .setProviderId(request.hasProviderId()
+ ? request.getProviderId() : null)
+ .build());
+ }
+
+ private static void validateExternalRequest(
+ AssumeRoleWithWebIdentityRequest request) throws OMException {
+ requirePresentNonBlank(request.hasRoleArn(), request.getRoleArn(),
+ "roleArn");
+ requirePresentNonBlank(request.hasRoleSessionName(),
+ request.getRoleSessionName(), "roleSessionName");
+ requirePresentNonBlank(request.hasWebIdentityToken(),
+ request.getWebIdentityToken(), "webIdentityToken");
+ requirePresentNonBlank(request.hasRequestId(), request.getRequestId(),
+ "requestId");
+ }
+
+ private static void validateUpdateRequest(
+ UpdateAssumeRoleWithWebIdentityRequest request) throws OMException {
+ requirePresentNonBlank(request.hasRoleArn(), request.getRoleArn(),
+ "roleArn");
+ requirePresentNonBlank(request.hasRoleSessionName(),
+ request.getRoleSessionName(), "roleSessionName");
+ requirePresent(request.hasDurationSeconds(), "durationSeconds");
+ requirePresentNonBlank(request.hasRequestId(), request.getRequestId(),
+ "requestId");
+ requirePresentNonBlank(request.hasTempAccessKeyId(),
+ request.getTempAccessKeyId(), "tempAccessKeyId");
+ requirePresentNonBlank(request.hasSecretAccessKey(),
+ request.getSecretAccessKey(), "secretAccessKey");
+ requirePresentNonBlank(request.hasRoleId(), request.getRoleId(),
+ "roleId");
+ requirePresentNonBlank(request.hasEffectiveUser(),
+ request.getEffectiveUser(), "effectiveUser");
+ requirePresentNonBlank(request.hasSubject(), request.getSubject(),
+ "subject");
+ requirePresentNonBlank(request.hasIssuer(), request.getIssuer(),
+ "issuer");
+ requirePresentNonBlank(request.hasAudience(), request.getAudience(),
+ "audience");
+ requirePresent(request.hasWebIdentityTokenExpiresAt(),
+ "webIdentityTokenExpiresAt");
+ requirePresent(request.hasAuthenticatedAt(), "authenticatedAt");
+ requirePresentNonBlank(request.hasTokenFingerprint(),
+ request.getTokenFingerprint(), "tokenFingerprint");
+ requirePresentNonBlank(request.hasSessionPolicy(),
+ request.getSessionPolicy(), "sessionPolicy");
+ requirePresent(request.hasCredentialExpirationEpochSeconds(),
+ "credentialExpirationEpochSeconds");
+ }
+
+ private static void requirePresent(boolean present, String field)
+ throws OMException {
+ if (!present) {
+ throw new OMException("Missing WebIdentity " + field, INVALID_REQUEST);
+ }
+ }
+
+ private static void requirePresentNonBlank(boolean present, String value,
+ String field) throws OMException {
+ requirePresent(present, field);
+ if (value.trim().isEmpty()) {
+ throw new OMException("Missing WebIdentity " + field, INVALID_REQUEST);
+ }
}
private static void addAuditParams(Map auditMap,
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/AuthCredentials.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/CachingJwksProvider.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksFetcher.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/JwksProvider.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcAuthenticationException.java
diff --git a/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
new file mode 100644
index 000000000000..3aa1fc44b631
--- /dev/null
+++ b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcConfig.java
@@ -0,0 +1,461 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.security.oidc;
+
+import static org.apache.hadoop.hdds.conf.ConfigTag.OM;
+import static org.apache.hadoop.hdds.conf.ConfigTag.SECURITY;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hadoop.hdds.conf.Config;
+import org.apache.hadoop.hdds.conf.ConfigGroup;
+import org.apache.hadoop.hdds.conf.ConfigType;
+import org.apache.hadoop.hdds.conf.ConfigurationSource;
+import org.apache.hadoop.hdds.conf.PostConstruct;
+import org.apache.hadoop.hdds.conf.StorageUnit;
+
+/**
+ * Configuration for the experimental OIDC identity provider.
+ */
+@ConfigGroup(prefix = OidcConfig.PREFIX)
+public final class OidcConfig {
+
+ static final String PREFIX = "ozone.sts.web.identity";
+
+ public static final String OZONE_STS_WEB_IDENTITY_ENABLED =
+ PREFIX + ".enabled";
+ public static final String OZONE_STS_WEB_IDENTITY_ISSUER_URI =
+ PREFIX + ".issuer.uri";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_URI =
+ PREFIX + ".jwks.uri";
+ public static final String OZONE_STS_WEB_IDENTITY_AUDIENCE =
+ PREFIX + ".audience";
+ public static final String OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM =
+ PREFIX + ".username.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM =
+ PREFIX + ".subject.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM =
+ PREFIX + ".groups.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_ROLES_CLAIM =
+ PREFIX + ".roles.claim";
+ public static final String OZONE_STS_WEB_IDENTITY_CLOCK_SKEW =
+ PREFIX + ".clock.skew";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL =
+ PREFIX + ".jwks.refresh.interval";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT =
+ PREFIX + ".jwks.connect.timeout";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT =
+ PREFIX + ".jwks.read.timeout";
+ public static final String OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT =
+ PREFIX + ".jwks.size.limit";
+ public static final String OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS =
+ PREFIX + ".require.https";
+
+ private static final String ENABLED_DEFAULT = "false";
+ private static final String ISSUER_URI_DEFAULT = "";
+ private static final String JWKS_URI_DEFAULT = "";
+ private static final String AUDIENCE_DEFAULT = "";
+ private static final String USERNAME_CLAIM_DEFAULT = "preferred_username";
+ private static final String SUBJECT_CLAIM_DEFAULT = "sub";
+ private static final String GROUPS_CLAIM_DEFAULT = "groups";
+ private static final String ROLES_CLAIM_DEFAULT = "realm_access.roles";
+ private static final String CLOCK_SKEW_DEFAULT = "60s";
+ private static final String JWKS_REFRESH_INTERVAL_DEFAULT = "10m";
+ private static final String JWKS_CONNECT_TIMEOUT_DEFAULT = "5s";
+ private static final String JWKS_READ_TIMEOUT_DEFAULT = "5s";
+ private static final String JWKS_SIZE_LIMIT_DEFAULT = "1MB";
+ private static final String REQUIRE_HTTPS_DEFAULT = "true";
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_ENABLED,
+ defaultValue = ENABLED_DEFAULT,
+ type = ConfigType.BOOLEAN,
+ description = "Enables experimental Ozone STS "
+ + "AssumeRoleWithWebIdentity support.",
+ tags = {OM, SECURITY})
+ private boolean enabled;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_ISSUER_URI,
+ defaultValue = ISSUER_URI_DEFAULT,
+ type = ConfigType.STRING,
+ description = "Expected OIDC issuer URI. Required when "
+ + "AssumeRoleWithWebIdentity support is enabled.",
+ tags = {OM, SECURITY})
+ private String issuerUri;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_URI,
+ defaultValue = JWKS_URI_DEFAULT,
+ type = ConfigType.STRING,
+ description = "Optional JWKS endpoint URI. If unset, OM resolves JWKS "
+ + "from the issuer discovery metadata.",
+ tags = {OM, SECURITY})
+ private String jwksUri;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_AUDIENCE,
+ defaultValue = AUDIENCE_DEFAULT,
+ type = ConfigType.STRING,
+ description = "Expected OIDC audience value for WebIdentity tokens.",
+ tags = {OM, SECURITY})
+ private String audience;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM,
+ defaultValue = USERNAME_CLAIM_DEFAULT,
+ type = ConfigType.STRING,
+ description = "OIDC claim used as the Ozone user name.",
+ tags = {OM, SECURITY})
+ private String usernameClaim = USERNAME_CLAIM_DEFAULT;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM,
+ defaultValue = SUBJECT_CLAIM_DEFAULT,
+ type = ConfigType.STRING,
+ description = "OIDC claim used as the stable token subject.",
+ tags = {OM, SECURITY})
+ private String subjectClaim = SUBJECT_CLAIM_DEFAULT;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM,
+ defaultValue = GROUPS_CLAIM_DEFAULT,
+ type = ConfigType.STRING,
+ description = "OIDC claim used to import group attributes.",
+ tags = {OM, SECURITY})
+ private String groupsClaim = GROUPS_CLAIM_DEFAULT;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_ROLES_CLAIM,
+ defaultValue = ROLES_CLAIM_DEFAULT,
+ type = ConfigType.STRING,
+ description = "OIDC claim path used to import role attributes.",
+ tags = {OM, SECURITY})
+ private String rolesClaim = ROLES_CLAIM_DEFAULT;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_CLOCK_SKEW,
+ defaultValue = CLOCK_SKEW_DEFAULT,
+ type = ConfigType.TIME,
+ timeUnit = TimeUnit.MILLISECONDS,
+ description = "Allowed clock skew while validating OIDC temporal "
+ + "claims.",
+ tags = {OM, SECURITY})
+ private Duration clockSkew = Duration.ofSeconds(60);
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL,
+ defaultValue = JWKS_REFRESH_INTERVAL_DEFAULT,
+ type = ConfigType.TIME,
+ timeUnit = TimeUnit.MILLISECONDS,
+ description = "Interval after which cached OIDC JWKS keys are "
+ + "refreshed.",
+ tags = {OM, SECURITY})
+ private Duration jwksRefreshInterval = Duration.ofMinutes(10);
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT,
+ defaultValue = JWKS_CONNECT_TIMEOUT_DEFAULT,
+ type = ConfigType.TIME,
+ timeUnit = TimeUnit.MILLISECONDS,
+ description = "Connect timeout for loading OIDC JWKS keys.",
+ tags = {OM, SECURITY})
+ private Duration jwksConnectTimeout = Duration.ofSeconds(5);
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT,
+ defaultValue = JWKS_READ_TIMEOUT_DEFAULT,
+ type = ConfigType.TIME,
+ timeUnit = TimeUnit.MILLISECONDS,
+ description = "Read timeout for loading OIDC JWKS keys.",
+ tags = {OM, SECURITY})
+ private Duration jwksReadTimeout = Duration.ofSeconds(5);
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT,
+ defaultValue = JWKS_SIZE_LIMIT_DEFAULT,
+ type = ConfigType.SIZE,
+ sizeUnit = StorageUnit.BYTES,
+ description = "Maximum accepted OIDC JWKS response size.",
+ tags = {OM, SECURITY})
+ private int jwksSizeLimit = 1024 * 1024;
+
+ @Config(key = OZONE_STS_WEB_IDENTITY_REQUIRE_HTTPS,
+ defaultValue = REQUIRE_HTTPS_DEFAULT,
+ type = ConfigType.BOOLEAN,
+ description = "Requires HTTPS issuer and JWKS URIs for WebIdentity "
+ + "token validation. Tests and local development may set this to "
+ + "false explicitly.",
+ tags = {OM, SECURITY})
+ private boolean requireHttps = true;
+
+ public OidcConfig() {
+ }
+
+ private OidcConfig(Builder builder) {
+ this.enabled = builder.enabled;
+ this.issuerUri = builder.issuerUri;
+ this.jwksUri = builder.jwksUri;
+ this.audience = builder.audience;
+ this.usernameClaim = builder.usernameClaim;
+ this.subjectClaim = builder.subjectClaim;
+ this.groupsClaim = builder.groupsClaim;
+ this.rolesClaim = builder.rolesClaim;
+ this.clockSkew = builder.clockSkew;
+ this.jwksRefreshInterval = builder.jwksRefreshInterval;
+ this.jwksConnectTimeout = builder.jwksConnectTimeout;
+ this.jwksReadTimeout = builder.jwksReadTimeout;
+ this.jwksSizeLimit = builder.jwksSizeLimit;
+ this.requireHttps = builder.requireHttps;
+ normalizeAndValidate();
+ }
+
+ public static OidcConfig from(ConfigurationSource conf) {
+ return conf.getObject(OidcConfig.class);
+ }
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public String getIssuerUri() {
+ return issuerUri;
+ }
+
+ public String getJwksUri() {
+ return jwksUri;
+ }
+
+ public String getAudience() {
+ return audience;
+ }
+
+ public String getUsernameClaim() {
+ return usernameClaim;
+ }
+
+ public String getSubjectClaim() {
+ return subjectClaim;
+ }
+
+ public String getGroupsClaim() {
+ return groupsClaim;
+ }
+
+ public String getRolesClaim() {
+ return rolesClaim;
+ }
+
+ public Duration getClockSkew() {
+ return clockSkew;
+ }
+
+ public Duration getJwksRefreshInterval() {
+ return jwksRefreshInterval;
+ }
+
+ public Duration getJwksConnectTimeout() {
+ return jwksConnectTimeout;
+ }
+
+ public Duration getJwksReadTimeout() {
+ return jwksReadTimeout;
+ }
+
+ public int getJwksSizeLimit() {
+ return jwksSizeLimit;
+ }
+
+ public boolean isRequireHttps() {
+ return requireHttps;
+ }
+
+ @PostConstruct
+ public void normalizeAndValidate() {
+ issuerUri = StringUtils.trimToEmpty(issuerUri);
+ jwksUri = StringUtils.trimToEmpty(jwksUri);
+ audience = StringUtils.trimToEmpty(audience);
+ usernameClaim = requireNonBlank(usernameClaim,
+ OZONE_STS_WEB_IDENTITY_USERNAME_CLAIM);
+ subjectClaim = requireNonBlank(subjectClaim,
+ OZONE_STS_WEB_IDENTITY_SUBJECT_CLAIM);
+ groupsClaim = requireNonBlank(groupsClaim,
+ OZONE_STS_WEB_IDENTITY_GROUPS_CLAIM);
+ rolesClaim = requireNonBlank(rolesClaim,
+ OZONE_STS_WEB_IDENTITY_ROLES_CLAIM);
+ clockSkew = requireNonNegative(clockSkew,
+ OZONE_STS_WEB_IDENTITY_CLOCK_SKEW);
+ jwksRefreshInterval = requireNonNegative(jwksRefreshInterval,
+ OZONE_STS_WEB_IDENTITY_JWKS_REFRESH_INTERVAL);
+ jwksConnectTimeout = requirePositive(jwksConnectTimeout,
+ OZONE_STS_WEB_IDENTITY_JWKS_CONNECT_TIMEOUT);
+ jwksReadTimeout = requirePositive(jwksReadTimeout,
+ OZONE_STS_WEB_IDENTITY_JWKS_READ_TIMEOUT);
+ jwksSizeLimit = requirePositive(jwksSizeLimit,
+ OZONE_STS_WEB_IDENTITY_JWKS_SIZE_LIMIT);
+
+ if (enabled) {
+ validateForProvider();
+ }
+ }
+
+ void validateForProvider() {
+ requireNonBlank(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
+ requireNonBlank(audience, OZONE_STS_WEB_IDENTITY_AUDIENCE);
+
+ if (requireHttps) {
+ requireHttpsUri(issuerUri, OZONE_STS_WEB_IDENTITY_ISSUER_URI);
+ if (StringUtils.isNotEmpty(jwksUri)) {
+ requireHttpsUri(jwksUri, OZONE_STS_WEB_IDENTITY_JWKS_URI);
+ }
+ }
+ }
+
+ private static Duration requireNonNegative(Duration value, String key) {
+ if (value == null || value.isNegative()) {
+ throw new IllegalArgumentException(key + " must not be negative");
+ }
+ return value;
+ }
+
+ private static Duration requirePositive(Duration value, String key) {
+ if (value == null || value.isZero() || value.isNegative()) {
+ throw new IllegalArgumentException(key + " must be positive");
+ }
+ return value;
+ }
+
+ private static int requirePositive(int value, String key) {
+ if (value <= 0) {
+ throw new IllegalArgumentException(key + " must be positive");
+ }
+ return value;
+ }
+
+ private static String requireNonBlank(String value, String key) {
+ String trimmed = StringUtils.trimToEmpty(value);
+ if (StringUtils.isBlank(trimmed)) {
+ throw new IllegalArgumentException(key + " must not be empty");
+ }
+ return trimmed;
+ }
+
+ private static void requireHttpsUri(String value, String key) {
+ URI uri;
+ try {
+ uri = new URI(value);
+ } catch (URISyntaxException e) {
+ throw new IllegalArgumentException(key + " is not a valid URI", e);
+ }
+ if (!"https".equalsIgnoreCase(uri.getScheme())) {
+ throw new IllegalArgumentException(key + " must use https");
+ }
+ }
+
+ /**
+ * Builder for {@link OidcConfig}.
+ */
+ public static final class Builder {
+
+ private boolean enabled;
+ private String issuerUri = ISSUER_URI_DEFAULT;
+ private String jwksUri = JWKS_URI_DEFAULT;
+ private String audience = AUDIENCE_DEFAULT;
+ private String usernameClaim = USERNAME_CLAIM_DEFAULT;
+ private String subjectClaim = SUBJECT_CLAIM_DEFAULT;
+ private String groupsClaim = GROUPS_CLAIM_DEFAULT;
+ private String rolesClaim = ROLES_CLAIM_DEFAULT;
+ private Duration clockSkew = Duration.ofSeconds(60);
+ private Duration jwksRefreshInterval = Duration.ofMinutes(10);
+ private Duration jwksConnectTimeout = Duration.ofSeconds(5);
+ private Duration jwksReadTimeout = Duration.ofSeconds(5);
+ private int jwksSizeLimit = 1024 * 1024;
+ private boolean requireHttps = true;
+
+ private Builder() {
+ }
+
+ public Builder setEnabled(boolean value) {
+ this.enabled = value;
+ return this;
+ }
+
+ public Builder setIssuerUri(String value) {
+ this.issuerUri = value;
+ return this;
+ }
+
+ public Builder setJwksUri(String value) {
+ this.jwksUri = value;
+ return this;
+ }
+
+ public Builder setAudience(String value) {
+ this.audience = value;
+ return this;
+ }
+
+ public Builder setUsernameClaim(String value) {
+ this.usernameClaim = value;
+ return this;
+ }
+
+ public Builder setSubjectClaim(String value) {
+ this.subjectClaim = value;
+ return this;
+ }
+
+ public Builder setGroupsClaim(String value) {
+ this.groupsClaim = value;
+ return this;
+ }
+
+ public Builder setRolesClaim(String value) {
+ this.rolesClaim = value;
+ return this;
+ }
+
+ public Builder setClockSkew(Duration value) {
+ this.clockSkew = value;
+ return this;
+ }
+
+ public Builder setJwksRefreshInterval(Duration value) {
+ this.jwksRefreshInterval = value;
+ return this;
+ }
+
+ public Builder setJwksConnectTimeout(Duration value) {
+ this.jwksConnectTimeout = value;
+ return this;
+ }
+
+ public Builder setJwksReadTimeout(Duration value) {
+ this.jwksReadTimeout = value;
+ return this;
+ }
+
+ public Builder setJwksSizeLimit(int value) {
+ this.jwksSizeLimit = value;
+ return this;
+ }
+
+ public Builder setRequireHttps(boolean value) {
+ this.requireHttps = value;
+ return this;
+ }
+
+ public OidcConfig build() {
+ return new OidcConfig(this);
+ }
+ }
+}
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OidcJwtIdentityProvider.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentity.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/OzoneIdentityProvider.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/UrlJwksFetcher.java
diff --git a/hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java b/hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
similarity index 100%
rename from hadoop-ozone/common/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
rename to hadoop-ozone/ozone-manager/src/main/java/org/apache/hadoop/ozone/security/oidc/package-info.java
diff --git a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
index fb96f37aac18..8563e73daca7 100644
--- a/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
+++ b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/om/request/s3/security/TestS3AssumeRoleWithWebIdentityRequest.java
@@ -17,12 +17,13 @@
package org.apache.hadoop.ozone.om.request.s3.security;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_AUDIENCE;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
-import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.FEATURE_NOT_ENABLED;
+import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.INVALID_REQUEST;
import static org.apache.hadoop.ozone.om.exceptions.OMException.ResultCodes.TOKEN_EXPIRED;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_AUDIENCE;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_ISSUER_URI;
+import static org.apache.hadoop.ozone.security.oidc.OidcConfig.OZONE_STS_WEB_IDENTITY_JWKS_URI;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -279,6 +280,47 @@ public void testDisabledFeatureFailsBeforeTokenValidation() {
assertThat(identityProvider.getCapturedToken()).isNull();
}
+ @Test
+ public void testMissingExternalRequiredFieldFailsClosed() {
+ final CapturingIdentityProvider identityProvider =
+ new CapturingIdentityProvider(identity(3600));
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(
+ externalRequest(requestBuilder(3600, RAW_JWT)
+ .clearWebIdentityToken()
+ .build()), CLOCK, identityProvider);
+
+ assertThatThrownBy(() -> request.preExecute(ozoneManager))
+ .isInstanceOf(OMException.class)
+ .satisfies(e -> assertThat(((OMException) e).getResult())
+ .isEqualTo(INVALID_REQUEST))
+ .hasMessageContaining("webIdentityToken");
+ assertThat(identityProvider.getCapturedToken()).isNull();
+ }
+
+ @Test
+ public void testMissingSanitizedRequiredFieldFailsClosed()
+ throws Exception {
+ final S3AssumeRoleWithWebIdentityRequest request =
+ new S3AssumeRoleWithWebIdentityRequest(externalRequest(3600), CLOCK,
+ new CapturingIdentityProvider(identity(3600)));
+ final OMRequest preExecuted = request.preExecute(ozoneManager);
+ final UpdateAssumeRoleWithWebIdentityRequest malformed =
+ preExecuted.getUpdateAssumeRoleWithWebIdentityRequest().toBuilder()
+ .clearSessionPolicy()
+ .build();
+ final OMRequest malformedOmRequest = preExecuted.toBuilder()
+ .setUpdateAssumeRoleWithWebIdentityRequest(malformed)
+ .build();
+
+ final OMClientResponse clientResponse =
+ new S3AssumeRoleWithWebIdentityRequest(malformedOmRequest, CLOCK)
+ .validateAndUpdateCache(ozoneManager, context);
+
+ assertThat(clientResponse.getOMResponse().getStatus())
+ .isEqualTo(Status.INVALID_REQUEST);
+ }
+
@Test
public void testExceptionCauseChainDoesNotExposeTokenMaterial() {
final String sensitiveToken = "raw.jwt.SecretAccessKey.SessionToken."
@@ -302,22 +344,33 @@ private static OMRequest externalRequest(int durationSeconds) {
private static OMRequest externalRequest(int durationSeconds,
String webIdentityToken) {
+ return externalRequest(requestBuilder(durationSeconds, webIdentityToken)
+ .build());
+ }
+
+ private static OMRequest externalRequest(
+ org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos
+ .AssumeRoleWithWebIdentityRequest request) {
return OMRequest.newBuilder()
.setCmdType(Type.AssumeRoleWithWebIdentity)
.setClientId("client-1")
- .setAssumeRoleWithWebIdentityRequest(
- org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos
- .AssumeRoleWithWebIdentityRequest.newBuilder()
- .setRoleArn(ROLE_ARN)
- .setRoleSessionName(ROLE_SESSION_NAME)
- .setDurationSeconds(durationSeconds)
- .setProviderId(PROVIDER_ID)
- .setRequestId(REQUEST_ID)
- .setWebIdentityToken(webIdentityToken)
- .build())
+ .setAssumeRoleWithWebIdentityRequest(request)
.build();
}
+ private static org.apache.hadoop.ozone.protocol.proto
+ .OzoneManagerProtocolProtos.AssumeRoleWithWebIdentityRequest.Builder
+ requestBuilder(int durationSeconds, String webIdentityToken) {
+ return org.apache.hadoop.ozone.protocol.proto.OzoneManagerProtocolProtos
+ .AssumeRoleWithWebIdentityRequest.newBuilder()
+ .setRoleArn(ROLE_ARN)
+ .setRoleSessionName(ROLE_SESSION_NAME)
+ .setDurationSeconds(durationSeconds)
+ .setProviderId(PROVIDER_ID)
+ .setRequestId(REQUEST_ID)
+ .setWebIdentityToken(webIdentityToken);
+ }
+
private static void assertThrowableChainDoesNotContain(Throwable throwable,
String... sensitiveValues) {
Throwable current = throwable;
diff --git a/hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java b/hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
similarity index 100%
rename from hadoop-ozone/common/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
rename to hadoop-ozone/ozone-manager/src/test/java/org/apache/hadoop/ozone/security/oidc/TestOidcJwtIdentityProvider.java
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
index e406bc0b4af4..616ec0214104 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
@@ -20,7 +20,6 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT;
-import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import javax.annotation.Priority;
import javax.inject.Inject;
@@ -45,8 +44,16 @@
public class S3STSWebIdentityAuthBypassFilter
implements ContainerRequestFilter {
+ private final OzoneConfiguration ozoneConfiguration;
+
+ public S3STSWebIdentityAuthBypassFilter() {
+ this(null);
+ }
+
@Inject
- private OzoneConfiguration ozoneConfiguration;
+ public S3STSWebIdentityAuthBypassFilter(OzoneConfiguration conf) {
+ this.ozoneConfiguration = conf;
+ }
@Override
public void filter(ContainerRequestContext context) throws IOException {
@@ -67,8 +74,4 @@ private boolean isWebIdentityEnabled() {
OZONE_STS_WEB_IDENTITY_ENABLED_DEFAULT);
}
- @VisibleForTesting
- void setOzoneConfiguration(OzoneConfiguration conf) {
- this.ozoneConfiguration = conf;
- }
}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
index 7489b1b58777..7a0bd0f16f87 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
@@ -18,7 +18,6 @@
package org.apache.hadoop.ozone.s3sts;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
@@ -26,6 +25,7 @@
import java.util.List;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.container.ContainerRequestContext;
+import org.apache.commons.io.IOUtils;
/**
* Parses the STS action without logging request parameters.
@@ -65,7 +65,7 @@ private static String getFormAction(ContainerRequestContext context)
return null;
}
- byte[] body = readFully(stream);
+ byte[] body = IOUtils.toByteArray(stream);
context.setEntityStream(new ByteArrayInputStream(body));
String form = new String(body, StandardCharsets.UTF_8);
String action = null;
@@ -85,16 +85,6 @@ private static String getFormAction(ContainerRequestContext context)
return action;
}
- private static byte[] readFully(InputStream stream) throws IOException {
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- byte[] buffer = new byte[4096];
- int read;
- while ((read = stream.read(buffer)) != -1) {
- out.write(buffer, 0, read);
- }
- return out.toByteArray();
- }
-
private static String decode(String value) throws IOException {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
}
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
index 1d3ea0ecaa4d..4e3abd5b37c4 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
@@ -27,7 +27,6 @@
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -35,6 +34,7 @@
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.UriInfo;
+import org.apache.commons.io.IOUtils;
import org.apache.hadoop.hdds.conf.OzoneConfiguration;
import org.apache.hadoop.ozone.s3.AuthorizationFilter;
import org.junit.jupiter.api.Test;
@@ -124,10 +124,7 @@ public void duplicateQueryActionDoesNotSkipAwsAuth() throws Exception {
private static S3STSWebIdentityAuthBypassFilter filter(boolean enabled) {
OzoneConfiguration conf = new OzoneConfiguration();
conf.setBoolean(OZONE_STS_WEB_IDENTITY_ENABLED, enabled);
- S3STSWebIdentityAuthBypassFilter filter =
- new S3STSWebIdentityAuthBypassFilter();
- filter.setOzoneConfiguration(conf);
- return filter;
+ return new S3STSWebIdentityAuthBypassFilter(conf);
}
private static void assertDoesNotSkipPost(String body) throws Exception {
@@ -172,12 +169,6 @@ private static ContainerRequestContext contextWithQueryActions(
}
private static String read(InputStream stream) throws Exception {
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- byte[] buffer = new byte[256];
- int read;
- while ((read = stream.read(buffer)) != -1) {
- out.write(buffer, 0, read);
- }
- return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ return new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8);
}
}
diff --git a/pom.xml b/pom.xml
index e5a94101c6eb..2de60387e3a5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -224,7 +224,7 @@
1.5.4
${test.build.dir}
${project.build.directory}/test-dir
- 1.21.3
+ 2.0.5
4
From 38c6a8e2367a0fa301539fd6b069679c5b5b8448 Mon Sep 17 00:00:00 2001
From: paf91
Date: Mon, 25 May 2026 00:32:19 +0300
Subject: [PATCH 9/9] HDDS-15273. Bound WebIdentity STS request body size
---
.../S3STSWebIdentityAuthBypassFilter.java | 5 ++
.../s3sts/S3STSWebIdentityRequestParser.java | 32 ++++++++++++-
.../TestS3STSWebIdentityAuthBypassFilter.java | 46 +++++++++++++++++++
3 files changed, 81 insertions(+), 2 deletions(-)
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
index 616ec0214104..8096542c92b2 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityAuthBypassFilter.java
@@ -46,6 +46,11 @@ public class S3STSWebIdentityAuthBypassFilter
private final OzoneConfiguration ozoneConfiguration;
+ /**
+ * No-arg constructor for Jersey provider instantiation without injection.
+ * Falls back to OzoneConfigurationHolder and fails closed when no
+ * configuration is available.
+ */
public S3STSWebIdentityAuthBypassFilter() {
this(null);
}
diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
index 7a0bd0f16f87..2765b308c62e 100644
--- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
+++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3sts/S3STSWebIdentityRequestParser.java
@@ -18,14 +18,16 @@
package org.apache.hadoop.ozone.s3sts;
import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import javax.ws.rs.HttpMethod;
+import javax.ws.rs.WebApplicationException;
import javax.ws.rs.container.ContainerRequestContext;
-import org.apache.commons.io.IOUtils;
+import javax.ws.rs.core.Response;
/**
* Parses the STS action without logging request parameters.
@@ -35,6 +37,10 @@ final class S3STSWebIdentityRequestParser {
static final String ACTION = "Action";
static final String ASSUME_ROLE_WITH_WEB_IDENTITY =
"AssumeRoleWithWebIdentity";
+ static final int MAX_FORM_BODY_BYTES = 64 * 1024;
+
+ private static final int BUFFER_SIZE = 8192;
+ private static final int HTTP_PAYLOAD_TOO_LARGE = 413;
private S3STSWebIdentityRequestParser() {
}
@@ -65,7 +71,7 @@ private static String getFormAction(ContainerRequestContext context)
return null;
}
- byte[] body = IOUtils.toByteArray(stream);
+ byte[] body = readBoundedFormBody(stream);
context.setEntityStream(new ByteArrayInputStream(body));
String form = new String(body, StandardCharsets.UTF_8);
String action = null;
@@ -89,6 +95,28 @@ private static String decode(String value) throws IOException {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
}
+ private static byte[] readBoundedFormBody(InputStream stream)
+ throws IOException {
+ ByteArrayOutputStream body =
+ new ByteArrayOutputStream(Math.min(MAX_FORM_BODY_BYTES, BUFFER_SIZE));
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int remaining = MAX_FORM_BODY_BYTES;
+ while (remaining > 0) {
+ int read = stream.read(buffer, 0, Math.min(buffer.length, remaining));
+ if (read == -1) {
+ return body.toByteArray();
+ }
+ body.write(buffer, 0, read);
+ remaining -= read;
+ }
+ if (stream.read() != -1) {
+ throw new WebApplicationException(
+ "STS WebIdentity request body is too large",
+ Response.status(HTTP_PAYLOAD_TOO_LARGE).build());
+ }
+ return body.toByteArray();
+ }
+
private static String singleAction(List values) {
return values != null && values.size() == 1 ? values.get(0) : null;
}
diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
index 4e3abd5b37c4..bcc22b26e6ab 100644
--- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
+++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3sts/TestS3STSWebIdentityAuthBypassFilter.java
@@ -19,6 +19,7 @@
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_STS_WEB_IDENTITY_ENABLED;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
@@ -31,6 +32,7 @@
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import javax.ws.rs.HttpMethod;
+import javax.ws.rs.WebApplicationException;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.UriInfo;
@@ -98,6 +100,39 @@ public void postWebIdentityRequestSkipsAwsAuthAndRestoresBody()
assertEquals(body, read(streamCaptor.getValue()));
}
+ @Test
+ public void postWebIdentityRequestAtBodyLimitSkipsAwsAuthAndRestoresBody()
+ throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ String body = formBody(S3STSWebIdentityRequestParser.MAX_FORM_BODY_BYTES);
+ ContainerRequestContext context = context(HttpMethod.POST, null, body);
+
+ filter.filter(context);
+
+ verify(context).setProperty(AuthorizationFilter.SKIP_AWS_AUTH_PROPERTY,
+ Boolean.TRUE);
+ ArgumentCaptor streamCaptor =
+ ArgumentCaptor.forClass(InputStream.class);
+ verify(context).setEntityStream(streamCaptor.capture());
+ assertEquals(body, read(streamCaptor.getValue()));
+ }
+
+ @Test
+ public void oversizedPostWebIdentityRequestFailsBeforeBypass()
+ throws Exception {
+ S3STSWebIdentityAuthBypassFilter filter = filter(true);
+ String body = formBody(
+ S3STSWebIdentityRequestParser.MAX_FORM_BODY_BYTES + 1);
+ ContainerRequestContext context = context(HttpMethod.POST, null, body);
+
+ WebApplicationException ex = assertThrows(WebApplicationException.class,
+ () -> filter.filter(context));
+
+ assertEquals(413, ex.getResponse().getStatus());
+ verify(context, never()).setProperty(anyString(), any());
+ verify(context, never()).setEntityStream(any());
+ }
+
@Test
public void adversarialActionValuesDoNotSkipAwsAuth() throws Exception {
assertDoesNotSkipPost("Action=AssumeRoleWithWebIdentity%20");
@@ -171,4 +206,15 @@ private static ContainerRequestContext contextWithQueryActions(
private static String read(InputStream stream) throws Exception {
return new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8);
}
+
+ private static String formBody(int size) {
+ String prefix = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=";
+ return prefix + repeat('a', size - prefix.length());
+ }
+
+ private static String repeat(char ch, int count) {
+ char[] chars = new char[count];
+ Arrays.fill(chars, ch);
+ return new String(chars);
+ }
}