From 1e3aedbd82cf4c0248259c1072481e63a167a83f Mon Sep 17 00:00:00 2001 From: browndav-msft Date: Thu, 11 Jun 2026 12:19:09 -0400 Subject: [PATCH 1/9] Feature/storage/bifrost create session (#49448) * generate base files based on swagger docs * create live tests for createSession - downgrade blobserviceversion to 2026_04_06 - change AZURE_LIVE_TEST_SERVICE_VERSION to V2026_04_06 in ci.system.properties in azure-storage-common - create both sync and async * add recordings * create new files based on swagger update * add two params to BlobContainerClient#createSessionWithResponse * add sanitizers for SessionToken and SessionKey to BlobTestBase * add recording for createSessionReturnsTokenAndKey * create StorageSessionCredential with isExpired * create BlobSessionClient so that BlobSessionProvider takes it as a dep instead of ContainersImpl * create BlobSEssionClient with tests * add recorings for BlobSessionClient * fix BlobContainerAsyncClient to match new swagger, add new recording * add SessionProvider and SessionProviderTest * add accountName to BlobSessionClient * add accountName to StorageSessionCredential and SesionTestHelper * wip * change sessionprovider to SEssionTokenCredentialPolicy * wip * move session tests from containerapi to blobsessionclienttests * fix blobsessiontests and add place holder for end-to-end tests in containerapitests * add recordings for blobsessionclient * linting * refactor cache into separate class so it follows BearerTokenAuthenticationPolicy + AccessTokenCache pattern * add 503 fallback * add tests for udsas, but disabled for now * refactor createContext to use hardcoded endpoint * add SessionMode and tests for SessionMode * add sessionOptions to buildPipeline, add null to builders not using sessions * move SessionTokenCredentialPolicy ahead of StorageBearerTokenChallengeAuthorizationPolicy * fix linting issues * add session to BlobServiceClients and BlobServiceClientBuildeer * change expiration so that it defaults to 5 minutes, if there is no expiration * move SessionOptions so that it is public * remove old SessionOptions * remove unnecessary references to containerName and serviceVersion * add BlobContainerSessionInfo, add other Copilot recommendations * delete BlobContainerSessionInfo, restore return CreateSessionResponse * create createSession end-to-end test with recordings * only allow get requests for getblob * wrap tests in try-with-resources * make createSession package private * fixes based on copilot suggestions * Update sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add containerName to SessionOptions * move accountName to SessionOptions * refactor: SessionTokenCredentialPolicy accepts bearer policy as constructor dependency SessionTokenCredentialPolicy now takes StorageBearerTokenChallengeAuthorizationPolicy as a constructor dependency instead of relying on pipeline ordering to coordinate with the bearer policy. Key changes: - SessionTokenCredentialPolicy delegates to bearerPolicy.process() for non-session requests (non-GetBlob, NONE mode, AUTO first request) and fallback (503). - Added getBearerPolicy() accessor for use in per-container pipeline construction. - BuilderHelper updated to pass bearer policy through to SessionTokenCredentialPolicy constructor in both addSessionPolicyIfEnabled and wrapWithSessionPolicy. - Tests updated to mock bearerPolicy and verify delegation behavior. This is step 1 of the session auth refactor to align with the .NET SDK's SessionAuthenticationPolicy pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: introduce AuthStrategy enum and consolidate analyzeRequest Consolidate isGetBlobRequest() and shouldUseSession() into a single analyzeRequest() method that returns an AuthStrategy enum (USE_BEARER_TOKEN or USE_SESSION_TOKEN), following the .NET SessionAuthenticationPolicy pattern. Also extract response handling into handleSessionResponse() and handleSessionResponseSync() methods for cleaner process()/processSync() flow. The process methods now have a clear structure: 1. analyzeRequest() -> decide strategy 2. USE_BEARER_TOKEN -> delegate to bearer policy 3. USE_SESSION_TOKEN -> acquire session, sign, send, handle response No behavioral changes - all existing tests pass unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove redundant restype check from analyzeRequest The restype query parameter check was redundant because requests with restype=container are container-level operations that have no blob name. They are already filtered by the blob name presence check. This aligns more closely with the .NET SessionAuthenticationPolicy which relies on URL structure rather than query parameter checks. The comp check is retained as a safety measure to exclude sub-operations like GetBlobMetadata (comp=metadata) that have a blob name but are not download operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * change sessionmode from always to singlespecifciedcontainer, add resolve method * wrap bearer token in sessioncredentialpolicy * fix NPE for SessionOptions, sessionoptions always non null * add tests for sessiontokencredpolicy and storagesessioncred * add logic to avoid wrapping Bearertoken, if session is not needed * add overloaded oauth in blobtestbase to be able to add sessionoptions * add overloaded getOAuthServiceAsyncClient to be able to pass session options * add custom buildStringToSign to remove `0` from get requests * readd versions * readd ci.system.properties * change session options check for null in BuilderHelper which affected other tests * add recordings for create sessions, change time to testResource time * add requestInspectionPolicy and overloaded getoauth client in base test * fix null sessionsoptions issue * add fix in storagesessioncredntial for query params * add SessionTokenCredPolicy to checks for anonymousAccess * remove constructor for BlobSessionClients that uses parse url * linting issues, remove SessionOptions from service methods * add comments to policyrefreshNearExpiry test * fix linting issues * add check for container name * fix return javadoc for SessionMode * add LOGGER and appropriate error throwing in BlobSessionClient * changes based on feedback from isabelle * add single retry for all 401 errors * remove unused imports * add suppression for SessionTokenPolicy for linting * fix ubuntu tests hanging by removing local dns bypass * add session for blob client with tests * create unified http transport between data requests and session request, add ResourceLock for live tests * test multiple concurrency * add branching if for bearer policy * refactor validateSessionMode to builderhelper * add revert to bearertoken for 400 errors * remove unused imports * readd comment to bearertokechallenge --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../templates/steps/verify-agent-os.yml | 2 - sdk/storage/azure-storage-blob/assets.json | 2 +- .../checkstyle-suppressions.xml | 1 + .../azure/storage/blob/BlobClientBuilder.java | 44 +- .../blob/BlobContainerAsyncClient.java | 45 +- .../storage/blob/BlobContainerClient.java | 36 + .../blob/BlobContainerClientBuilder.java | 48 +- .../azure/storage/blob/BlobServiceClient.java | 1 + .../blob/BlobServiceClientBuilder.java | 29 +- .../blob/implementation/ContainersImpl.java | 177 ++++ .../models/AuthenticationType.java | 51 ++ .../models/CreateSessionConfiguration.java | 119 +++ .../models/CreateSessionResponse.java | 221 +++++ .../models/SessionCredentials.java | 149 ++++ .../util/BlobSessionClient.java | 75 ++ .../implementation/util/BuilderHelper.java | 74 +- .../util/SessionTokenCredentialPolicy.java | 284 ++++++ .../util/StorageSessionCredential.java | 173 ++++ .../util/StorageSessionCredentialCache.java | 159 ++++ .../storage/blob/models/SessionMode.java | 44 + .../storage/blob/models/SessionOptions.java | 101 +++ .../SpecializedBlobClientBuilder.java | 2 +- .../com/azure/storage/blob/BlobApiTests.java | 49 ++ .../azure/storage/blob/BlobAsyncApiTests.java | 48 ++ .../com/azure/storage/blob/BlobTestBase.java | 48 +- .../storage/blob/BuilderHelperTests.java | 166 +++- .../azure/storage/blob/ContainerApiTests.java | 163 ++++ .../storage/blob/ContainerAsyncApiTests.java | 169 ++++ .../storage/blob/RequestInspectionPolicy.java | 51 ++ .../util/BlobSessionClientTests.java | 167 ++++ .../util/SessionTestHelper.java | 37 + .../SessionTokenCredentialPolicyTest.java | 811 ++++++++++++++++++ .../util/StorageSessionCredentialTest.java | 127 +++ .../azure-storage-blob/swagger/README.md | 2 +- .../StorageSharedKeyCredentialTests.java | 34 + 35 files changed, 3667 insertions(+), 42 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml index a9109cf51803..5c5fe54c957d 100644 --- a/eng/common/pipelines/templates/steps/verify-agent-os.yml +++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml @@ -14,5 +14,3 @@ steps: filePath: ${{ parameters.ScriptDirectory }}/Verify-AgentOS.ps1 arguments: > -AgentImage "${{ parameters.AgentImage }}" - - - template: /eng/common/pipelines/templates/steps/bypass-local-dns.yml diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 8cad139f33ff..92108b8c51bb 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_47f4243e59" + "Tag": "java/storage/azure-storage-blob_dbe8c45320" } diff --git a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml index 90d7f65ae375..a5ed026c13b5 100644 --- a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml +++ b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml @@ -6,6 +6,7 @@ + diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 1d0ac36d4ce8..a099f0aab5c5 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -31,6 +31,8 @@ import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; import com.azure.storage.common.implementation.connectionstring.StorageConnectionString; @@ -92,6 +94,7 @@ public final class BlobClientBuilder private Configuration configuration; private BlobServiceVersion version; private BlobAudience audience; + private SessionOptions sessionOptions = new SessionOptions(); /** * Creates a builder instance that is able to configure and construct {@link BlobClient BlobClients} and {@link @@ -133,6 +136,8 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); + /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier to read and debug. @@ -189,18 +194,27 @@ public BlobAsyncClient buildAsyncClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, blobName, snapshot, customerProvidedKey, encryptionScope, versionId); } - private HttpPipeline constructPipeline() { - return (httpPipeline != null) - ? httpPipeline - : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, - endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + private HttpPipeline constructPipeline(String containerName, BlobServiceVersion serviceVersion) { + if (httpPipeline != null) { + return httpPipeline; + } + + if (containerName != null) { + sessionOptions.setContainerName(containerName); + } + if (sessionOptions.getAccountName() == null) { + sessionOptions.setAccountName(accountName); + } + + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, sessionOptions, serviceVersion); } /** @@ -650,4 +664,20 @@ public BlobClientBuilder audience(BlobAudience audience) { this.audience = audience; return this; } + + /** + * Sets the {@link SessionOptions} that controls how the SDK manages session-based authentication for this blob. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them with a lightweight + * HMAC key instead of a full bearer token. When the session mode within the options is set to a value other than + * {@link SessionMode#NONE}, this builder's configured container name is used when the options don't specify one. + * + * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * when identity-based authentication (bearer token) is configured. + * @return the updated BlobClientBuilder object. + */ + public BlobClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index b86fe4e76b2f..d79a68c24a96 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -28,6 +28,9 @@ import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1691,11 +1694,39 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } - // private boolean validateNoTime(BlobRequestConditions modifiedRequestConditions) { - // if (modifiedRequestConditions == null) { - // return true; - // } - // return modifiedRequestConditions.getIfModifiedSince() == null - // && modifiedRequestConditions.getIfUnmodifiedSince() == null; - // } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Mono createSession() { + return createSessionWithResponse().flatMap(FluxUtil::toMono); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Mono> createSessionWithResponse() { + try { + return withContext(this::createSessionWithResponse); + } catch (RuntimeException ex) { + return monoError(LOGGER, ex); + } + } + + Mono> createSessionWithResponse(Context context) { + context = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + return this.azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, null, null, context) + .map(response -> new SimpleResponse<>(response, response.getValue())); + } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 64de81617f9c..426e38c46b76 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -31,6 +31,9 @@ import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1509,4 +1512,37 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return The {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + CreateSessionResponse createSession() { + return createSessionWithResponse(null, Context.NONE).getValue(); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. + * @param context Additional context that is passed through the Http pipeline during the service call. + * @return A {@link Response} containing the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Response createSessionWithResponse(Duration timeout, Context context) { + Context finalContext = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Callable> operation = () -> { + Response response = this.azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, finalContext); + return new SimpleResponse<>(response, response.getValue()); + }; + + return sendRequest(operation, timeout, BlobStorageException.class); + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 5ca6281bb1fb..1f0b003f01cc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -32,6 +32,8 @@ import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; import com.azure.storage.common.implementation.connectionstring.StorageConnectionString; @@ -91,6 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. When the session mode within the options + * is set to a value other than {@link SessionMode#NONE}, + * {@link #containerName(String) containerName} must also be set. + * + * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * when identity-based authentication (bearer token) is configured. + * @return the updated BlobContainerClientBuilder object. + */ + public BlobContainerClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 3ad51c9a9b5f..641c40b1a0a0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -147,6 +147,7 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } + return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 5fb46965824f..3cefa0395364 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,6 +30,8 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.implementation.util.SessionTokenCredentialPolicy; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; @@ -93,6 +95,7 @@ public final class BlobServiceClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. This setting is passed to container + * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}. + * + * @param sessionOptions The session options for the HTTP pipeline. + * @return the updated BlobServiceClientBuilder object. + */ + public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index 7fd2af96e4df..8bc27e750abc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java @@ -46,6 +46,8 @@ import com.azure.storage.blob.implementation.models.ContainersSetAccessPolicyHeaders; import com.azure.storage.blob.implementation.models.ContainersSetMetadataHeaders; import com.azure.storage.blob.implementation.models.ContainersSubmitBatchHeaders; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.FilterBlobsIncludeItem; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; @@ -938,6 +940,26 @@ Response getAccountInfoNoCustomHeadersSync(@HostParam("url") String url, @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Mono> createSession(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Response createSessionSync(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); } /** @@ -6707,4 +6729,159 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container throw ModelHelper.mapToBlobStorageException(internalException); } } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + return FluxUtil + .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, + requestId, context)) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service + .createSession(this.client.getUrl(), containerName, restype, comp, timeout, this.client.getVersion(), + requestId, createSessionConfiguration, accept, context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId, context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createSessionWithResponse(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + try { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, timeout, + this.client.getVersion(), requestId, createSessionConfiguration, accept, context); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public CreateSessionResponse createSession(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + try { + return createSessionWithResponse(containerName, createSessionConfiguration, timeout, requestId, + Context.NONE).getValue(); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java new file mode 100644 index 000000000000..76a92bba45e3 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Generated; +import com.azure.core.util.ExpandableStringEnum; +import java.util.Collection; + +/** + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ +public final class AuthenticationType extends ExpandableStringEnum { + /** + * Static value HMAC for AuthenticationType. + */ + @Generated + public static final AuthenticationType HMAC = fromString("HMAC"); + + /** + * Creates a new instance of AuthenticationType value. + * + * @deprecated Use the {@link #fromString(String)} factory method. + */ + @Generated + @Deprecated + public AuthenticationType() { + } + + /** + * Creates or finds a AuthenticationType from its string representation. + * + * @param name a name to look for. + * @return the corresponding AuthenticationType. + */ + @Generated + public static AuthenticationType fromString(String name) { + return fromString(name, AuthenticationType.class); + } + + /** + * Gets known AuthenticationType values. + * + * @return known AuthenticationType values. + */ + @Generated + public static Collection values() { + return values(AuthenticationType.class); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java new file mode 100644 index 000000000000..b52e86f169cd --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionConfiguration model. + */ +@Fluent +public final class CreateSessionConfiguration implements XmlSerializable { + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /** + * Creates an instance of CreateSessionConfiguration class. + */ + @Generated + public CreateSessionConfiguration() { + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionConfiguration object itself. + */ + @Generated + public CreateSessionConfiguration setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader, String rootElementName) + throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionConfiguration deserializedCreateSessionConfiguration = new CreateSessionConfiguration(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionConfiguration.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionConfiguration; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java new file mode 100644 index 000000000000..610080c98fd4 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import java.time.OffsetDateTime; +import java.util.Objects; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionResponse model. + */ +@Fluent +public final class CreateSessionResponse implements XmlSerializable { + /* + * A unique identifier for the created session. + */ + @Generated + private String id; + + /* + * The time when the session will expire. The format follows RFC 1123. + */ + @Generated + private DateTimeRfc1123 expiration; + + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /* + * The Credentials property. + */ + @Generated + private SessionCredentials credentials; + + /** + * Creates an instance of CreateSessionResponse class. + */ + @Generated + public CreateSessionResponse() { + } + + /** + * Get the id property: A unique identifier for the created session. + * + * @return the id value. + */ + @Generated + public String getId() { + return this.id; + } + + /** + * Set the id property: A unique identifier for the created session. + * + * @param id the id value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setId(String id) { + this.id = id; + return this; + } + + /** + * Get the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @return the expiration value. + */ + @Generated + public OffsetDateTime getExpiration() { + if (this.expiration == null) { + return null; + } + return this.expiration.getDateTime(); + } + + /** + * Set the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @param expiration the expiration value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setExpiration(OffsetDateTime expiration) { + if (expiration == null) { + this.expiration = null; + } else { + this.expiration = new DateTimeRfc1123(expiration); + } + return this; + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + /** + * Get the credentials property: The Credentials property. + * + * @return the credentials value. + */ + @Generated + public SessionCredentials getCredentials() { + return this.credentials; + } + + /** + * Set the credentials property: The Credentials property. + * + * @param credentials the credentials value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setCredentials(SessionCredentials credentials) { + this.credentials = credentials; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("Id", this.id); + xmlWriter.writeStringElement("Expiration", Objects.toString(this.expiration, null)); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + xmlWriter.writeXml(this.credentials, "Credentials"); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionResponse deserializedCreateSessionResponse = new CreateSessionResponse(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("Id".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.id = reader.getStringElement(); + } else if ("Expiration".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.expiration = reader.getNullableElement(DateTimeRfc1123::new); + } else if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else if ("Credentials".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.credentials = SessionCredentials.fromXml(reader, "Credentials"); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionResponse; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java new file mode 100644 index 000000000000..ed427a221aa7 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The SessionCredentials model. + */ +@Fluent +public final class SessionCredentials implements XmlSerializable { + /* + * An opaque token used to authorize subsequent requests in the session. Must be treated as a security credential. + */ + @Generated + private String sessionToken; + + /* + * Only returned when AuthenticationType is HMAC. A symmetric encryption key used to sign requests in the session + * using the Shared Key protocol. + */ + @Generated + private String sessionKey; + + /** + * Creates an instance of SessionCredentials class. + */ + @Generated + public SessionCredentials() { + } + + /** + * Get the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @return the sessionToken value. + */ + @Generated + public String getSessionToken() { + return this.sessionToken; + } + + /** + * Set the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @param sessionToken the sessionToken value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + + /** + * Get the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @return the sessionKey value. + */ + @Generated + public String getSessionKey() { + return this.sessionKey; + } + + /** + * Set the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @param sessionKey the sessionKey value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("SessionToken", this.sessionToken); + xmlWriter.writeStringElement("SessionKey", this.sessionKey); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + SessionCredentials deserializedSessionCredentials = new SessionCredentials(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("SessionToken".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionToken = reader.getStringElement(); + } else if ("SessionKey".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionKey = reader.getStringElement(); + } else { + reader.skipElement(); + } + } + + return deserializedSessionCredentials; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java new file mode 100644 index 000000000000..00b3e376b826 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.rest.Response; +import com.azure.core.util.Context; +import com.azure.core.util.logging.ClientLogger; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.implementation.AzureBlobStorageImpl; +import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; +import com.azure.storage.blob.implementation.models.SessionCredentials; +import reactor.core.publisher.Mono; + +/** + * Package-private client for creating sessions via the CreateSession REST API. + * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}: + * takes an {@link HttpPipeline} (bearer-only, no SessionPolicy) and builds an + * {@link AzureBlobStorageImpl} internally. + */ +final class BlobSessionClient { + + private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class); + private final AzureBlobStorageImpl azureBlobStorage; + private final String accountName; + private final String containerName; + + BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, + String containerName) { + this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) + .url(url) + .version(serviceVersion.getVersion()) + .buildClient(); + this.accountName = accountName; + this.containerName = containerName; + } + + Mono createSessionAsync() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + return azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, null, null) + .map(this::toCredential); + } + + StorageSessionCredential createSessionSync() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Response response = azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, Context.NONE); + return toCredential(response); + } + + private StorageSessionCredential toCredential(Response response) { + CreateSessionResponse session = response.getValue(); + if (session == null) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("CreateSession response did not contain a session payload.")); + } + + SessionCredentials creds = session.getCredentials(); + if (creds == null) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("CreateSession response did not contain HMAC session credentials.")); + } + return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), + accountName); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 0866d310981c..914794f2bde1 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -24,12 +24,16 @@ import com.azure.core.util.ClientOptions; import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; +import com.azure.core.util.HttpClientOptions; import com.azure.core.util.TracingOptions; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; +import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; @@ -64,7 +68,8 @@ public final class BuilderHelper { } /** - * Constructs a {@link HttpPipeline} from values passed from a builder. + * Constructs a {@link HttpPipeline} from values passed from a builder, with optional session-based + * authentication support. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. * @param tokenCredential {@link TokenCredential} if present. @@ -81,6 +86,8 @@ public final class BuilderHelper { * @param configuration Configuration store contain environment settings. * @param logger {@link ClientLogger} used to log any exception. * @param audience {@link BlobAudience} used to determine the audience of the blob. + * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication. + * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -88,7 +95,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger) { + ClientLogger logger, SessionOptions sessionOptions, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -119,12 +126,40 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } + // Session credentials are bound to the client's network context. When the caller doesn't provide an + // HttpClient, create one default instance and share it between CreateSession and data requests instead of + // letting each pipeline create its own transport. + HttpClient effectiveHttpClient + = tokenCredential == null ? httpClient : getOrCreateHttpClient(httpClient, clientOptions); + + // When sessionOptions is non-null and the resolved session mode is not SessionMode.NONE, and a tokenCredential is + // present, a single SessionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer + // token policy internally and delegates to it for non-session-eligible requests. When sessions are not active, + // the bearer token policy is added directly. if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) : Constants.STORAGE_SCOPE; - policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy + = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); + + SessionOptions effectiveSessionOptions = SessionOptions.orDefault(sessionOptions); + + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + + HttpPipeline bearerPipeline + = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions); + BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); + + if (effectiveSessionOptions.getSessionMode() == SessionMode.NONE) { + policies.add(bearerPolicy); + } else { + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + } } if (azureSasCredential != null) { @@ -144,12 +179,38 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new ScrubEtagPolicy()); return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(effectiveHttpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) + .build(); + } + + /** + * Builds a bearer-only {@link HttpPipeline} for CreateSession calls. This pipeline contains + * all pre-auth policies plus the bearer token policy, but no session policy. + */ + private static HttpPipeline buildBearerPipeline(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, + ClientOptions clientOptions) { + List bearerPolicies = new ArrayList<>(preAuthPolicies); + bearerPolicies.add(bearerPolicy); + return new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(clientOptions) .tracer(createTracer(clientOptions)) .build(); } + private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) { + if (httpClient != null) { + return httpClient; + } + + return clientOptions instanceof HttpClientOptions + ? HttpClient.createDefault((HttpClientOptions) clientOptions) + : HttpClient.createDefault(); + } + /** * Gets the default http log option for Storage Blob. * @@ -232,4 +293,11 @@ public static Tracer createTracer(ClientOptions clientOptions) { public static void logCredentialChange(ClientLogger logger, String newCredentialType) { logger.info("Credential set to '{}' when it was previously configured.", newCredentialType); } + + public static void validateSessionMode(SessionOptions sessionOptions, String containerName, ClientLogger logger) { + if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) { + throw logger.logExceptionAsError(new IllegalArgumentException( + "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); + } + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java new file mode 100644 index 000000000000..e7d875389856 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.CoreUtils; +import com.azure.storage.blob.BlobUrlParts; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.Objects; + +/** + * A pipeline policy that selects between session token and bearer token authentication. + *

+ * This policy occupies the authentication policy slot in the pipeline, wrapping the + * {@link StorageBearerTokenChallengeAuthorizationPolicy}. For eligible blob GET requests, + * the policy authenticates with a session token. For all other requests, it delegates to the + * wrapped bearer token policy. + *

+ * Request analysis is performed by {@link #analyzeRequest(HttpPipelineCallContext)} which returns + * an {@link AuthStrategy} indicating the authentication approach to use. + */ +public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { + private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; + private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); + private static final String SESSION_EXPIRING = "session_expiring"; + private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; + + private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private final StorageSessionCredentialCache sessionCredentialCache; + private final SessionOptions sessionOptions; + + /** + * Authentication strategy determined by {@link #analyzeRequest(HttpPipelineCallContext)}. + */ + enum AuthStrategy { + /** Delegate to the wrapped bearer token policy. */ + USE_BEARER_TOKEN, + /** Acquire a session token and sign the request. */ + USE_SESSION_TOKEN + } + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + StorageSessionCredentialCache sessionCredentialCache, SessionOptions sessionOptions) { + this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); + this.sessionCredentialCache + = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + + if (this.sessionOptions.getSessionMode().resolve() == SessionMode.SINGLE_SPECIFIED_CONTAINER + && CoreUtils.isNullOrEmpty(this.sessionOptions.getContainerName())) { + throw new IllegalArgumentException( + "Container name must be specified when using SINGLE_SPECIFIED_CONTAINER session mode."); + } + } + + /** + * Returns the wrapped bearer token policy. Used when constructing per-container pipelines from a service + * pipeline so that the bearer policy can be reused without scanning the pipeline. + */ + StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() { + return bearerPolicy; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + return bearerPolicy.process(context, next); + } + + HttpPipelineNextPolicy retryNext = next.clone(); + return getValidSessionAsync().flatMap(session -> { + signRequest(context, session); + return next.process().flatMap(response -> handleSessionResponse(context, response, session, retryNext)); + }); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + return bearerPolicy.processSync(context, next); + } + + HttpPipelineNextSyncPolicy retryNext = next.clone(); + StorageSessionCredential session = getValidSessionSync(); + signRequest(context, session); + + HttpResponse response = next.processSync(); + return handleSessionResponseSync(context, response, session, retryNext); + } + + /** + * Analyzes the request to determine whether a session token or bearer token should be used. + * Session tokens are only used for blob GET operations in + * {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode targeting the configured container. + * + * @param context the pipeline call context for the request being analyzed. + * @return {@link AuthStrategy#USE_SESSION_TOKEN} if the request is eligible for session-token + * authentication (a GET against a blob in the configured container, with no {@code comp} query + * parameter, while in {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode); + * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise. + */ + AuthStrategy analyzeRequest(HttpPipelineCallContext context) { + SessionMode effectiveMode = sessionOptions.getSessionMode().resolve(); + + if (effectiveMode == SessionMode.NONE) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + + // If Service-level request (no container in path) + if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) + && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // If Container level request (container in path but no blob) + if (CoreUtils.isNullOrEmpty(parts.getBlobName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth. + Map queryParams = parts.getUnparsedParameters(); + if (queryParams.containsKey("comp")) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + if (parts.getBlobContainerName().compareToIgnoreCase(sessionOptions.getContainerName()) != 0) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + return AuthStrategy.USE_SESSION_TOKEN; + } + + /** + * Handles the response after a session-authenticated async request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextPolicy retryNext) { + + handleSessionExpiringHeader(response); + + if (isUnauthorizedResponse(response)) { + invalidateSession(session); + } + + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + return getValidSessionAsync().flatMap(refreshed -> { + signRequest(context, refreshed); + return retryNext.process(); + }); + } + + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.process(context, retryNext); + } + + return Mono.just(response); + } + + /** + * Handles the response after a session-authenticated sync request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextSyncPolicy retryNext) { + + handleSessionExpiringHeader(response); + + if (isUnauthorizedResponse(response)) { + invalidateSession(session); + } + + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + + StorageSessionCredential refreshed = getValidSessionSync(); + signRequest(context, refreshed); + return retryNext.processSync(); + } + + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.processSync(context, retryNext); + } + + return response; + } + + Mono getValidSessionAsync() { + return sessionCredentialCache.getValidSessionAsync(); + } + + StorageSessionCredential getValidSessionSync() { + return sessionCredentialCache.getValidSessionSync(); + } + + void invalidateSession(StorageSessionCredential target) { + sessionCredentialCache.invalidateSession(target); + } + + private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { + cred.signRequest(context.getHttpRequest()); + } + + private void handleSessionExpiringHeader(HttpResponse response) { + String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); + if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { + sessionCredentialCache.refreshSessionInBackground(); + } + } + + /** + * Returns true when the session-authenticated request was rejected as unauthorized. + * Used to decide whether to invalidate the cached session. + */ + private static boolean isUnauthorizedResponse(HttpResponse response) { + return response.getStatusCode() == 401; + } + + /** + * Returns true for 401 responses where the request should be retried once with a refreshed session. + */ + private static boolean isRetryableSessionFailure(HttpResponse response) { + return response.getStatusCode() == 401; + } + + private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpResponse response) { + if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { + return false; + } + + return isRetryableSessionFailure(response); + } + + /** + * Returns true for responses where retrying with bearer authentication can preserve + * request compatibility when session authentication is unavailable or rejected. + */ + private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) { + if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { + return false; + } + + return isBadRequest(response) || isSessionUnavailable(response); + } + + private static boolean isBadRequest(HttpResponse response) { + return response.getStatusCode() == 400; + } + + private static boolean isSessionUnavailable(HttpResponse response) { + if (response.getStatusCode() != 503) { + return false; + } + String errorCode = response.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code")); + return SESSION_OPS_UNAVAILABLE.equals(errorCode); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java new file mode 100644 index 000000000000..d74a4ebc5792 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.common.StorageSharedKeyCredential; +import com.azure.storage.common.Utility; + +import java.net.URL; +import java.text.Collator; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Holds session credentials and signs requests using the Shared Key string-to-sign with the + * Session scheme prefix. + */ +final class StorageSessionCredential { + + private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); + private static final String SESSION_PREFIX = "Session "; + + private final String sessionToken; + private final String sessionKey; + private final OffsetDateTime expiration; + private final String accountName; + private final StorageSharedKeyCredential sharedKey; + + StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, String accountName) { + this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); + this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); + this.expiration = expiration != null ? expiration : OffsetDateTime.now().plusMinutes(5L); + this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); + this.sharedKey = new StorageSharedKeyCredential(accountName, sessionKey); + } + + void signRequest(HttpRequest request) { + // Pin x-ms-date so the value we sign matches what is on the wire (AddDatePolicy only sets Date). + // Honor any pre-set x-ms-date so callers (e.g., tests, retries) can pin a deterministic value. + if (request.getHeaders().getValue(X_MS_DATE) == null) { + request.setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now())); + } + + String stringToSign = buildStringToSign(request); + String signature = sharedKey.computeHmac256(stringToSign); + request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature); + } + + // Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for + // Content-Length. The Session protocol signs the literal value the wire carries. + // + // We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in + // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/ + // implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls + // `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including + // GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization + // then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it + // sees on the wire, so delegating produces a signature mismatch. + // + // TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE, + // delete this method and delegate to sharedKey.generateAuthorizationHeader(...). + // This matches what happens in dotnet: + // https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99 + private String buildStringToSign(HttpRequest request) { + HttpHeaders headers = request.getHeaders(); + Collator collator = Collator.getInstance(Locale.ROOT); + + String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH); + // If x-ms-date is present, the Date slot is empty. + String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE); + + return String.join("\n", request.getHttpMethod().toString(), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_ENCODING), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength, + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_MD5), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_TYPE), dateHeader, + getHeaderOrEmpty(headers, HttpHeaderName.IF_MODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.IF_MATCH), getHeaderOrEmpty(headers, HttpHeaderName.IF_NONE_MATCH), + getHeaderOrEmpty(headers, HttpHeaderName.IF_UNMODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.RANGE), canonicalizedXmsHeaders(headers, collator), + canonicalizedResource(request.getUrl(), collator)); + } + + private static String getHeaderOrEmpty(HttpHeaders headers, HttpHeaderName name) { + String value = headers.getValue(name); + return value == null ? "" : value; + } + + private static String canonicalizedXmsHeaders(HttpHeaders headers, Collator collator) { + List xmsHeaders = new ArrayList<>(); + for (HttpHeader header : headers) { + if ("x-ms-".regionMatches(true, 0, header.getName(), 0, 5)) { + xmsHeaders.add(header); + } + } + if (xmsHeaders.isEmpty()) { + return ""; + } + xmsHeaders.sort((a, b) -> collator.compare(a.getName(), b.getName())); + StringBuilder sb = new StringBuilder(); + for (HttpHeader h : xmsHeaders) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(h.getName().toLowerCase(Locale.ROOT)).append(':').append(h.getValue()); + } + return sb.toString(); + } + + private String canonicalizedResource(URL url, Collator collator) { + String path = url.getPath(); + if (CoreUtils.isNullOrEmpty(path)) { + path = "/"; + } + String query = url.getQuery(); + if (CoreUtils.isNullOrEmpty(query)) { + return "/" + accountName + path; + } + + // Sort query parameters with locale-insensitive collation, lower-cased keys. + // Values must be URL-decoded (and split on commas) to match the canonicalization that the + // service performs; otherwise percent-encoded characters (e.g., %3A in a snapshot timestamp) + // would produce a different HMAC than Shared Key. + TreeMap> params = new TreeMap<>(collator); + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = Utility.urlDecode(eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + List decoded = params.computeIfAbsent(key, k -> new ArrayList<>()); + for (String v : rawValue.split(",")) { + decoded.add(Utility.urlDecode(v)); + } + } + + StringBuilder sb = new StringBuilder("/").append(accountName).append(path); + for (java.util.Map.Entry> entry : params.entrySet()) { + List values = entry.getValue(); + java.util.Collections.sort(values); + sb.append('\n').append(entry.getKey()).append(':').append(String.join(",", values)); + } + return sb.toString(); + } + + String getSessionToken() { + return sessionToken; + } + + String getSessionKey() { + return sessionKey; + } + + OffsetDateTime getExpiration() { + return expiration; + } + + String getAccountName() { + return accountName; + } + + boolean isExpired() { + return OffsetDateTime.now().isAfter(expiration); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java new file mode 100644 index 000000000000..594f976207df --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Cache for container-scoped storage session credentials. + */ +final class StorageSessionCredentialCache { + private static final ClientLogger LOGGER = new ClientLogger(StorageSessionCredentialCache.class); + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + + private final BlobSessionClient sessionClient; + private final Object creationLock = new Object(); + private volatile StorageSessionCredential credential; + private volatile OffsetDateTime nextRefreshTime; + private volatile boolean refreshing; + private volatile Mono inflightCreation; + + StorageSessionCredentialCache(BlobSessionClient sessionClient) { + this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + } + + Mono getValidSessionAsync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return Mono.just(current); + } + + return startSessionCreationAsync(); + } + + StorageSessionCredential getValidSessionSync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. + Mono inFlight = inflightCreation; + if (inFlight != null) { + StorageSessionCredential refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + + synchronized (creationLock) { + current = credential; + now = OffsetDateTime.now(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + StorageSessionCredential created = sessionClient.createSessionSync(); + setActiveCredential(created); + return created; + } + } + + void invalidateSession(StorageSessionCredential target) { + synchronized (creationLock) { + if (credential == target) { + credential = null; + nextRefreshTime = null; + refreshing = false; + } + inflightCreation = null; + } + } + + void refreshSessionInBackground() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { + return; + } + refreshing = true; + } + + startSessionCreationAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + private Mono startSessionCreationAsync() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now) && !isRefreshDue(now)) { + return Mono.just(current); + } + + if (inflightCreation != null) { + return inflightCreation; + } + + refreshing = true; + + inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { + synchronized (creationLock) { + setActiveCredential(cred); + } + }).doFinally(ignored -> { + synchronized (creationLock) { + inflightCreation = null; + refreshing = false; + } + }).cache(); + + return inflightCreation; + } + } + + private void setActiveCredential(StorageSessionCredential newCredential) { + credential = newCredential; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); + refreshing = false; + } + + private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { + return cred != null && !now.isAfter(cred.getExpiration()); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { + long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); + if (availableMillis <= 0) { + return now; + } + + double refreshPoint + = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); + return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java new file mode 100644 index 000000000000..1a87ea5845fe --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Defines the session management strategy used by the SDK when sending requests to a container. + *

+ * A session is a temporary security context scoped to a container that amortizes authentication + * and authorization cost across many requests by signing them with a lightweight HMAC key instead + * of a full bearer token. + * {@link #NONE} + * {@link #SINGLE_SPECIFIED_CONTAINER} + * {@link #AUTO} + */ +public enum SessionMode { + + /** + * Always use bearer token authentication. No session tokens are used. + */ + NONE, + + /** + * Default behavior. This is currently equivalent to {@link #NONE} + */ + AUTO, + + /** + * The SDK creates a session on the first request and keeps an active session until it + * receives no requests for 5 minutes. + */ + SINGLE_SPECIFIED_CONTAINER; + + /** + * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to + * {@link #NONE}; this may change in a future release without breaking callers that + * use {@code resolve()} consistently. + * @return returns the effective session mode, never {@code AUTO} + */ + public SessionMode resolve() { + return this == AUTO ? NONE : this; + } + +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java new file mode 100644 index 000000000000..b70e682db554 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Options bag that configures session-based authentication on blob storage builders. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. + * + * @see SessionMode + */ +public final class SessionOptions { + + private SessionMode sessionMode = SessionMode.AUTO; + private String containerName; + private String accountName; + + /** + * Creates a new {@link SessionOptions} instance with default values. + * Note: This currently only applies when using TokenCredential for GET Blob operations. + */ + public SessionOptions() { + } + + /** + * Returns {@code options} if non-null, otherwise a freshly constructed {@link SessionOptions} + * with default values. Use this helper instead of inlining {@code opts != null ? opts : new SessionOptions()} + * so default construction stays in one place. + * + * @param options the options instance to validate; may be {@code null}. + * @return {@code options} if non-null; a new default {@link SessionOptions} otherwise. + */ + public static SessionOptions orDefault(SessionOptions options) { + return options != null ? options : new SessionOptions(); + } + + /** + * Gets the session mode. + * + * @return the {@link SessionMode}; defaults to {@link SessionMode#AUTO}. + */ + public SessionMode getSessionMode() { + return sessionMode; + } + + /** + * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#AUTO}. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode == null ? SessionMode.AUTO : sessionMode; + return this; + } + + /** + * Gets the container name that the session is scoped to. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name that the session is scoped to. This is required when the session mode + * is not {@link SessionMode#NONE}. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the storage account name used for session HMAC signing. + * + * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL). + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the storage account name used for session HMAC signing. When set, this takes precedence + * over the account name parsed from the endpoint URL. This is useful for custom domain URLs + * where the account name cannot be inferred from the hostname. + * + * @param accountName the storage account name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setAccountName(String accountName) { + this.accountName = accountName; + return this; + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java index 54fd3682e72c..42e12896bc4b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java @@ -242,7 +242,7 @@ private HttpPipeline getHttpPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + perRetryPolicies, configuration, audience, LOGGER, null, null); } private BlobServiceVersion getServiceVersion() { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java index 50a9eb63ef21..b0fac6bbc9d6 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java @@ -6,6 +6,7 @@ import com.azure.core.http.HttpAuthorization; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; import com.azure.core.http.RequestConditions; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.Response; @@ -50,6 +51,8 @@ import com.azure.storage.blob.models.ObjectReplicationStatus; import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.SyncCopyStatusType; import com.azure.storage.blob.options.BlobBeginCopyOptions; @@ -81,6 +84,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -120,6 +124,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -3177,6 +3182,50 @@ public void audienceFromString() { assertTrue(aadBlob.exists()); } + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobClient blobClient = cc.getBlobClient(blobName); + blobClient.getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) + .buildClient(); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, + false, null, null); + + assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } + @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2025-07-05") @Test @LiveOnly diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java index 049e4254e92a..e8a74ac57729 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java @@ -7,6 +7,7 @@ import com.azure.core.http.HttpAuthorization; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.Response; import com.azure.core.test.TestMode; @@ -48,6 +49,8 @@ import com.azure.storage.blob.models.ObjectReplicationStatus; import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.options.BlobBeginCopyOptions; import com.azure.storage.blob.options.BlobCopyFromUrlOptions; import com.azure.storage.blob.options.BlobDownloadToFileOptions; @@ -76,6 +79,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -2965,6 +2969,50 @@ public void audienceErrorBearerChallengeRetry() { StepVerifier.create(aadBlob.getProperties()).assertNext(Assertions::assertNotNull).verifyComplete(); } + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) + .buildAsyncClient(); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, + false)).expectNextCount(1).verifyComplete(); + + Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } + @Test public void audienceFromString() { String url = String.format("https://%s.blob.core.windows.net/", ccAsync.getAccountName()); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java index 99d925bff60c..b96e458ab8e7 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java @@ -49,6 +49,7 @@ import com.azure.storage.blob.models.LeaseStateType; import com.azure.storage.blob.models.ListBlobContainersOptions; import com.azure.storage.blob.models.PublicAccessType; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.options.BlobBreakLeaseOptions; import com.azure.storage.blob.sas.BlobSasPermission; import com.azure.storage.blob.specialized.BlobAsyncClientBase; @@ -196,7 +197,11 @@ public void beforeTest() { TestProxySanitizerType.HEADER), new TestProxySanitizer("x-ms-rename-source", "((?<=http://|https://)([^/?]+)|sig=(.*))", "REDACTED", TestProxySanitizerType.HEADER), - new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL))); + new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"))); } // Ignore changes to the order of query parameters and wholly ignore the 'sv' (service version) query parameter @@ -408,20 +413,53 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien } protected BlobServiceClient getOAuthServiceClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceClient(new SessionOptions()); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient(); } protected BlobServiceAsyncClient getOAuthServiceAsyncClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceAsyncClient(new SessionOptions()); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) { + return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient(); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java index 0af01b5fe437..370b33e88424 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -22,6 +22,8 @@ import com.azure.core.util.Header; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -48,6 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -72,10 +75,10 @@ private static HttpRequest request(String url) { */ @Test public void freshDateAppliedOnRetry() { - HttpPipeline pipeline - = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null, - BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new FreshDateTestClient(), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, + REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new FreshDateTestClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -176,7 +179,7 @@ public void customApplicationIdInUAString(String logOptionsUA, String clientOpti HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, new HttpLogOptions().setApplicationId(logOptionsUA), new ClientOptions().setApplicationId(clientOptionsUA), new ApplicationIdUAStringTestClient(expectedUA), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -305,7 +308,7 @@ public void customHeadersClientOptions() { HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions().setHeaders(headers), new ClientOptionsHeadersTestClient(headers), new ArrayList<>(), - new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -680,4 +683,155 @@ public Mono send(HttpRequest request) { return Mono.just(new MockHttpResponse(request, 200)); } } + + // region buildPipeline session tests + + @Test + public void buildPipelineWithTokenCredentialAlwaysHasSessionPolicy() { + HttpPipeline pipeline = buildBearerPipeline(); + + assertTrue(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with tokenCredential should always contain SessionTokenCredentialPolicy"); + } + + @Test + public void buildPipelineWithSharedKeyDoesNotHaveSessionPolicy() { + HttpPipeline pipeline = buildSharedKeyPipeline(); + + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with shared key should not contain SessionTokenCredentialPolicy"); + } + + /** + * Helper to build a pipeline with bearer token auth. + */ + private static HttpPipeline buildBearerPipeline() { + return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), null, BlobServiceVersion.getLatest()); + } + + /** + * Helper to build a pipeline without bearer token auth (shared key only). + */ + private static HttpPipeline buildSharedKeyPipeline() { + return BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, + BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); + } + + /** + * Checks whether the pipeline contains a policy whose simple class name matches the given name. + */ + private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first policy whose simple class name matches, or -1 if not found. + */ + private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return i; + } + } + return -1; + } + + // endregion + + // region BlobClientBuilder sessionOptions tests + + @Test + public void blobBuilderWithSingleSpecifiedContainerSessionBuilds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void blobBuilderWithSingleSpecifiedContainerSessionAndNoContainerNameThrows() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertThrows(IllegalArgumentException.class, + () -> new BlobClientBuilder().endpoint(ENDPOINT) + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void blobBuilderWithoutSessionOptionsBuilds() { + assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient()); + } + + // endregion + + // region BlobContainerClientBuilder sessionOptions tests + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndNoContainerNameThrows() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertThrows(IllegalArgumentException.class, + () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsNoneAndNoContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithNoSessionOptionsSucceeds() { + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient()); + } + + // endregion } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index f46116acdbb5..76195bb37083 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -4,12 +4,16 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.BinaryData; import com.azure.core.http.rest.PagedIterable; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -31,8 +35,11 @@ import com.azure.storage.blob.models.ListBlobsOptions; import com.azure.storage.blob.models.ObjectReplicationPolicy; import com.azure.storage.blob.models.ObjectReplicationStatus; +import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.PublicAccessType; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.TaggedBlobItem; import com.azure.storage.blob.options.BlobContainerCreateOptions; @@ -53,16 +60,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Base64; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -74,6 +86,7 @@ import java.util.stream.Stream; import static com.azure.storage.common.implementation.StorageImplUtils.INVALID_VERSION_HEADER_MESSAGE; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -2128,4 +2141,154 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } + + // Need to create a container client test here to test that sessions have been enabled and used + + @Test + @ResourceLock("BlobSessionAuth") + public void createSession() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + CreateSessionResponse response = oauthCc.createSession(); + + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSessionWithResponse() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + Response response = oauthCc.createSessionWithResponse(null, Context.NONE); + + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + cc.getBlobClient(blobName) + .getBlockBlobClient() + .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); + + for (String blobName : blobNames) { + BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); + assertEquals(DATA.getDefaultText(), downloaded.toString()); + } + + // Greater than or equal to because there might be a retry that has a Session token as well if test is run with + // listBlobsOverSessionEnabledClient() + assertTrue(downloadAuthSchemes.size() >= blobCount, + "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + cc.getBlobClient(blobName).getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, + null, false, null, null); + + assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } finally { + Files.deleteIfExists(outFile.toPath()); + } + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // This test validates that listing blobs with a session-enabled client uses Bearer authorization because + // List Blobs is a container-level GET request, not a blob-level GET request so it users Bearer tokens instead of session tokens. + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); + + assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); + + assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); + assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), + "Container list operation must use Bearer authorization; saw " + listAuthSchemes); + } + + private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName()); + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index 04ebc06dc2b6..ee3579271283 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,14 +4,18 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedFlux; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.TestMode; import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.*; import com.azure.storage.blob.options.BlobContainerCreateOptions; import com.azure.storage.blob.options.BlobParallelUploadOptions; @@ -23,6 +27,7 @@ import com.azure.storage.blob.specialized.BlockBlobAsyncClient; import com.azure.storage.blob.specialized.PageBlobAsyncClient; import com.azure.storage.common.test.shared.TestHttpClientType; +import com.azure.storage.common.implementation.Constants; import com.azure.storage.common.test.shared.extensions.LiveOnly; import com.azure.storage.common.test.shared.extensions.PlaybackOnly; import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion; @@ -31,6 +36,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -40,7 +46,10 @@ import reactor.test.StepVerifier; import reactor.util.function.Tuple2; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.Duration; import java.time.OffsetDateTime; import java.util.*; @@ -2142,4 +2151,164 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSession() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSession()).assertNext(response -> { + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSessionWithResponse() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> { + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); + + for (String blobName : blobNames) { + StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) + .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) + .verifyComplete(); + } + + // Greater than or equal to because there might be a retry that has a Session token as well if test is run with + // listBlobsOverSessionEnabledClient() + assertTrue(downloadAuthSchemes.size() >= blobCount, + "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob = sessionEnabledContainerAsyncClient(inspect).getBlobAsyncClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, + null, false)).expectNextCount(1).verifyComplete(); + + Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } finally { + Files.deleteIfExists(outFile.toPath()); + } + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // This test validates that listing blobs with a session-enabled client uses Bearer authorization because + // List Blobs is a container-level GET request, not a blob-level GET request. + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); + + StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements()) + .expectNext(true) + .verifyComplete(); + + assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); + assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), + "Container list operation must use Bearer authorization; saw " + listAuthSchemes); + } + + private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + return getOAuthServiceAsyncClient(sessionOptions, policies) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java new file mode 100644 index 000000000000..99d0b115a622 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.storage.blob; + +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +import java.util.function.Consumer; + +/** + * Test-only pipeline policy that lets a test peek at every {@link HttpRequest} as it + * goes on the wire. Registers at {@link HttpPipelinePosition#PER_RETRY} so it sees + * the {@code Authorization} header that the auth policies set. + * + *

Used by the session-auth live tests as a wire-level sanity check (e.g. to assert + * which authentication scheme was applied to a given request).

+ */ +public final class RequestInspectionPolicy implements HttpPipelinePolicy { + private final Consumer inspector; + + public RequestInspectionPolicy(Consumer inspector) { + this.inspector = inspector; + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.process(); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.processSync(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java new file mode 100644 index 000000000000..154ef16cb7bd --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.storage.blob.BlobContainerAsyncClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobContainerClientBuilder; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.blob.sas.BlobContainerSasPermission; +import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; +import com.azure.storage.common.test.shared.StorageCommonTestUtils; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class BlobSessionClientTests extends BlobTestBase { + + @Test + public void createSessionReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + BlobSessionClient sessionClient + = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + } + + @Test + public void createSessionAsyncReturnsTokenAndKey() { + BlobContainerAsyncClient oauthCc + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + }).verifyComplete(); + } + + @Test + public void createSessionSyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(1, policyInvocationCount.get()); + } + + @Test + public void createSessionAsyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + }).verifyComplete(); + + assertEquals(1, policyInvocationCount.get()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()); + + BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); + + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionAsyncWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClient sasCc + = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) + .buildClient(); + + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + }).verifyComplete(); + } + + private String generateUserDelegationContainerSas(BlobContainerClient containerClient) { + BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true) + .setWritePermission(true) + .setCreatePermission(true) + .setListPermission(true); + BlobServiceSasSignatureValues sasValues + = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions); + + return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient() + .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1))); + } + + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) { + HttpPipelinePolicy policy = (context, next) -> { + policyInvocationCount.incrementAndGet(); + return next.process(); + }; + + BlobServiceClientBuilder builder + = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()) + .credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)) + .addPolicy(policy); + + instrument(builder); + return builder.buildClient().getHttpPipeline(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java new file mode 100644 index 000000000000..592fc5f22241 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import java.time.OffsetDateTime; + +/** + * Shared test constants and factories for session-based auth tests. + */ +final class SessionTestHelper { + + // A valid Base64-encoded 32-byte key for testing + static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + static final String TEST_ACCOUNT_NAME = "myaccount"; + static final String TEST_CONTAINER_NAME = "testcontainer"; + + static StorageSessionCredential createCredential(OffsetDateTime expiration) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, TEST_ACCOUNT_NAME); + } + + static StorageSessionCredential createCredential(OffsetDateTime expiration, String accountName) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, accountName); + } + + static StorageSessionCredential createValidCredential() { + return createCredential(OffsetDateTime.now().plusHours(1)); + } + + static StorageSessionCredential createExpiredCredential() { + return createCredential(OffsetDateTime.now().minusMinutes(5)); + } + + private SessionTestHelper() { + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java new file mode 100644 index 000000000000..020284e56f78 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -0,0 +1,811 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SessionTokenCredentialPolicyTest { + + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token"; + HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; + + private BlobSessionClient sessionClient; + private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private SessionTokenCredentialPolicy policy; + + @BeforeEach + public void beforeEach() { + sessionClient = mock(BlobSessionClient.class); + bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); + + // Default mock behavior: bearer policy delegates to next policy in the pipeline. + when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.process(); + }); + when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.processSync(); + }); + + policy = createPolicy(SessionMode.SINGLE_SPECIFIED_CONTAINER); + } + + @Test + public void policyCreatesSessionOnFirstAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + + StorageSessionCredential credential = policy.getValidSessionAsync().block(); + + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + + List results + = Flux.range(0, 5).flatMap(ignored -> policy.getValidSessionAsync()).collectList().block(); + + assertNotNull(results); + assertEquals(5, results.size()); + results.forEach(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { + StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2)); + StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); + + when(sessionClient.createSessionSync()).thenReturn(nearExpiry); + // This is a Reactor quirk where Mono.just() emits synchronously on subscribe, so the refresh happens + // immediately when the cache determines the credential is near expiry + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed)); + + // Cold call to getValidSessionSync triggers session creation via createSessionSync + StorageSessionCredential initial = policy.getValidSessionSync(); + // Trigger refresh, which uses sessionClient.createSessionAsync() to get the refreshed session + StorageSessionCredential duringRefresh = policy.getValidSessionSync(); + StorageSessionCredential afterRefresh = policy.getValidSessionSync(); + + assertEquals(FIRST_TOKEN, initial.getSessionToken()); + assertEquals(FIRST_TOKEN, duringRefresh.getSessionToken()); + assertEquals(SECOND_TOKEN, afterRefresh.getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { + when(sessionClient.createSessionSync()).thenAnswer(invocation -> { + Thread.sleep(100); + return credentialWithToken(FIRST_TOKEN); + }).thenReturn(credentialWithToken(SECOND_TOKEN)); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) policy::getValidSessionSync) + .collect(Collectors.toList()); + + List> futures = executor.invokeAll(tasks); + for (Future future : futures) { + assertEquals(FIRST_TOKEN, future.get().getSessionToken()); + } + + verify(sessionClient, times(1)).createSessionSync(); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void policySignsRequestWithSessionCredential() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(response, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + verify(next, times(1)).process(); + } + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceAsync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).process(); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) + .thenReturn(credentialWithToken(SECOND_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(initialResponse); + when(retryNext.processSync()).thenReturn(retriedResponse); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + } + } + + @Test + public void policyOnlyRetriesOncePerRequest() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + } + + @Test + public void policyReturns403WithoutRetry() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse forbiddenResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(forbiddenResponse)); + when(forbiddenResponse.getStatusCode()).thenReturn(403); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(forbiddenResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(forbiddenResponse, times(0)).close(); + verify(sessionClient, times(1)).createSessionAsync(); + } + } + + @Test + public void policyRetriesAny401WithNewSession() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse unauthorizedResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(unauthorizedResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(unauthorizedResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(unauthorizedResponse, times(1)).close(); + verify(next, times(1)).process(); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + } + + @Test + public void policyFallsToBearerOn503SessionUnavailableAsync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(unavailableResponse)); + when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).process(any(), any()); + // Authorization header should have been stripped so bearer policy can add its own + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyFallsToBearerOn400Async() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse badRequestResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(badRequestResponse)); + when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); + when(badRequestResponse.getStatusCode()).thenReturn(400); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(bearerResponse, actualResponse); + verify(badRequestResponse, times(1)).close(); + verify(bearerPolicy, times(1)).process(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyFallsToBearerOn503SessionUnavailableSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(unavailableResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).processSync(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyFallsToBearerOn400Sync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse badRequestResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(badRequestResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(badRequestResponse.getStatusCode()).thenReturn(400); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(badRequestResponse, times(1)).close(); + verify(bearerPolicy, times(1)).processSync(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyReturns503ServerBusyWithoutBearerFallback() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse busyResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(busyResponse)); + when(busyResponse.getStatusCode()).thenReturn(503); + when(busyResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))).thenReturn("ServerBusy"); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + // ServerBusy 503 is not session-specific — return as-is for retry policy to handle + assertEquals(busyResponse, actualResponse); + verify(retryNext, times(0)).process(); + verify(busyResponse, times(0)).close(); + } + } + + @Test + public void noneModeAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) { + assertEquals(response, actualResponse); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + } + + @Test + public void noneModeSyncAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.processSync()).thenReturn(response); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) { + assertEquals(response, actualResponse); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + } + + @Test + public void alwaysModeSignsFirstRequest() { + // The default `policy` in setUp is ALWAYS — verify it signs the very first request + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + + when(response.getStatusCode()).thenReturn(200); + + // AUTO resolves to NONE, so all requests should delegate to bearer + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); + when(next1.process()).thenReturn(Mono.just(response)); + + try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { + assertEquals(response, actual1); + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + // Second GetBlob also delegates to bearer (AUTO == NONE, no session ever) + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextPolicy next2 = mock(HttpPipelineNextPolicy.class); + when(next2.process()).thenReturn(Mono.just(response)); + + try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) { + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + } + + @Test + public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + + when(response.getStatusCode()).thenReturn(200); + + // AUTO resolves to NONE, so all requests should delegate to bearer + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); + when(next1.processSync()).thenReturn(response); + + try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { + assertEquals(response, actual1); + verify(bearerPolicy, times(1)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextSyncPolicy next2 = mock(HttpPipelineNextSyncPolicy.class); + when(next2.processSync()).thenReturn(response); + + try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) { + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + } + + private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { + SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); + return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), + options); + } + + private static StorageSessionCredential credentialWithToken(String token) { + return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); + } + + private static StorageSessionCredential credentialWithToken(String token, OffsetDateTime expiration) { + return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration, + SessionTestHelper.TEST_ACCOUNT_NAME); + } + + private static HttpPipelineCallContext createContext() { + return createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + } + + private static HttpPipelineCallContext createContextForUrl(String url) { + return createContextForRequest(new HttpRequest(HttpMethod.GET, url)); + } + + private static HttpPipelineCallContext createContextForRequest(HttpRequest request) { + HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); + Map data = new ConcurrentHashMap<>(); + + when(context.getHttpRequest()).thenReturn(request); + when(context.getData(anyString())) + .thenAnswer(invocation -> Optional.ofNullable(data.get(invocation.getArgument(0)))); + doAnswer(invocation -> { + data.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(context).setData(anyString(), org.mockito.ArgumentMatchers.any()); + + return context; + } + + @Test + public void getBlobRequestUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob request should be signed with session auth"); + } + + @Test + public void getBlobRequestProducesWellFormedSessionAuthHeader() { + StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023"); + + HttpPipelineCallContext context = createContextForRequest(request); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(cred)); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + // The policy must delegate signing to StorageSessionCredential, producing a Session-scheme + // Authorization header of the form `Session :`. End-to-end signature + // correctness against the live service is covered by ContainerApiTests.downloadBlobOverSessionAuth. + String actual = request.getHeaders().getValue(authHeaderName); + assertNotNull(actual, "Authorization header should be set by the policy"); + assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"), + "Authorization should use the Session scheme with the cached session token, but was: " + actual); + String actualSignature = actual.substring(actual.indexOf(':') + 1); + assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"), + "Signature must be base64-encoded, but was: " + actualSignature); + } + + /** + * Guards the workaround in {@link StorageSessionCredential#buildStringToSign}: the Session + * protocol signs the literal {@code Content-Length} value rather than normalizing + * {@code "0" -> ""} like SharedKey does. This is required today because azure-core's + * {@code RestProxyBase} unconditionally adds {@code Content-Length: 0} to body-less GET + * requests. Once that is fixed in azure-core, the buildStringToSign workaround can be removed + * and this test should be updated (or deleted) to reflect the new behavior. + */ + @Test + public void contentLengthZeroIsIncludedInSessionSignature() { + String pinnedDate = "Wed, 22 Apr 2026 20:00:00 GMT"; + + HttpRequest withCl0 + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withCl0.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "0") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withCl0); + String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName)); + + HttpRequest withoutCl + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withoutCl.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withoutCl); + String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); + + assertTrue(!sigWithCl0.equals(sigWithoutCl), + "Session signature must include literal Content-Length value: signing with " + + "Content-Length: 0 must differ from signing without Content-Length"); + } + + private static String extractSignature(String authHeader) { + return authHeader.substring(authHeader.indexOf(':') + 1); + } + + @Test + public void putBlobRequestSkipsSessionAuth() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // Non-GetBlob requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void listBlobsRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container&comp=list"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // ListBlobs requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobPropertiesRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob?comp=metadata"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // GetBlobProperties (comp=metadata) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobWithSnapshotUsesSessionAuth() { + HttpPipelineCallContext context = createContextForUrl( + "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2021-01-01T00:00:00Z"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob with snapshot should still use session auth"); + } + + @Test + public void containerLevelGetRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // Container-level GET (restype=container) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void autoModeAlwaysDelegatesToBearerEvenForGetBlobRequests() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(200); + + // PUT request — delegates to bearer (AUTO == NONE) + HttpPipelineCallContext putContext = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); + when(putNext.process()).thenReturn(Mono.just(response)); + autoPolicy.process(putContext, putNext).block().close(); + + // GET blob — also delegates to bearer (AUTO == NONE) + HttpPipelineCallContext getContext + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy getNext = mock(HttpPipelineNextPolicy.class); + when(getNext.process()).thenReturn(Mono.just(response)); + Objects.requireNonNull(autoPolicy.process(getContext, getNext).block()).close(); + + verify(bearerPolicy, times(2)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void singleSpecifiedContainerModeNonGetBlobSkipsSession() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.DELETE, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + Objects.requireNonNull(policy.process(context, next).block()).close(); + + // SINGLE_SPECIFIED_CONTAINER mode non-GetBlob requests delegate to bearer instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void ipStyleEndpointGetBlobUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://127.0.0.1:10000/devstoreaccount1/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + Objects.requireNonNull(policy.process(context, next).block()).close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob on IP-style endpoint should use session auth"); + } + + // endregion +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java new file mode 100644 index 000000000000..2215af27c1a0 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.common.StorageSharedKeyCredential; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class StorageSessionCredentialTest { + + @Test + public void signRequestUsesSessionScheme() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); + + credential.signRequest(request); + + String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertNotNull(authHeader); + assertTrue(authHeader.startsWith("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":"), + "Authorization header should start with 'Session :' but was: " + authHeader); + String signaturePart = authHeader.substring(authHeader.indexOf(':') + 1); + assertFalse(signaturePart.isEmpty(), "Signature should not be empty"); + } + + @Test + public void signRequestSetsXmsDateHeader() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); + + assertNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date"))); + + credential.signRequest(request); + + assertNotNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")), + "signRequest must set x-ms-date so the signed value matches what is sent on the wire"); + } + + // Regression guard for the URL-decode fix in StorageSessionCredential.canonicalizedResource: + // verifies Session and SharedKey produce the same HMAC for a well-formed GET with an + // encoded query string (e.g. snapshot=...%3A...). + // + // Scope is intentionally narrow. Session and SharedKey legitimately diverge on: + // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits "") + // - Content-Length "0" on GETs (SharedKey normalizes to ""; Session preserves "0" to match + // what azure-core's RestProxyBase puts on the wire — see the comment on + // StorageSessionCredential.buildStringToSign). + // Content-Length is pinned to a realistic non-zero value to bypass both quirks. + // + // DELETE this test once azure-core stops setting Content-Length: 0 on GETs and + // StorageSessionCredential.buildStringToSign is removed in favor of delegating to + // sharedKey.generateAuthorizationHeader(...). At that point this assertion becomes + // tautological (SharedKey vs. SharedKey). + @Test + public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException { + StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); + StorageSharedKeyCredential sharedKeyCred + = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY); + + HttpRequest request = new HttpRequest(HttpMethod.GET, + new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=" + + "2025-03-31T00%3A00%3A00.0000000Z")); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), BlobServiceVersion.getLatest().getVersion()) + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "1024"); + + sessionCred.signRequest(request); + + String sessionAuth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); + + HttpHeaders headersForSharedKey = request.getHeaders(); + headersForSharedKey.remove(HttpHeaderName.AUTHORIZATION); + String sharedKeyAuth + = sharedKeyCred.generateAuthorizationHeader(request.getUrl(), "GET", headersForSharedKey, false); + String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); + + assertEquals(sharedKeySignature, sessionSignature, + "Session HMAC must match Shared Key HMAC for the same URL/method/headers"); + } + + @Test + public void isExpiredReturnsTrueWhenPastExpiration() { + assertTrue(SessionTestHelper.createExpiredCredential().isExpired(), + "Credential should be expired when expiration is in the past"); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + assertFalse(SessionTestHelper.createValidCredential().isExpired(), + "Credential should not be expired when expiration is in the future"); + } + + @Test + public void getExpirationDefaultsWhenConstructedWithNull() { + OffsetDateTime before = OffsetDateTime.now(); + StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME); + OffsetDateTime after = OffsetDateTime.now(); + + OffsetDateTime expiration = credential.getExpiration(); + assertNotNull(expiration); + assertTrue( + !expiration.isBefore(before.plusMinutes(5L).minusSeconds(1)) + && !expiration.isAfter(after.plusMinutes(5L).plusSeconds(1)), + "Default expiration should be ~5 minutes from construction time, but was " + expiration); + } +} diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md index 292d2f7c231d..e3f3f4ead37b 100644 --- a/sdk/storage/azure-storage-blob/swagger/README.md +++ b/sdk/storage/azure-storage-blob/swagger/README.md @@ -16,7 +16,7 @@ autorest ### Code generation settings ``` yaml use: '@autorest/java@4.1.63' -input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/15d7f54a5389d5906ffb4e56bb2f38fe5525c0d3/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-06-06/blob.json +input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/013866b01623e6f2cc6c313b44c9c6460de3e91e/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json java: true output-folder: ../ namespace: com.azure.storage.blob diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java index dfe6de66b555..ab8f7aa109d0 100644 --- a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java +++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java @@ -3,15 +3,21 @@ package com.azure.storage.common; import com.azure.core.credential.AzureNamedKeyCredential; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; import com.azure.core.util.CoreUtils; import com.azure.storage.common.implementation.StorageImplUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import java.net.MalformedURLException; +import java.net.URL; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class StorageSharedKeyCredentialTests { @Test @@ -74,4 +80,32 @@ public void cannotParseInvalidConnectionString(String connectionString) { assertThrows(IllegalArgumentException.class, () -> StorageSharedKeyCredential.fromConnectionString(connectionString)); } + + @Test + public void ipStyleUrlCanonicalizedResourceIncludesAccountNameTwice() throws MalformedURLException { + // For IP-style URLs (e.g., Azurite), the account name appears in the URL path. + // The canonicalized resource prepends / to the absolute path, + // so the account name correctly appears twice: ///container/blob + String accountName = "myaccount"; + String accountKey = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + + URL url = new URL("http://127.0.0.1:10000/myaccount/mycontainer/myblob"); + HttpHeaders headers + = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT") + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.CONTENT_LENGTH, "0"); + + String authHeader = credential.generateAuthorizationHeader(url, "GET", headers, false); + + // Verify the signature matches a string-to-sign with account name appearing twice + String stringToSign = "GET\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:Mon, 31 Mar 2025 00:00:00 GMT\n" + + "x-ms-version:2025-01-05\n" + "/myaccount/myaccount/mycontainer/myblob"; + String expectedSignature = credential.computeHmac256(stringToSign); + + assertTrue(authHeader.startsWith("SharedKey myaccount:"), + "Authorization header should start with 'SharedKey myaccount:' but was: " + authHeader); + assertEquals("SharedKey myaccount:" + expectedSignature, authHeader); + } } From db1c3da5bcd16ba541ad7c6bd99503c2c713af2e Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 10 Jun 2026 16:55:39 -0400 Subject: [PATCH 2/9] fix bug in storage session credential with get content length 0 --- .../util/StorageSessionCredential.java | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index d74a4ebc5792..89006929ea8a 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -56,26 +56,31 @@ void signRequest(HttpRequest request) { request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature); } - // Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for - // Content-Length. The Session protocol signs the literal value the wire carries. + // Mirrors StorageSharedKeyCredential.buildStringToSign. The server canonicalizes + // Content-Length: 0 to "" before computing its HMAC (matching the documented Shared Key + // canonicalization), so we must do the same here to produce a matching signature. // - // We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in - // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/ - // implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls - // `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including - // GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization - // then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it - // sees on the wire, so delegating produces a signature mismatch. - // - // TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE, - // delete this method and delegate to sharedKey.generateAuthorizationHeader(...). - // This matches what happens in dotnet: - // https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99 + // TODO (azure-core, RFC hygiene only — does NOT affect Storage signing correctness): + // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/.../RestProxyBase.java) + // unconditionally sets Content-Length: 0 on body-less requests, including GETs. Per + // RFC 7230 §3.3.2 a user agent SHOULD NOT send a Content-Length header when the request + // has no body and the method does not anticipate one (.NET's transports skip it). This + // does NOT cause a signing mismatch here — the server normalizes "0" -> "" and our local + // normalization above matches — so it is purely an RFC-hygiene issue. The Content-Length + // normalization in this method should remain in place even if azure-core is fixed: it + // reflects the documented Shared Key canonicalization rule, not a workaround for + // azure-core behavior. Track the azure-core fix separately if pursued. + private String buildStringToSign(HttpRequest request) { HttpHeaders headers = request.getHeaders(); Collator collator = Collator.getInstance(Locale.ROOT); String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH); + // Normalize "0" to "" to match the server's canonicalization (matches + // StorageSharedKeyCredential.buildStringToSign). + if ("0".equals(contentLength)) { + contentLength = ""; + } // If x-ms-date is present, the Date slot is empty. String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE); From 314c99c159d715ab66280c166c467badeda9e6e1 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 10 Jun 2026 17:02:18 -0400 Subject: [PATCH 3/9] patch to enable session with environement variables --- .../azure/storage/blob/BlobClientBuilder.java | 10 + .../blob/BlobContainerClientBuilder.java | 10 + .../blob/BlobServiceClientBuilder.java | 12 +- .../implementation/util/BuilderHelper.java | 64 ++++++ .../storage/blob/BuilderHelperTests.java | 190 ++++++++++++++++++ .../azure/storage/blob/ContainerApiTests.java | 57 ++++++ .../storage/blob/ContainerAsyncApiTests.java | 58 ++++++ .../SessionTokenCredentialPolicyTest.java | 23 ++- .../util/StorageSessionCredentialTest.java | 13 +- 9 files changed, 412 insertions(+), 25 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index a099f0aab5c5..6df5f792d0d4 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -136,6 +136,11 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); + if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + containerName = sessionOptions.getContainerName(); + } + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* @@ -185,6 +190,11 @@ public BlobAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); + if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + containerName = sessionOptions.getContainerName(); + } + /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier to read and debug. diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 1f0b003f01cc..e3f79b712bf6 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -127,6 +127,11 @@ public BlobContainerClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); + if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + containerName = sessionOptions.getContainerName(); + } + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* @@ -170,6 +175,11 @@ public BlobContainerAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); + if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + containerName = sessionOptions.getContainerName(); + } + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 3cefa0395364..b367447de870 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -154,11 +154,13 @@ public BlobServiceClient buildClient() { } private HttpPipeline constructPipeline() { - return (httpPipeline != null) - ? httpPipeline - : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, - endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, sessionOptions, null); + if (httpPipeline != null) { + return httpPipeline; + } + BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, sessionOptions, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 914794f2bde1..a359a79536a9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -48,6 +48,7 @@ import java.net.MalformedURLException; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; import static com.azure.storage.common.Utility.STORAGE_TRACING_NAMESPACE_VALUE; @@ -61,6 +62,20 @@ public final class BuilderHelper { private static final String CLIENT_NAME; private static final String CLIENT_VERSION; + /** + * Environment variable / configuration key that, when set, selects the {@link SessionMode} + * to use on a builder that has not been explicitly configured (i.e. still using + * {@link SessionMode#AUTO}). Accepted values are the names of {@link SessionMode} + * (case-insensitive): {@code NONE}, {@code AUTO}, {@code SINGLE_SPECIFIED_CONTAINER}. + */ + public static final String PROPERTY_AZURE_STORAGE_SESSION_MODE = "AZURE_STORAGE_SESSION_MODE"; + + /** + * Environment variable / configuration key that, when set, supplies the container name to + * scope the session to on a builder where it has not been explicitly configured. + */ + public static final String PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME = "AZURE_STORAGE_SESSION_CONTAINER_NAME"; + static { Map properties = CoreUtils.getProperties("azure-storage-blob.properties"); CLIENT_NAME = properties.getOrDefault("name", "UnknownName"); @@ -300,4 +315,53 @@ public static void validateSessionMode(SessionOptions sessionOptions, String con "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); } } + + /** + * Applies environment / configuration based defaults to the supplied {@link SessionOptions}. + *

+ * This is a fallback that only fills in values the caller has not explicitly configured on the + * builder, so explicit programmatic configuration always wins: + *

    + *
  • {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is consulted only when + * {@link SessionOptions#getSessionMode()} is still {@link SessionMode#AUTO} (the default). + * The env var value is matched case-insensitively against the names of {@link SessionMode}.
  • + *
  • {@link #PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME} is consulted only when + * {@link SessionOptions#getContainerName()} is {@code null} or empty.
  • + *
+ * Mutates {@code sessionOptions} in place. + * + * @param sessionOptions the options instance to populate; must not be {@code null}. + * @param configuration the configuration store to read from; if {@code null}, the global + * configuration is used. + * @param logger {@link ClientLogger} used to log any exception. + * @throws IllegalArgumentException if {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is set to a + * value that does not name a known {@link SessionMode}. + */ + public static void applyEnvironmentSessionDefaults(SessionOptions sessionOptions, Configuration configuration, + ClientLogger logger) { + Configuration effectiveConfiguration + = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; + + if (sessionOptions.getSessionMode() == SessionMode.AUTO) { + String envMode = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_MODE); + if (!CoreUtils.isNullOrEmpty(envMode)) { + SessionMode parsed; + try { + parsed = SessionMode.valueOf(envMode.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw logger.logExceptionAsError(new IllegalArgumentException("Invalid value '" + envMode + + "' for environment variable " + PROPERTY_AZURE_STORAGE_SESSION_MODE + + ". Allowed values are: NONE, AUTO, SINGLE_SPECIFIED_CONTAINER.", ex)); + } + sessionOptions.setSessionMode(parsed); + } + } + + if (CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + String envContainer = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); + if (!CoreUtils.isNullOrEmpty(envContainer)) { + sessionOptions.setContainerName(envContainer.trim()); + } + } + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java index 370b33e88424..c060adfb642f 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -16,7 +16,10 @@ import com.azure.core.test.http.MockHttpResponse; import com.azure.core.test.http.NoOpHttpClient; import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.test.utils.TestConfigurationSource; import com.azure.core.util.ClientOptions; +import com.azure.core.util.Configuration; +import com.azure.core.util.ConfigurationBuilder; import com.azure.core.util.CoreUtils; import com.azure.core.util.DateTimeRfc1123; import com.azure.core.util.Header; @@ -31,6 +34,7 @@ import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.policy.RequestRetryOptions; import com.azure.storage.common.policy.RetryPolicyType; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -834,4 +838,190 @@ public void containerBuilderWithNoSessionOptionsSucceeds() { } // endregion + + // region environment variable session activation tests + + private static Configuration envConfiguration(String mode, String container) { + TestConfigurationSource envSource = new TestConfigurationSource(); + if (mode != null) { + envSource.put(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_MODE, mode); + } + if (container != null) { + envSource.put(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME, container); + } + return new ConfigurationBuilder(new TestConfigurationSource(), new TestConfigurationSource(), envSource) + .build(); + } + + @Test + public void containerBuilderActivatesSessionFromEnvWhenNothingExplicit() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .buildClient()); + } + + @Test + public void containerBuilderEnvModeWithoutContainerNameStillThrows() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", null); + + assertThrows(IllegalArgumentException.class, + () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .buildClient()); + } + + @Test + public void containerBuilderEnvModeIsCaseInsensitive() { + Configuration config = envConfiguration("single_specified_container", "envcontainer"); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .buildClient()); + } + + @Test + public void containerBuilderInvalidEnvModeThrows() { + Configuration config = envConfiguration("NOT_A_REAL_MODE", "envcontainer"); + + assertThrows(IllegalArgumentException.class, + () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .buildClient()); + } + + @Test + public void containerBuilderExplicitSessionModeOverridesEnv() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + SessionOptions explicitNone = new SessionOptions().setSessionMode(SessionMode.NONE); + + // Explicit NONE must not be upgraded by env vars and no container is required. + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .sessionOptions(explicitNone) + .buildClient()); + } + + @Test + public void containerBuilderExplicitContainerNameWinsOverEnv() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + SessionOptions options = new SessionOptions().setContainerName("explicitcontainer"); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("explicitcontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .sessionOptions(options) + .buildClient()); + + assertEquals("explicitcontainer", options.getContainerName()); + } + + @Test + public void blobBuilderActivatesSessionFromEnvWhenNothingExplicit() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + + assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .buildClient()); + } + + @Test + public void serviceBuilderActivatesSessionFromEnvWhenNothingExplicit() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + SessionOptions options = new SessionOptions(); + + assertDoesNotThrow(() -> new BlobServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .configuration(config) + .sessionOptions(options) + .buildClient()); + + // Env vars must have flowed through to the SessionOptions instance. + assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode()); + assertEquals("envcontainer", options.getContainerName()); + } + + @Test + public void applyEnvironmentSessionDefaultsLeavesExplicitValuesIntact() { + Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE).setContainerName("explicit"); + + BuilderHelper.applyEnvironmentSessionDefaults(options, config, new ClientLogger(BuilderHelperTests.class)); + + assertEquals(SessionMode.NONE, options.getSessionMode()); + assertEquals("explicit", options.getContainerName()); + } + + @Test + public void applyEnvironmentSessionDefaultsAppliesOnlyContainerNameWhenModeExplicit() { + Configuration config = envConfiguration(null, "envcontainer"); + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + BuilderHelper.applyEnvironmentSessionDefaults(options, config, new ClientLogger(BuilderHelperTests.class)); + + assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode()); + assertEquals("envcontainer", options.getContainerName()); + } + + // endregion + + // region environment-variable end-to-end test + // + // This single test verifies that a customer can activate the session feature with NO code + // change at all -- just by exporting environment variables before starting the JVM: + // + // set AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER + // set AZURE_STORAGE_SESSION_CONTAINER_NAME=mycontainer + // + // It is @Disabled by default because: + // 1. CI doesn't (and shouldn't) set these process-level env vars. + // 2. EnvironmentConfiguration in azure-core caches reads from the global Configuration + // for the lifetime of the JVM, so it cannot be reliably reset between tests inside + // the same Surefire fork. + // + // To run it manually after setting the env vars above: + // + // mvn -pl sdk/storage/azure-storage-blob test ^ + // "-Dtest=BuilderHelperTests#environmentVariablesActivateSession" + // + // The 10 injection-based tests above already cover all of the helper's branching logic + // by injecting a Configuration directly; this test exists only to prove the real + // System.getenv lookup path also works. + + @Test + @Disabled("Run manually after exporting AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER and " + + "AZURE_STORAGE_SESSION_CONTAINER_NAME=. See the comment above for details.") + public void environmentVariablesActivateSession() { + SessionOptions options = new SessionOptions(); + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + + String expectedContainer = System.getenv(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); + assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode(), + "Expected env var AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER to populate sessionMode"); + assertEquals(expectedContainer, options.getContainerName(), + "Expected env var AZURE_STORAGE_SESSION_CONTAINER_NAME to populate containerName"); + } + + // endregion } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index 76195bb37083..8b83c216b69f 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -2291,4 +2291,61 @@ private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... .setAccountName(cc.getAccountName()); return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName()); } + + @Test + @LiveOnly + @Disabled("This test is disabled since it requires specific environment vars to be set that are not normally set") + @ResourceLock("BlobSessionAuth") + // Verifies the env-var session-activation feature end-to-end. With NO explicit SessionOptions + // and NO .sessionOptions(...) call, a customer who only exports the AZURE_STORAGE_SESSION_MODE + // and AZURE_STORAGE_SESSION_CONTAINER_NAME environment variables should get blob downloads + // automatically signed with the "Session" auth scheme instead of "Bearer". + public void downloadBlobUsingEnvVarSessionAuth() { + + String myContainerName = "session-test-container"; + String endpoint = ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(); + + // Setup: provision the container and upload a blob the customer will later download. + primaryBlobServiceClient.createBlobContainer(myContainerName); + String blobName = generateBlobName(); + primaryBlobServiceClient.getBlobContainerClient(myContainerName) + .getBlobClient(blobName) + .getBlockBlobClient() + .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + // A real customer would deploy with these environment variables set on the process: + // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER + // AZURE_STORAGE_SESSION_CONTAINER_NAME= + // A JVM cannot mutate its own env, so we set the equivalent system properties. + // azure-core's EnvironmentConfiguration reads system properties as a fallback for env + // vars, so this faithfully simulates the deployed scenario from inside a test. + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + try { + // Customer code: no .sessionOptions(...), no .configuration(...). The env vars + // set above are the only thing turning on session-based auth. + BlobContainerClient sessionCc = instrument(new BlobContainerClientBuilder().endpoint(endpoint) + .containerName(myContainerName) + .credential(new DefaultAzureCredentialBuilder().build()) + .addPolicy(inspect)).buildClient(); + + BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); + assertEquals(DATA.getDefaultText(), downloaded.toString()); + + assertFalse(downloadAuthSchemes.isEmpty(), "Expected to observe at least one blob download request"); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected env-var-configured client to use Session auth on blob downloads; saw " + downloadAuthSchemes); + } finally { + primaryBlobServiceClient.deleteBlobContainer(myContainerName); + } + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index ee3579271283..a08e31e4c5cf 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -2311,4 +2311,62 @@ private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipeline .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); } + @Test + @LiveOnly + @Disabled("This test is disabled since it requires specific environment vars to be set that are not normally set") + @ResourceLock("BlobSessionAuth") + // Async twin of ContainerApiTests#downloadBlobUsingEnvVarSessionAuth. Verifies the env-var + // session-activation feature end-to-end. With NO explicit SessionOptions and NO + // .sessionOptions(...) call, a customer who only exports AZURE_STORAGE_SESSION_MODE and + // AZURE_STORAGE_SESSION_CONTAINER_NAME should get blob downloads automatically signed with + // the "Session" auth scheme instead of "Bearer". + public void downloadBlobUsingEnvVarSessionAuth() { + + String myContainerName = "session-test-container"; + String endpoint = ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(); + + // Setup: provision the container and upload a blob the customer will later download. + primaryBlobServiceAsyncClient.createBlobContainer(myContainerName).block(); + String blobName = generateBlobName(); + primaryBlobServiceAsyncClient.getBlobContainerAsyncClient(myContainerName) + .getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + + // A real customer would deploy with these environment variables set on the process: + // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER + // AZURE_STORAGE_SESSION_CONTAINER_NAME= + // This test relies on those env vars being set on the host running it. + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + try { + // Customer code: no .sessionOptions(...), no .configuration(...). The env vars + // set on the host are the only thing turning on session-based auth. + BlobContainerAsyncClient sessionCcAsync = instrument(new BlobContainerClientBuilder().endpoint(endpoint) + .containerName(myContainerName) + .credential(new DefaultAzureCredentialBuilder().build()) + .addPolicy(inspect)).buildAsyncClient(); + + StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) + .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) + .verifyComplete(); + + assertFalse(downloadAuthSchemes.isEmpty(), "Expected to observe at least one blob download request"); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected env-var-configured client to use Session auth on blob downloads; saw " + downloadAuthSchemes); + } finally { + primaryBlobServiceAsyncClient.deleteBlobContainer(myContainerName).block(); + } + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 020284e56f78..3d9c28e41947 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -626,15 +626,18 @@ public void getBlobRequestProducesWellFormedSessionAuthHeader() { } /** - * Guards the workaround in {@link StorageSessionCredential#buildStringToSign}: the Session - * protocol signs the literal {@code Content-Length} value rather than normalizing - * {@code "0" -> ""} like SharedKey does. This is required today because azure-core's - * {@code RestProxyBase} unconditionally adds {@code Content-Length: 0} to body-less GET - * requests. Once that is fixed in azure-core, the buildStringToSign workaround can be removed - * and this test should be updated (or deleted) to reflect the new behavior. + * Regression guard: the Session protocol must normalize {@code Content-Length: "0"} to + * {@code ""} in the string-to-sign, matching the server's canonicalization (which is the + * same as documented Shared Key canonicalization). Signing with {@code Content-Length: 0} + * must therefore produce the same HMAC as signing without a Content-Length header at all. + *

+ * Originally we expected the opposite (signing the literal "0") based on a misread of the + * service behavior; that caused 401 InvalidAuthenticationInfo errors on real blob GETs + * because azure-core's {@code RestProxyBase} unconditionally puts {@code Content-Length: 0} + * on body-less GETs while the server canonicalizes that to "" before computing its HMAC. */ @Test - public void contentLengthZeroIsIncludedInSessionSignature() { + public void contentLengthZeroProducesSameSignatureAsMissingContentLength() { String pinnedDate = "Wed, 22 Apr 2026 20:00:00 GMT"; HttpRequest withCl0 @@ -658,9 +661,9 @@ public void contentLengthZeroIsIncludedInSessionSignature() { credentialWithToken(FIRST_TOKEN).signRequest(withoutCl); String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); - assertTrue(!sigWithCl0.equals(sigWithoutCl), - "Session signature must include literal Content-Length value: signing with " - + "Content-Length: 0 must differ from signing without Content-Length"); + assertEquals(sigWithCl0, sigWithoutCl, + "Session signature must normalize Content-Length: 0 to empty: signing with " + + "Content-Length: 0 must match signing without Content-Length"); } private static String extractSignature(String authHeader) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java index 2215af27c1a0..dc70b97398d4 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -58,16 +58,9 @@ public void signRequestSetsXmsDateHeader() throws MalformedURLException { // encoded query string (e.g. snapshot=...%3A...). // // Scope is intentionally narrow. Session and SharedKey legitimately diverge on: - // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits "") - // - Content-Length "0" on GETs (SharedKey normalizes to ""; Session preserves "0" to match - // what azure-core's RestProxyBase puts on the wire — see the comment on - // StorageSessionCredential.buildStringToSign). - // Content-Length is pinned to a realistic non-zero value to bypass both quirks. - // - // DELETE this test once azure-core stops setting Content-Length: 0 on GETs and - // StorageSessionCredential.buildStringToSign is removed in favor of delegating to - // sharedKey.generateAuthorizationHeader(...). At that point this assertion becomes - // tautological (SharedKey vs. SharedKey). + // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits ""). + // Content-Length is pinned to a realistic non-zero value to bypass that quirk. Equivalence for + // Content-Length: 0 (which the server normalizes to "") is covered separately. @Test public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException { StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); From 44a9ee3125e1d9e3b0b4f65d0d3041998ba4bc07 Mon Sep 17 00:00:00 2001 From: Isabelle <141270045+ibrandes@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:10:55 -0700 Subject: [PATCH 4/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../java/com/azure/storage/blob/ContainerApiTests.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index 8b83c216b69f..b7daa22469ce 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -2314,11 +2314,9 @@ public void downloadBlobUsingEnvVarSessionAuth() { .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); // A real customer would deploy with these environment variables set on the process: - // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER - // AZURE_STORAGE_SESSION_CONTAINER_NAME= - // A JVM cannot mutate its own env, so we set the equivalent system properties. - // azure-core's EnvironmentConfiguration reads system properties as a fallback for env - // vars, so this faithfully simulates the deployed scenario from inside a test. +// AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER +// AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container +// This test relies on those env vars being set on the host running it. List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { From 45b7a4bbcd3b1982ed944e6304f8902b439025df Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 11 Jun 2026 13:30:27 -0400 Subject: [PATCH 5/9] add code to ensure container name only accepted when session enabled --- .../src/main/java/com/azure/storage/blob/BlobClientBuilder.java | 1 + .../azure/storage/blob/implementation/util/BuilderHelper.java | 2 +- .../java/com/azure/storage/blob/ContainerAsyncApiTests.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 6df5f792d0d4..0a2fc03574d1 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -194,6 +194,7 @@ public BlobAsyncClient buildAsyncClient() { if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { containerName = sessionOptions.getContainerName(); } + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index a359a79536a9..4ce72b4164d9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -357,7 +357,7 @@ public static void applyEnvironmentSessionDefaults(SessionOptions sessionOptions } } - if (CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { String envContainer = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); if (!CoreUtils.isNullOrEmpty(envContainer)) { sessionOptions.setContainerName(envContainer.trim()); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index a08e31e4c5cf..b8555db0cbee 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -2336,7 +2336,7 @@ public void downloadBlobUsingEnvVarSessionAuth() { // A real customer would deploy with these environment variables set on the process: // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER - // AZURE_STORAGE_SESSION_CONTAINER_NAME= + // AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container // This test relies on those env vars being set on the host running it. List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); From 06aa7efc3714ae7dd06e065e18569db263298d9c Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 11 Jun 2026 14:11:09 -0400 Subject: [PATCH 6/9] fix formatting and javadocs alignment --- .../implementation/util/BuilderHelper.java | 47 ++++++++++--------- .../azure/storage/blob/ContainerApiTests.java | 6 +-- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 4ce72b4164d9..79c6b6558c19 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -87,22 +87,22 @@ public final class BuilderHelper { * authentication support. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. - * @param tokenCredential {@link TokenCredential} if present. - * @param azureSasCredential {@link AzureSasCredential} if present. - * @param sasToken SAS token if present. - * @param endpoint The endpoint for the client. - * @param retryOptions Storage's retry options to set in the retry policy. - * @param coreRetryOptions Core's retry options to set in the retry policy. - * @param logOptions Logging options to set in the logging policy. - * @param clientOptions Client options. - * @param httpClient HttpClient to use in the builder. - * @param perCallPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per call. - * @param perRetryPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per retry. - * @param configuration Configuration store contain environment settings. - * @param logger {@link ClientLogger} used to log any exception. - * @param audience {@link BlobAudience} used to determine the audience of the blob. - * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication. - * @param serviceVersion The service version for session creation. Required when session is active. + * @param tokenCredential {@link TokenCredential} if present. + * @param azureSasCredential {@link AzureSasCredential} if present. + * @param sasToken SAS token if present. + * @param endpoint The endpoint for the client. + * @param retryOptions Storage's retry options to set in the retry policy. + * @param coreRetryOptions Core's retry options to set in the retry policy. + * @param logOptions Logging options to set in the logging policy. + * @param clientOptions Client options. + * @param httpClient HttpClient to use in the builder. + * @param perCallPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per call. + * @param perRetryPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per retry. + * @param configuration Configuration store contain environment settings. + * @param logger {@link ClientLogger} used to log any exception. + * @param audience {@link BlobAudience} used to determine the audience of the blob. + * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication. + * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -256,8 +256,8 @@ public static String getEndpoint(BlobUrlParts parts) throws MalformedURLExceptio * Validates that the client is properly configured to use https. * * @param objectToCheck The object to check for. - * @param objectName The name of the object. - * @param endpoint The endpoint for the client. + * @param objectName The name of the object. + * @param endpoint The endpoint for the client. */ public static void httpsValidation(Object objectToCheck, String objectName, String endpoint, ClientLogger logger) { if (objectToCheck != null && !BlobUrlParts.parse(endpoint).getScheme().equals(Constants.HTTPS)) { @@ -302,7 +302,7 @@ public static Tracer createTracer(ClientOptions clientOptions) { /** * Logs information about credential changes in builders. * - * @param logger The logger to use. + * @param logger The logger to use. * @param newCredentialType The credential type being set. */ public static void logCredentialChange(ClientLogger logger, String newCredentialType) { @@ -331,9 +331,9 @@ public static void validateSessionMode(SessionOptions sessionOptions, String con * Mutates {@code sessionOptions} in place. * * @param sessionOptions the options instance to populate; must not be {@code null}. - * @param configuration the configuration store to read from; if {@code null}, the global - * configuration is used. - * @param logger {@link ClientLogger} used to log any exception. + * @param configuration the configuration store to read from; if {@code null}, the global + * configuration is used. + * @param logger {@link ClientLogger} used to log any exception. * @throws IllegalArgumentException if {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is set to a * value that does not name a known {@link SessionMode}. */ @@ -357,7 +357,8 @@ public static void applyEnvironmentSessionDefaults(SessionOptions sessionOptions } } - if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE + && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { String envContainer = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); if (!CoreUtils.isNullOrEmpty(envContainer)) { sessionOptions.setContainerName(envContainer.trim()); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index b7daa22469ce..cff568e88547 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -2314,9 +2314,9 @@ public void downloadBlobUsingEnvVarSessionAuth() { .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); // A real customer would deploy with these environment variables set on the process: -// AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER -// AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container -// This test relies on those env vars being set on the host running it. + // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER + // AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container + // This test relies on those env vars being set on the host running it. List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { From 51fbeb092b5dbfbcab4ee123c44a5abb3c409af9 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 22 Jun 2026 11:10:44 -0400 Subject: [PATCH 7/9] add forceRefreshSessionInBackground() to SessionTokenCredentialPolicy --- sdk/storage/azure-storage-blob/CHANGELOG.md | 5 + .../util/SessionTokenCredentialPolicy.java | 2 +- .../util/StorageSessionCredentialCache.java | 29 ++- .../azure/storage/blob/ContainerApiTests.java | 202 ++++++++++++++++++ .../SessionTokenCredentialPolicyTest.java | 45 ++++ .../StorageSessionCredentialCacheTest.java | 183 ++++++++++++++++ 6 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index 6c06de3117ad..0ac7d7148209 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -8,6 +8,11 @@ ### Bugs Fixed +- Fixed an issue where the service's proactive `x-ms-auth-info: session_expiring` hint was ignored when the + client's own session-refresh timer had not yet elapsed, allowing a container session to be used past the + point the service rotated its network-context binding and surfacing as a `401 InvalidAuthenticationInfo` + (`session_token_invalid` / network context mismatch). The hint now forces a proactive background refresh. + ### Other Changes ## 12.33.3 (2026-03-30) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index e7d875389856..5b21b659f532 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -231,7 +231,7 @@ private void signRequest(HttpPipelineCallContext context, StorageSessionCredenti private void handleSessionExpiringHeader(HttpResponse response) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { - sessionCredentialCache.refreshSessionInBackground(); + sessionCredentialCache.forceRefreshSessionInBackground(); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java index 594f976207df..a9ae2b2dde14 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java @@ -6,6 +6,7 @@ import com.azure.core.util.logging.ClientLogger; import reactor.core.publisher.Mono; +import java.time.Clock; import java.time.Duration; import java.time.OffsetDateTime; import java.util.Objects; @@ -20,6 +21,7 @@ final class StorageSessionCredentialCache { private static final double JITTER_WINDOW_START_RATIO = 0.8d; private final BlobSessionClient sessionClient; + private final Clock clock; private final Object creationLock = new Object(); private volatile StorageSessionCredential credential; private volatile OffsetDateTime nextRefreshTime; @@ -27,11 +29,16 @@ final class StorageSessionCredentialCache { private volatile Mono inflightCreation; StorageSessionCredentialCache(BlobSessionClient sessionClient) { + this(sessionClient, Clock.systemUTC()); + } + + StorageSessionCredentialCache(BlobSessionClient sessionClient, Clock clock) { this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); } Mono getValidSessionAsync() { - OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime now = OffsetDateTime.now(clock); StorageSessionCredential current = credential; if (isUsable(current, now)) { if (isRefreshDue(now)) { @@ -44,7 +51,7 @@ Mono getValidSessionAsync() { } StorageSessionCredential getValidSessionSync() { - OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime now = OffsetDateTime.now(clock); StorageSessionCredential current = credential; if (isUsable(current, now)) { if (isRefreshDue(now)) { @@ -64,7 +71,7 @@ StorageSessionCredential getValidSessionSync() { synchronized (creationLock) { current = credential; - now = OffsetDateTime.now(); + now = OffsetDateTime.now(clock); if (isUsable(current, now)) { if (isRefreshDue(now)) { refreshSessionInBackground(); @@ -91,7 +98,7 @@ void invalidateSession(StorageSessionCredential target) { void refreshSessionInBackground() { synchronized (creationLock) { - OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime now = OffsetDateTime.now(clock); if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { return; } @@ -102,9 +109,19 @@ void refreshSessionInBackground() { }, error -> LOGGER.warning("Background session refresh failed.", error)); } + void forceRefreshSessionInBackground() { + synchronized (creationLock) { + if (isUsable(credential, OffsetDateTime.now(clock))) { + nextRefreshTime = OffsetDateTime.now(clock); + } + } + + refreshSessionInBackground(); + } + private Mono startSessionCreationAsync() { synchronized (creationLock) { - OffsetDateTime now = OffsetDateTime.now(); + OffsetDateTime now = OffsetDateTime.now(clock); StorageSessionCredential current = credential; if (isUsable(current, now) && !isRefreshDue(now)) { return Mono.just(current); @@ -133,7 +150,7 @@ private Mono startSessionCreationAsync() { private void setActiveCredential(StorageSessionCredential newCredential) { credential = newCredential; - nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), newCredential.getExpiration()); refreshing = false; } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index cff568e88547..0ceaa4981470 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -5,6 +5,12 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.BinaryData; import com.azure.core.http.rest.PagedIterable; @@ -66,6 +72,8 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; @@ -2285,6 +2293,200 @@ public void listBlobsOverSessionEnabledClient() { "Container list operation must use Bearer authorization; saw " + listAuthSchemes); } + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Verifies that the cached session token rotates on its own while a client keeps issuing blob GET requests. + // A session credential is short-lived (~5 minutes) and the credential cache fetches a fresh one in the + // background before the current one expires. This test keeps issuing small GETs across more than one session + // lifetime and asserts that the session token observed on the wire changes at least once (rotation happened). + public void sessionTokenRotates() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Continuously issue small GET requests for slightly longer than one session lifetime so we are + // guaranteed to cross at least one background rotation boundary while requests are in flight. + long testDurationMillis = 6 * 60 * 1000L; + long pollIntervalMillis = 10 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // Each GET is small (the default test data) and must succeed with the expected content. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + sleepIfRunningAgainstService(pollIntervalMillis); + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the test window; only saw " + distinctTokens); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Verifies that, while a client hammers the service with small, rapid, back-to-back blob GET requests, the + // cached session token rotates and every download still succeeds. The service legitimately returns transient + // "session_token_invalid" (401, network-context-mismatch) responses while it rotates a session's binding; the + // SDK recovers from those by invalidating the session, creating a fresh one, and retrying, so the caller's GET + // never fails. We therefore assert the contract the SDK can actually honor - every download returns the + // correct content (no invalid-token failure ever surfaces to the caller) and the token rotates at least once - + // rather than asserting the wire never carries a 401, which the service does not guarantee. (Recovered + // invalid-token responses are still recorded and surfaced in the failure message below for diagnostics.) + public void sessionTokenRotatesWithoutInvalidTokenGets() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Continuously issue small GET requests, back-to-back with no delay, for slightly longer than one + // session lifetime so we are guaranteed to cross at least one rotation boundary while a high volume of + // requests are in flight. + long testDurationMillis = 6 * 60 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // Each GET must succeed with the expected content. If a transient invalid-token 401 reaches the wire, + // the SDK's retry transparently recovers it, so this download still returns the blob - the caller + // never observes a failure. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the test window; saw tokens " + distinctTokens + + " and transparently-recovered invalid-token responses " + inspect.getInvalidAuthStatuses()); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Simulates a slow-polling client that issues a single small blob GET roughly every 30 seconds. Because the + // requests are sparse, the client can go a long time between responses and may miss the service's proactive + // "x-ms-auth-info: session_expiring" hint window entirely - so it can end up signing a request with a token + // that has expired purely due to the passage of time. This verifies the SDK handles that gracefully: every + // download still returns the correct content (the cache rotates to a fresh session - proactively via its own + // refresh timer when it can, or via the one-shot 401 retry as a backstop when an expired token slips onto the + // wire) and the session token observed on the wire rotates at least once over the multi-lifetime window. + public void sessionTokenRotatesWithSparsePolling() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Poll once every ~30s for longer than two session lifetimes (~5 min each) so we are guaranteed to cross + // multiple expiry boundaries. The wide gap between requests is what makes it possible to land a request + // on an already-expired token: the proactive refresh point can come due during the idle gap, and the + // first request after it - 30s later - may be signed just after the token has lapsed. + long testDurationMillis = 11 * 60 * 1000L; + long pollIntervalMillis = 30 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // The caller must never observe a failure: each sparse GET returns the expected content, whether the + // cached token was still valid, was proactively rotated, or had to be re-acquired after a 401. Any + // expired-token use is recovered transparently by the policy's single retry with a fresh session. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + sleepIfRunningAgainstService(pollIntervalMillis); + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the sparse-polling window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the sparse-polling window; only saw " + + distinctTokens); + } + + /** + * Test-only pipeline policy that watches blob-level GET requests for a single blob and records, at the wire + * level (PER_RETRY), the Session token used to sign each request and any invalid-token (401/403) responses + * those requests receive. Used to assert that session tokens rotate over time without any request ever being + * signed with an expired/invalid token. + */ + private static final class SessionGetInspectionPolicy implements HttpPipelinePolicy { + private final String blobName; + private final List sessionTokens = Collections.synchronizedList(new ArrayList<>()); + private final List invalidAuthStatuses = Collections.synchronizedList(new ArrayList<>()); + + SessionGetInspectionPolicy(String blobName) { + this.blobName = blobName; + } + + private boolean isBlobGet(HttpRequest request) { + String path = request.getUrl().getPath(); + String query = request.getUrl().getQuery(); + return request.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp=")); + } + + private void onRequest(HttpRequest request) { + if (!isBlobGet(request)) { + return; + } + String auth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + if (auth != null && auth.startsWith("Session ")) { + // Header form is "Session :" - extract just the token. + int tokenStart = "Session ".length(); + int sigSeparator = auth.indexOf(':', tokenStart); + sessionTokens + .add(sigSeparator < 0 ? auth.substring(tokenStart) : auth.substring(tokenStart, sigSeparator)); + } + } + + private void onResponse(HttpRequest request, int statusCode) { + if (isBlobGet(request) && (statusCode == 401 || statusCode == 403)) { + invalidAuthStatuses.add(statusCode); + } + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + onRequest(context.getHttpRequest()); + return next.process().doOnNext(response -> onResponse(context.getHttpRequest(), response.getStatusCode())); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + onRequest(context.getHttpRequest()); + HttpResponse response = next.processSync(); + onResponse(context.getHttpRequest(), response.getStatusCode()); + return response; + } + + List getSessionTokens() { + return sessionTokens; + } + + List getInvalidAuthStatuses() { + return invalidAuthStatuses; + } + } + private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) { SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) .setContainerName(cc.getBlobContainerName()) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 3d9c28e41947..d715c0b954b2 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -536,6 +536,51 @@ public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { } } + @Test + public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + // Fresh session far from expiry, so the client's own jittered refresh timer is NOT due. + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + // The service signals (via x-ms-auth-info: session_expiring) that this session is about to stop + // being honored — for example, just before its network-context binding rotates. + when(response.getHeaderValue(HttpHeaderName.fromString("x-ms-auth-info"))).thenReturn("session_expiring"); + + policy.process(context, next).block().close(); + + // The service hint must trigger a proactive background refresh (a second createSession), even + // though the client's own refresh timer had not yet elapsed. Dropping the hint here is what + // previously let the session be used past the rotation boundary, surfacing as a 401 + // "session_token_invalid" (network context mismatch). + verify(sessionClient, times(2)).createSessionAsync(); + } + + @Test + public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + // No x-ms-auth-info hint on the response. + when(response.getHeaderValue(HttpHeaderName.fromString("x-ms-auth-info"))).thenReturn(null); + + policy.process(context, next).block().close(); + + // Without the hint and with a fresh session, only the initial session is created. + verify(sessionClient, times(1)).createSessionAsync(); + } + private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java new file mode 100644 index 000000000000..735747ab0f58 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Deterministic, network-free tests for {@link StorageSessionCredentialCache} time-based behavior. + *

+ * These tests drive the cache with an injectable {@link Clock} and a mocked {@link BlobSessionClient} so the + * expiry and proactive-refresh logic can be exercised without sleeping or hitting the service. The end-to-end + * confidence that real rotation works on the wire is covered separately by the live + * {@code ContainerApiTests.sessionTokenRotates} / {@code sessionTokenRotatesWithoutInvalidTokenGets} tests. + */ +public class StorageSessionCredentialCacheTest { + + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token"; + + // A session's usable lifetime in these tests (the service issues ~5 minute sessions). + private static final Duration SESSION_LIFETIME = Duration.ofMinutes(5); + + /** + * A request returns a good (valid) token. The clock then advances past the token's expiration. The next + * request must detect that the cached token is expired purely due to the passage of time and request a + * brand-new session rather than reuse or send the expired one. + */ + @Test + public void expiredByTimeOnSecondRequestCreatesNewSession() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + + OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); + when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, expiration)) + .thenReturn(credential(SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2)))); + + // First request: cold cache mints a good token and uses it. + StorageSessionCredential firstRequest = cache.getValidSessionSync(); + assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, never()).createSessionAsync(); + + // Time advances past the first token's expiration with no traffic in between. + clock.advance(SESSION_LIFETIME.plusSeconds(1)); + + // Second request: the cached token is expired by time, so a new session is created instead of reused. + StorageSessionCredential secondRequest = cache.getValidSessionSync(); + assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); + verify(sessionClient, times(2)).createSessionSync(); + // The expiry path mints inline; it must not have leaned on the background (async) refresh. + verify(sessionClient, never()).createSessionAsync(); + } + + /** + * When the service has NOT sent a {@code session_expiring} hint, the cache must still refresh + * automatically once its own jittered timer elapses (while the current token is still usable), serving + * the current token until the refreshed one is ready. + */ + @Test + public void automaticBackgroundRefreshFiresWithoutServiceHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + + OffsetDateTime firstExpiration = now(clock).plus(SESSION_LIFETIME); + when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, firstExpiration)); + // Mono.just emits synchronously on subscribe, so the background swap completes inline for the test. + when(sessionClient.createSessionAsync()) + .thenReturn(Mono.just(credential(SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))))); + + // First request: cold cache mints the initial token. + assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, never()).createSessionAsync(); + + // Advance to a point guaranteed to be past the jittered refresh time (80-100% of lifetime minus the + // 5s safety buffer => at most lifetime-5s) but still before hard expiry, so the token remains usable. + clock.advance(SESSION_LIFETIME.minusSeconds(2)); + + // Second request: token still usable, refresh timer elapsed, no service hint => automatic background + // refresh. The current token is served while the refresh happens. + assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + verify(sessionClient, times(1)).createSessionAsync(); + + // Third request: the background refresh has swapped in the new token, which is now served. + assertEquals(SECOND_TOKEN, cache.getValidSessionSync().getSessionToken()); + // Still only one inline creation and one background refresh overall (no over-eager churn). + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, times(1)).createSessionAsync(); + } + + /** + * Guards against over-eager refreshing: while the token is comfortably before its jittered refresh point + * and no service hint has arrived, repeated requests must reuse the same cached token and never trigger a + * refresh. + */ + @Test + public void noRefreshBeforeJitterWindowWithoutServiceHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + + OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); + when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, expiration)); + + // First request mints the token. + assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + + // Advance only slightly — well before the earliest jittered refresh point (80% of lifetime). + clock.advance(Duration.ofSeconds(30)); + + // Several more requests reuse the same token; no refresh is triggered. + for (int i = 0; i < 3; i++) { + assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + } + + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, never()).createSessionAsync(); + } + + private static OffsetDateTime now(Clock clock) { + return OffsetDateTime.now(clock); + } + + private static StorageSessionCredential credential(String token, OffsetDateTime expiration) { + return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration, + SessionTestHelper.TEST_ACCOUNT_NAME); + } + + /** + * A {@link Clock} whose instant can be advanced, allowing deterministic control of the cache's notion of + * "now" without sleeping. + */ + private static final class MutableClock extends Clock { + private final ZoneId zone; + private Instant instant; + + MutableClock(Instant instant) { + this(instant, ZoneOffset.UTC); + } + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId newZone) { + return new MutableClock(instant, newZone); + } + + @Override + public Instant instant() { + return instant; + } + + void advance(Duration duration) { + instant = instant.plus(duration); + } + } +} From 7faf5db9216e610f8fa1262aa2433d4fc70e3839 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 4 Aug 2026 15:49:46 -0400 Subject: [PATCH 8/9] remove .env initialization for sessions --- .../azure/storage/blob/BlobClientBuilder.java | 2 - .../blob/BlobContainerClientBuilder.java | 2 - .../blob/BlobServiceClientBuilder.java | 1 - .../implementation/util/BuilderHelper.java | 64 ------ .../storage/blob/BuilderHelperTests.java | 189 ------------------ .../azure/storage/blob/ContainerApiTests.java | 54 ----- .../storage/blob/ContainerAsyncApiTests.java | 58 ------ 7 files changed, 370 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 0a2fc03574d1..0e6a09db55f4 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -136,7 +136,6 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { containerName = sessionOptions.getContainerName(); } @@ -190,7 +189,6 @@ public BlobAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { containerName = sessionOptions.getContainerName(); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index e3f79b712bf6..0505f84b8411 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -127,7 +127,6 @@ public BlobContainerClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { containerName = sessionOptions.getContainerName(); } @@ -175,7 +174,6 @@ public BlobContainerAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { containerName = sessionOptions.getContainerName(); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index b367447de870..f061abab1f90 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -157,7 +157,6 @@ private HttpPipeline constructPipeline() { if (httpPipeline != null) { return httpPipeline; } - BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER); return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, perRetryPolicies, configuration, audience, LOGGER, sessionOptions, null); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 79c6b6558c19..e03993b3654f 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -48,7 +48,6 @@ import java.net.MalformedURLException; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.Map; import static com.azure.storage.common.Utility.STORAGE_TRACING_NAMESPACE_VALUE; @@ -62,20 +61,6 @@ public final class BuilderHelper { private static final String CLIENT_NAME; private static final String CLIENT_VERSION; - /** - * Environment variable / configuration key that, when set, selects the {@link SessionMode} - * to use on a builder that has not been explicitly configured (i.e. still using - * {@link SessionMode#AUTO}). Accepted values are the names of {@link SessionMode} - * (case-insensitive): {@code NONE}, {@code AUTO}, {@code SINGLE_SPECIFIED_CONTAINER}. - */ - public static final String PROPERTY_AZURE_STORAGE_SESSION_MODE = "AZURE_STORAGE_SESSION_MODE"; - - /** - * Environment variable / configuration key that, when set, supplies the container name to - * scope the session to on a builder where it has not been explicitly configured. - */ - public static final String PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME = "AZURE_STORAGE_SESSION_CONTAINER_NAME"; - static { Map properties = CoreUtils.getProperties("azure-storage-blob.properties"); CLIENT_NAME = properties.getOrDefault("name", "UnknownName"); @@ -316,53 +301,4 @@ public static void validateSessionMode(SessionOptions sessionOptions, String con } } - /** - * Applies environment / configuration based defaults to the supplied {@link SessionOptions}. - *

- * This is a fallback that only fills in values the caller has not explicitly configured on the - * builder, so explicit programmatic configuration always wins: - *

    - *
  • {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is consulted only when - * {@link SessionOptions#getSessionMode()} is still {@link SessionMode#AUTO} (the default). - * The env var value is matched case-insensitively against the names of {@link SessionMode}.
  • - *
  • {@link #PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME} is consulted only when - * {@link SessionOptions#getContainerName()} is {@code null} or empty.
  • - *
- * Mutates {@code sessionOptions} in place. - * - * @param sessionOptions the options instance to populate; must not be {@code null}. - * @param configuration the configuration store to read from; if {@code null}, the global - * configuration is used. - * @param logger {@link ClientLogger} used to log any exception. - * @throws IllegalArgumentException if {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is set to a - * value that does not name a known {@link SessionMode}. - */ - public static void applyEnvironmentSessionDefaults(SessionOptions sessionOptions, Configuration configuration, - ClientLogger logger) { - Configuration effectiveConfiguration - = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration; - - if (sessionOptions.getSessionMode() == SessionMode.AUTO) { - String envMode = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_MODE); - if (!CoreUtils.isNullOrEmpty(envMode)) { - SessionMode parsed; - try { - parsed = SessionMode.valueOf(envMode.trim().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException ex) { - throw logger.logExceptionAsError(new IllegalArgumentException("Invalid value '" + envMode - + "' for environment variable " + PROPERTY_AZURE_STORAGE_SESSION_MODE - + ". Allowed values are: NONE, AUTO, SINGLE_SPECIFIED_CONTAINER.", ex)); - } - sessionOptions.setSessionMode(parsed); - } - } - - if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE - && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { - String envContainer = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); - if (!CoreUtils.isNullOrEmpty(envContainer)) { - sessionOptions.setContainerName(envContainer.trim()); - } - } - } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java index c060adfb642f..4475bc94cd2a 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -16,10 +16,7 @@ import com.azure.core.test.http.MockHttpResponse; import com.azure.core.test.http.NoOpHttpClient; import com.azure.core.test.utils.MockTokenCredential; -import com.azure.core.test.utils.TestConfigurationSource; import com.azure.core.util.ClientOptions; -import com.azure.core.util.Configuration; -import com.azure.core.util.ConfigurationBuilder; import com.azure.core.util.CoreUtils; import com.azure.core.util.DateTimeRfc1123; import com.azure.core.util.Header; @@ -34,7 +31,6 @@ import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.policy.RequestRetryOptions; import com.azure.storage.common.policy.RetryPolicyType; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -839,189 +835,4 @@ public void containerBuilderWithNoSessionOptionsSucceeds() { // endregion - // region environment variable session activation tests - - private static Configuration envConfiguration(String mode, String container) { - TestConfigurationSource envSource = new TestConfigurationSource(); - if (mode != null) { - envSource.put(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_MODE, mode); - } - if (container != null) { - envSource.put(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME, container); - } - return new ConfigurationBuilder(new TestConfigurationSource(), new TestConfigurationSource(), envSource) - .build(); - } - - @Test - public void containerBuilderActivatesSessionFromEnvWhenNothingExplicit() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .buildClient()); - } - - @Test - public void containerBuilderEnvModeWithoutContainerNameStillThrows() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", null); - - assertThrows(IllegalArgumentException.class, - () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .buildClient()); - } - - @Test - public void containerBuilderEnvModeIsCaseInsensitive() { - Configuration config = envConfiguration("single_specified_container", "envcontainer"); - - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .buildClient()); - } - - @Test - public void containerBuilderInvalidEnvModeThrows() { - Configuration config = envConfiguration("NOT_A_REAL_MODE", "envcontainer"); - - assertThrows(IllegalArgumentException.class, - () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .buildClient()); - } - - @Test - public void containerBuilderExplicitSessionModeOverridesEnv() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - SessionOptions explicitNone = new SessionOptions().setSessionMode(SessionMode.NONE); - - // Explicit NONE must not be upgraded by env vars and no container is required. - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .sessionOptions(explicitNone) - .buildClient()); - } - - @Test - public void containerBuilderExplicitContainerNameWinsOverEnv() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - SessionOptions options = new SessionOptions().setContainerName("explicitcontainer"); - - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .containerName("explicitcontainer") - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .sessionOptions(options) - .buildClient()); - - assertEquals("explicitcontainer", options.getContainerName()); - } - - @Test - public void blobBuilderActivatesSessionFromEnvWhenNothingExplicit() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - - assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) - .blobName("myblob") - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .buildClient()); - } - - @Test - public void serviceBuilderActivatesSessionFromEnvWhenNothingExplicit() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - SessionOptions options = new SessionOptions(); - - assertDoesNotThrow(() -> new BlobServiceClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .configuration(config) - .sessionOptions(options) - .buildClient()); - - // Env vars must have flowed through to the SessionOptions instance. - assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode()); - assertEquals("envcontainer", options.getContainerName()); - } - - @Test - public void applyEnvironmentSessionDefaultsLeavesExplicitValuesIntact() { - Configuration config = envConfiguration("SINGLE_SPECIFIED_CONTAINER", "envcontainer"); - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE).setContainerName("explicit"); - - BuilderHelper.applyEnvironmentSessionDefaults(options, config, new ClientLogger(BuilderHelperTests.class)); - - assertEquals(SessionMode.NONE, options.getSessionMode()); - assertEquals("explicit", options.getContainerName()); - } - - @Test - public void applyEnvironmentSessionDefaultsAppliesOnlyContainerNameWhenModeExplicit() { - Configuration config = envConfiguration(null, "envcontainer"); - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); - - BuilderHelper.applyEnvironmentSessionDefaults(options, config, new ClientLogger(BuilderHelperTests.class)); - - assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode()); - assertEquals("envcontainer", options.getContainerName()); - } - - // endregion - - // region environment-variable end-to-end test - // - // This single test verifies that a customer can activate the session feature with NO code - // change at all -- just by exporting environment variables before starting the JVM: - // - // set AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER - // set AZURE_STORAGE_SESSION_CONTAINER_NAME=mycontainer - // - // It is @Disabled by default because: - // 1. CI doesn't (and shouldn't) set these process-level env vars. - // 2. EnvironmentConfiguration in azure-core caches reads from the global Configuration - // for the lifetime of the JVM, so it cannot be reliably reset between tests inside - // the same Surefire fork. - // - // To run it manually after setting the env vars above: - // - // mvn -pl sdk/storage/azure-storage-blob test ^ - // "-Dtest=BuilderHelperTests#environmentVariablesActivateSession" - // - // The 10 injection-based tests above already cover all of the helper's branching logic - // by injecting a Configuration directly; this test exists only to prove the real - // System.getenv lookup path also works. - - @Test - @Disabled("Run manually after exporting AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER and " - + "AZURE_STORAGE_SESSION_CONTAINER_NAME=. See the comment above for details.") - public void environmentVariablesActivateSession() { - SessionOptions options = new SessionOptions(); - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .sessionOptions(options) - .buildClient()); - - String expectedContainer = System.getenv(BuilderHelper.PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME); - assertEquals(SessionMode.SINGLE_SPECIFIED_CONTAINER, options.getSessionMode(), - "Expected env var AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER to populate sessionMode"); - assertEquals(expectedContainer, options.getContainerName(), - "Expected env var AZURE_STORAGE_SESSION_CONTAINER_NAME to populate containerName"); - } - - // endregion } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index 0ceaa4981470..2764287ca156 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -2494,58 +2494,4 @@ private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName()); } - @Test - @LiveOnly - @Disabled("This test is disabled since it requires specific environment vars to be set that are not normally set") - @ResourceLock("BlobSessionAuth") - // Verifies the env-var session-activation feature end-to-end. With NO explicit SessionOptions - // and NO .sessionOptions(...) call, a customer who only exports the AZURE_STORAGE_SESSION_MODE - // and AZURE_STORAGE_SESSION_CONTAINER_NAME environment variables should get blob downloads - // automatically signed with the "Session" auth scheme instead of "Bearer". - public void downloadBlobUsingEnvVarSessionAuth() { - - String myContainerName = "session-test-container"; - String endpoint = ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(); - - // Setup: provision the container and upload a blob the customer will later download. - primaryBlobServiceClient.createBlobContainer(myContainerName); - String blobName = generateBlobName(); - primaryBlobServiceClient.getBlobContainerClient(myContainerName) - .getBlobClient(blobName) - .getBlockBlobClient() - .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); - - // A real customer would deploy with these environment variables set on the process: - // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER - // AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container - // This test relies on those env vars being set on the host running it. - - List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); - RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { - String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); - String path = req.getUrl().getPath(); - String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; - if (auth != null && trimmed != null && trimmed.contains("/")) { - downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); - } - }); - - try { - // Customer code: no .sessionOptions(...), no .configuration(...). The env vars - // set above are the only thing turning on session-based auth. - BlobContainerClient sessionCc = instrument(new BlobContainerClientBuilder().endpoint(endpoint) - .containerName(myContainerName) - .credential(new DefaultAzureCredentialBuilder().build()) - .addPolicy(inspect)).buildClient(); - - BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); - assertEquals(DATA.getDefaultText(), downloaded.toString()); - - assertFalse(downloadAuthSchemes.isEmpty(), "Expected to observe at least one blob download request"); - assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), - "Expected env-var-configured client to use Session auth on blob downloads; saw " + downloadAuthSchemes); - } finally { - primaryBlobServiceClient.deleteBlobContainer(myContainerName); - } - } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index b8555db0cbee..ee3579271283 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -2311,62 +2311,4 @@ private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipeline .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); } - @Test - @LiveOnly - @Disabled("This test is disabled since it requires specific environment vars to be set that are not normally set") - @ResourceLock("BlobSessionAuth") - // Async twin of ContainerApiTests#downloadBlobUsingEnvVarSessionAuth. Verifies the env-var - // session-activation feature end-to-end. With NO explicit SessionOptions and NO - // .sessionOptions(...) call, a customer who only exports AZURE_STORAGE_SESSION_MODE and - // AZURE_STORAGE_SESSION_CONTAINER_NAME should get blob downloads automatically signed with - // the "Session" auth scheme instead of "Bearer". - public void downloadBlobUsingEnvVarSessionAuth() { - - String myContainerName = "session-test-container"; - String endpoint = ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(); - - // Setup: provision the container and upload a blob the customer will later download. - primaryBlobServiceAsyncClient.createBlobContainer(myContainerName).block(); - String blobName = generateBlobName(); - primaryBlobServiceAsyncClient.getBlobContainerAsyncClient(myContainerName) - .getBlobAsyncClient(blobName) - .getBlockBlobAsyncClient() - .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) - .block(); - - // A real customer would deploy with these environment variables set on the process: - // AZURE_STORAGE_SESSION_MODE=SINGLE_SPECIFIED_CONTAINER - // AZURE_STORAGE_SESSION_CONTAINER_NAME=session-test-container - // This test relies on those env vars being set on the host running it. - - List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); - RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { - String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); - String path = req.getUrl().getPath(); - String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; - if (auth != null && trimmed != null && trimmed.contains("/")) { - downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); - } - }); - - try { - // Customer code: no .sessionOptions(...), no .configuration(...). The env vars - // set on the host are the only thing turning on session-based auth. - BlobContainerAsyncClient sessionCcAsync = instrument(new BlobContainerClientBuilder().endpoint(endpoint) - .containerName(myContainerName) - .credential(new DefaultAzureCredentialBuilder().build()) - .addPolicy(inspect)).buildAsyncClient(); - - StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) - .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) - .verifyComplete(); - - assertFalse(downloadAuthSchemes.isEmpty(), "Expected to observe at least one blob download request"); - assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), - "Expected env-var-configured client to use Session auth on blob downloads; saw " + downloadAuthSchemes); - } finally { - primaryBlobServiceAsyncClient.deleteBlobContainer(myContainerName).block(); - } - } - } From 00a9c6c0aabfa5982ef86ecbc7184d32c88cb36e Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 5 Aug 2026 15:31:50 -0400 Subject: [PATCH 9/9] change SessionCredentialCache to AutoRefreshingCache --- .../util/BlobSessionClient.java | 9 +- .../implementation/util/BuilderHelper.java | 5 +- .../util/SessionTokenCredentialPolicy.java | 13 +-- .../util/StorageSessionCredential.java | 6 +- .../storage/blob/models/SessionMode.java | 4 +- ...Test.java => AutoRefreshingCacheTest.java} | 55 ++++++----- .../util/BlobSessionClientTests.java | 12 +-- .../SessionTokenCredentialPolicyTest.java | 98 +++++++++---------- .../util/AutoRefreshingCache.java} | 89 +++++++++-------- .../src/main/java/module-info.java | 1 + 10 files changed, 156 insertions(+), 136 deletions(-) rename sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/{StorageSessionCredentialCacheTest.java => AutoRefreshingCacheTest.java} (75%) rename sdk/storage/{azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java => azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java} (63%) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index 00b3e376b826..b391272537d9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -14,6 +14,7 @@ import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.models.SessionCredentials; +import com.azure.storage.common.implementation.util.AutoRefreshingCache.ValueProvider; import reactor.core.publisher.Mono; /** @@ -22,7 +23,7 @@ * takes an {@link HttpPipeline} (bearer-only, no SessionPolicy) and builds an * {@link AzureBlobStorageImpl} internally. */ -final class BlobSessionClient { +final class BlobSessionClient implements ValueProvider { private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class); private final AzureBlobStorageImpl azureBlobStorage; @@ -39,7 +40,8 @@ final class BlobSessionClient { this.containerName = containerName; } - Mono createSessionAsync() { + @Override + public Mono createAsync() { CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); @@ -48,7 +50,8 @@ Mono createSessionAsync() { .map(this::toCredential); } - StorageSessionCredential createSessionSync() { + @Override + public StorageSessionCredential createSync() { CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index e03993b3654f..fd4824a138c8 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -35,6 +35,7 @@ import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; import com.azure.storage.common.implementation.credentials.CredentialValidator; @@ -157,8 +158,8 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare if (effectiveSessionOptions.getSessionMode() == SessionMode.NONE) { policies.add(bearerPolicy); } else { - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, - new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, new AutoRefreshingCache<>(sessionClient), + effectiveSessionOptions)); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 5b21b659f532..07d49c5ccafc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -14,6 +14,7 @@ import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; @@ -38,7 +39,7 @@ public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; - private final StorageSessionCredentialCache sessionCredentialCache; + private final AutoRefreshingCache sessionCredentialCache; private final SessionOptions sessionOptions; /** @@ -52,7 +53,7 @@ enum AuthStrategy { } SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, - StorageSessionCredentialCache sessionCredentialCache, SessionOptions sessionOptions) { + AutoRefreshingCache sessionCredentialCache, SessionOptions sessionOptions) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionCredentialCache = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); @@ -213,15 +214,15 @@ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, } Mono getValidSessionAsync() { - return sessionCredentialCache.getValidSessionAsync(); + return sessionCredentialCache.getValidValueAsync(); } StorageSessionCredential getValidSessionSync() { - return sessionCredentialCache.getValidSessionSync(); + return sessionCredentialCache.getValidValueSync(); } void invalidateSession(StorageSessionCredential target) { - sessionCredentialCache.invalidateSession(target); + sessionCredentialCache.invalidateValue(target); } private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { @@ -231,7 +232,7 @@ private void signRequest(HttpPipelineCallContext context, StorageSessionCredenti private void handleSessionExpiringHeader(HttpResponse response) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { - sessionCredentialCache.forceRefreshSessionInBackground(); + sessionCredentialCache.forceRefreshValueInBackground(); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index 89006929ea8a..366d25273a4e 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -11,6 +11,7 @@ import com.azure.core.util.DateTimeRfc1123; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.Utility; +import com.azure.storage.common.implementation.util.AutoRefreshingCache.ExpiringValue; import java.net.URL; import java.text.Collator; @@ -25,7 +26,7 @@ * Holds session credentials and signs requests using the Shared Key string-to-sign with the * Session scheme prefix. */ -final class StorageSessionCredential { +final class StorageSessionCredential implements ExpiringValue { private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); private static final String SESSION_PREFIX = "Session "; @@ -164,7 +165,8 @@ String getSessionKey() { return sessionKey; } - OffsetDateTime getExpiration() { + @Override + public OffsetDateTime getExpiration() { return expiration; } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java index 1a87ea5845fe..87553db6b3da 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -33,12 +33,12 @@ public enum SessionMode { /** * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to - * {@link #NONE}; this may change in a future release without breaking callers that + * {@link #SINGLE_SPECIFIED_CONTAINER}; this may change in a future release without breaking callers that * use {@code resolve()} consistently. * @return returns the effective session mode, never {@code AUTO} */ public SessionMode resolve() { - return this == AUTO ? NONE : this; + return this == AUTO ? SINGLE_SPECIFIED_CONTAINER : this; } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java similarity index 75% rename from sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java rename to sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java index 735747ab0f58..065d85afff5b 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCacheTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java @@ -3,6 +3,7 @@ package com.azure.storage.blob.implementation.util; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -21,14 +22,14 @@ import static org.mockito.Mockito.when; /** - * Deterministic, network-free tests for {@link StorageSessionCredentialCache} time-based behavior. + * Deterministic, network-free tests for {@link AutoRefreshingCache} time-based behavior. *

* These tests drive the cache with an injectable {@link Clock} and a mocked {@link BlobSessionClient} so the * expiry and proactive-refresh logic can be exercised without sleeping or hitting the service. The end-to-end * confidence that real rotation works on the wire is covered separately by the live * {@code ContainerApiTests.sessionTokenRotates} / {@code sessionTokenRotatesWithoutInvalidTokenGets} tests. */ -public class StorageSessionCredentialCacheTest { +public class AutoRefreshingCacheTest { private static final String FIRST_TOKEN = "first-session-token"; private static final String SECOND_TOKEN = "second-session-token"; @@ -45,27 +46,27 @@ public class StorageSessionCredentialCacheTest { public void expiredByTimeOnSecondRequestCreatesNewSession() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); BlobSessionClient sessionClient = mock(BlobSessionClient.class); - StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); - when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, expiration)) + when(sessionClient.createSync()).thenReturn(credential(FIRST_TOKEN, expiration)) .thenReturn(credential(SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2)))); // First request: cold cache mints a good token and uses it. - StorageSessionCredential firstRequest = cache.getValidSessionSync(); + StorageSessionCredential firstRequest = cache.getValidValueSync(); assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); - verify(sessionClient, times(1)).createSessionSync(); - verify(sessionClient, never()).createSessionAsync(); + verify(sessionClient, times(1)).createSync(); + verify(sessionClient, never()).createAsync(); // Time advances past the first token's expiration with no traffic in between. clock.advance(SESSION_LIFETIME.plusSeconds(1)); // Second request: the cached token is expired by time, so a new session is created instead of reused. - StorageSessionCredential secondRequest = cache.getValidSessionSync(); + StorageSessionCredential secondRequest = cache.getValidValueSync(); assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); - verify(sessionClient, times(2)).createSessionSync(); + verify(sessionClient, times(2)).createSync(); // The expiry path mints inline; it must not have leaned on the background (async) refresh. - verify(sessionClient, never()).createSessionAsync(); + verify(sessionClient, never()).createAsync(); } /** @@ -77,18 +78,18 @@ public void expiredByTimeOnSecondRequestCreatesNewSession() { public void automaticBackgroundRefreshFiresWithoutServiceHint() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); BlobSessionClient sessionClient = mock(BlobSessionClient.class); - StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); OffsetDateTime firstExpiration = now(clock).plus(SESSION_LIFETIME); - when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, firstExpiration)); + when(sessionClient.createSync()).thenReturn(credential(FIRST_TOKEN, firstExpiration)); // Mono.just emits synchronously on subscribe, so the background swap completes inline for the test. - when(sessionClient.createSessionAsync()) + when(sessionClient.createAsync()) .thenReturn(Mono.just(credential(SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))))); // First request: cold cache mints the initial token. - assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); - verify(sessionClient, times(1)).createSessionSync(); - verify(sessionClient, never()).createSessionAsync(); + assertEquals(FIRST_TOKEN, cache.getValidValueSync().getSessionToken()); + verify(sessionClient, times(1)).createSync(); + verify(sessionClient, never()).createAsync(); // Advance to a point guaranteed to be past the jittered refresh time (80-100% of lifetime minus the // 5s safety buffer => at most lifetime-5s) but still before hard expiry, so the token remains usable. @@ -96,14 +97,14 @@ public void automaticBackgroundRefreshFiresWithoutServiceHint() { // Second request: token still usable, refresh timer elapsed, no service hint => automatic background // refresh. The current token is served while the refresh happens. - assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); - verify(sessionClient, times(1)).createSessionAsync(); + assertEquals(FIRST_TOKEN, cache.getValidValueSync().getSessionToken()); + verify(sessionClient, times(1)).createAsync(); // Third request: the background refresh has swapped in the new token, which is now served. - assertEquals(SECOND_TOKEN, cache.getValidSessionSync().getSessionToken()); + assertEquals(SECOND_TOKEN, cache.getValidValueSync().getSessionToken()); // Still only one inline creation and one background refresh overall (no over-eager churn). - verify(sessionClient, times(1)).createSessionSync(); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createSync(); + verify(sessionClient, times(1)).createAsync(); } /** @@ -115,24 +116,24 @@ public void automaticBackgroundRefreshFiresWithoutServiceHint() { public void noRefreshBeforeJitterWindowWithoutServiceHint() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); BlobSessionClient sessionClient = mock(BlobSessionClient.class); - StorageSessionCredentialCache cache = new StorageSessionCredentialCache(sessionClient, clock); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); - when(sessionClient.createSessionSync()).thenReturn(credential(FIRST_TOKEN, expiration)); + when(sessionClient.createSync()).thenReturn(credential(FIRST_TOKEN, expiration)); // First request mints the token. - assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + assertEquals(FIRST_TOKEN, cache.getValidValueSync().getSessionToken()); // Advance only slightly — well before the earliest jittered refresh point (80% of lifetime). clock.advance(Duration.ofSeconds(30)); // Several more requests reuse the same token; no refresh is triggered. for (int i = 0; i < 3; i++) { - assertEquals(FIRST_TOKEN, cache.getValidSessionSync().getSessionToken()); + assertEquals(FIRST_TOKEN, cache.getValidValueSync().getSessionToken()); } - verify(sessionClient, times(1)).createSessionSync(); - verify(sessionClient, never()).createSessionAsync(); + verify(sessionClient, times(1)).createSync(); + verify(sessionClient, never()).createAsync(); } private static OffsetDateTime now(Clock clock) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 154ef16cb7bd..636255e2508b 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -32,7 +32,7 @@ public void createSessionReturnsTokenAndKey() { = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); - StorageSessionCredential credential = sessionClient.createSessionSync(); + StorageSessionCredential credential = sessionClient.createSync(); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -48,7 +48,7 @@ public void createSessionAsyncReturnsTokenAndKey() { ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); - StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + StepVerifier.create(sessionClient.createAsync()).assertNext(credential -> { assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); @@ -63,7 +63,7 @@ public void createSessionSyncUsesProvidedHttpPipeline() { ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); - StorageSessionCredential credential = sessionClient.createSessionSync(); + StorageSessionCredential credential = sessionClient.createSync(); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -79,7 +79,7 @@ public void createSessionAsyncUsesProvidedHttpPipeline() { ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); - StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + StepVerifier.create(sessionClient.createAsync()).assertNext(credential -> { assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); @@ -105,7 +105,7 @@ public void createSessionWithUserDelegationSas() { ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName()); - StorageSessionCredential credential = sessionClient.createSessionSync(); + StorageSessionCredential credential = sessionClient.createSync(); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -129,7 +129,7 @@ public void createSessionAsyncWithUserDelegationSas() { ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); - StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + StepVerifier.create(sessionClient.createAsync()).assertNext(credential -> { assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index d715c0b954b2..58304619438c 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -12,6 +12,7 @@ import com.azure.core.http.HttpResponse; import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -72,18 +73,18 @@ public void beforeEach() { @Test public void policyCreatesSessionOnFirstAsyncAccess() { - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); StorageSessionCredential credential = policy.getValidSessionAsync().block(); assertNotNull(credential); assertEquals(FIRST_TOKEN, credential.getSessionToken()); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createAsync(); } @Test public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); List results @@ -92,7 +93,7 @@ public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { assertNotNull(results); assertEquals(5, results.size()); results.forEach(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createAsync(); } @Test @@ -100,10 +101,10 @@ public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2)); StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); - when(sessionClient.createSessionSync()).thenReturn(nearExpiry); + when(sessionClient.createSync()).thenReturn(nearExpiry); // This is a Reactor quirk where Mono.just() emits synchronously on subscribe, so the refresh happens // immediately when the cache determines the credential is near expiry - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed)); + when(sessionClient.createAsync()).thenReturn(Mono.just(refreshed)); // Cold call to getValidSessionSync triggers session creation via createSessionSync StorageSessionCredential initial = policy.getValidSessionSync(); @@ -114,13 +115,13 @@ public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { assertEquals(FIRST_TOKEN, initial.getSessionToken()); assertEquals(FIRST_TOKEN, duringRefresh.getSessionToken()); assertEquals(SECOND_TOKEN, afterRefresh.getSessionToken()); - verify(sessionClient, times(1)).createSessionSync(); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createSync(); + verify(sessionClient, times(1)).createAsync(); } @Test public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { - when(sessionClient.createSessionSync()).thenAnswer(invocation -> { + when(sessionClient.createSync()).thenAnswer(invocation -> { Thread.sleep(100); return credentialWithToken(FIRST_TOKEN); }).thenReturn(credentialWithToken(SECOND_TOKEN)); @@ -137,7 +138,7 @@ public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { assertEquals(FIRST_TOKEN, future.get().getSessionToken()); } - verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, times(1)).createSync(); } finally { executor.shutdownNow(); } @@ -149,7 +150,7 @@ public void policySignsRequestWithSessionCredential() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -171,7 +172,7 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { HttpResponse initialResponse = mock(HttpResponse.class); HttpResponse retriedResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(initialResponse)); @@ -188,7 +189,7 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { verify(initialResponse, times(1)).close(); verify(next, times(1)).process(); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createSessionAsync(); + verify(sessionClient, times(2)).createAsync(); } } @@ -200,7 +201,7 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { HttpResponse initialResponse = mock(HttpResponse.class); HttpResponse retriedResponse = mock(HttpResponse.class); - when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) + when(sessionClient.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) .thenReturn(credentialWithToken(SECOND_TOKEN)); when(next.clone()).thenReturn(retryNext); when(next.processSync()).thenReturn(initialResponse); @@ -228,7 +229,7 @@ public void policyOnlyRetriesOncePerRequest() { HttpResponse initialResponse = mock(HttpResponse.class); HttpResponse retriedResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(initialResponse)); @@ -243,7 +244,7 @@ public void policyOnlyRetriesOncePerRequest() { try (HttpResponse actualResponse = policy.process(context, next).block()) { assertEquals(retriedResponse, actualResponse); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createSessionAsync(); + verify(sessionClient, times(2)).createAsync(); } } @@ -254,7 +255,7 @@ public void policyReturns403WithoutRetry() { HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse forbiddenResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(forbiddenResponse)); when(forbiddenResponse.getStatusCode()).thenReturn(403); @@ -264,7 +265,7 @@ public void policyReturns403WithoutRetry() { verify(next, times(1)).process(); verify(retryNext, times(0)).process(); verify(forbiddenResponse, times(0)).close(); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createAsync(); } } @@ -276,7 +277,7 @@ public void policyRetriesAny401WithNewSession() { HttpResponse unauthorizedResponse = mock(HttpResponse.class); HttpResponse retriedResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(unauthorizedResponse)); @@ -291,7 +292,7 @@ public void policyRetriesAny401WithNewSession() { verify(unauthorizedResponse, times(1)).close(); verify(next, times(1)).process(); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createSessionAsync(); + verify(sessionClient, times(2)).createAsync(); } } @@ -303,7 +304,7 @@ public void policyFallsToBearerOn503SessionUnavailableAsync() { HttpResponse unavailableResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(unavailableResponse)); when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); @@ -332,7 +333,7 @@ public void policyFallsToBearerOn400Async() { HttpResponse badRequestResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(badRequestResponse)); when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); @@ -357,7 +358,7 @@ public void policyFallsToBearerOn503SessionUnavailableSync() { HttpResponse unavailableResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(sessionClient.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); when(next.clone()).thenReturn(retryNext); when(next.processSync()).thenReturn(unavailableResponse); when(retryNext.processSync()).thenReturn(bearerResponse); @@ -385,7 +386,7 @@ public void policyFallsToBearerOn400Sync() { HttpResponse badRequestResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(sessionClient.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); when(next.clone()).thenReturn(retryNext); when(next.processSync()).thenReturn(badRequestResponse); when(retryNext.processSync()).thenReturn(bearerResponse); @@ -409,7 +410,7 @@ public void policyReturns503ServerBusyWithoutBearerFallback() { HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse busyResponse = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(busyResponse)); when(busyResponse.getStatusCode()).thenReturn(503); @@ -437,7 +438,7 @@ public void noneModeAlwaysPassesThrough() { assertEquals(response, actualResponse); // Verify bearer policy was invoked (session delegates to bearer in NONE mode) verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } } @@ -455,7 +456,7 @@ public void noneModeSyncAlwaysPassesThrough() { assertEquals(response, actualResponse); // Verify bearer policy was invoked (session delegates to bearer in NONE mode) verify(bearerPolicy, times(1)).processSync(any(), any()); - verify(sessionClient, times(0)).createSessionSync(); + verify(sessionClient, times(0)).createSync(); } } @@ -466,7 +467,7 @@ public void alwaysModeSignsFirstRequest() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -474,7 +475,7 @@ public void alwaysModeSignsFirstRequest() { policy.process(context, next).block().close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createAsync(); } @Test @@ -492,7 +493,7 @@ public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() { try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { assertEquals(response, actual1); verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } // Second GetBlob also delegates to bearer (AUTO == NONE, no session ever) @@ -503,7 +504,7 @@ public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() { try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) { assertEquals(response, actual2); verify(bearerPolicy, times(2)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } } @@ -522,7 +523,7 @@ public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { assertEquals(response, actual1); verify(bearerPolicy, times(1)).processSync(any(), any()); - verify(sessionClient, times(0)).createSessionSync(); + verify(sessionClient, times(0)).createSync(); } HttpPipelineCallContext context2 = createContext(); @@ -532,7 +533,7 @@ public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) { assertEquals(response, actual2); verify(bearerPolicy, times(2)).processSync(any(), any()); - verify(sessionClient, times(0)).createSessionSync(); + verify(sessionClient, times(0)).createSync(); } } @@ -543,7 +544,7 @@ public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { HttpResponse response = mock(HttpResponse.class); // Fresh session far from expiry, so the client's own jittered refresh timer is NOT due. - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); @@ -558,7 +559,7 @@ public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { // though the client's own refresh timer had not yet elapsed. Dropping the hint here is what // previously let the session be used past the rotation boundary, surfacing as a 401 // "session_token_invalid" (network context mismatch). - verify(sessionClient, times(2)).createSessionAsync(); + verify(sessionClient, times(2)).createAsync(); } @Test @@ -567,7 +568,7 @@ public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); @@ -578,13 +579,12 @@ public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { policy.process(context, next).block().close(); // Without the hint and with a fresh session, only the initial session is created. - verify(sessionClient, times(1)).createSessionAsync(); + verify(sessionClient, times(1)).createAsync(); } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); - return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), - options); + return new SessionTokenCredentialPolicy(bearerPolicy, new AutoRefreshingCache<>(sessionClient), options); } private static StorageSessionCredential credentialWithToken(String token) { @@ -626,7 +626,7 @@ public void getBlobRequestUsesSessionAuth() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -651,7 +651,7 @@ public void getBlobRequestProducesWellFormedSessionAuthHeader() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(cred)); + when(sessionClient.createAsync()).thenReturn(Mono.just(cred)); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -728,7 +728,7 @@ public void putBlobRequestSkipsSessionAuth() { // Non-GetBlob requests delegate to bearer policy instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -744,7 +744,7 @@ public void listBlobsRequestSkipsSessionAuth() { // ListBlobs requests delegate to bearer policy instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -760,7 +760,7 @@ public void getBlobPropertiesRequestSkipsSessionAuth() { // GetBlobProperties (comp=metadata) delegates to bearer policy instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -770,7 +770,7 @@ public void getBlobWithSnapshotUsesSessionAuth() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -794,7 +794,7 @@ public void containerLevelGetRequestSkipsSessionAuth() { // Container-level GET (restype=container) delegates to bearer policy instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -818,7 +818,7 @@ public void autoModeAlwaysDelegatesToBearerEvenForGetBlobRequests() { Objects.requireNonNull(autoPolicy.process(getContext, getNext).block()).close(); verify(bearerPolicy, times(2)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -834,7 +834,7 @@ public void singleSpecifiedContainerModeNonGetBlobSkipsSession() { // SINGLE_SPECIFIED_CONTAINER mode non-GetBlob requests delegate to bearer instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createSessionAsync(); + verify(sessionClient, times(0)).createAsync(); } @Test @@ -844,7 +844,7 @@ public void ipStyleEndpointGetBlobUsesSessionAuth() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java similarity index 63% rename from sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java rename to sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java index a9ae2b2dde14..d0ba527842fb 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package com.azure.storage.blob.implementation.util; +package com.azure.storage.common.implementation.util; import com.azure.core.util.logging.ClientLogger; import reactor.core.publisher.Mono; @@ -11,38 +11,49 @@ import java.time.OffsetDateTime; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; +import com.azure.storage.common.implementation.util.AutoRefreshingCache.ExpiringValue; /** * Cache for container-scoped storage session credentials. */ -final class StorageSessionCredentialCache { - private static final ClientLogger LOGGER = new ClientLogger(StorageSessionCredentialCache.class); +public final class AutoRefreshingCache { + public interface ValueProvider { + Mono createAsync(); + + T createSync(); + } + + public interface ExpiringValue { + OffsetDateTime getExpiration(); + } + + private static final ClientLogger LOGGER = new ClientLogger(AutoRefreshingCache.class); private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); private static final double JITTER_WINDOW_START_RATIO = 0.8d; - private final BlobSessionClient sessionClient; + private ValueProvider valueProvider; private final Clock clock; private final Object creationLock = new Object(); - private volatile StorageSessionCredential credential; + private volatile T value; private volatile OffsetDateTime nextRefreshTime; private volatile boolean refreshing; - private volatile Mono inflightCreation; + private volatile Mono inflightCreation; - StorageSessionCredentialCache(BlobSessionClient sessionClient) { - this(sessionClient, Clock.systemUTC()); + public AutoRefreshingCache(ValueProvider valueProvider) { + this(valueProvider, Clock.systemUTC()); } - StorageSessionCredentialCache(BlobSessionClient sessionClient, Clock clock) { - this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + public AutoRefreshingCache(ValueProvider valueProvider, Clock clock) { + this.valueProvider = Objects.requireNonNull(valueProvider, "'valueProvider' cannot be null."); this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); } - Mono getValidSessionAsync() { + public Mono getValidValueAsync() { OffsetDateTime now = OffsetDateTime.now(clock); - StorageSessionCredential current = credential; + T current = value; if (isUsable(current, now)) { if (isRefreshDue(now)) { - refreshSessionInBackground(); + refreshValueInBackground(); } return Mono.just(current); } @@ -50,45 +61,45 @@ Mono getValidSessionAsync() { return startSessionCreationAsync(); } - StorageSessionCredential getValidSessionSync() { + public T getValidValueSync() { OffsetDateTime now = OffsetDateTime.now(clock); - StorageSessionCredential current = credential; + T current = value; if (isUsable(current, now)) { if (isRefreshDue(now)) { - refreshSessionInBackground(); + refreshValueInBackground(); } return current; } // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. - Mono inFlight = inflightCreation; + Mono inFlight = inflightCreation; if (inFlight != null) { - StorageSessionCredential refreshed = inFlight.block(); + T refreshed = inFlight.block(); if (refreshed != null) { return refreshed; } } synchronized (creationLock) { - current = credential; + current = value; now = OffsetDateTime.now(clock); if (isUsable(current, now)) { if (isRefreshDue(now)) { - refreshSessionInBackground(); + refreshValueInBackground(); } return current; } - StorageSessionCredential created = sessionClient.createSessionSync(); - setActiveCredential(created); + T created = valueProvider.createSync(); + setActiveValue(created); return created; } } - void invalidateSession(StorageSessionCredential target) { + public void invalidateValue(T target) { synchronized (creationLock) { - if (credential == target) { - credential = null; + if (value == target) { + value = null; nextRefreshTime = null; refreshing = false; } @@ -96,10 +107,10 @@ void invalidateSession(StorageSessionCredential target) { } } - void refreshSessionInBackground() { + public void refreshValueInBackground() { synchronized (creationLock) { OffsetDateTime now = OffsetDateTime.now(clock); - if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { + if (!isUsable(value, now) || !isRefreshDue(now) || refreshing) { return; } refreshing = true; @@ -109,20 +120,20 @@ void refreshSessionInBackground() { }, error -> LOGGER.warning("Background session refresh failed.", error)); } - void forceRefreshSessionInBackground() { + public void forceRefreshValueInBackground() { synchronized (creationLock) { - if (isUsable(credential, OffsetDateTime.now(clock))) { + if (isUsable(value, OffsetDateTime.now(clock))) { nextRefreshTime = OffsetDateTime.now(clock); } } - refreshSessionInBackground(); + refreshValueInBackground(); } - private Mono startSessionCreationAsync() { + private Mono startSessionCreationAsync() { synchronized (creationLock) { OffsetDateTime now = OffsetDateTime.now(clock); - StorageSessionCredential current = credential; + T current = value; if (isUsable(current, now) && !isRefreshDue(now)) { return Mono.just(current); } @@ -133,9 +144,9 @@ private Mono startSessionCreationAsync() { refreshing = true; - inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { + inflightCreation = valueProvider.createAsync().doOnNext(cred -> { synchronized (creationLock) { - setActiveCredential(cred); + setActiveValue(cred); } }).doFinally(ignored -> { synchronized (creationLock) { @@ -148,14 +159,14 @@ private Mono startSessionCreationAsync() { } } - private void setActiveCredential(StorageSessionCredential newCredential) { - credential = newCredential; - nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), newCredential.getExpiration()); + private void setActiveValue(T newValue) { + value = newValue; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), newValue.getExpiration()); refreshing = false; } - private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { - return cred != null && !now.isAfter(cred.getExpiration()); + private boolean isUsable(T value, OffsetDateTime now) { + return value != null && !now.isAfter(value.getExpiration()); } private boolean isRefreshDue(OffsetDateTime now) { diff --git a/sdk/storage/azure-storage-common/src/main/java/module-info.java b/sdk/storage/azure-storage-common/src/main/java/module-info.java index f5f1cfa99b2b..41560c81dca7 100644 --- a/sdk/storage/azure-storage-common/src/main/java/module-info.java +++ b/sdk/storage/azure-storage-common/src/main/java/module-info.java @@ -25,4 +25,5 @@ exports com.azure.storage.common.implementation.connectionstring to // FIXME this should not be a long-term solution com.azure.data.tables, com.azure.storage.blob, com.azure.storage.blob.cryptography, com.azure.storage.file.share, com.azure.storage.file.datalake, com.azure.storage.queue; + exports com.azure.storage.common.implementation.util to com.azure.storage.blob; //FIXME this should not be a long-term solution }