From 1e3aedbd82cf4c0248259c1072481e63a167a83f Mon Sep 17 00:00:00 2001 From: browndav-msft Date: Thu, 11 Jun 2026 12:19:09 -0400 Subject: [PATCH 01/21] 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 02/21] 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 03/21] 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 04/21] 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 05/21] 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 06/21] 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 07/21] 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 08/21] 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 09/21] 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 } From 3482998fd0ac1e01e2443bd6ddc5764f5a116aaf Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 7 Aug 2026 10:53:26 -0400 Subject: [PATCH 10/21] rename params for SessionTokenCrednetialPolicy --- .../implementation/util/SessionTokenCredentialPolicy.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 07d49c5ccafc..b0f48202701b 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 @@ -53,10 +53,10 @@ enum AuthStrategy { } SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, - AutoRefreshingCache sessionCredentialCache, SessionOptions sessionOptions) { + AutoRefreshingCache autoRefreshingCache, SessionOptions sessionOptions) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionCredentialCache - = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); + = Objects.requireNonNull(autoRefreshingCache, "'sessionCredentialCache' cannot be null."); this.sessionOptions = SessionOptions.orDefault(sessionOptions); if (this.sessionOptions.getSessionMode().resolve() == SessionMode.SINGLE_SPECIFIED_CONTAINER From 3849bccbf6c7a33d355db34ff4877234516d0205 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 7 Aug 2026 10:57:10 -0400 Subject: [PATCH 11/21] add autorefreshcasche specific tests for blob session credential --- .../util/AutoRefreshingCacheTest.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java index 065d85afff5b..93db32bf0ed1 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java @@ -6,6 +6,8 @@ import com.azure.storage.common.implementation.util.AutoRefreshingCache; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; import java.time.Clock; import java.time.Duration; @@ -136,6 +138,110 @@ public void noRefreshBeforeJitterWindowWithoutServiceHint() { verify(sessionClient, never()).createAsync(); } + /** + * The async path on a cold cache must mint a value through {@code createAsync} and emit exactly one + * element before completing. The synchronous creation path must not be involved at all. + */ + @Test + public void coldCacheCreatesValueAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); + + when(sessionClient.createAsync()) + .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); + + StepVerifier.create(cache.getValidValueAsync()) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + verify(sessionClient, times(1)).createAsync(); + verify(sessionClient, never()).createSync(); + } + + /** + * Once the async path has cached a usable value, later async requests made before the jittered refresh + * window must replay that cached value rather than creating a second one. + */ + @Test + public void cachedValueIsReusedOnLaterAsyncRequests() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); + + when(sessionClient.createAsync()) + .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); + + StepVerifier.create(cache.getValidValueAsync()) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + // Advance well short of the earliest jittered refresh point (80% of lifetime). + clock.advance(Duration.ofSeconds(30)); + + StepVerifier.create(cache.getValidValueAsync()) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + verify(sessionClient, times(1)).createAsync(); + verify(sessionClient, never()).createSync(); + } + + /** + * Concurrent async callers arriving while a creation is still in flight must join that single in-flight + * creation instead of each triggering their own, and all of them must observe the same value. + */ + @Test + public void concurrentAsyncRequestsShareASingleInFlightCreation() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); + + // A sink that has not emitted yet models a creation that is still outstanding. + Sinks.One pendingCreation = Sinks.one(); + when(sessionClient.createAsync()).thenReturn(pendingCreation.asMono()); + + Mono first = cache.getValidValueAsync(); + Mono second = cache.getValidValueAsync(); + + // The second caller joined the in-flight creation rather than starting another one. + verify(sessionClient, times(1)).createAsync(); + + pendingCreation.tryEmitValue(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME))); + + StepVerifier.create(first) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + StepVerifier.create(second) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + verify(sessionClient, times(1)).createAsync(); + } + + /** + * A failed creation must surface to the caller as an error signal rather than an empty completion, and it + * must not poison the cache: the in-flight creation is cleared so a later request can retry successfully. + */ + @Test + public void creationFailurePropagatesAndAllowsRetryAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + BlobSessionClient sessionClient = mock(BlobSessionClient.class); + AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); + + when(sessionClient.createAsync()).thenReturn(Mono.error(new IllegalStateException("CreateSession failed."))) + .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); + + StepVerifier.create(cache.getValidValueAsync()).verifyErrorMessage("CreateSession failed."); + + // The failure left no cached value behind, so the retry mints a fresh one. + StepVerifier.create(cache.getValidValueAsync()) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + verify(sessionClient, times(2)).createAsync(); + } + private static OffsetDateTime now(Clock clock) { return OffsetDateTime.now(clock); } From 773be49ec857f431db8087704ab45df33e20d256 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 15:08:48 -0400 Subject: [PATCH 12/21] wip --- sdk/storage/azure-storage-blob/CHANGELOG.md | 12 + .../azure/storage/blob/BlobClientBuilder.java | 38 +- .../blob/BlobContainerClientBuilder.java | 40 +- .../blob/BlobServiceClientBuilder.java | 2 +- .../util/BlobSessionClient.java | 218 +++++++- .../implementation/util/BuilderHelper.java | 36 +- .../util/SessionAcquisitionCooldown.java | 96 ++++ ...dential.java => SessionRequestSigner.java} | 70 +-- .../util/SessionTokenCredentialPolicy.java | 222 ++++---- .../blob/models/SessionCredential.java | 89 +++ .../storage/blob/models/SessionMode.java | 33 +- .../storage/blob/models/SessionOptions.java | 54 +- .../storage/blob/models/SessionProvider.java | 132 +++++ .../blob/models/SessionRequestContext.java | 73 +++ .../com/azure/storage/blob/BlobApiTests.java | 7 +- .../azure/storage/blob/BlobAsyncApiTests.java | 7 +- .../storage/blob/BuilderHelperTests.java | 120 ++--- .../azure/storage/blob/ContainerApiTests.java | 2 +- .../storage/blob/ContainerAsyncApiTests.java | 2 +- .../util/AutoRefreshingCacheTest.java | 290 ---------- .../util/BlobSessionClientCacheTest.java | 506 ++++++++++++++++++ .../util/BlobSessionClientTests.java | 99 ++-- .../util/SessionAcquisitionCooldownTest.java | 83 +++ .../util/SessionProviderSeamTest.java | 140 +++++ ...est.java => SessionRequestSignerTest.java} | 34 +- .../util/SessionTestHelper.java | 14 +- .../SessionTokenCredentialPolicyTest.java | 474 ++++------------ .../util/AutoRefreshingCache.java | 48 +- 28 files changed, 1804 insertions(+), 1137 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java rename sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/{StorageSessionCredential.java => SessionRequestSigner.java} (75%) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java delete mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java rename sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/{StorageSessionCredentialTest.java => SessionRequestSignerTest.java} (78%) diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index 0ac7d7148209..69e02b085abf 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -4,14 +4,26 @@ ### Features Added +- Added per-container session credential caching for identity-authenticated blob downloads. +- Added `SessionProvider`, `SessionCredential`, and `SessionRequestContext` APIs so applications can supply + custom session credentials through `SessionOptions` on `BlobServiceClientBuilder`. + ### Breaking Changes +- Session authentication is now enabled by default for eligible identity-authenticated blob downloads. +- Replaced the `SessionMode` values `NONE`, `AUTO`, and `SINGLE_SPECIFIED_CONTAINER` with `ENABLED` and + `DISABLED`. + ### 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. +- Session acquisition failures now fall back to bearer authentication. HTTP `400`, `403`, and `5xx` + acquisition failures suppress additional session acquisition attempts for the account for five minutes. +- A session-authenticated request rejected with HTTP `401` now invalidates the cached session and falls back + directly to bearer authentication without retrying with another session. ### Other Changes 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 0e6a09db55f4..386a2a671632 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,8 +31,6 @@ 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; @@ -94,7 +92,6 @@ 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 @@ -136,12 +133,6 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - 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 to read and debug. @@ -189,11 +180,6 @@ public BlobAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - 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 to read and debug. @@ -214,16 +200,9 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion 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); + perRetryPolicies, configuration, audience, LOGGER, null, serviceVersion); } /** @@ -674,19 +653,4 @@ public BlobClientBuilder audience(BlobAudience 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/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 0505f84b8411..98e927af64a0 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,8 +32,6 @@ 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; @@ -93,7 +91,6 @@ 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/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index f061abab1f90..a413cd08a7f9 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 @@ -612,7 +612,7 @@ public BlobServiceClientBuilder audience(BlobAudience audience) { * @return the updated BlobServiceClientBuilder object. */ public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { - this.sessionOptions = SessionOptions.orDefault(sessionOptions); + this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); return this; } } 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 b391272537d9..e92fdcd3feb1 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 @@ -6,6 +6,7 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.rest.Response; import com.azure.core.util.Context; +import com.azure.core.util.CoreUtils; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.implementation.AzureBlobStorageImpl; @@ -14,53 +15,180 @@ 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 com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; import reactor.core.publisher.Mono; +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + /** - * Package-private client for creating sessions via the CreateSession REST API. + * Built-in {@link SessionProvider} implementation that creates sessions via the CreateSession REST + * API and manages their lifecycle: per-container caching, proactive background refresh, idle + * eviction, and compare-and-invalidate race safety. + * + *

Caching model

+ *

+ * One {@link AutoRefreshingCache} of {@link SessionCredential} per container (keyed by a + * lowercase-normalized name) is maintained, allowing a single {@link BlobSessionClient} to serve + * many containers without creating a new session for every request. Entries are opportunistically + * evicted once they have not been accessed for {@value #IDLE_EVICTION_THRESHOLD_MINUTES} minutes. + * + *

Invalidation

+ *

+ * {@link #invalidateSession} performs a compare-and-swap: only the first caller presenting a given + * rejected credential succeeds; later callers presenting the same instance return {@code false}. + * This prevents a stale 401 response from evicting a credential that was already replaced by a + * concurrent refresh. + * + *

Background refresh

+ *

+ * {@link #refreshSession} forces an immediate background refresh even when the client's own + * jittered refresh timer has not yet elapsed, ensuring the service's + * {@code x-ms-auth-info: session_expiring} hint is always honoured. + * + *

* Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}: - * takes an {@link HttpPipeline} (bearer-only, no SessionPolicy) and builds an + * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an * {@link AzureBlobStorageImpl} internally. */ -final class BlobSessionClient implements ValueProvider { +final class BlobSessionClient implements SessionProvider { + + static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5; private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class); + private static final Duration IDLE_EVICTION_THRESHOLD = Duration.ofMinutes(IDLE_EVICTION_THRESHOLD_MINUTES); + // Defensive fallback expiration for a malformed/absent service response. + private static final Duration DEFAULT_EXPIRATION_OFFSET = Duration.ofMinutes(5L); + private final AzureBlobStorageImpl azureBlobStorage; private final String accountName; - private final String containerName; + private final Clock clock; + private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>(); + BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName) { + this(bearerPipeline, url, serviceVersion, accountName, Clock.systemUTC()); + } + + /** Package-private constructor that accepts an injectable clock for deterministic testing. */ BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, - String containerName) { + Clock clock) { this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) .url(url) .version(serviceVersion.getVersion()) .buildClient(); this.accountName = accountName; - this.containerName = containerName; + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); } @Override - public Mono createAsync() { - CreateSessionConfiguration config - = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + public Mono getSessionAsync(SessionRequestContext context) { + return Mono.defer(() -> { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + ContainerSessionCache containerSessionCache = updateCache(container, resolvedAccount); + return containerSessionCache.cache.getValidValueAsync() + .doOnNext(containerSessionCache::setSessionCredential); + }); + } - return azureBlobStorage.getContainers() - .createSessionWithResponseAsync(containerName, config, null, null) - .map(this::toCredential); + @Override + public SessionCredential getSession(SessionRequestContext context) { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + ContainerSessionCache containerSessionCache = updateCache(container, resolvedAccount); + SessionCredential cred = containerSessionCache.cache.getValidValueSync(); + containerSessionCache.setSessionCredential(cred); + return cred; } @Override - public StorageSessionCredential createSync() { + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + if (context == null) { + return false; + } + String key = normalize(context.getContainerName()); + ContainerSessionCache containerSessionCache = containerSessionCaches.get(key); + return containerSessionCache != null && containerSessionCache.invalidateSession(rejectedCredential); + } + + @Override + public void refreshSession(SessionRequestContext context) { + if (context == null) { + return; + } + String key = normalize(context.getContainerName()); + ContainerSessionCache containerSessionCache = containerSessionCaches.get(key); + if (containerSessionCache != null) { + containerSessionCache.cache.forceRefreshValueInBackground(); + } + } + + private String requireContainerName(SessionRequestContext context) { + String containerName = context == null ? null : context.getContainerName(); + if (CoreUtils.isNullOrEmpty(containerName)) { + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("'context.getContainerName()' cannot be null or empty.")); + } + return containerName; + } + + private String resolveAccountName(SessionRequestContext context) { + String contextAccountName = context == null ? null : context.getAccountName(); + String resolvedAccountName = CoreUtils.isNullOrEmpty(accountName) ? contextAccountName : accountName; + if (CoreUtils.isNullOrEmpty(resolvedAccountName)) { + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("The account name could not be resolved from the request URL.")); + } + return resolvedAccountName; + } + + private ContainerSessionCache updateCache(String containerName, String resolvedAccountName) { + String key = normalize(containerName); + OffsetDateTime now = OffsetDateTime.now(clock); + ContainerSessionCache containerSessionCache = containerSessionCaches.compute(key, (k, existing) -> { + if (existing == null) { + return new ContainerSessionCache(this, clock, containerName, resolvedAccountName, now); + } + existing.lastAccess = now; + return existing; + }); + evictStaleCaches(); + return containerSessionCache; + } + + private void evictStaleCaches() { + OffsetDateTime now = OffsetDateTime.now(clock); + containerSessionCaches.forEach((key, cache) -> { + if (Duration.between(cache.lastAccess, now).compareTo(IDLE_EVICTION_THRESHOLD) >= 0) { + containerSessionCaches.remove(key, cache); + } + }); + } + + private Mono createSessionAsync(String container, String resolvedAccountName) { CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + return azureBlobStorage.getContainers() + .createSessionWithResponseAsync(container, config, null, null) + .map(response -> toCredential(response, resolvedAccountName)); + } - Response response = azureBlobStorage.getContainers() - .createSessionWithResponse(containerName, config, null, null, Context.NONE); - return toCredential(response); + private SessionCredential createSessionSync(String container, String resolvedAccountName) { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + Response response + = azureBlobStorage.getContainers().createSessionWithResponse(container, config, null, null, Context.NONE); + return toCredential(response, resolvedAccountName); } - private StorageSessionCredential toCredential(Response response) { + private SessionCredential toCredential(Response response, String resolvedAccountName) { CreateSessionResponse session = response.getValue(); if (session == null) { throw LOGGER.logExceptionAsError( @@ -72,7 +200,57 @@ private StorageSessionCredential toCredential(Response re throw LOGGER.logExceptionAsError( new IllegalStateException("CreateSession response did not contain HMAC session credentials.")); } - return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), - accountName); + + OffsetDateTime expiration = session.getExpiration(); + if (expiration == null) { + expiration = OffsetDateTime.now().plus(DEFAULT_EXPIRATION_OFFSET); + } + return new SessionCredential(creds.getSessionToken(), creds.getSessionKey(), expiration, resolvedAccountName); + } + + private static String normalize(String name) { + return CoreUtils.isNullOrEmpty(name) ? "" : name.trim().toLowerCase(Locale.ROOT); + } + + private static final class ContainerSessionCache { + final AutoRefreshingCache cache; + volatile OffsetDateTime lastAccess; + private SessionCredential currentSessionCredential; + + private ContainerSessionCache(BlobSessionClient client, Clock clock, String containerName, + String resolvedAccountName, OffsetDateTime lastAccess) { + this.cache = createCache(client, clock, containerName, resolvedAccountName); + this.lastAccess = lastAccess; + } + + private static AutoRefreshingCache createCache(BlobSessionClient client, Clock clock, + String containerName, String resolvedAccountName) { + AutoRefreshingCache.ValueProvider valueProvider + = new AutoRefreshingCache.ValueProvider() { + @Override + public Mono createAsync() { + return client.createSessionAsync(containerName, resolvedAccountName); + } + + @Override + public SessionCredential createSync() { + return client.createSessionSync(containerName, resolvedAccountName); + } + }; + return new AutoRefreshingCache<>(valueProvider, SessionCredential::getExpiresAt, clock); + } + + synchronized void setSessionCredential(SessionCredential credential) { + currentSessionCredential = credential; + } + + synchronized boolean invalidateSession(SessionCredential credential) { + if (currentSessionCredential != credential) { + return false; + } + cache.invalidateValue(credential); + currentSessionCredential = null; + return true; + } } } 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 fd4824a138c8..fa277ba0c0cf 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 @@ -34,8 +34,8 @@ 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.blob.models.SessionProvider; 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; @@ -133,7 +133,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare 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 + // When the resolved session mode is enabled 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. @@ -145,21 +145,20 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare 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) { + if (sessionOptions == null || sessionOptions.getSessionMode() == SessionMode.DISABLED) { policies.add(bearerPolicy); } else { - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, new AutoRefreshingCache<>(sessionClient), - effectiveSessionOptions)); + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + SessionProvider sessionProvider = sessionOptions.getSessionProvider(); + if (sessionProvider == null) { + HttpPipeline bearerPipeline + = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions); + sessionProvider = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + sessionOptions.getAccountName()); + } + SessionAcquisitionCooldown cooldown = new SessionAcquisitionCooldown(); + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, cooldown, sessionOptions)); } } @@ -295,11 +294,4 @@ public static void logCredentialChange(ClientLogger logger, String newCredential 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/SessionAcquisitionCooldown.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java new file mode 100644 index 000000000000..23274ae76671 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.util.CoreUtils; + +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks a per-account acquisition cooldown: after a CreateSession acquisition failure with HTTP + * 400, 403, or 5xx, the affected account is placed in a five-minute cooldown during which all + * further session acquisition attempts are suppressed and requests fall back to bearer authentication. + *

+ * The cooldown is account-scoped (case-insensitive), not container-scoped, because the failure + * indicates an account-level authorization or server issue rather than a container-specific one. + *

+ * Thread-safe: all mutations are performed via compare-and-swap operations on a + * {@link ConcurrentHashMap}. + */ +final class SessionAcquisitionCooldown { + + private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(5); + + private final Clock clock; + private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>(); + + SessionAcquisitionCooldown() { + this(Clock.systemUTC()); + } + + SessionAcquisitionCooldown(Clock clock) { + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + /** + * Returns {@code true} if the given account is currently within its acquisition cooldown period. + * The check is case-insensitive; an expired cooldown entry is removed opportunistically. + * + * @param accountName the storage account name to check. + * @return {@code true} if the account is in cooldown; {@code false} otherwise. + */ + boolean isAccountInCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime cooldownUntil = accountCooldowns.get(key); + if (cooldownUntil == null) { + return false; + } + + OffsetDateTime now = OffsetDateTime.now(clock); + if (now.isBefore(cooldownUntil)) { + return true; + } + + accountCooldowns.remove(key, cooldownUntil); + return false; + } + + /** + * Attempts to start (or extend) the acquisition cooldown for the given account. + * If the account is already in cooldown, this is a no-op and returns {@code false}. + * The check and the write are performed atomically via compare-and-swap. + * + * @param accountName the storage account name to place in cooldown. + * @return {@code true} if the cooldown was newly started (first caller wins); + * {@code false} if it was already active. + */ + boolean beginAccountCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime now = OffsetDateTime.now(clock); + OffsetDateTime cooldownUntil = now.plus(COOLDOWN_DURATION); + + while (true) { + OffsetDateTime existing = accountCooldowns.get(key); + if (existing != null && now.isBefore(existing)) { + return false; + } + + boolean updated = existing == null + ? accountCooldowns.putIfAbsent(key, cooldownUntil) == null + : accountCooldowns.replace(key, existing, cooldownUntil); + if (updated) { + return true; + } + } + } + + private static String normalize(String name) { + return CoreUtils.isNullOrEmpty(name) ? "" : name.trim().toLowerCase(Locale.ROOT); + } +} 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/SessionRequestSigner.java similarity index 75% rename from sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java rename to sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionRequestSigner.java index 366d25273a4e..90b866725497 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/SessionRequestSigner.java @@ -9,52 +9,50 @@ import com.azure.core.http.HttpRequest; import com.azure.core.util.CoreUtils; import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.blob.models.SessionCredential; 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; import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Locale; -import java.util.Objects; +import java.util.Map; import java.util.TreeMap; /** - * Holds session credentials and signs requests using the Shared Key string-to-sign with the - * Session scheme prefix. + * Signs requests using a {@link SessionCredential}, following the Shared Key string-to-sign algorithm with + * the Session scheme prefix. + *

+ * This is deliberately internal: {@link SessionCredential} is public API so that a customer-supplied + * {@link com.azure.storage.blob.models.SessionProvider} can produce one, but the signing scheme itself is + * fixed by the storage service and is not something a {@code SessionProvider} implementation should (or + * needs to) reimplement. */ -final class StorageSessionCredential implements ExpiringValue { +final class SessionRequestSigner { 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); + private SessionRequestSigner() { } - void signRequest(HttpRequest request) { + static void signRequest(HttpRequest request, SessionCredential credential) { // 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 stringToSign = buildStringToSign(request, credential.getAccountName()); + StorageSharedKeyCredential sharedKey + = new StorageSharedKeyCredential(credential.getAccountName(), credential.getSessionKey()); String signature = sharedKey.computeHmac256(stringToSign); - request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature); + request.setHeader(HttpHeaderName.AUTHORIZATION, + SESSION_PREFIX + credential.getSessionToken() + ":" + signature); } // Mirrors StorageSharedKeyCredential.buildStringToSign. The server canonicalizes @@ -71,8 +69,7 @@ void signRequest(HttpRequest request) { // 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) { + private static String buildStringToSign(HttpRequest request, String accountName) { HttpHeaders headers = request.getHeaders(); Collator collator = Collator.getInstance(Locale.ROOT); @@ -94,7 +91,7 @@ private String buildStringToSign(HttpRequest request) { 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)); + canonicalizedResource(request.getUrl(), collator, accountName)); } private static String getHeaderOrEmpty(HttpHeaders headers, HttpHeaderName name) { @@ -123,7 +120,7 @@ private static String canonicalizedXmsHeaders(HttpHeaders headers, Collator coll return sb.toString(); } - private String canonicalizedResource(URL url, Collator collator) { + private static String canonicalizedResource(URL url, Collator collator, String accountName) { String path = url.getPath(); if (CoreUtils.isNullOrEmpty(path)) { path = "/"; @@ -149,32 +146,11 @@ private String canonicalizedResource(URL url, Collator collator) { } StringBuilder sb = new StringBuilder("/").append(accountName).append(path); - for (java.util.Map.Entry> entry : params.entrySet()) { + for (Map.Entry> entry : params.entrySet()) { List values = entry.getValue(); - java.util.Collections.sort(values); + 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; - } - - @Override - public 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/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index b0f48202701b..4663b43e9a18 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 @@ -3,6 +3,7 @@ package com.azure.storage.blob.implementation.util; +import com.azure.core.exception.HttpResponseException; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipelineCallContext; @@ -11,15 +12,19 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobUrlParts; +import com.azure.storage.blob.models.SessionCredential; 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.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; import java.util.Map; import java.util.Objects; +import java.util.Optional; /** * A pipeline policy that selects between session token and bearer token authentication. @@ -33,13 +38,14 @@ * an {@link AuthStrategy} indicating the authentication approach to use. */ public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { + private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); 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 AutoRefreshingCache sessionCredentialCache; + private final SessionProvider sessionProvider; + private final SessionAcquisitionCooldown cooldown; private final SessionOptions sessionOptions; /** @@ -53,17 +59,11 @@ enum AuthStrategy { } SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, - AutoRefreshingCache autoRefreshingCache, SessionOptions sessionOptions) { + SessionProvider sessionProvider, SessionAcquisitionCooldown cooldown, SessionOptions sessionOptions) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); - this.sessionCredentialCache - = Objects.requireNonNull(autoRefreshingCache, "'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."); - } + this.sessionProvider = Objects.requireNonNull(sessionProvider, "'sessionProvider' cannot be null."); + this.cooldown = Objects.requireNonNull(cooldown, "'cooldown' cannot be null."); + this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); } /** @@ -76,77 +76,114 @@ StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.process(context, next); + } + if (cooldown.isAccountInCooldown(requestContext.getAccountName())) { return bearerPolicy.process(context, next); } HttpPipelineNextPolicy retryNext = next.clone(); - return getValidSessionAsync().flatMap(session -> { + Mono sessionMono; + try { + sessionMono = sessionProvider.getSessionAsync(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + return bearerPolicy.process(context, next); + } + + return sessionMono.map(Optional::of).onErrorResume(error -> { + handleSessionAcquisitionFailure(requestContext, error); + return Mono.just(Optional.empty()); + }).defaultIfEmpty(Optional.empty()).flatMap(sessionResult -> { + if (!sessionResult.isPresent()) { + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.process(context, next); + } + SessionCredential session = sessionResult.get(); signRequest(context, session); - return next.process().flatMap(response -> handleSessionResponse(context, response, session, retryNext)); + return next.process() + .flatMap(response -> handleSessionResponse(context, response, session, requestContext, retryNext)); }); } @Override public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { - if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.processSync(context, next); + } + if (cooldown.isAccountInCooldown(requestContext.getAccountName())) { return bearerPolicy.processSync(context, next); } HttpPipelineNextSyncPolicy retryNext = next.clone(); - StorageSessionCredential session = getValidSessionSync(); + SessionCredential session; + try { + session = sessionProvider.getSession(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.processSync(context, next); + } signRequest(context, session); HttpResponse response = next.processSync(); - return handleSessionResponseSync(context, response, session, retryNext); + return handleSessionResponseSync(context, response, session, requestContext, 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. + * {@link SessionMode#ENABLED} 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); + * parameter, while in {@link SessionMode#ENABLED} mode); * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise. */ AuthStrategy analyzeRequest(HttpPipelineCallContext context) { - SessionMode effectiveMode = sessionOptions.getSessionMode().resolve(); + return resolveSessionRequest(context) == null ? AuthStrategy.USE_BEARER_TOKEN : AuthStrategy.USE_SESSION_TOKEN; + } - if (effectiveMode == SessionMode.NONE) { - return AuthStrategy.USE_BEARER_TOKEN; + private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext context) { + if (sessionOptions.getSessionMode() == SessionMode.DISABLED) { + return null; } if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { - return AuthStrategy.USE_BEARER_TOKEN; + return null; } - 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; + BlobUrlParts parts; + try { + parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + } catch (RuntimeException ex) { + LOGGER.warning("Unable to resolve session authentication context from request URL. Using bearer token.", + ex); + return null; } - // If Container level request (container in path but no blob) - if (CoreUtils.isNullOrEmpty(parts.getBlobName())) { - return AuthStrategy.USE_BEARER_TOKEN; + String containerName = CoreUtils.isNullOrEmpty(sessionOptions.getContainerName()) + ? parts.getBlobContainerName() + : sessionOptions.getContainerName(); + String accountName = CoreUtils.isNullOrEmpty(sessionOptions.getAccountName()) + ? parts.getAccountName() + : sessionOptions.getAccountName(); + + if (CoreUtils.isNullOrEmpty(containerName) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { + return null; } // 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 null; } - return AuthStrategy.USE_SESSION_TOKEN; + return new SessionRequestContext().setContainerName(containerName).setAccountName(accountName); } /** @@ -154,21 +191,12 @@ AuthStrategy analyzeRequest(HttpPipelineCallContext context) { * session-expiring hints, retryable failures, and fallback conditions. */ private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, - StorageSessionCredential session, HttpPipelineNextPolicy retryNext) { + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextPolicy retryNext) { - handleSessionExpiringHeader(response); + handleSessionExpiringHeader(response, requestContext); 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(); - }); + logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); } if (shouldFallBackToBearer(context, response)) { @@ -186,21 +214,12 @@ private Mono handleSessionResponse(HttpPipelineCallContext context * session-expiring hints, retryable failures, and fallback conditions. */ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response, - StorageSessionCredential session, HttpPipelineNextSyncPolicy retryNext) { + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextSyncPolicy retryNext) { - handleSessionExpiringHeader(response); + handleSessionExpiringHeader(response, requestContext); 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(); + logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); } if (shouldFallBackToBearer(context, response)) { @@ -213,26 +232,28 @@ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, return response; } - Mono getValidSessionAsync() { - return sessionCredentialCache.getValidValueAsync(); + private void signRequest(HttpPipelineCallContext context, SessionCredential credential) { + SessionRequestSigner.signRequest(context.getHttpRequest(), credential); } - StorageSessionCredential getValidSessionSync() { - return sessionCredentialCache.getValidValueSync(); - } - - void invalidateSession(StorageSessionCredential target) { - sessionCredentialCache.invalidateValue(target); - } - - private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { - cred.signRequest(context.getHttpRequest()); - } - - private void handleSessionExpiringHeader(HttpResponse response) { + private void handleSessionExpiringHeader(HttpResponse response, SessionRequestContext requestContext) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { - sessionCredentialCache.forceRefreshValueInBackground(); + sessionProvider.refreshSession(requestContext); + } + } + + private static void logSessionInvalidation(SessionRequestContext requestContext, boolean invalidated) { + if (invalidated) { + LOGGER.warning( + "Session authentication was rejected with HTTP 401 for container '{}'. " + + "The cached session was invalidated and the request will proceed using bearer token.", + requestContext.getContainerName()); + } else { + LOGGER.verbose( + "Session authentication was rejected with HTTP 401 for container '{}', but the cached " + + "session was already invalidated. The request will proceed using bearer token.", + requestContext.getContainerName()); } } @@ -244,21 +265,6 @@ 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. @@ -268,18 +274,32 @@ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, H return false; } - return isBadRequest(response) || isSessionUnavailable(response); + return isUnauthorizedResponse(response) || isBadRequest(response); } private static boolean isBadRequest(HttpResponse response) { return response.getStatusCode() == 400; } - private static boolean isSessionUnavailable(HttpResponse response) { - if (response.getStatusCode() != 503) { - return false; + private void handleSessionAcquisitionFailure(SessionRequestContext requestContext, Throwable error) { + Throwable current = error; + while (current != null && !(current instanceof HttpResponseException)) { + current = current.getCause(); } - String errorCode = response.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code")); - return SESSION_OPS_UNAVAILABLE.equals(errorCode); + + if (current != null && ((HttpResponseException) current).getResponse() != null) { + int statusCode = ((HttpResponseException) current).getResponse().getStatusCode(); + if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) { + if (cooldown.beginAccountCooldown(requestContext.getAccountName())) { + LOGGER.warning( + "Session acquisition failed with HTTP {}. Suppressing session acquisition for this account " + + "for five minutes and using bearer token.", + statusCode); + } + return; + } + } + + LOGGER.warning("Unable to obtain a session credential. Using bearer token.", error); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java new file mode 100644 index 000000000000..9258b2e7ffe7 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import java.time.OffsetDateTime; +import java.util.Objects; + +/** + * Represents a session credential used to authenticate blob storage requests via the lightweight, + * per-container session authentication scheme. + *

+ * This is modeled after {@code com.azure.core.credential.AccessToken}: a small, immutable holder for the + * session token, session key, and expiration returned by the storage service's CreateSession operation (or + * by a customer-supplied {@link SessionProvider}). Actual request signing is performed internally using the + * fixed HMAC scheme the service defines for session authentication; this type only carries the data needed + * to do so. + * + * @see SessionProvider + */ +public final class SessionCredential { + + private final String sessionToken; + private final String sessionKey; + private final OffsetDateTime expiresAt; + private final String accountName; + + /** + * Creates a new {@link SessionCredential}. + * + * @param sessionToken the session token issued by the service (or a custom {@link SessionProvider}). + * @param sessionKey the Base64-encoded session key used to sign requests. + * @param expiresAt the instant at which this session credential expires. + * @param accountName the storage account name this session credential is scoped to. + * @throws NullPointerException if {@code sessionToken}, {@code sessionKey}, or {@code accountName} is + * {@code null}. + */ + public SessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiresAt, String accountName) { + this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); + this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); + this.expiresAt = Objects.requireNonNull(expiresAt, "'expiresAt' cannot be null."); + this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); + } + + /** + * Gets the session token. + * + * @return the session token. + */ + public String getSessionToken() { + return sessionToken; + } + + /** + * Gets the Base64-encoded session key used to sign requests. + * + * @return the session key. + */ + public String getSessionKey() { + return sessionKey; + } + + /** + * Gets the instant at which this session credential expires. + * + * @return the expiration instant. + */ + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + /** + * Gets the storage account name this session credential is scoped to. + * + * @return the account name. + */ + public String getAccountName() { + return accountName; + } + + /** + * Gets whether this session credential is expired. + * + * @return {@code true} if the current time is after {@link #getExpiresAt()}; {@code false} otherwise. + */ + public boolean isExpired() { + return OffsetDateTime.now().isAfter(expiresAt); + } +} 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 87553db6b3da..d47c1628fe67 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 @@ -4,41 +4,26 @@ package com.azure.storage.blob.models; /** - * Defines the session management strategy used by the SDK when sending requests to a container. + * Defines whether the SDK uses session-based authentication 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} + * {@link #ENABLED} + * {@link #DISABLED} */ public enum SessionMode { /** - * Always use bearer token authentication. No session tokens are used. + * The SDK creates a session on the first eligible request and keeps an active session until it + * receives no requests for 5 minutes. This is the default. If session creation or use fails for + * any reason, the SDK transparently falls back to bearer token authentication. */ - NONE, + ENABLED, /** - * Default behavior. This is currently equivalent to {@link #NONE} + * Always use bearer token authentication. No session tokens are ever created or used. */ - 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 #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 ? SINGLE_SPECIFIED_CONTAINER : this; - } + DISABLED } 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 index b70e682db554..7c569c8eb9af 100644 --- 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 @@ -4,7 +4,8 @@ package com.azure.storage.blob.models; /** - * Options bag that configures session-based authentication on blob storage builders. + * Options bag that configures session-based authentication for a + * {@link com.azure.storage.blob.BlobServiceClientBuilder}. *

* Sessions amortize authentication and authorization cost across many requests by signing them * with a lightweight HMAC key instead of a full bearer token. @@ -13,51 +14,42 @@ */ public final class SessionOptions { - private SessionMode sessionMode = SessionMode.AUTO; + private SessionMode sessionMode = SessionMode.ENABLED; private String containerName; private String accountName; + private SessionProvider sessionProvider; /** * Creates a new {@link SessionOptions} instance with default values. - * Note: This currently only applies when using TokenCredential for GET Blob operations. + * This only applies to clients created from a {@link com.azure.storage.blob.BlobServiceClientBuilder} + * configured with a TokenCredential, and to eligible GET Blob operations made by clients derived from + * that service client. */ 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}. + * @return the {@link SessionMode}; defaults to {@link SessionMode#ENABLED}. */ public SessionMode getSessionMode() { return sessionMode; } /** - * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#AUTO}. + * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#ENABLED}. * * @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; + this.sessionMode = sessionMode == null ? SessionMode.ENABLED : sessionMode; return this; } /** - * Gets the container name that the session is scoped to. + * Gets the container name override used when it cannot be resolved from the request URL. * * @return the container name, or {@code null} if not set. */ @@ -66,8 +58,7 @@ public String getContainerName() { } /** - * Sets the container name that the session is scoped to. This is required when the session mode - * is not {@link SessionMode#NONE}. + * Sets the container name override used when it cannot be resolved from the request URL. * * @param containerName the container name. * @return the updated {@link SessionOptions} object. @@ -98,4 +89,25 @@ public SessionOptions setAccountName(String accountName) { this.accountName = accountName; return this; } + + /** + * Gets the custom provider used to obtain session credentials. + * + * @return the custom {@link SessionProvider}, or {@code null} to use the built-in provider. + */ + public SessionProvider getSessionProvider() { + return sessionProvider; + } + + /** + * Sets the custom provider used to obtain session credentials. The SDK continues to cache the returned + * credentials independently per container and performs request signing internally. + * + * @param sessionProvider the custom {@link SessionProvider}, or {@code null} to use the built-in provider. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionProvider(SessionProvider sessionProvider) { + this.sessionProvider = sessionProvider; + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java new file mode 100644 index 000000000000..a02107defdd4 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import reactor.core.publisher.Mono; + +/** + * An extension point for supplying custom {@link SessionCredential session credentials}, scoped to a + * particular request as described by a {@link SessionRequestContext} (e.g. the target container). + *

+ * Implement this interface to bring your own session-issuing logic - for example, proxying CreateSession + * calls through another service, or layering a custom caching/rotation policy - while still benefiting from + * this SDK's request signing and account-level cooldown handling. Set an instance via + * {@link SessionOptions#setSessionProvider(SessionProvider)}, then pass those options to + * {@link com.azure.storage.blob.BlobServiceClientBuilder#sessionOptions(SessionOptions)}, to have it used in + * place of the default, built-in provider (which calls the storage service's CreateSession REST API directly + * and manages its own per-container caching). + *

+ * This is modeled loosely on {@code com.azure.core.credential.TokenCredential} from azure-core: a small, + * request-parameterized factory for a credential-bearing value. Unlike {@code TokenCredential}, it is + * deliberately not annotated {@code @FunctionalInterface} - both {@link #getSessionAsync} and + * {@link #getSession} are true abstract methods, so implementers must provide efficient sync and async paths + * independently rather than being forced to accept a default, blocking-on-async implementation of the + * synchronous method (or vice versa). + * + *

Lifecycle

+ *

+ * A {@link SessionProvider} implementation is expected to support the full session lifecycle: + *

    + *
  1. Retrieve - {@link #getSessionAsync} / {@link #getSession} return a usable + * {@link SessionCredential} for the container described by the request context, minting or refreshing one + * as needed. This is the only part of the contract every implementation must provide.
  2. + *
  3. Invalidate - {@link #invalidateSession} is called when the service rejects a + * previously-issued credential with HTTP 401, giving the implementation the opportunity to evict it so the + * next retrieval mints a fresh one.
  4. + *
  5. Refresh - {@link #refreshSession} is called when the service signals (via an + * {@code x-ms-auth-info: session_expiring} response header) that the current session is about to stop being + * honored, giving the implementation the opportunity to proactively refresh it in the background.
  6. + *
+ *

+ * The invalidate and refresh steps are supplied as {@code default} no-op methods precisely so that BYO + * providers are not required to implement them: a minimal implementation that only overrides + * {@link #getSessionAsync} and {@link #getSession} remains correct (if slightly less efficient, since a + * 401 or an expiring-session hint will simply result in a fresh {@link #getSessionAsync} / {@link #getSession} + * call on the next eligible request rather than a proactively refreshed one). + *

+ * Regardless of the provider used, the SDK always retains ownership of HMAC request signing, bearer-token + * fallback for ineligible requests, and account-level acquisition cooldown (suppressing further session + * acquisition attempts for an account for a period after a 400/403/5xx failure). A {@link SessionProvider} + * implementation is only responsible for producing, invalidating, and refreshing credentials - never for + * signing requests or deciding when to fall back to bearer authentication. + * + *

Thread safety

+ *

+ * Implementations must be thread-safe: {@link #getSessionAsync}, {@link #getSession}, + * {@link #invalidateSession}, and {@link #refreshSession} may all be invoked concurrently from multiple + * pipeline threads. In particular, {@link #invalidateSession} must perform its compare-and-invalidate as a + * single atomic operation (see its documentation for details), and {@link #refreshSession} must not block. + * + *

Scoping

+ *

+ * A single {@link SessionProvider} instance may be asked to serve many different containers (and, in + * principle, multiple accounts) over its lifetime; the {@link SessionRequestContext} passed to each method + * call identifies which container (and account) the call applies to. + * + * @see SessionCredential + * @see SessionRequestContext + * @see SessionOptions + */ +public interface SessionProvider { + + /** + * Asynchronously obtains a {@link SessionCredential} for the request described by the given + * {@link SessionRequestContext}. + * + * @param context the request-scoped parameters (e.g. container name) the session should be created for. + * @return a {@link Mono} that emits the resulting {@link SessionCredential}. + */ + Mono getSessionAsync(SessionRequestContext context); + + /** + * Synchronously obtains a {@link SessionCredential} for the request described by the given + * {@link SessionRequestContext}. + * + * @param context the request-scoped parameters (e.g. container name) the session should be created for. + * @return the resulting {@link SessionCredential}. + */ + SessionCredential getSession(SessionRequestContext context); + + /** + * Attempts a compare-and-invalidate on the credential currently held for the container described by + * {@code context}: if {@code rejectedCredential} is still the active credential, it is atomically + * replaced so the next call to {@link #getSession} or {@link #getSessionAsync} returns a fresh one. + *

+ * Thread safety: Implementations must treat the compare and the invalidate as a + * single atomic operation. Exactly one thread presenting the same {@code rejectedCredential} should + * succeed in invalidating it; all later threads presenting the same instance must return {@code false}. + *

+ * Warning semantics: The SDK logs a one-time warning when this returns {@code true} + * (the first invalidation for a given rejected credential) and a verbose message when it returns + * {@code false} (already replaced). Custom implementations are encouraged but not required to track + * per-credential state; the default returns {@code false} (no invalidation performed). + * + * @param context the request-scoped parameters (container, account) identifying the session scope. + * @param rejectedCredential the credential the service rejected with HTTP 401. + * @return {@code true} if this call invalidated the credential (first invalidator wins); + * {@code false} if the credential was already replaced or this provider does not track it. + */ + default boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + return false; + } + + /** + * Non-blocking hint that the service has indicated the current session for the container described by + * {@code context} is about to expire (signalled via an {@code x-ms-auth-info: session_expiring} + * response header). Implementations should trigger a proactive background refresh immediately so the + * next request uses a fresh session without an inline latency penalty. + *

+ * Non-blocking contract: This method is called from both synchronous and + * asynchronous response-processing paths and must return immediately without waiting for the + * refresh to complete. It must not throw. + *

+ * The default implementation is a no-op. Custom implementations are encouraged but not required to + * perform a background refresh. + * + * @param context the request-scoped parameters (container, account) identifying the session scope. + */ + default void refreshSession(SessionRequestContext context) { + // no-op by default + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java new file mode 100644 index 000000000000..081595413c25 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Carries the request-scoped parameters needed to obtain a {@link SessionCredential}, such as the target + * container and account. + *

+ * Both {@code containerName} and {@code accountName} are optional: they are resolved automatically from the + * request URL in the common case, and are only needed here when that automatic resolution isn't possible or + * isn't correct - for example, a custom domain URL that a {@link SessionProvider} implementation cannot + * parse the account name from, or a proxying scenario where the effective container differs from the one on + * the wire. A {@link SessionProvider} implementation should treat either value as a hint that may be absent + * rather than something it can always rely on. + *

+ * This exists so a single {@link SessionProvider} instance can be asked for a session that is scoped to a + * specific container at call time, rather than being permanently bound to one container at construction + * time - allowing one provider to serve sessions for many containers. + * + * @see SessionProvider + */ +public final class SessionRequestContext { + + private String containerName; + private String accountName; + + /** + * Creates a new {@link SessionRequestContext}. + */ + public SessionRequestContext() { + } + + /** + * Gets the name of the container the session should be scoped to, if known. + * + * @return the container name, or {@code null} if not resolved/known for this request. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the name of the container the session should be scoped to. + * + * @param containerName the container name. + * @return the updated {@link SessionRequestContext} object. + */ + public SessionRequestContext setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the name of the storage account the session should be scoped to, if known. + * + * @return the account name, or {@code null} if not resolved/known for this request. + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the name of the storage account the session should be scoped to. + * + * @param accountName the account name. + * @return the updated {@link SessionRequestContext} object. + */ + public SessionRequestContext setAccountName(String accountName) { + this.accountName = accountName; + return this; + } +} 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 b0fac6bbc9d6..fb8cff8e359e 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 @@ -3207,9 +3207,10 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { } }); - BlobClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) - .buildClient(); + BlobClient sessionBlob + = getOAuthServiceClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerClient(cc.getBlobContainerName()) + .getBlobClient(blobName); File outFile = new File(prefix + "-session-download.tmp"); createdFiles.add(outFile); 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 e8a74ac57729..d6b588d2f10f 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 @@ -2994,9 +2994,10 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { } }); - BlobAsyncClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) - .buildAsyncClient(); + BlobAsyncClient sessionBlob + = getOAuthServiceAsyncClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()) + .getBlobAsyncClient(blobName); File outFile = new File(prefix + "-session-download.tmp"); createdFiles.add(outFile); 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 4475bc94cd2a..0a941fd6cb17 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,8 +22,10 @@ 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.SessionCredential; import com.azure.storage.blob.models.SessionOptions; -import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -43,9 +45,11 @@ import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -687,11 +691,11 @@ public Mono send(HttpRequest request) { // region buildPipeline session tests @Test - public void buildPipelineWithTokenCredentialAlwaysHasSessionPolicy() { + public void buildPipelineWithoutSessionOptionsUsesBearerPolicy() { HttpPipeline pipeline = buildBearerPipeline(); - assertTrue(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), - "Pipeline with tokenCredential should always contain SessionTokenCredentialPolicy"); + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline without service-level session options should not contain SessionTokenCredentialPolicy"); } @Test @@ -747,90 +751,72 @@ private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) // endregion - // region BlobClientBuilder sessionOptions tests + // region Standalone builder session tests @Test - public void blobBuilderWithSingleSpecifiedContainerSessionBuilds() { - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); - - assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + public void standaloneBlobBuilderDoesNotCreateSessions() { + BlobClient client = new BlobClientBuilder().endpoint(ENDPOINT) .containerName("mycontainer") .blobName("myblob") .credential(new MockTokenCredential()) .httpClient(new NoOpHttpClient()) - .sessionOptions(options) - .buildClient()); + .buildClient(); + + assertFalse(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy")); } @Test - public void blobBuilderWithSingleSpecifiedContainerSessionAndNoContainerNameThrows() { - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + public void customSessionProviderReceivesResolvedRequestContext() { + AtomicReference receivedContext = new AtomicReference<>(); + SessionCredential credential = new SessionCredential("session-token", + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==", OffsetDateTime.now().plusMinutes(5), "account"); + SessionProvider provider = new SessionProvider() { + @Override + public Mono getSessionAsync(SessionRequestContext context) { + receivedContext.set(context); + return Mono.just(credential); + } - assertThrows(IllegalArgumentException.class, - () -> new BlobClientBuilder().endpoint(ENDPOINT) - .blobName("myblob") - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .sessionOptions(options) - .buildClient()); - } + @Override + public SessionCredential getSession(SessionRequestContext context) { + receivedContext.set(context); + return credential; + } + }; + SessionOptions options = new SessionOptions().setSessionProvider(provider); + AtomicReference receivedAuthorization = new AtomicReference<>(); + HttpClient testHttpClient = requestToSend -> { + receivedAuthorization.set(requestToSend.getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + return Mono.just(new MockHttpResponse(requestToSend, 200)); + }; + HttpPipeline pipeline + = BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, + null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), testHttpClient, new ArrayList<>(), + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), options, null); + HttpRequest request = new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"); + + StepVerifier.create(pipeline.send(request)) + .assertNext(response -> assertEquals(200, response.getStatusCode())) + .verifyComplete(); - @Test - public void blobBuilderWithoutSessionOptionsBuilds() { - assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) - .containerName("mycontainer") - .blobName("myblob") - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .buildClient()); + assertTrue(receivedAuthorization.get().startsWith("Session session-token:")); + assertEquals("container", receivedContext.get().getContainerName()); + assertEquals("account", receivedContext.get().getAccountName()); } // endregion - // region BlobContainerClientBuilder sessionOptions tests + // region BlobContainerClientBuilder session tests @Test - public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); - - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + public void standaloneContainerBuilderDoesNotCreateSessions() { + BlobContainerClient client = 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()); - } + .buildClient(); - @Test - public void containerBuilderWithNoSessionOptionsSucceeds() { - assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) - .containerName("mycontainer") - .credential(new MockTokenCredential()) - .httpClient(new NoOpHttpClient()) - .buildClient()); + assertFalse(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy")); } // 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 2764287ca156..01f77a552a35 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 @@ -2488,7 +2488,7 @@ List getInvalidAuthStatuses() { } private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) { - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.ENABLED) .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 ee3579271283..cfa71c25e97b 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 @@ -2304,7 +2304,7 @@ public void listBlobsOverSessionEnabledClient() { } private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) { - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.ENABLED) .setContainerName(ccAsync.getBlobContainerName()) .setAccountName(ccAsync.getAccountName()); return getOAuthServiceAsyncClient(sessionOptions, policies) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java deleted file mode 100644 index 93db32bf0ed1..000000000000 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/AutoRefreshingCacheTest.java +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -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; -import reactor.core.publisher.Sinks; -import reactor.test.StepVerifier; - -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 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 AutoRefreshingCacheTest { - - 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); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); - 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.getValidValueSync(); - assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); - 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.getValidValueSync(); - assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); - verify(sessionClient, times(2)).createSync(); - // The expiry path mints inline; it must not have leaned on the background (async) refresh. - verify(sessionClient, never()).createAsync(); - } - - /** - * 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); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - OffsetDateTime firstExpiration = now(clock).plus(SESSION_LIFETIME); - 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.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.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. - 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.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.getValidValueSync().getSessionToken()); - // Still only one inline creation and one background refresh overall (no over-eager churn). - verify(sessionClient, times(1)).createSync(); - verify(sessionClient, times(1)).createAsync(); - } - - /** - * 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); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - OffsetDateTime expiration = now(clock).plus(SESSION_LIFETIME); - when(sessionClient.createSync()).thenReturn(credential(FIRST_TOKEN, expiration)); - - // First request mints the token. - 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.getValidValueSync().getSessionToken()); - } - - verify(sessionClient, times(1)).createSync(); - verify(sessionClient, never()).createAsync(); - } - - /** - * The async path on a cold cache must mint a value through {@code createAsync} and emit exactly one - * element before completing. The synchronous creation path must not be involved at all. - */ - @Test - public void coldCacheCreatesValueAsync() { - MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); - BlobSessionClient sessionClient = mock(BlobSessionClient.class); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - when(sessionClient.createAsync()) - .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); - - StepVerifier.create(cache.getValidValueAsync()) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - - verify(sessionClient, times(1)).createAsync(); - verify(sessionClient, never()).createSync(); - } - - /** - * Once the async path has cached a usable value, later async requests made before the jittered refresh - * window must replay that cached value rather than creating a second one. - */ - @Test - public void cachedValueIsReusedOnLaterAsyncRequests() { - MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); - BlobSessionClient sessionClient = mock(BlobSessionClient.class); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - when(sessionClient.createAsync()) - .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); - - StepVerifier.create(cache.getValidValueAsync()) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - - // Advance well short of the earliest jittered refresh point (80% of lifetime). - clock.advance(Duration.ofSeconds(30)); - - StepVerifier.create(cache.getValidValueAsync()) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - - verify(sessionClient, times(1)).createAsync(); - verify(sessionClient, never()).createSync(); - } - - /** - * Concurrent async callers arriving while a creation is still in flight must join that single in-flight - * creation instead of each triggering their own, and all of them must observe the same value. - */ - @Test - public void concurrentAsyncRequestsShareASingleInFlightCreation() { - MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); - BlobSessionClient sessionClient = mock(BlobSessionClient.class); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - // A sink that has not emitted yet models a creation that is still outstanding. - Sinks.One pendingCreation = Sinks.one(); - when(sessionClient.createAsync()).thenReturn(pendingCreation.asMono()); - - Mono first = cache.getValidValueAsync(); - Mono second = cache.getValidValueAsync(); - - // The second caller joined the in-flight creation rather than starting another one. - verify(sessionClient, times(1)).createAsync(); - - pendingCreation.tryEmitValue(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME))); - - StepVerifier.create(first) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - StepVerifier.create(second) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - - verify(sessionClient, times(1)).createAsync(); - } - - /** - * A failed creation must surface to the caller as an error signal rather than an empty completion, and it - * must not poison the cache: the in-flight creation is cleared so a later request can retry successfully. - */ - @Test - public void creationFailurePropagatesAndAllowsRetryAsync() { - MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); - BlobSessionClient sessionClient = mock(BlobSessionClient.class); - AutoRefreshingCache cache = new AutoRefreshingCache<>(sessionClient, clock); - - when(sessionClient.createAsync()).thenReturn(Mono.error(new IllegalStateException("CreateSession failed."))) - .thenReturn(Mono.just(credential(FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)))); - - StepVerifier.create(cache.getValidValueAsync()).verifyErrorMessage("CreateSession failed."); - - // The failure left no cached value behind, so the retry mints a fresh one. - StepVerifier.create(cache.getValidValueAsync()) - .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) - .verifyComplete(); - - verify(sessionClient, times(2)).createAsync(); - } - - 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); - } - } -} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java new file mode 100644 index 000000000000..8583732e0be1 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionRequestContext; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.nio.charset.StandardCharsets; +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 java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Deterministic, network-free tests for {@link BlobSessionClient}'s time-based, per-container caching + * behavior. + *

+ * These tests drive {@link BlobSessionClient} with an injectable {@link Clock} and a fake HTTP transport + * ({@link ControllableHttpClient}) so the expiry, proactive-refresh, and per-container independence logic + * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which + * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which + * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is + * covered separately by {@code SessionAcquisitionCooldownTest}, since that concern now lives in + * {@link SessionAcquisitionCooldown} rather than in {@link BlobSessionClient}. + */ +public class BlobSessionClientCacheTest { + + private static final String ACCOUNT_NAME = "myaccount"; + private static final String CONTAINER_A = "container-a"; + private static final String CONTAINER_B = "container-b"; + 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")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + BlobSessionClient client = createClient(httpClient, clock); + + // First request: cold cache mints a good token and uses it. + SessionCredential firstRequest = client.getSession(contextFor(CONTAINER_A)); + assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + // 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. + SessionCredential secondRequest = client.getSession(contextFor(CONTAINER_A)); + assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * 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")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + BlobSessionClient client = createClient(httpClient, clock); + + // First request: cold cache mints the initial token. + assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + // 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, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + + // Third request: the background refresh has swapped in the new token, which is now served. The + // refresh runs on a background subscription, so poll briefly rather than asserting immediately. + assertEquals(SECOND_TOKEN, + waitForToken(() -> client.getSession(contextFor(CONTAINER_A)), SECOND_TOKEN).getSessionToken()); + // Still only one inline creation and one background refresh overall (no over-eager churn). + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Two different containers must refresh completely independently: advancing the clock past one + * container's jittered refresh point must trigger a background refresh for that container only, leaving + * the other container's still-fresh session untouched. + */ + @Test + public void independentContainersRefreshIndependently() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, "refreshed-a", now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + httpClient.enqueue(CONTAINER_B, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + // Mint an initial session for each container. + assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(SECOND_TOKEN, client.getSession(contextFor(CONTAINER_B)).getSessionToken()); + + // Advance past container A's jittered refresh window (both containers were minted at the same time, + // so this is also past B's refresh window by clock time - but B must only refresh once *it* is + // accessed, not merely because time passed). + clock.advance(SESSION_LIFETIME.minusSeconds(2)); + + // Touching container A triggers its background refresh. The refresh runs on a background + // subscription, so poll briefly rather than asserting the call count immediately. + assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + waitForCallCount(httpClient, CONTAINER_A, 2); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + + // Container B has not been touched since the clock advanced, so it must not have refreshed - proving + // the two containers' caches operate independently rather than sharing one refresh timer. + assertEquals(1, httpClient.getCallCount(CONTAINER_B)); + } + + /** + * 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")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + // First request mints the token. + assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).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, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + } + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * The async path on a cold cache must mint a value through the async CreateSession call and emit exactly + * one element before completing. + */ + @Test + public void coldCacheCreatesValueAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Once the async path has cached a usable value, later async requests made before the jittered refresh + * window must replay that cached value rather than creating a second one. + */ + @Test + public void cachedValueIsReusedOnLaterAsyncRequests() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + // Advance well short of the earliest jittered refresh point (80% of lifetime). + clock.advance(Duration.ofSeconds(30)); + + StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * A failed creation must surface to the caller as an error signal rather than an empty completion, and it + * must not poison the cache: the in-flight creation is cleared so a later request can retry successfully. + */ + @Test + public void creationFailurePropagatesAndAllowsRetryAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueueFailure(CONTAINER_A); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); + + // The failure left no cached value behind, so the retry mints a fresh one. + StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Container names must be matched case-insensitively: a container looked up with different casing must + * reuse the same cache entry rather than minting a duplicate session. + */ + @Test + public void containerNameLookupIsCaseInsensitive() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + BlobSessionClient client = createClient(httpClient, clock); + + assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, + client.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken()); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Concurrent async callers arriving while a creation is still in flight must join that single in-flight + * creation instead of each triggering their own, and all of them must observe the same value. + */ + @Test + public void concurrentAsyncRequestsShareASingleInFlightCreation() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + // A pending response models a CreateSession call that is still outstanding. + Sinks.One pendingResponse = httpClient.preparePendingResponse(CONTAINER_A); + BlobSessionClient client = createClient(httpClient, clock); + + Mono first = client.getSessionAsync(contextFor(CONTAINER_A)); + Mono second = client.getSessionAsync(contextFor(CONTAINER_A)); + + AtomicReference firstResult = new AtomicReference<>(); + AtomicReference secondResult = new AtomicReference<>(); + CountDownLatch firstLatch = new CountDownLatch(1); + CountDownLatch secondLatch = new CountDownLatch(1); + first.subscribe(cred -> { + firstResult.set(cred); + firstLatch.countDown(); + }); + second.subscribe(cred -> { + secondResult.set(cred); + secondLatch.countDown(); + }); + + // Only one CreateSession call was made even though two callers subscribed. + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + pendingResponse + .tryEmitValue(httpClient.buildResponseFor(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME))); + + awaitLatch(firstLatch); + awaitLatch(secondLatch); + + assertEquals(FIRST_TOKEN, firstResult.get().getSessionToken()); + assertEquals(FIRST_TOKEN, secondResult.get().getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + private static void awaitLatch(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for async result."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** + * Repeatedly invokes {@code supplier} (which triggers a synchronous cache lookup that may itself kick + * off a background refresh subscription) until it observes {@code expectedToken} or a timeout elapses. + * Background refreshes complete on a separate subscription from the caller that triggered them, so + * asserting on the very next call without allowing for that latency would be flaky. + */ + private static SessionCredential waitForToken(Supplier supplier, String expectedToken) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + SessionCredential last; + do { + last = supplier.get(); + if (expectedToken.equals(last.getSessionToken())) { + return last; + } + sleepBriefly(); + } while (System.nanoTime() < deadline); + return last; + } + + private static void waitForCallCount(ControllableHttpClient httpClient, String container, int expectedCount) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (httpClient.getCallCount(container) < expectedCount && System.nanoTime() < deadline) { + sleepBriefly(); + } + } + + private static void sleepBriefly() { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static BlobSessionClient createClient(HttpClient httpClient, Clock clock) { + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); + return new BlobSessionClient(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock); + } + + private static SessionRequestContext contextFor(String containerName) { + return new SessionRequestContext().setContainerName(containerName).setAccountName(ACCOUNT_NAME); + } + + private static OffsetDateTime now(Clock clock) { + return OffsetDateTime.now(clock); + } + + /** + * A fake transport that parses the container name out of the CreateSession request's path and returns + * pre-configured (token, expiration) pairs in FIFO order for that container, so cache timing behavior can + * be tested deterministically without a real service. Expirations are supplied by the test relative to + * the {@link MutableClock} under test, so the cache's expiry/refresh math lines up with the injected + * clock rather than the real one. + */ + private static final class ControllableHttpClient implements HttpClient { + private final Map> queuedByContainer = new ConcurrentHashMap<>(); + private final Map callCountByContainer = new ConcurrentHashMap<>(); + private final Map> pendingByContainer = new ConcurrentHashMap<>(); + private final Map lastRequestByContainer = new ConcurrentHashMap<>(); + + void enqueue(String container, String token, OffsetDateTime expiresAt) { + queuedByContainer.computeIfAbsent(normalize(container), k -> new ArrayDeque<>()) + .add(new CredentialConfig(token, expiresAt, false)); + } + + void enqueueFailure(String container) { + queuedByContainer.computeIfAbsent(normalize(container), k -> new ArrayDeque<>()) + .add(new CredentialConfig(null, null, true)); + } + + /** + * Registers a pending (not-yet-completed) response for the given container: the next request for + * that container will receive this response only once the returned sink is completed, modeling a + * CreateSession call that is still in flight. + */ + Sinks.One preparePendingResponse(String container) { + Sinks.One sink = Sinks.one(); + pendingByContainer.put(normalize(container), sink); + return sink; + } + + /** + * Builds a CreateSession success response for the most recent pending request captured for the + * given container, for use with {@link #preparePendingResponse(String)}. + */ + HttpResponse buildResponseFor(String container, String token, OffsetDateTime expiresAt) { + HttpRequest request = lastRequestByContainer.get(normalize(container)); + return buildResponse(request, token, expiresAt); + } + + int getCallCount(String container) { + return callCountByContainer.getOrDefault(normalize(container), 0); + } + + @Override + public Mono send(HttpRequest request) { + String path = request.getUrl().getPath(); + String container = path.startsWith("/") ? path.substring(1) : path; + String key = normalize(container); + + callCountByContainer.merge(key, 1, Integer::sum); + + Sinks.One pending = pendingByContainer.remove(key); + if (pending != null) { + lastRequestByContainer.put(key, request); + return pending.asMono(); + } + + Deque queue = queuedByContainer.get(key); + CredentialConfig config = queue == null || queue.isEmpty() ? null : queue.poll(); + if (config == null) { + return Mono.error(new IllegalStateException("No queued CreateSession response for " + container)); + } + if (config.failure) { + return Mono.error(new IllegalStateException("CreateSession failed.")); + } + + return Mono.just(buildResponse(request, config.token, config.expiresAt)); + } + + private static HttpResponse buildResponse(HttpRequest request, String token, OffsetDateTime expiresAt) { + String expiration = new DateTimeRfc1123(expiresAt).toString(); + String body = "" + "" + + "test-session-id" + "" + expiration + "" + + "HMAC" + "" + "" + token + + "" + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==" + + "" + ""; + + return new MockHttpResponse(request, 201, body.getBytes(StandardCharsets.UTF_8)).addHeader("Content-Type", + "application/xml"); + } + + private static String normalize(String name) { + return name == null ? "" : name.trim().toLowerCase(Locale.ROOT); + } + + private static final class CredentialConfig { + private final String token; + private final OffsetDateTime expiresAt; + private final boolean failure; + + private CredentialConfig(String token, OffsetDateTime expiresAt, boolean failure) { + this.token = token; + this.expiresAt = expiresAt; + this.failure = failure; + } + } + } + + /** + * 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); + } + } +} 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 636255e2508b..8589bafee116 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 @@ -11,6 +11,8 @@ import com.azure.storage.blob.BlobServiceClientBuilder; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionRequestContext; import com.azure.storage.blob.sas.BlobContainerSasPermission; import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; import com.azure.storage.common.test.shared.StorageCommonTestUtils; @@ -30,30 +32,35 @@ 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()); + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); - StorageSessionCredential credential = sessionClient.createSync(); + SessionCredential credential + = sessionClient.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); + assertNotNull(credential.getExpiresAt()); } @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.createAsync()).assertNext(credential -> { - assertNotNull(credential); - assertNotNull(credential.getSessionToken()); - assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); - }).verifyComplete(); + BlobSessionClient sessionClient + = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionClient + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + }) + .verifyComplete(); } @Test @@ -61,14 +68,15 @@ public void createSessionSyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); + ENVIRONMENT.getPrimaryAccount().getName()); - StorageSessionCredential credential = sessionClient.createSync(); + SessionCredential credential + = sessionClient.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); + assertNotNull(credential.getExpiresAt()); assertEquals(1, policyInvocationCount.get()); } @@ -77,15 +85,19 @@ 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.createAsync()).assertNext(credential -> { - assertNotNull(credential); - assertNotNull(credential.getSessionToken()); - assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); - // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); - }).verifyComplete(); + ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionClient + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + }) + .verifyComplete(); assertEquals(1, policyInvocationCount.get()); } @@ -101,16 +113,17 @@ public void createSessionWithUserDelegationSas() { BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); - BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), - ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName()); + BlobSessionClient sessionClient + = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); - StorageSessionCredential credential = sessionClient.createSync(); + SessionCredential credential + = sessionClient.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); + assertNotNull(credential.getExpiresAt()); assertEquals(false, credential.isExpired()); } @@ -125,17 +138,21 @@ public void createSessionAsyncWithUserDelegationSas() { = 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.createAsync()).assertNext(credential -> { - assertNotNull(credential); - assertNotNull(credential.getSessionToken()); - assertNotNull(credential.getSessionKey()); - assertNotNull(credential.getExpiration()); - assertEquals(false, credential.isExpired()); - }).verifyComplete(); + BlobSessionClient sessionClient + = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionClient + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertEquals(false, credential.isExpired()); + }) + .verifyComplete(); } private String generateUserDelegationContainerSas(BlobContainerClient containerClient) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java new file mode 100644 index 000000000000..c3b4b48fb33d --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java @@ -0,0 +1,83 @@ +// 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 java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Locale; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Deterministic, network-free tests for {@link SessionAcquisitionCooldown}'s time-based, per-account + * behavior. + *

+ * These tests drive the cooldown with an injectable {@link Clock} so its five-minute suppression window + * can be exercised without sleeping. This complements {@code BlobSessionClientCacheTest} (which covers + * per-container session caching) and {@code SessionTokenCredentialPolicyTest} (which covers the pipeline + * policy's use of both collaborators together). + */ +public class SessionAcquisitionCooldownTest { + + private static final String ACCOUNT_NAME = "myaccount"; + + @Test + public void accountCooldownExpiresAfterFiveMinutes() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + SessionAcquisitionCooldown cooldown = new SessionAcquisitionCooldown(clock); + + assertFalse(cooldown.isAccountInCooldown(ACCOUNT_NAME)); + assertTrue(cooldown.beginAccountCooldown(ACCOUNT_NAME)); + assertTrue(cooldown.isAccountInCooldown(ACCOUNT_NAME.toUpperCase(Locale.ROOT))); + assertFalse(cooldown.beginAccountCooldown(ACCOUNT_NAME)); + + clock.advance(Duration.ofMinutes(5)); + + assertFalse(cooldown.isAccountInCooldown(ACCOUNT_NAME)); + assertTrue(cooldown.beginAccountCooldown(ACCOUNT_NAME)); + } + + /** + * A {@link Clock} whose instant can be advanced, allowing deterministic control of the cooldown'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); + } + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java new file mode 100644 index 000000000000..1854caee0ce1 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Small, focused tests for the public {@link SessionProvider} contract implemented by {@link BlobSessionClient}. + *

+ * These verify that {@link BlobSessionClient#getSessionAsync(SessionRequestContext)} and + * {@link BlobSessionClient#getSession(SessionRequestContext)} route the CreateSession REST call to the + * container named on the {@link SessionRequestContext}, proving out the "per-request container" seam that + * backs the BYO {@link SessionProvider} extension point, and that a context missing a container + * name is rejected rather than silently falling back to some default. This complements (and does not + * duplicate) {@code BlobSessionClientTests}, which exercises these same paths against the live service, and + * {@code BlobSessionClientCacheTest}, which fakes the transport wholesale to test per-container cache timing + * behavior. Here {@link BlobSessionClient} is real and only the transport is faked, so the container name + * actually placed on the wire is what's being verified. + */ +public class SessionProviderSeamTest { + + private static final String ACCOUNT_NAME = "myaccount"; + private static final String CONTEXT_CONTAINER = "context-container"; + + @Test + public void getSessionAsyncUsesContainerFromContext() { + AtomicReference requestedContainer = new AtomicReference<>(); + BlobSessionClient sessionClient = createSessionClient(requestedContainer); + + SessionRequestContext context + = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); + + StepVerifier.create(sessionClient.getSessionAsync(context)).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + }).verifyComplete(); + + assertEquals(CONTEXT_CONTAINER, requestedContainer.get()); + } + + @Test + public void getSessionSyncUsesContainerFromContext() { + AtomicReference requestedContainer = new AtomicReference<>(); + BlobSessionClient sessionClient = createSessionClient(requestedContainer); + + SessionRequestContext context + = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); + + SessionCredential credential = sessionClient.getSession(context); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertEquals(CONTEXT_CONTAINER, requestedContainer.get()); + } + + @Test + public void missingContextContainerThrowsSync() { + AtomicReference requestedContainer = new AtomicReference<>(); + BlobSessionClient sessionClient = createSessionClient(requestedContainer); + + // There is no constructor-supplied fallback container: a context with no container name must be + // rejected rather than silently degrading to some default. + SessionRequestContext context = new SessionRequestContext(); + + assertThrows(IllegalArgumentException.class, () -> sessionClient.getSession(context)); + } + + @Test + public void missingContextContainerThrowsAsync() { + AtomicReference requestedContainer = new AtomicReference<>(); + BlobSessionClient sessionClient = createSessionClient(requestedContainer); + + SessionRequestContext context = new SessionRequestContext(); + + StepVerifier.create(sessionClient.getSessionAsync(context)).verifyError(IllegalArgumentException.class); + } + + private static BlobSessionClient createSessionClient(AtomicReference requestedContainer) { + HttpPipeline pipeline + = new HttpPipelineBuilder().httpClient(new CreateSessionMockClient(requestedContainer)).build(); + return new BlobSessionClient(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + BlobServiceVersion.getLatest(), ACCOUNT_NAME); + } + + /** + * A fake transport that parses the container name out of the CreateSession request's path (the + * container is the first path segment; {@code restype=container&comp=session} is on the query string) + * and echoes it back into the session token, so tests can assert on the container that was actually + * requested without needing a real service. + */ + private static final class CreateSessionMockClient implements HttpClient { + + private final AtomicReference requestedContainer; + + CreateSessionMockClient(AtomicReference requestedContainer) { + this.requestedContainer = requestedContainer; + } + + @Override + public Mono send(HttpRequest request) { + String path = request.getUrl().getPath(); + // Path looks like "/"; strip the leading slash. + String container = path.startsWith("/") ? path.substring(1) : path; + requestedContainer.set(container); + + String body = "" + "" + + "test-session-id" + "Wed, 09 Sep 2099 00:00:00 GMT" + + "HMAC" + "" + "session-token-for-" + + container + "" + + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==" + "" + + ""; + + HttpResponse response = new MockHttpResponse(request, 201, body.getBytes(StandardCharsets.UTF_8)) + .addHeader("Content-Type", "application/xml"); + return Mono.just(response); + } + } +} 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/SessionRequestSignerTest.java similarity index 78% rename from sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java rename to sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionRequestSignerTest.java index dc70b97398d4..0c959158a1e4 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/SessionRequestSignerTest.java @@ -8,28 +8,29 @@ import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpRequest; import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionCredential; 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -public class StorageSessionCredentialTest { +public class SessionRequestSignerTest { @Test public void signRequestUsesSessionScheme() throws MalformedURLException { - StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + SessionCredential credential = SessionTestHelper.createValidCredential(); HttpRequest request = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); - credential.signRequest(request); + SessionRequestSigner.signRequest(request, credential); String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); assertNotNull(authHeader); @@ -41,19 +42,19 @@ public void signRequestUsesSessionScheme() throws MalformedURLException { @Test public void signRequestSetsXmsDateHeader() throws MalformedURLException { - StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + SessionCredential 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); + SessionRequestSigner.signRequest(request, credential); 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: + // Regression guard for the URL-decode fix in SessionRequestSigner.canonicalizedResource: // verifies Session and SharedKey produce the same HMAC for a well-formed GET with an // encoded query string (e.g. snapshot=...%3A...). // @@ -63,7 +64,7 @@ public void signRequestSetsXmsDateHeader() throws MalformedURLException { // Content-Length: 0 (which the server normalizes to "") is covered separately. @Test public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException { - StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); + SessionCredential sessionCred = SessionTestHelper.createValidCredential(); StorageSharedKeyCredential sharedKeyCred = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY); @@ -76,7 +77,7 @@ public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedUR .set(HttpHeaderName.RANGE, "bytes=0-1023") .set(HttpHeaderName.CONTENT_LENGTH, "1024"); - sessionCred.signRequest(request); + SessionRequestSigner.signRequest(request, sessionCred); String sessionAuth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); @@ -104,17 +105,8 @@ public void isExpiredReturnsFalseWhenBeforeExpiration() { } @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); + public void constructorRejectsNullExpiration() { + assertThrows(NullPointerException.class, () -> new SessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME)); } } 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 index 592fc5f22241..5bc2f578e063 100644 --- 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 @@ -3,6 +3,8 @@ package com.azure.storage.blob.implementation.util; +import com.azure.storage.blob.models.SessionCredential; + import java.time.OffsetDateTime; /** @@ -16,19 +18,19 @@ final class SessionTestHelper { 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 SessionCredential createCredential(OffsetDateTime expiration) { + return new SessionCredential(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 SessionCredential createCredential(OffsetDateTime expiration, String accountName) { + return new SessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, accountName); } - static StorageSessionCredential createValidCredential() { + static SessionCredential createValidCredential() { return createCredential(OffsetDateTime.now().plusHours(1)); } - static StorageSessionCredential createExpiredCredential() { + static SessionCredential createExpiredCredential() { return createCredential(OffsetDateTime.now().minusMinutes(5)); } 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 58304619438c..6fc80b5f4091 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 @@ -10,35 +10,31 @@ import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpRequest; import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.storage.blob.models.BlobStorageException; 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.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionProvider; 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.assertNull; 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.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -49,13 +45,15 @@ public class SessionTokenCredentialPolicyTest { private static final String SECOND_TOKEN = "second-session-token"; HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; - private BlobSessionClient sessionClient; + private SessionProvider sessionProvider; + private SessionAcquisitionCooldown cooldown; private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private SessionTokenCredentialPolicy policy; @BeforeEach public void beforeEach() { - sessionClient = mock(BlobSessionClient.class); + sessionProvider = mock(SessionProvider.class); + cooldown = new SessionAcquisitionCooldown(); bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); // Default mock behavior: bearer policy delegates to next policy in the pipeline. @@ -68,80 +66,28 @@ public void beforeEach() { return nextPolicy.processSync(); }); - policy = createPolicy(SessionMode.SINGLE_SPECIFIED_CONTAINER); + policy = createPolicy(SessionMode.ENABLED); } @Test - public void policyCreatesSessionOnFirstAsyncAccess() { - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + public void sessionAcquisitionServerFailureStartsAccountCooldown() { + HttpPipelineNextPolicy firstNext = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy secondNext = mock(HttpPipelineNextPolicy.class); + HttpResponse firstBearerResponse = mock(HttpResponse.class); + HttpResponse secondBearerResponse = mock(HttpResponse.class); + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); - StorageSessionCredential credential = policy.getValidSessionAsync().block(); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)); + when(firstNext.process()).thenReturn(Mono.just(firstBearerResponse)); + when(secondNext.process()).thenReturn(Mono.just(secondBearerResponse)); - assertNotNull(credential); - assertEquals(FIRST_TOKEN, credential.getSessionToken()); - verify(sessionClient, times(1)).createAsync(); - } - - @Test - public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { - when(sessionClient.createAsync()).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)).createAsync(); - } + assertEquals(firstBearerResponse, policy.process(createContext(), firstNext).block()); + assertEquals(secondBearerResponse, policy.process(createContext(), secondNext).block()); - @Test - public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { - StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2)); - StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); - - 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.createAsync()).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)).createSync(); - verify(sessionClient, times(1)).createAsync(); - } - - @Test - public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { - when(sessionClient.createSync()).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)).createSync(); - } finally { - executor.shutdownNow(); - } + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(firstNext, times(1)).process(); + verify(secondNext, times(1)).process(); } @Test @@ -150,7 +96,7 @@ public void policySignsRequestWithSessionCredential() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -165,15 +111,14 @@ public void policySignsRequestWithSessionCredential() { } @Test - public void policyInvalidatesSessionAndRetriesOnceAsync() { + public void policyInvalidatesSessionAndFallsBackToBearerAsync() { 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.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(initialResponse)); when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); @@ -184,25 +129,24 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { try (HttpResponse actualResponse = policy.process(context, next).block()) { assertEquals(retriedResponse, actualResponse); - assertTrue( - context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + assertNull(context.getHttpRequest().getHeaders().getValue("Authorization")); verify(initialResponse, times(1)).close(); verify(next, times(1)).process(); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createAsync(); + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, times(1)).invalidateSession(any(), any()); } } @Test - public void policyInvalidatesSessionAndRetriesOnceSync() { + public void policyInvalidatesSessionAndFallsBackToBearerSync() { 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.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) - .thenReturn(credentialWithToken(SECOND_TOKEN)); + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken(FIRST_TOKEN)); when(next.clone()).thenReturn(retryNext); when(next.processSync()).thenReturn(initialResponse); when(retryNext.processSync()).thenReturn(retriedResponse); @@ -213,8 +157,7 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { try (HttpResponse actualResponse = policy.processSync(context, next)) { assertEquals(retriedResponse, actualResponse); - assertTrue( - context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + assertNull(context.getHttpRequest().getHeaders().getValue("Authorization")); verify(initialResponse, times(1)).close(); verify(next, times(1)).processSync(); verify(retryNext, times(1)).processSync(); @@ -222,29 +165,26 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { } @Test - public void policyOnlyRetriesOncePerRequest() { + public void policyDoesNotRetrySessionAfter401() { 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.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_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"); + when(retriedResponse.getStatusCode()).thenReturn(200); try (HttpResponse actualResponse = policy.process(context, next).block()) { assertEquals(retriedResponse, actualResponse); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createAsync(); + verify(sessionProvider, times(1)).getSessionAsync(any()); } } @@ -255,7 +195,7 @@ public void policyReturns403WithoutRetry() { HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse forbiddenResponse = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(forbiddenResponse)); when(forbiddenResponse.getStatusCode()).thenReturn(403); @@ -265,20 +205,19 @@ public void policyReturns403WithoutRetry() { verify(next, times(1)).process(); verify(retryNext, times(0)).process(); verify(forbiddenResponse, times(0)).close(); - verify(sessionClient, times(1)).createAsync(); + verify(sessionProvider, times(1)).getSessionAsync(any()); } } @Test - public void policyRetriesAny401WithNewSession() { + public void policyFallsBackToBearerOnAny401() { 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.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(unauthorizedResponse)); when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); @@ -287,41 +226,31 @@ public void policyRetriesAny401WithNewSession() { try (HttpResponse actualResponse = policy.process(context, next).block()) { assertEquals(retriedResponse, actualResponse); - assertTrue( - context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + assertNull(context.getHttpRequest().getHeaders().getValue("Authorization")); verify(unauthorizedResponse, times(1)).close(); verify(next, times(1)).process(); verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createAsync(); + verify(sessionProvider, times(1)).getSessionAsync(any()); } } @Test - public void policyFallsToBearerOn503SessionUnavailableAsync() { + public void policyReturnsDataRequest503WithoutBearerFallbackAsync() { 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.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).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); + assertEquals(unavailableResponse, actualResponse); + verify(unavailableResponse, times(0)).close(); + verify(bearerPolicy, times(0)).process(any(), any()); + verify(retryNext, times(0)).process(); } } @@ -333,7 +262,7 @@ public void policyFallsToBearerOn400Async() { HttpResponse badRequestResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).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)); @@ -351,30 +280,22 @@ public void policyFallsToBearerOn400Async() { } @Test - public void policyFallsToBearerOn503SessionUnavailableSync() { + public void policyReturnsDataRequest503WithoutBearerFallbackSync() { 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.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(sessionProvider.getSession(any())).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); + assertEquals(unavailableResponse, actualResponse); + verify(unavailableResponse, times(0)).close(); + verify(bearerPolicy, times(0)).processSync(any(), any()); + verify(retryNext, times(0)).processSync(); } } @@ -386,7 +307,7 @@ public void policyFallsToBearerOn400Sync() { HttpResponse badRequestResponse = mock(HttpResponse.class); HttpResponse bearerResponse = mock(HttpResponse.class); - when(sessionClient.createSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken(FIRST_TOKEN)); when(next.clone()).thenReturn(retryNext); when(next.processSync()).thenReturn(badRequestResponse); when(retryNext.processSync()).thenReturn(bearerResponse); @@ -410,7 +331,7 @@ public void policyReturns503ServerBusyWithoutBearerFallback() { HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse busyResponse = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(busyResponse)); when(busyResponse.getStatusCode()).thenReturn(503); @@ -425,8 +346,8 @@ public void policyReturns503ServerBusyWithoutBearerFallback() { } @Test - public void noneModeAlwaysPassesThrough() { - SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + public void disabledModeAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.DISABLED); HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); @@ -436,15 +357,15 @@ public void noneModeAlwaysPassesThrough() { try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) { assertEquals(response, actualResponse); - // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + // Verify bearer policy was invoked (session delegates to bearer in DISABLED mode) verify(bearerPolicy, times(1)).process(any(), any()); - verify(sessionClient, times(0)).createAsync(); + verify(sessionProvider, times(0)).getSessionAsync(any()); } } @Test - public void noneModeSyncAlwaysPassesThrough() { - SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + public void disabledModeSyncAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.DISABLED); HttpPipelineCallContext context = createContext(); HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); HttpResponse response = mock(HttpResponse.class); @@ -454,20 +375,20 @@ public void noneModeSyncAlwaysPassesThrough() { try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) { assertEquals(response, actualResponse); - // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + // Verify bearer policy was invoked (session delegates to bearer in DISABLED mode) verify(bearerPolicy, times(1)).processSync(any(), any()); - verify(sessionClient, times(0)).createSync(); + verify(sessionProvider, times(0)).getSession(any()); } } @Test - public void alwaysModeSignsFirstRequest() { - // The default `policy` in setUp is ALWAYS — verify it signs the very first request + public void enabledModeSignsFirstRequest() { + // The default `policy` in setUp is ENABLED — verify it signs the very first request HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -475,66 +396,7 @@ public void alwaysModeSignsFirstRequest() { policy.process(context, next).block().close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); - verify(sessionClient, times(1)).createAsync(); - } - - @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)).createAsync(); - } - - // 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)).createAsync(); - } - } - - @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)).createSync(); - } - - 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)).createSync(); - } + verify(sessionProvider, times(1)).getSessionAsync(any()); } @Test @@ -544,8 +406,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.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -555,11 +416,13 @@ public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { 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)).createAsync(); + // The service hint must trigger a proactive background refresh call, 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). The refresh itself is delegated to the provider via refreshSession, distinct from the + // single getSessionAsync call used to obtain the credential for this request. + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, times(1)).refreshSession(any()); } @Test @@ -568,8 +431,7 @@ public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -578,21 +440,23 @@ 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)).createAsync(); + // Without the hint and with a fresh session, only the initial session is created and no refresh + // hint is forwarded to the provider. + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, never()).refreshSession(any()); } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); - return new SessionTokenCredentialPolicy(bearerPolicy, new AutoRefreshingCache<>(sessionClient), options); + return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, cooldown, options); } - private static StorageSessionCredential credentialWithToken(String token) { + private static SessionCredential 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, + private static SessionCredential credentialWithToken(String token, OffsetDateTime expiration) { + return new SessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration, SessionTestHelper.TEST_ACCOUNT_NAME); } @@ -626,7 +490,7 @@ public void getBlobRequestUsesSessionAuth() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); when(next.clone()).thenReturn(next); when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); @@ -639,7 +503,7 @@ public void getBlobRequestUsesSessionAuth() { @Test public void getBlobRequestProducesWellFormedSessionAuthHeader() { - StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN); + SessionCredential cred = credentialWithToken(FIRST_TOKEN); HttpRequest request = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); request.getHeaders() @@ -651,14 +515,14 @@ public void getBlobRequestProducesWellFormedSessionAuthHeader() { HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createAsync()).thenReturn(Mono.just(cred)); + when(sessionProvider.getSessionAsync(any())).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 + // The policy must delegate signing to SessionRequestSigner, 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); @@ -693,7 +557,7 @@ public void contentLengthZeroProducesSameSignatureAsMissingContentLength() { .set(HttpHeaderName.RANGE, "bytes=0-1023") .set(HttpHeaderName.CONTENT_LENGTH, "0") .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); - credentialWithToken(FIRST_TOKEN).signRequest(withCl0); + SessionRequestSigner.signRequest(withCl0, credentialWithToken(FIRST_TOKEN)); String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName)); HttpRequest withoutCl @@ -703,157 +567,15 @@ public void contentLengthZeroProducesSameSignatureAsMissingContentLength() { .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); + SessionRequestSigner.signRequest(withoutCl, credentialWithToken(FIRST_TOKEN)); String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); - assertEquals(sigWithCl0, sigWithoutCl, - "Session signature must normalize Content-Length: 0 to empty: signing with " - + "Content-Length: 0 must match signing without Content-Length"); + assertEquals(sigWithoutCl, sigWithCl0, + "Signing with Content-Length: 0 must produce the same signature as omitting it entirely."); } private static String extractSignature(String authHeader) { + assertNotNull(authHeader, "Authorization header should be set"); 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)).createAsync(); - } - - @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)).createAsync(); - } - - @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)).createAsync(); - } - - @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.createAsync()).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)).createAsync(); - } - - @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)).createAsync(); - } - - @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)).createAsync(); - } - - @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.createAsync()).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-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java index d0ba527842fb..83d9acd8ca49 100644 --- a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java @@ -11,27 +11,29 @@ import java.time.OffsetDateTime; import java.util.Objects; import java.util.concurrent.ThreadLocalRandom; -import com.azure.storage.common.implementation.util.AutoRefreshingCache.ExpiringValue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; /** * Cache for container-scoped storage session credentials. + *

+ * {@code T} is not required to implement any particular interface; the caller supplies a + * {@link Function} that extracts the expiration instant from a value, decoupling this cache from any + * specific credential shape. */ -public final class AutoRefreshingCache { - public interface ValueProvider { +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 ValueProvider valueProvider; + private final ValueProvider valueProvider; + private final Function expirationExtractor; private final Clock clock; private final Object creationLock = new Object(); private volatile T value; @@ -39,12 +41,14 @@ public interface ExpiringValue { private volatile boolean refreshing; private volatile Mono inflightCreation; - public AutoRefreshingCache(ValueProvider valueProvider) { - this(valueProvider, Clock.systemUTC()); + public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor) { + this(valueProvider, expirationExtractor, Clock.systemUTC()); } - public AutoRefreshingCache(ValueProvider valueProvider, Clock clock) { + public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor, + Clock clock) { this.valueProvider = Objects.requireNonNull(valueProvider, "'valueProvider' cannot be null."); + this.expirationExtractor = Objects.requireNonNull(expirationExtractor, "'expirationExtractor' cannot be null."); this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); } @@ -144,16 +148,21 @@ private Mono startSessionCreationAsync() { refreshing = true; - inflightCreation = valueProvider.createAsync().doOnNext(cred -> { + AtomicReference> creationReference = new AtomicReference<>(); + Mono creation = valueProvider.createAsync().doOnNext(cred -> { synchronized (creationLock) { setActiveValue(cred); } }).doFinally(ignored -> { synchronized (creationLock) { - inflightCreation = null; - refreshing = false; + if (inflightCreation == creationReference.get()) { + inflightCreation = null; + refreshing = false; + } } }).cache(); + creationReference.set(creation); + inflightCreation = creation; return inflightCreation; } @@ -161,12 +170,19 @@ private Mono startSessionCreationAsync() { private void setActiveValue(T newValue) { value = newValue; - nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), newValue.getExpiration()); + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), expirationExtractor.apply(newValue)); refreshing = false; + // Clear the in-flight marker here (not just in doFinally). doFinally only runs once the Mono + // reaches its terminal signal, but a downstream subscriber's onNext handler (e.g. inspecting the + // HTTP response for a "session expiring" hint) can run synchronously before that terminal signal + // is emitted. If a forced background refresh is triggered from within that onNext handler, it + // must see this creation as no-longer-in-flight so it starts a fresh one instead of returning the + // same (already-delivering) cached Mono. + inflightCreation = null; } private boolean isUsable(T value, OffsetDateTime now) { - return value != null && !now.isAfter(value.getExpiration()); + return value != null && !now.isAfter(expirationExtractor.apply(value)); } private boolean isRefreshDue(OffsetDateTime now) { From 41f1408896074f53fc6727c034dddab35165998c Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 15:31:43 -0400 Subject: [PATCH 13/21] Session provider lifecycle refactor: expand public contract, move caching into BlobSessionClient - Expand SessionProvider with default invalidateSession/refreshSession lifecycle methods - Move AutoRefreshingCache per-container caching from deleted SessionCredentialManager into BlobSessionClient - Extract account cooldown into narrow SessionAcquisitionCooldown helper - SessionTokenCredentialPolicy depends directly on SessionProvider; no manager wrapper - BYO custom providers are used directly without SDK-owned cache wrapping - Remove SessionOptions.orDefault (internal helper made public accidentally) - Update CHANGELOG and SessionOptions JavaDoc to reflect new BYO provider ownership model - Add BlobSessionClientCacheTest, SessionAcquisitionCooldownTest, update SessionTokenCredentialPolicyTest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/storage/azure-storage-blob/CHANGELOG.md | 7 +++++++ .../com/azure/storage/blob/models/SessionOptions.java | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index 69e02b085abf..e97acfb715e1 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -7,12 +7,19 @@ - Added per-container session credential caching for identity-authenticated blob downloads. - Added `SessionProvider`, `SessionCredential`, and `SessionRequestContext` APIs so applications can supply custom session credentials through `SessionOptions` on `BlobServiceClientBuilder`. +- Added `SessionProvider#invalidateSession` and `SessionProvider#refreshSession` default methods, so custom + `SessionProvider` implementations can opt into compare-and-invalidate eviction on HTTP `401` and proactive + background refresh on the service's `x-ms-auth-info: session_expiring` hint. Both default to safe no-ops, + so existing custom providers that only implement `getSession`/`getSessionAsync` continue to work unchanged. ### Breaking Changes - Session authentication is now enabled by default for eligible identity-authenticated blob downloads. - Replaced the `SessionMode` values `NONE`, `AUTO`, and `SINGLE_SPECIFIED_CONTAINER` with `ENABLED` and `DISABLED`. +- Removed `SessionOptions.orDefault(SessionOptions)`. This was an internal helper that should not have been + public; callers do not need a replacement since `BlobServiceClientBuilder#sessionOptions` already treats a + `null` argument as equivalent to a default-constructed `SessionOptions`. ### Bugs Fixed 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 index 7c569c8eb9af..cde0e68f0671 100644 --- 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 @@ -100,8 +100,12 @@ public SessionProvider getSessionProvider() { } /** - * Sets the custom provider used to obtain session credentials. The SDK continues to cache the returned - * credentials independently per container and performs request signing internally. + * Sets the custom provider used to obtain session credentials. When set, the provider is called directly + * for each eligible request: the SDK does not layer additional caching on top of a custom provider, so + * the provider is responsible for its own caching and refresh strategy. The SDK retains ownership of + * HMAC request signing, bearer-token fallback, and account-level acquisition cooldown. + * When {@code null}, the built-in provider is used, which calls the storage service's CreateSession REST + * API and manages per-container credential caching, proactive refresh, and idle eviction automatically. * * @param sessionProvider the custom {@link SessionProvider}, or {@code null} to use the built-in provider. * @return the updated {@link SessionOptions} object. From fd1c082cdea63cf1a7b29a31be0292c49653953a Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 16:04:32 -0400 Subject: [PATCH 14/21] remove sessionacquisitioncooldown, remove log entry --- sdk/storage/azure-storage-blob/CHANGELOG.md | 24 ---- .../implementation/util/BuilderHelper.java | 3 +- .../util/SessionAcquisitionCooldown.java | 96 -------------- .../util/SessionTokenCredentialPolicy.java | 125 ++++++++++-------- .../util/BlobSessionClientCacheTest.java | 3 +- .../util/SessionAcquisitionCooldownTest.java | 83 ------------ .../SessionTokenCredentialPolicyTest.java | 71 +++++++++- 7 files changed, 137 insertions(+), 268 deletions(-) delete mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java delete mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java diff --git a/sdk/storage/azure-storage-blob/CHANGELOG.md b/sdk/storage/azure-storage-blob/CHANGELOG.md index e97acfb715e1..6c06de3117ad 100644 --- a/sdk/storage/azure-storage-blob/CHANGELOG.md +++ b/sdk/storage/azure-storage-blob/CHANGELOG.md @@ -4,34 +4,10 @@ ### Features Added -- Added per-container session credential caching for identity-authenticated blob downloads. -- Added `SessionProvider`, `SessionCredential`, and `SessionRequestContext` APIs so applications can supply - custom session credentials through `SessionOptions` on `BlobServiceClientBuilder`. -- Added `SessionProvider#invalidateSession` and `SessionProvider#refreshSession` default methods, so custom - `SessionProvider` implementations can opt into compare-and-invalidate eviction on HTTP `401` and proactive - background refresh on the service's `x-ms-auth-info: session_expiring` hint. Both default to safe no-ops, - so existing custom providers that only implement `getSession`/`getSessionAsync` continue to work unchanged. - ### Breaking Changes -- Session authentication is now enabled by default for eligible identity-authenticated blob downloads. -- Replaced the `SessionMode` values `NONE`, `AUTO`, and `SINGLE_SPECIFIED_CONTAINER` with `ENABLED` and - `DISABLED`. -- Removed `SessionOptions.orDefault(SessionOptions)`. This was an internal helper that should not have been - public; callers do not need a replacement since `BlobServiceClientBuilder#sessionOptions` already treats a - `null` argument as equivalent to a default-constructed `SessionOptions`. - ### 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. -- Session acquisition failures now fall back to bearer authentication. HTTP `400`, `403`, and `5xx` - acquisition failures suppress additional session acquisition attempts for the account for five minutes. -- A session-authenticated request rejected with HTTP `401` now invalidates the cached session and falls back - directly to bearer authentication without retrying with another session. - ### 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/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index fa277ba0c0cf..2f21e11722ca 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 @@ -157,8 +157,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare sessionProvider = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, sessionOptions.getAccountName()); } - SessionAcquisitionCooldown cooldown = new SessionAcquisitionCooldown(); - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, cooldown, sessionOptions)); + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, sessionOptions)); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java deleted file mode 100644 index 23274ae76671..000000000000 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldown.java +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.storage.blob.implementation.util; - -import com.azure.core.util.CoreUtils; - -import java.time.Clock; -import java.time.Duration; -import java.time.OffsetDateTime; -import java.util.Locale; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Tracks a per-account acquisition cooldown: after a CreateSession acquisition failure with HTTP - * 400, 403, or 5xx, the affected account is placed in a five-minute cooldown during which all - * further session acquisition attempts are suppressed and requests fall back to bearer authentication. - *

- * The cooldown is account-scoped (case-insensitive), not container-scoped, because the failure - * indicates an account-level authorization or server issue rather than a container-specific one. - *

- * Thread-safe: all mutations are performed via compare-and-swap operations on a - * {@link ConcurrentHashMap}. - */ -final class SessionAcquisitionCooldown { - - private static final Duration COOLDOWN_DURATION = Duration.ofMinutes(5); - - private final Clock clock; - private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>(); - - SessionAcquisitionCooldown() { - this(Clock.systemUTC()); - } - - SessionAcquisitionCooldown(Clock clock) { - this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); - } - - /** - * Returns {@code true} if the given account is currently within its acquisition cooldown period. - * The check is case-insensitive; an expired cooldown entry is removed opportunistically. - * - * @param accountName the storage account name to check. - * @return {@code true} if the account is in cooldown; {@code false} otherwise. - */ - boolean isAccountInCooldown(String accountName) { - String key = normalize(accountName); - OffsetDateTime cooldownUntil = accountCooldowns.get(key); - if (cooldownUntil == null) { - return false; - } - - OffsetDateTime now = OffsetDateTime.now(clock); - if (now.isBefore(cooldownUntil)) { - return true; - } - - accountCooldowns.remove(key, cooldownUntil); - return false; - } - - /** - * Attempts to start (or extend) the acquisition cooldown for the given account. - * If the account is already in cooldown, this is a no-op and returns {@code false}. - * The check and the write are performed atomically via compare-and-swap. - * - * @param accountName the storage account name to place in cooldown. - * @return {@code true} if the cooldown was newly started (first caller wins); - * {@code false} if it was already active. - */ - boolean beginAccountCooldown(String accountName) { - String key = normalize(accountName); - OffsetDateTime now = OffsetDateTime.now(clock); - OffsetDateTime cooldownUntil = now.plus(COOLDOWN_DURATION); - - while (true) { - OffsetDateTime existing = accountCooldowns.get(key); - if (existing != null && now.isBefore(existing)) { - return false; - } - - boolean updated = existing == null - ? accountCooldowns.putIfAbsent(key, cooldownUntil) == null - : accountCooldowns.replace(key, existing, cooldownUntil); - if (updated) { - return true; - } - } - } - - private static String normalize(String name) { - return CoreUtils.isNullOrEmpty(name) ? "" : name.trim().toLowerCase(Locale.ROOT); - } -} 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 4663b43e9a18..ddca03675aa9 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 @@ -22,9 +22,13 @@ import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; -import java.util.Map; +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; /** * A pipeline policy that selects between session token and bearer token authentication. @@ -33,45 +37,31 @@ * {@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 ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); 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 Duration SESSION_ACQUISITION_COOLDOWN = Duration.ofMinutes(5); private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private final SessionProvider sessionProvider; - private final SessionAcquisitionCooldown cooldown; private final SessionOptions sessionOptions; + private final Clock clock; + private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>(); - /** - * 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, + SessionProvider sessionProvider, SessionOptions sessionOptions) { + this(bearerPolicy, sessionProvider, sessionOptions, Clock.systemUTC()); } SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, - SessionProvider sessionProvider, SessionAcquisitionCooldown cooldown, SessionOptions sessionOptions) { + SessionProvider sessionProvider, SessionOptions sessionOptions, Clock clock) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionProvider = Objects.requireNonNull(sessionProvider, "'sessionProvider' cannot be null."); - this.cooldown = Objects.requireNonNull(cooldown, "'cooldown' cannot be null."); this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); - } - - /** - * 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; + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); } @Override @@ -80,7 +70,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN if (requestContext == null) { return bearerPolicy.process(context, next); } - if (cooldown.isAccountInCooldown(requestContext.getAccountName())) { + if (isAccountInCooldown(requestContext.getAccountName())) { return bearerPolicy.process(context, next); } @@ -114,7 +104,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex if (requestContext == null) { return bearerPolicy.processSync(context, next); } - if (cooldown.isAccountInCooldown(requestContext.getAccountName())) { + if (isAccountInCooldown(requestContext.getAccountName())) { return bearerPolicy.processSync(context, next); } @@ -133,27 +123,9 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex return handleSessionResponseSync(context, response, session, requestContext, 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#ENABLED} 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#ENABLED} mode); - * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise. - */ - AuthStrategy analyzeRequest(HttpPipelineCallContext context) { - return resolveSessionRequest(context) == null ? AuthStrategy.USE_BEARER_TOKEN : AuthStrategy.USE_SESSION_TOKEN; - } - private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext context) { - if (sessionOptions.getSessionMode() == SessionMode.DISABLED) { - return null; - } - - if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + if (sessionOptions.getSessionMode() == SessionMode.DISABLED + || context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { return null; } @@ -166,26 +138,23 @@ private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext cont return null; } - String containerName = CoreUtils.isNullOrEmpty(sessionOptions.getContainerName()) - ? parts.getBlobContainerName() - : sessionOptions.getContainerName(); - String accountName = CoreUtils.isNullOrEmpty(sessionOptions.getAccountName()) - ? parts.getAccountName() - : sessionOptions.getAccountName(); - - if (CoreUtils.isNullOrEmpty(containerName) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { - return null; - } + String containerName = getOverrideOrDefault(sessionOptions.getContainerName(), parts.getBlobContainerName()); + String accountName = getOverrideOrDefault(sessionOptions.getAccountName(), parts.getAccountName()); // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth. - Map queryParams = parts.getUnparsedParameters(); - if (queryParams.containsKey("comp")) { + if (CoreUtils.isNullOrEmpty(containerName) + || CoreUtils.isNullOrEmpty(parts.getBlobName()) + || parts.getUnparsedParameters().containsKey("comp")) { return null; } return new SessionRequestContext().setContainerName(containerName).setAccountName(accountName); } + private static String getOverrideOrDefault(String override, String defaultValue) { + return CoreUtils.isNullOrEmpty(override) ? defaultValue : override; + } + /** * Handles the response after a session-authenticated async request. Inspects for * session-expiring hints, retryable failures, and fallback conditions. @@ -290,7 +259,7 @@ private void handleSessionAcquisitionFailure(SessionRequestContext requestContex if (current != null && ((HttpResponseException) current).getResponse() != null) { int statusCode = ((HttpResponseException) current).getResponse().getStatusCode(); if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) { - if (cooldown.beginAccountCooldown(requestContext.getAccountName())) { + if (beginAccountCooldown(requestContext.getAccountName())) { LOGGER.warning( "Session acquisition failed with HTTP {}. Suppressing session acquisition for this account " + "for five minutes and using bearer token.", @@ -302,4 +271,44 @@ private void handleSessionAcquisitionFailure(SessionRequestContext requestContex LOGGER.warning("Unable to obtain a session credential. Using bearer token.", error); } + + private boolean isAccountInCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime cooldownUntil = accountCooldowns.get(key); + if (cooldownUntil == null) { + return false; + } + + OffsetDateTime now = OffsetDateTime.now(clock); + if (now.isBefore(cooldownUntil)) { + return true; + } + + accountCooldowns.remove(key, cooldownUntil); + return false; + } + + private boolean beginAccountCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime now = OffsetDateTime.now(clock); + OffsetDateTime cooldownUntil = now.plus(SESSION_ACQUISITION_COOLDOWN); + + while (true) { + OffsetDateTime existing = accountCooldowns.get(key); + if (existing != null && now.isBefore(existing)) { + return false; + } + + boolean updated = existing == null + ? accountCooldowns.putIfAbsent(key, cooldownUntil) == null + : accountCooldowns.replace(key, existing, cooldownUntil); + if (updated) { + return true; + } + } + } + + private static String normalize(String accountName) { + return CoreUtils.isNullOrEmpty(accountName) ? "" : accountName.trim().toLowerCase(Locale.ROOT); + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java index 8583732e0be1..cfb6d5ce46cf 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java @@ -47,8 +47,7 @@ * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is - * covered separately by {@code SessionAcquisitionCooldownTest}, since that concern now lives in - * {@link SessionAcquisitionCooldown} rather than in {@link BlobSessionClient}. + * covered separately by {@code SessionTokenCredentialPolicyTest}. */ public class BlobSessionClientCacheTest { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java deleted file mode 100644 index c3b4b48fb33d..000000000000 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionAcquisitionCooldownTest.java +++ /dev/null @@ -1,83 +0,0 @@ -// 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 java.time.Clock; -import java.time.Duration; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.Locale; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Deterministic, network-free tests for {@link SessionAcquisitionCooldown}'s time-based, per-account - * behavior. - *

- * These tests drive the cooldown with an injectable {@link Clock} so its five-minute suppression window - * can be exercised without sleeping. This complements {@code BlobSessionClientCacheTest} (which covers - * per-container session caching) and {@code SessionTokenCredentialPolicyTest} (which covers the pipeline - * policy's use of both collaborators together). - */ -public class SessionAcquisitionCooldownTest { - - private static final String ACCOUNT_NAME = "myaccount"; - - @Test - public void accountCooldownExpiresAfterFiveMinutes() { - MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); - SessionAcquisitionCooldown cooldown = new SessionAcquisitionCooldown(clock); - - assertFalse(cooldown.isAccountInCooldown(ACCOUNT_NAME)); - assertTrue(cooldown.beginAccountCooldown(ACCOUNT_NAME)); - assertTrue(cooldown.isAccountInCooldown(ACCOUNT_NAME.toUpperCase(Locale.ROOT))); - assertFalse(cooldown.beginAccountCooldown(ACCOUNT_NAME)); - - clock.advance(Duration.ofMinutes(5)); - - assertFalse(cooldown.isAccountInCooldown(ACCOUNT_NAME)); - assertTrue(cooldown.beginAccountCooldown(ACCOUNT_NAME)); - } - - /** - * A {@link Clock} whose instant can be advanced, allowing deterministic control of the cooldown'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); - } - } -} 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 6fc80b5f4091..3e9e1bbdcd64 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 @@ -21,7 +21,12 @@ 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 java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -46,14 +51,12 @@ public class SessionTokenCredentialPolicyTest { HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; private SessionProvider sessionProvider; - private SessionAcquisitionCooldown cooldown; private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private SessionTokenCredentialPolicy policy; @BeforeEach public void beforeEach() { sessionProvider = mock(SessionProvider.class); - cooldown = new SessionAcquisitionCooldown(); bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); // Default mock behavior: bearer policy delegates to next policy in the pipeline. @@ -90,6 +93,31 @@ public void sessionAcquisitionServerFailureStartsAccountCooldown() { verify(secondNext, times(1)).process(); } + @Test + public void sessionAcquisitionCooldownExpiresAfterFiveMinutes() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + policy = createPolicy(SessionMode.ENABLED, clock); + HttpPipelineNextPolicy firstNext = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy cooldownNext = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy expiredNext = mock(HttpPipelineNextPolicy.class); + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); + + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)) + .thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(firstNext.process()).thenReturn(Mono.just(mock(HttpResponse.class))); + when(cooldownNext.process()).thenReturn(Mono.just(mock(HttpResponse.class))); + when(expiredNext.clone()).thenReturn(expiredNext); + when(expiredNext.process()).thenReturn(Mono.just(mock(HttpResponse.class))); + + policy.process(createContext(), firstNext).block(); + policy.process(createContext(), cooldownNext).block(); + clock.advance(Duration.ofMinutes(5)); + policy.process(createContext(), expiredNext).block(); + + verify(sessionProvider, times(2)).getSessionAsync(any()); + } + @Test public void policySignsRequestWithSessionCredential() { HttpPipelineCallContext context = createContext(); @@ -447,8 +475,12 @@ public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { + return createPolicy(mode, Clock.systemUTC()); + } + + private SessionTokenCredentialPolicy createPolicy(SessionMode mode, Clock clock) { SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); - return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, cooldown, options); + return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, options, clock); } private static SessionCredential credentialWithToken(String token) { @@ -578,4 +610,37 @@ private static String extractSignature(String authHeader) { assertNotNull(authHeader, "Authorization header should be set"); return authHeader.substring(authHeader.indexOf(':') + 1); } + + private static final class MutableClock extends Clock { + private final ZoneId zone; + private Instant instant; + + private 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; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + } } From cec3ba6d14792c86b016caaa23ea6465b54e71f9 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 16:53:14 -0400 Subject: [PATCH 15/21] remove SessionRequestSigner, rename SessionClient to SessionProvider --- .../blob/BlobServiceClientBuilder.java | 4 +- ...onClient.java => BlobSessionProvider.java} | 21 +-- .../implementation/util/BuilderHelper.java | 2 +- .../util/SessionRequestSigner.java | 156 ------------------ .../util/SessionTokenCredentialPolicy.java | 29 +++- .../storage/blob/models/SessionOptions.java | 1 + .../storage/blob/models/SessionProvider.java | 44 ++--- .../storage/blob/BuilderHelperTests.java | 33 +++- .../util/BlobSessionClientTests.java | 32 ++-- ...java => BlobSessionProviderCacheTest.java} | 87 ++++++---- .../util/SessionCredentialTest.java | 30 ++++ .../util/SessionProviderSeamTest.java | 30 ++-- .../util/SessionRequestSignerTest.java | 112 ------------- .../SessionTokenCredentialPolicyTest.java | 18 +- 14 files changed, 216 insertions(+), 383 deletions(-) rename sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/{BlobSessionClient.java => BlobSessionProvider.java} (93%) delete mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionRequestSigner.java rename sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/{BlobSessionClientCacheTest.java => BlobSessionProviderCacheTest.java} (83%) create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java delete mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionRequestSignerTest.java 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 a413cd08a7f9..d18707d9efc8 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 @@ -606,7 +606,9 @@ public BlobServiceClientBuilder audience(BlobAudience audience) { *

* 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)}. + * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}. If the options do not + * specify a {@link com.azure.storage.blob.models.SessionProvider}, the SDK creates a built-in provider + * scoped to this service client's pipeline. * * @param sessionOptions The session options for the HTTP pipeline. * @return the updated BlobServiceClientBuilder object. 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/BlobSessionProvider.java similarity index 93% rename from sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java rename to sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionProvider.java index e92fdcd3feb1..540b2d1d5105 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/BlobSessionProvider.java @@ -36,7 +36,7 @@ *

Caching model

*

* One {@link AutoRefreshingCache} of {@link SessionCredential} per container (keyed by a - * lowercase-normalized name) is maintained, allowing a single {@link BlobSessionClient} to serve + * lowercase-normalized name) is maintained, allowing a single {@link BlobSessionProvider} to serve * many containers without creating a new session for every request. Entries are opportunistically * evicted once they have not been accessed for {@value #IDLE_EVICTION_THRESHOLD_MINUTES} minutes. * @@ -58,11 +58,11 @@ * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an * {@link AzureBlobStorageImpl} internally. */ -final class BlobSessionClient implements SessionProvider { +final class BlobSessionProvider implements SessionProvider { static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5; - private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class); + private static final ClientLogger LOGGER = new ClientLogger(BlobSessionProvider.class); private static final Duration IDLE_EVICTION_THRESHOLD = Duration.ofMinutes(IDLE_EVICTION_THRESHOLD_MINUTES); // Defensive fallback expiration for a malformed/absent service response. private static final Duration DEFAULT_EXPIRATION_OFFSET = Duration.ofMinutes(5L); @@ -72,12 +72,13 @@ final class BlobSessionClient implements SessionProvider { private final Clock clock; private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>(); - BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName) { + BlobSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String accountName) { this(bearerPipeline, url, serviceVersion, accountName, Clock.systemUTC()); } /** Package-private constructor that accepts an injectable clock for deterministic testing. */ - BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, + BlobSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, Clock clock) { this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) .url(url) @@ -217,24 +218,24 @@ private static final class ContainerSessionCache { volatile OffsetDateTime lastAccess; private SessionCredential currentSessionCredential; - private ContainerSessionCache(BlobSessionClient client, Clock clock, String containerName, + private ContainerSessionCache(BlobSessionProvider provider, Clock clock, String containerName, String resolvedAccountName, OffsetDateTime lastAccess) { - this.cache = createCache(client, clock, containerName, resolvedAccountName); + this.cache = createCache(provider, clock, containerName, resolvedAccountName); this.lastAccess = lastAccess; } - private static AutoRefreshingCache createCache(BlobSessionClient client, Clock clock, + private static AutoRefreshingCache createCache(BlobSessionProvider provider, Clock clock, String containerName, String resolvedAccountName) { AutoRefreshingCache.ValueProvider valueProvider = new AutoRefreshingCache.ValueProvider() { @Override public Mono createAsync() { - return client.createSessionAsync(containerName, resolvedAccountName); + return provider.createSessionAsync(containerName, resolvedAccountName); } @Override public SessionCredential createSync() { - return client.createSessionSync(containerName, resolvedAccountName); + return provider.createSessionSync(containerName, resolvedAccountName); } }; return new AutoRefreshingCache<>(valueProvider, SessionCredential::getExpiresAt, clock); 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 2f21e11722ca..9295dc2f6bcf 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 @@ -154,7 +154,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare if (sessionProvider == null) { HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions); - sessionProvider = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + sessionProvider = new BlobSessionProvider(bearerPipeline, endpoint, effectiveServiceVersion, sessionOptions.getAccountName()); } policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, sessionOptions)); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionRequestSigner.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionRequestSigner.java deleted file mode 100644 index 90b866725497..000000000000 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionRequestSigner.java +++ /dev/null @@ -1,156 +0,0 @@ -// 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.blob.models.SessionCredential; -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.Collections; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.TreeMap; - -/** - * Signs requests using a {@link SessionCredential}, following the Shared Key string-to-sign algorithm with - * the Session scheme prefix. - *

- * This is deliberately internal: {@link SessionCredential} is public API so that a customer-supplied - * {@link com.azure.storage.blob.models.SessionProvider} can produce one, but the signing scheme itself is - * fixed by the storage service and is not something a {@code SessionProvider} implementation should (or - * needs to) reimplement. - */ -final class SessionRequestSigner { - - private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); - private static final String SESSION_PREFIX = "Session "; - - private SessionRequestSigner() { - } - - static void signRequest(HttpRequest request, SessionCredential credential) { - // 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, credential.getAccountName()); - StorageSharedKeyCredential sharedKey - = new StorageSharedKeyCredential(credential.getAccountName(), credential.getSessionKey()); - String signature = sharedKey.computeHmac256(stringToSign); - request.setHeader(HttpHeaderName.AUTHORIZATION, - SESSION_PREFIX + credential.getSessionToken() + ":" + signature); - } - - // 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. - // - // 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 static String buildStringToSign(HttpRequest request, String accountName) { - 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); - - 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, accountName)); - } - - 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 static String canonicalizedResource(URL url, Collator collator, String accountName) { - 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 (Map.Entry> entry : params.entrySet()) { - List values = entry.getValue(); - Collections.sort(values); - sb.append('\n').append(entry.getKey()).append(':').append(String.join(",", values)); - } - return sb.toString(); - } -} 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 ddca03675aa9..6d22c8c530c2 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 @@ -12,6 +12,7 @@ import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.CoreUtils; +import com.azure.core.util.DateTimeRfc1123; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.SessionCredential; @@ -19,6 +20,7 @@ import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.SessionProvider; import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; @@ -42,7 +44,9 @@ public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); 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 HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); private static final String SESSION_EXPIRING = "session_expiring"; + private static final String SESSION_PREFIX = "Session "; private static final Duration SESSION_ACQUISITION_COOLDOWN = Duration.ofMinutes(5); private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; @@ -202,7 +206,30 @@ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, } private void signRequest(HttpPipelineCallContext context, SessionCredential credential) { - SessionRequestSigner.signRequest(context.getHttpRequest(), credential); + if (context.getHttpRequest().getHeaders().getValue(X_MS_DATE) == null) { + context.getHttpRequest().setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now())); + } + + StorageSharedKeyCredential sharedKey + = new StorageSharedKeyCredential(credential.getAccountName(), credential.getSessionKey()); + boolean contentLengthMissing + = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_LENGTH) == null; + if (contentLengthMissing) { + context.getHttpRequest().setHeader(HttpHeaderName.CONTENT_LENGTH, "0"); + } + + String sharedKeyAuthorization; + try { + sharedKeyAuthorization = sharedKey.generateAuthorizationHeader(context.getHttpRequest().getUrl(), + context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders(), false); + } finally { + if (contentLengthMissing) { + context.getHttpRequest().getHeaders().remove(HttpHeaderName.CONTENT_LENGTH); + } + } + String signature = sharedKeyAuthorization.substring(sharedKeyAuthorization.indexOf(':') + 1); + context.getHttpRequest() + .setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + credential.getSessionToken() + ":" + signature); } private void handleSessionExpiringHeader(HttpResponse response, SessionRequestContext requestContext) { 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 index cde0e68f0671..ad0feafee2ed 100644 --- 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 @@ -104,6 +104,7 @@ public SessionProvider getSessionProvider() { * for each eligible request: the SDK does not layer additional caching on top of a custom provider, so * the provider is responsible for its own caching and refresh strategy. The SDK retains ownership of * HMAC request signing, bearer-token fallback, and account-level acquisition cooldown. + * The same provider instance may be supplied to multiple service client builders to share its cache. * When {@code null}, the built-in provider is used, which calls the storage service's CreateSession REST * API and manages per-container credential caching, proactive refresh, and idle eviction automatically. * diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java index a02107defdd4..24ef8e536061 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java @@ -6,12 +6,11 @@ import reactor.core.publisher.Mono; /** - * An extension point for supplying custom {@link SessionCredential session credentials}, scoped to a - * particular request as described by a {@link SessionRequestContext} (e.g. the target container). + * Provides and manages cached {@link SessionCredential session credentials} for storage containers. *

- * Implement this interface to bring your own session-issuing logic - for example, proxying CreateSession - * calls through another service, or layering a custom caching/rotation policy - while still benefiting from - * this SDK's request signing and account-level cooldown handling. Set an instance via + * Implement this interface to bring your own session creation and caching logic - for example, proxying + * CreateSession calls through another service or sharing a credential cache across clients - while still + * benefiting from this SDK's request signing and account-level cooldown handling. Set an instance via * {@link SessionOptions#setSessionProvider(SessionProvider)}, then pass those options to * {@link com.azure.storage.blob.BlobServiceClientBuilder#sessionOptions(SessionOptions)}, to have it used in * place of the default, built-in provider (which calls the storage service's CreateSession REST API directly @@ -30,7 +29,7 @@ *

    *
  1. Retrieve - {@link #getSessionAsync} / {@link #getSession} return a usable * {@link SessionCredential} for the container described by the request context, minting or refreshing one - * as needed. This is the only part of the contract every implementation must provide.
  2. + * as needed. *
  3. Invalidate - {@link #invalidateSession} is called when the service rejects a * previously-issued credential with HTTP 401, giving the implementation the opportunity to evict it so the * next retrieval mints a fresh one.
  4. @@ -39,12 +38,6 @@ * honored, giving the implementation the opportunity to proactively refresh it in the background. *
*

- * The invalidate and refresh steps are supplied as {@code default} no-op methods precisely so that BYO - * providers are not required to implement them: a minimal implementation that only overrides - * {@link #getSessionAsync} and {@link #getSession} remains correct (if slightly less efficient, since a - * 401 or an expiring-session hint will simply result in a fresh {@link #getSessionAsync} / {@link #getSession} - * call on the next eligible request rather than a proactively refreshed one). - *

* Regardless of the provider used, the SDK always retains ownership of HMAC request signing, bearer-token * fallback for ineligible requests, and account-level acquisition cooldown (suppressing further session * acquisition attempts for an account for a period after a 400/403/5xx failure). A {@link SessionProvider} @@ -62,7 +55,8 @@ *

* A single {@link SessionProvider} instance may be asked to serve many different containers (and, in * principle, multiple accounts) over its lifetime; the {@link SessionRequestContext} passed to each method - * call identifies which container (and account) the call applies to. + * call identifies which container (and account) the call applies to. Applications may reuse one provider + * instance across service clients when they intentionally want those clients to share the provider's cache. * * @see SessionCredential * @see SessionRequestContext @@ -71,8 +65,8 @@ public interface SessionProvider { /** - * Asynchronously obtains a {@link SessionCredential} for the request described by the given - * {@link SessionRequestContext}. + * Asynchronously returns a valid cached {@link SessionCredential} for the container described by + * {@code context}, creating or refreshing the credential when needed. * * @param context the request-scoped parameters (e.g. container name) the session should be created for. * @return a {@link Mono} that emits the resulting {@link SessionCredential}. @@ -80,8 +74,8 @@ public interface SessionProvider { Mono getSessionAsync(SessionRequestContext context); /** - * Synchronously obtains a {@link SessionCredential} for the request described by the given - * {@link SessionRequestContext}. + * Synchronously returns a valid cached {@link SessionCredential} for the container described by + * {@code context}, creating or refreshing the credential when needed. * * @param context the request-scoped parameters (e.g. container name) the session should be created for. * @return the resulting {@link SessionCredential}. @@ -99,17 +93,14 @@ public interface SessionProvider { *

* Warning semantics: The SDK logs a one-time warning when this returns {@code true} * (the first invalidation for a given rejected credential) and a verbose message when it returns - * {@code false} (already replaced). Custom implementations are encouraged but not required to track - * per-credential state; the default returns {@code false} (no invalidation performed). + * {@code false} (already replaced). * * @param context the request-scoped parameters (container, account) identifying the session scope. * @param rejectedCredential the credential the service rejected with HTTP 401. * @return {@code true} if this call invalidated the credential (first invalidator wins); - * {@code false} if the credential was already replaced or this provider does not track it. + * {@code false} if the credential was already replaced. */ - default boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { - return false; - } + boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential); /** * Non-blocking hint that the service has indicated the current session for the container described by @@ -120,13 +111,8 @@ default boolean invalidateSession(SessionRequestContext context, SessionCredenti * Non-blocking contract: This method is called from both synchronous and * asynchronous response-processing paths and must return immediately without waiting for the * refresh to complete. It must not throw. - *

- * The default implementation is a no-op. Custom implementations are encouraged but not required to - * perform a background refresh. * * @param context the request-scoped parameters (container, account) identifying the session scope. */ - default void refreshSession(SessionRequestContext context) { - // no-op by default - } + void refreshSession(SessionRequestContext context); } 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 0a941fd6cb17..f1033db155c0 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 @@ -49,6 +49,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; @@ -766,22 +767,44 @@ public void standaloneBlobBuilderDoesNotCreateSessions() { } @Test - public void customSessionProviderReceivesResolvedRequestContext() { + public void serviceBuilderUsesBuiltInSessionProviderByDefault() { + BlobServiceClient client = new BlobServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + assertTrue(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy")); + } + + @Test + public void customSessionProviderReceivesResolvedRequestContextWithoutSdkCaching() { AtomicReference receivedContext = new AtomicReference<>(); + AtomicInteger retrievalCount = new AtomicInteger(); SessionCredential credential = new SessionCredential("session-token", "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==", OffsetDateTime.now().plusMinutes(5), "account"); SessionProvider provider = new SessionProvider() { @Override public Mono getSessionAsync(SessionRequestContext context) { receivedContext.set(context); + retrievalCount.incrementAndGet(); return Mono.just(credential); } @Override public SessionCredential getSession(SessionRequestContext context) { receivedContext.set(context); + retrievalCount.incrementAndGet(); return credential; } + + @Override + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + return false; + } + + @Override + public void refreshSession(SessionRequestContext context) { + } }; SessionOptions options = new SessionOptions().setSessionProvider(provider); AtomicReference receivedAuthorization = new AtomicReference<>(); @@ -793,15 +816,17 @@ public SessionCredential getSession(SessionRequestContext context) { = BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), testHttpClient, new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), options, null); - HttpRequest request = new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"); - - StepVerifier.create(pipeline.send(request)) + StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"))) + .assertNext(response -> assertEquals(200, response.getStatusCode())) + .verifyComplete(); + StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"))) .assertNext(response -> assertEquals(200, response.getStatusCode())) .verifyComplete(); assertTrue(receivedAuthorization.get().startsWith("Session session-token:")); assertEquals("container", receivedContext.get().getContainerName()); assertEquals("account", receivedContext.get().getAccountName()); + assertEquals(2, retrievalCount.get()); } // endregion 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 8589bafee116..823a3847a504 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 @@ -30,12 +30,12 @@ public class BlobSessionClientTests extends BlobTestBase { @Test public void createSessionReturnsTokenAndKey() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); - BlobSessionClient sessionClient - = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobSessionProvider sessionProvider + = new BlobSessionProvider(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential - = sessionClient.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -47,12 +47,12 @@ public void createSessionReturnsTokenAndKey() { public void createSessionAsyncReturnsTokenAndKey() { BlobContainerAsyncClient oauthCc = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); - BlobSessionClient sessionClient - = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobSessionProvider sessionProvider + = new BlobSessionProvider(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier - .create(sessionClient + .create(sessionProvider .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) .assertNext(credential -> { assertNotNull(credential); @@ -66,12 +66,12 @@ public void createSessionAsyncReturnsTokenAndKey() { @Test public void createSessionSyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); - BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + BlobSessionProvider sessionProvider = new BlobSessionProvider(createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential - = sessionClient.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -83,12 +83,12 @@ public void createSessionSyncUsesProvidedHttpPipeline() { @Test public void createSessionAsyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); - BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + BlobSessionProvider sessionProvider = new BlobSessionProvider(createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier - .create(sessionClient + .create(sessionProvider .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) .assertNext(credential -> { assertNotNull(credential); @@ -113,12 +113,12 @@ public void createSessionWithUserDelegationSas() { BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); - BlobSessionClient sessionClient - = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobSessionProvider sessionProvider + = new BlobSessionProvider(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential - = sessionClient.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); + = sessionProvider.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -138,12 +138,12 @@ public void createSessionAsyncWithUserDelegationSas() { = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) .buildClient(); - BlobSessionClient sessionClient - = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobSessionProvider sessionProvider + = new BlobSessionProvider(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier - .create(sessionClient + .create(sessionProvider .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) .assertNext(credential -> { assertNotNull(credential); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java similarity index 83% rename from sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java rename to sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java index cfb6d5ce46cf..7c5037270346 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientCacheTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java @@ -36,20 +36,21 @@ import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Deterministic, network-free tests for {@link BlobSessionClient}'s time-based, per-container caching + * Deterministic, network-free tests for {@link BlobSessionProvider}'s time-based, per-container caching * behavior. *

- * These tests drive {@link BlobSessionClient} with an injectable {@link Clock} and a fake HTTP transport + * These tests drive {@link BlobSessionProvider} with an injectable {@link Clock} and a fake HTTP transport * ({@link ControllableHttpClient}) so the expiry, proactive-refresh, and per-container independence logic * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is * covered separately by {@code SessionTokenCredentialPolicyTest}. */ -public class BlobSessionClientCacheTest { +public class BlobSessionProviderCacheTest { private static final String ACCOUNT_NAME = "myaccount"; private static final String CONTAINER_A = "container-a"; @@ -71,10 +72,10 @@ public void expiredByTimeOnSecondRequestCreatesNewSession() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); // First request: cold cache mints a good token and uses it. - SessionCredential firstRequest = client.getSession(contextFor(CONTAINER_A)); + SessionCredential firstRequest = provider.getSession(contextFor(CONTAINER_A)); assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); assertEquals(1, httpClient.getCallCount(CONTAINER_A)); @@ -82,7 +83,7 @@ public void expiredByTimeOnSecondRequestCreatesNewSession() { clock.advance(SESSION_LIFETIME.plusSeconds(1)); // Second request: the cached token is expired by time, so a new session is created instead of reused. - SessionCredential secondRequest = client.getSession(contextFor(CONTAINER_A)); + SessionCredential secondRequest = provider.getSession(contextFor(CONTAINER_A)); assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); assertEquals(2, httpClient.getCallCount(CONTAINER_A)); } @@ -98,10 +99,10 @@ public void automaticBackgroundRefreshFiresWithoutServiceHint() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); // First request: cold cache mints the initial token. - assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); assertEquals(1, httpClient.getCallCount(CONTAINER_A)); // Advance to a point guaranteed to be past the jittered refresh time (80-100% of lifetime minus the @@ -110,13 +111,13 @@ 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, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); assertEquals(2, httpClient.getCallCount(CONTAINER_A)); // Third request: the background refresh has swapped in the new token, which is now served. The // refresh runs on a background subscription, so poll briefly rather than asserting immediately. assertEquals(SECOND_TOKEN, - waitForToken(() -> client.getSession(contextFor(CONTAINER_A)), SECOND_TOKEN).getSessionToken()); + waitForToken(() -> provider.getSession(contextFor(CONTAINER_A)), SECOND_TOKEN).getSessionToken()); // Still only one inline creation and one background refresh overall (no over-eager churn). assertEquals(2, httpClient.getCallCount(CONTAINER_A)); } @@ -133,11 +134,11 @@ public void independentContainersRefreshIndependently() { httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, "refreshed-a", now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); httpClient.enqueue(CONTAINER_B, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); // Mint an initial session for each container. - assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); - assertEquals(SECOND_TOKEN, client.getSession(contextFor(CONTAINER_B)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(SECOND_TOKEN, provider.getSession(contextFor(CONTAINER_B)).getSessionToken()); // Advance past container A's jittered refresh window (both containers were minted at the same time, // so this is also past B's refresh window by clock time - but B must only refresh once *it* is @@ -146,7 +147,7 @@ public void independentContainersRefreshIndependently() { // Touching container A triggers its background refresh. The refresh runs on a background // subscription, so poll briefly rather than asserting the call count immediately. - assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); waitForCallCount(httpClient, CONTAINER_A, 2); assertEquals(2, httpClient.getCallCount(CONTAINER_A)); @@ -165,17 +166,17 @@ public void noRefreshBeforeJitterWindowWithoutServiceHint() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); // First request mints the token. - assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).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, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); } assertEquals(1, httpClient.getCallCount(CONTAINER_A)); @@ -190,9 +191,9 @@ public void coldCacheCreatesValueAsync() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); - StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) .verifyComplete(); @@ -208,16 +209,16 @@ public void cachedValueIsReusedOnLaterAsyncRequests() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); - StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) .verifyComplete(); // Advance well short of the earliest jittered refresh point (80% of lifetime). clock.advance(Duration.ofSeconds(30)); - StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) .verifyComplete(); @@ -234,12 +235,12 @@ public void creationFailurePropagatesAndAllowsRetryAsync() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueueFailure(CONTAINER_A); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); - StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); // The failure left no cached value behind, so the retry mints a fresh one. - StepVerifier.create(client.getSessionAsync(contextFor(CONTAINER_A))) + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) .verifyComplete(); @@ -255,15 +256,35 @@ public void containerNameLookupIsCaseInsensitive() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); - assertEquals(FIRST_TOKEN, client.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); assertEquals(FIRST_TOKEN, - client.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken()); + provider.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken()); assertEquals(1, httpClient.getCallCount(CONTAINER_A)); } + @Test + public void refreshAndInvalidationAreOwnedByProvider() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, "third-session-token", now(clock).plus(SESSION_LIFETIME)); + BlobSessionProvider provider = createProvider(httpClient, clock); + SessionRequestContext context = contextFor(CONTAINER_A); + + SessionCredential first = provider.getSession(context); + provider.refreshSession(context); + SessionCredential second = waitForToken(() -> provider.getSession(context), SECOND_TOKEN); + + assertFalse(provider.invalidateSession(context, first)); + assertTrue(provider.invalidateSession(context, second)); + assertFalse(provider.invalidateSession(context, second)); + assertEquals("third-session-token", provider.getSession(context).getSessionToken()); + } + /** * Concurrent async callers arriving while a creation is still in flight must join that single in-flight * creation instead of each triggering their own, and all of them must observe the same value. @@ -274,10 +295,10 @@ public void concurrentAsyncRequestsShareASingleInFlightCreation() { ControllableHttpClient httpClient = new ControllableHttpClient(); // A pending response models a CreateSession call that is still outstanding. Sinks.One pendingResponse = httpClient.preparePendingResponse(CONTAINER_A); - BlobSessionClient client = createClient(httpClient, clock); + BlobSessionProvider provider = createProvider(httpClient, clock); - Mono first = client.getSessionAsync(contextFor(CONTAINER_A)); - Mono second = client.getSessionAsync(contextFor(CONTAINER_A)); + Mono first = provider.getSessionAsync(contextFor(CONTAINER_A)); + Mono second = provider.getSessionAsync(contextFor(CONTAINER_A)); AtomicReference firstResult = new AtomicReference<>(); AtomicReference secondResult = new AtomicReference<>(); @@ -350,9 +371,9 @@ private static void sleepBriefly() { } } - private static BlobSessionClient createClient(HttpClient httpClient, Clock clock) { + private static BlobSessionProvider createProvider(HttpClient httpClient, Clock clock) { HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); - return new BlobSessionClient(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + return new BlobSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java new file mode 100644 index 000000000000..cee7029ca6ed --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.storage.blob.models.SessionCredential; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SessionCredentialTest { + + @Test + public void isExpiredReturnsTrueWhenPastExpiration() { + assertTrue(SessionTestHelper.createExpiredCredential().isExpired()); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + assertFalse(SessionTestHelper.createValidCredential().isExpired()); + } + + @Test + public void constructorRejectsNullExpiration() { + assertThrows(NullPointerException.class, () -> new SessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME)); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java index 1854caee0ce1..210b9a269e43 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java @@ -25,16 +25,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows; /** - * Small, focused tests for the public {@link SessionProvider} contract implemented by {@link BlobSessionClient}. + * Small, focused tests for the public {@link SessionProvider} contract implemented by {@link BlobSessionProvider}. *

- * These verify that {@link BlobSessionClient#getSessionAsync(SessionRequestContext)} and - * {@link BlobSessionClient#getSession(SessionRequestContext)} route the CreateSession REST call to the + * These verify that {@link BlobSessionProvider#getSessionAsync(SessionRequestContext)} and + * {@link BlobSessionProvider#getSession(SessionRequestContext)} route the CreateSession REST call to the * container named on the {@link SessionRequestContext}, proving out the "per-request container" seam that * backs the BYO {@link SessionProvider} extension point, and that a context missing a container * name is rejected rather than silently falling back to some default. This complements (and does not * duplicate) {@code BlobSessionClientTests}, which exercises these same paths against the live service, and - * {@code BlobSessionClientCacheTest}, which fakes the transport wholesale to test per-container cache timing - * behavior. Here {@link BlobSessionClient} is real and only the transport is faked, so the container name + * {@code BlobSessionProviderCacheTest}, which fakes the transport wholesale to test per-container cache timing + * behavior. Here {@link BlobSessionProvider} is real and only the transport is faked, so the container name * actually placed on the wire is what's being verified. */ public class SessionProviderSeamTest { @@ -45,12 +45,12 @@ public class SessionProviderSeamTest { @Test public void getSessionAsyncUsesContainerFromContext() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionClient sessionClient = createSessionClient(requestedContainer); + BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); - StepVerifier.create(sessionClient.getSessionAsync(context)).assertNext(credential -> { + StepVerifier.create(sessionProvider.getSessionAsync(context)).assertNext(credential -> { assertNotNull(credential); assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); @@ -62,12 +62,12 @@ public void getSessionAsyncUsesContainerFromContext() { @Test public void getSessionSyncUsesContainerFromContext() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionClient sessionClient = createSessionClient(requestedContainer); + BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); - SessionCredential credential = sessionClient.getSession(context); + SessionCredential credential = sessionProvider.getSession(context); assertNotNull(credential); assertNotNull(credential.getSessionToken()); @@ -78,29 +78,29 @@ public void getSessionSyncUsesContainerFromContext() { @Test public void missingContextContainerThrowsSync() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionClient sessionClient = createSessionClient(requestedContainer); + BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); // There is no constructor-supplied fallback container: a context with no container name must be // rejected rather than silently degrading to some default. SessionRequestContext context = new SessionRequestContext(); - assertThrows(IllegalArgumentException.class, () -> sessionClient.getSession(context)); + assertThrows(IllegalArgumentException.class, () -> sessionProvider.getSession(context)); } @Test public void missingContextContainerThrowsAsync() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionClient sessionClient = createSessionClient(requestedContainer); + BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext(); - StepVerifier.create(sessionClient.getSessionAsync(context)).verifyError(IllegalArgumentException.class); + StepVerifier.create(sessionProvider.getSessionAsync(context)).verifyError(IllegalArgumentException.class); } - private static BlobSessionClient createSessionClient(AtomicReference requestedContainer) { + private static BlobSessionProvider createSessionProvider(AtomicReference requestedContainer) { HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new CreateSessionMockClient(requestedContainer)).build(); - return new BlobSessionClient(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + return new BlobSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", BlobServiceVersion.getLatest(), ACCOUNT_NAME); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionRequestSignerTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionRequestSignerTest.java deleted file mode 100644 index 0c959158a1e4..000000000000 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionRequestSignerTest.java +++ /dev/null @@ -1,112 +0,0 @@ -// 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.blob.models.SessionCredential; -import com.azure.storage.common.StorageSharedKeyCredential; -import org.junit.jupiter.api.Test; - -import java.net.MalformedURLException; -import java.net.URL; - -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.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class SessionRequestSignerTest { - - @Test - public void signRequestUsesSessionScheme() throws MalformedURLException { - SessionCredential credential = SessionTestHelper.createValidCredential(); - HttpRequest request - = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); - - SessionRequestSigner.signRequest(request, credential); - - 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 { - SessionCredential 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"))); - - SessionRequestSigner.signRequest(request, credential); - - 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 SessionRequestSigner.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 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 { - SessionCredential 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"); - - SessionRequestSigner.signRequest(request, sessionCred); - - 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 constructorRejectsNullExpiration() { - assertThrows(NullPointerException.class, () -> new SessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, - SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME)); - } -} 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 3e9e1bbdcd64..3882f259e030 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 @@ -554,9 +554,7 @@ public void getBlobRequestProducesWellFormedSessionAuthHeader() { policy.process(context, next).block().close(); - // The policy must delegate signing to SessionRequestSigner, producing a Session-scheme - // Authorization header of the form `Session :`. End-to-end signature - // correctness against the live service is covered by ContainerApiTests.downloadBlobOverSessionAuth. + // The policy adapts Shared Key signing to the Session authorization scheme. String actual = request.getHeaders().getValue(authHeaderName); assertNotNull(actual, "Authorization header should be set by the policy"); assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"), @@ -589,7 +587,7 @@ public void contentLengthZeroProducesSameSignatureAsMissingContentLength() { .set(HttpHeaderName.RANGE, "bytes=0-1023") .set(HttpHeaderName.CONTENT_LENGTH, "0") .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); - SessionRequestSigner.signRequest(withCl0, credentialWithToken(FIRST_TOKEN)); + signRequestWithPolicy(withCl0); String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName)); HttpRequest withoutCl @@ -599,7 +597,7 @@ public void contentLengthZeroProducesSameSignatureAsMissingContentLength() { .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); - SessionRequestSigner.signRequest(withoutCl, credentialWithToken(FIRST_TOKEN)); + signRequestWithPolicy(withoutCl); String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); assertEquals(sigWithoutCl, sigWithCl0, @@ -611,6 +609,16 @@ private static String extractSignature(String authHeader) { return authHeader.substring(authHeader.indexOf(':') + 1); } + private void signRequestWithPolicy(HttpRequest request) { + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + when(sessionProvider.getSessionAsync(any())).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(createContextForRequest(request), next).block().close(); + } + private static final class MutableClock extends Clock { private final ZoneId zone; private Instant instant; From ec36ad0d664d9e2e47e547649120097e19cbb958 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 16:56:25 -0400 Subject: [PATCH 16/21] rename SessionProviderSeamTests to SessionProviderTests --- .../{SessionProviderSeamTest.java => SessionProviderTests.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/{SessionProviderSeamTest.java => SessionProviderTests.java} (99%) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java similarity index 99% rename from sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java rename to sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java index 210b9a269e43..7e5155f29379 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderSeamTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java @@ -37,7 +37,7 @@ * behavior. Here {@link BlobSessionProvider} is real and only the transport is faked, so the container name * actually placed on the wire is what's being verified. */ -public class SessionProviderSeamTest { +public class SessionProviderTests { private static final String ACCOUNT_NAME = "myaccount"; private static final String CONTEXT_CONTAINER = "context-container"; From 88c29c4601c191a4efb3142f46ba4f3c7d5a3fc1 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 17:23:39 -0400 Subject: [PATCH 17/21] create helper method for session provider inside BuilderHelper --- .../blob/implementation/util/BuilderHelper.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 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 9295dc2f6bcf..f0159a7a980e 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 @@ -152,10 +152,8 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); SessionProvider sessionProvider = sessionOptions.getSessionProvider(); if (sessionProvider == null) { - HttpPipeline bearerPipeline - = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions); - sessionProvider = new BlobSessionProvider(bearerPipeline, endpoint, effectiveServiceVersion, - sessionOptions.getAccountName()); + sessionProvider = createDefaultSessionProvider(policies, bearerPolicy, effectiveHttpClient, + clientOptions, endpoint, effectiveServiceVersion, sessionOptions.getAccountName()); } policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, sessionOptions)); } @@ -200,6 +198,15 @@ private static HttpPipeline buildBearerPipeline(List preAuth .build(); } + private static SessionProvider createDefaultSessionProvider(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, ClientOptions clientOptions, + String endpoint, BlobServiceVersion serviceVersion, String accountName) { + HttpPipeline bearerPipeline = buildBearerPipeline(preAuthPolicies, bearerPolicy, httpClient, clientOptions); + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + return new BlobSessionProvider(bearerPipeline, endpoint, effectiveServiceVersion, accountName); + } + private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) { if (httpClient != null) { return httpClient; From 974c904380f5fc5265b62e85a71729ba8d445eea Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 17:33:12 -0400 Subject: [PATCH 18/21] refactor beginAccountCooldown --- .../util/SessionTokenCredentialPolicy.java | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) 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 6d22c8c530c2..8237d5aeb5a8 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 @@ -31,6 +31,7 @@ import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; /** * A pipeline policy that selects between session token and bearer token authentication. @@ -319,20 +320,16 @@ private boolean beginAccountCooldown(String accountName) { String key = normalize(accountName); OffsetDateTime now = OffsetDateTime.now(clock); OffsetDateTime cooldownUntil = now.plus(SESSION_ACQUISITION_COOLDOWN); - - while (true) { - OffsetDateTime existing = accountCooldowns.get(key); - if (existing != null && now.isBefore(existing)) { - return false; + AtomicBoolean cooldownStarted = new AtomicBoolean(); + accountCooldowns.compute(key, (ignored, currentExpirationTime) -> { + if (currentExpirationTime != null && now.isBefore(currentExpirationTime)) { + return currentExpirationTime; } - boolean updated = existing == null - ? accountCooldowns.putIfAbsent(key, cooldownUntil) == null - : accountCooldowns.replace(key, existing, cooldownUntil); - if (updated) { - return true; - } - } + cooldownStarted.set(true); + return cooldownUntil; + }); + return cooldownStarted.get(); } private static String normalize(String accountName) { From 737253c23fd8f5abe09c67389ed7ee4ab22a5f2d Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 18:16:16 -0400 Subject: [PATCH 19/21] inline error codes, make async code easier to read --- .../util/SessionTokenCredentialPolicy.java | 37 +++++-------------- 1 file changed, 10 insertions(+), 27 deletions(-) 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 8237d5aeb5a8..1e9cb163af1a 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 @@ -29,7 +29,6 @@ import java.time.OffsetDateTime; import java.util.Locale; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -88,19 +87,14 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN return bearerPolicy.process(context, next); } - return sessionMono.map(Optional::of).onErrorResume(error -> { + return sessionMono.onErrorResume(error -> { handleSessionAcquisitionFailure(requestContext, error); - return Mono.just(Optional.empty()); - }).defaultIfEmpty(Optional.empty()).flatMap(sessionResult -> { - if (!sessionResult.isPresent()) { - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - return bearerPolicy.process(context, next); - } - SessionCredential session = sessionResult.get(); + return Mono.empty(); + }).flatMap(session -> { signRequest(context, session); return next.process() .flatMap(response -> handleSessionResponse(context, response, session, requestContext, retryNext)); - }); + }).switchIfEmpty(Mono.defer(() -> bearerPolicy.process(context, next))); } @Override @@ -119,7 +113,6 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex session = sessionProvider.getSession(requestContext); } catch (RuntimeException ex) { handleSessionAcquisitionFailure(requestContext, ex); - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); return bearerPolicy.processSync(context, next); } signRequest(context, session); @@ -169,7 +162,7 @@ private Mono handleSessionResponse(HttpPipelineCallContext context handleSessionExpiringHeader(response, requestContext); - if (isUnauthorizedResponse(response)) { + if (response.getStatusCode() == 401) { logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); } @@ -192,7 +185,7 @@ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, handleSessionExpiringHeader(response, requestContext); - if (isUnauthorizedResponse(response)) { + if (response.getStatusCode() == 401) { logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); } @@ -254,14 +247,6 @@ private static void logSessionInvalidation(SessionRequestContext requestContext, } } - /** - * 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 responses where retrying with bearer authentication can preserve * request compatibility when session authentication is unavailable or rejected. @@ -271,11 +256,8 @@ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, H return false; } - return isUnauthorizedResponse(response) || isBadRequest(response); - } - - private static boolean isBadRequest(HttpResponse response) { - return response.getStatusCode() == 400; + int statusCode = response.getStatusCode(); + return statusCode == 400 || statusCode == 401; } private void handleSessionAcquisitionFailure(SessionRequestContext requestContext, Throwable error) { @@ -285,7 +267,8 @@ private void handleSessionAcquisitionFailure(SessionRequestContext requestContex } if (current != null && ((HttpResponseException) current).getResponse() != null) { - int statusCode = ((HttpResponseException) current).getResponse().getStatusCode(); + HttpResponse response = ((HttpResponseException) current).getResponse(); + int statusCode = response.getStatusCode(); if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) { if (beginAccountCooldown(requestContext.getAccountName())) { LOGGER.warning( From ecbd95ea3f3813edd34bf633256ec9917db29d30 Mon Sep 17 00:00:00 2001 From: Ray Chen Date: Thu, 9 Apr 2026 10:44:50 -0700 Subject: [PATCH 20/21] Use mirror settings for CFS in CI (#48718) * Turned back using mirror settings for CFS in CI * Restore POM repository overrides for local dev CFS routing Keep and blocks in POMs so local development continues to route through CFS (no-auth) by default. The CI mirror settings in eng/settings.xml handle the tools/extensions that don't respect POM-level repos. * Updated contributing doc * Added clarification for using mirror settings locally * Added azure-artifacts folder to gitignore --- .gitignore | 3 +++ CONTRIBUTING.md | 8 ++++++++ .../templates/steps/maven-authenticate.yml | 9 ++++++++- eng/settings.xml | 13 +++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 eng/settings.xml diff --git a/.gitignore b/.gitignore index 997c0e0648a2..5cac5c04a890 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ stress-test-addons* # Temp typespec files TempTypeSpecFiles/ + +# Azure Artifacts Credential Provider runtime +.azure-artifacts/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f9de9fca603..73cffade0293 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,14 @@ For detailed instructions, refer to the [Maven Credential Provider documentation > **Note:** For Maven Azure DevOps pipeline authentication, use the [MavenAuthenticate@0](https://learn.microsoft.com/azure/devops/pipelines/tasks/reference/maven-authenticate-v0) pipeline task. +##### Matching CI behavior locally + +All Maven dependency and artifact resolution already uses the Azure Artifacts feed by default via `` declarations in the project POMs. However, Maven plugins and extensions do not honor POM-level repositories. To route plugin traffic through the Azure Artifacts feed as well (matching CI behavior), copy the mirror settings: + +```bash +cp eng/settings.xml ~/.m2/settings.xml +``` + ##### Troubleshooting 401 Unauthorized errors If you encounter a `401 Unauthorized` error when running Maven commands: diff --git a/eng/pipelines/templates/steps/maven-authenticate.yml b/eng/pipelines/templates/steps/maven-authenticate.yml index 906102779e59..8aeb866d5209 100644 --- a/eng/pipelines/templates/steps/maven-authenticate.yml +++ b/eng/pipelines/templates/steps/maven-authenticate.yml @@ -1,6 +1,13 @@ steps: + # Copy mirror settings to default Maven location so all requests go through CFS + - pwsh: | + $m2Dir = if ($env:USERPROFILE) { "$env:USERPROFILE\.m2" } else { "$HOME/.m2" } + New-Item -ItemType Directory -Force -Path $m2Dir | Out-Null + Copy-Item -Path "$(Build.SourcesDirectory)/eng/settings.xml" -Destination "$m2Dir/settings.xml" + displayName: 'Setup Maven mirror settings' + # Authenticate with Azure Artifacts feeds - # Repo URLs are defined in azure-sdk-parent pom.xml with id 'azure-sdk-for-java' + # MavenAuthenticate adds entries to ~/.m2/settings.xml matching mirror id 'azure-sdk-for-java' - task: MavenAuthenticate@0 displayName: 'Maven Authenticate' inputs: diff --git a/eng/settings.xml b/eng/settings.xml new file mode 100644 index 000000000000..f77985887676 --- /dev/null +++ b/eng/settings.xml @@ -0,0 +1,13 @@ + + + + azure-sdk-for-java + Azure Artifacts Maven Mirror + https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-java/maven/v1 + external:*,!confluent,!repository.spring.milestone + + + From bbb79a1ce861b6cbd416b63d29c8cdfb75304304 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 10 Aug 2026 19:42:36 -0400 Subject: [PATCH 21/21] rename BlobSessionProvider to TokenCredentialSessionProvider --- .../implementation/util/BuilderHelper.java | 2 +- ...va => TokenCredentialSessionProvider.java} | 18 +++++----- .../util/BlobSessionClientTests.java | 36 +++++++++---------- .../util/SessionProviderTests.java | 24 +++++++------ ...enCredentialSessionProviderCacheTest.java} | 30 ++++++++-------- 5 files changed, 56 insertions(+), 54 deletions(-) rename sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/{BlobSessionProvider.java => TokenCredentialSessionProvider.java} (94%) rename sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/{BlobSessionProviderCacheTest.java => TokenCredentialSessionProviderCacheTest.java} (94%) 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 f0159a7a980e..01b5d5cc82ac 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 @@ -204,7 +204,7 @@ private static SessionProvider createDefaultSessionProvider(ListCaching model *

* One {@link AutoRefreshingCache} of {@link SessionCredential} per container (keyed by a - * lowercase-normalized name) is maintained, allowing a single {@link BlobSessionProvider} to serve + * lowercase-normalized name) is maintained, allowing a single {@link TokenCredentialSessionProvider} to serve * many containers without creating a new session for every request. Entries are opportunistically * evicted once they have not been accessed for {@value #IDLE_EVICTION_THRESHOLD_MINUTES} minutes. * @@ -58,11 +58,11 @@ * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an * {@link AzureBlobStorageImpl} internally. */ -final class BlobSessionProvider implements SessionProvider { +final class TokenCredentialSessionProvider implements SessionProvider { static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5; - private static final ClientLogger LOGGER = new ClientLogger(BlobSessionProvider.class); + private static final ClientLogger LOGGER = new ClientLogger(TokenCredentialSessionProvider.class); private static final Duration IDLE_EVICTION_THRESHOLD = Duration.ofMinutes(IDLE_EVICTION_THRESHOLD_MINUTES); // Defensive fallback expiration for a malformed/absent service response. private static final Duration DEFAULT_EXPIRATION_OFFSET = Duration.ofMinutes(5L); @@ -72,14 +72,14 @@ final class BlobSessionProvider implements SessionProvider { private final Clock clock; private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>(); - BlobSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName) { this(bearerPipeline, url, serviceVersion, accountName, Clock.systemUTC()); } /** Package-private constructor that accepts an injectable clock for deterministic testing. */ - BlobSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, - Clock clock) { + TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String accountName, Clock clock) { this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) .url(url) .version(serviceVersion.getVersion()) @@ -218,14 +218,14 @@ private static final class ContainerSessionCache { volatile OffsetDateTime lastAccess; private SessionCredential currentSessionCredential; - private ContainerSessionCache(BlobSessionProvider provider, Clock clock, String containerName, + private ContainerSessionCache(TokenCredentialSessionProvider provider, Clock clock, String containerName, String resolvedAccountName, OffsetDateTime lastAccess) { this.cache = createCache(provider, clock, containerName, resolvedAccountName); this.lastAccess = lastAccess; } - private static AutoRefreshingCache createCache(BlobSessionProvider provider, Clock clock, - String containerName, String resolvedAccountName) { + private static AutoRefreshingCache createCache(TokenCredentialSessionProvider provider, + Clock clock, String containerName, String resolvedAccountName) { AutoRefreshingCache.ValueProvider valueProvider = new AutoRefreshingCache.ValueProvider() { @Override 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 823a3847a504..fae50c4a46d6 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 @@ -30,9 +30,9 @@ public class BlobSessionClientTests extends BlobTestBase { @Test public void createSessionReturnsTokenAndKey() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); - BlobSessionProvider sessionProvider - = new BlobSessionProvider(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); @@ -47,9 +47,9 @@ public void createSessionReturnsTokenAndKey() { public void createSessionAsyncReturnsTokenAndKey() { BlobContainerAsyncClient oauthCc = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); - BlobSessionProvider sessionProvider - = new BlobSessionProvider(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier .create(sessionProvider @@ -66,9 +66,9 @@ public void createSessionAsyncReturnsTokenAndKey() { @Test public void createSessionSyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); - BlobSessionProvider sessionProvider = new BlobSessionProvider(createOAuthPipeline(policyInvocationCount), - ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); @@ -83,9 +83,9 @@ public void createSessionSyncUsesProvidedHttpPipeline() { @Test public void createSessionAsyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); - BlobSessionProvider sessionProvider = new BlobSessionProvider(createOAuthPipeline(policyInvocationCount), - ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier .create(sessionProvider @@ -113,9 +113,9 @@ public void createSessionWithUserDelegationSas() { BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); - BlobSessionProvider sessionProvider - = new BlobSessionProvider(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); SessionCredential credential = sessionProvider.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); @@ -138,9 +138,9 @@ public void createSessionAsyncWithUserDelegationSas() { = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) .buildClient(); - BlobSessionProvider sessionProvider - = new BlobSessionProvider(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); StepVerifier .create(sessionProvider diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java index 7e5155f29379..8190ec5edbaf 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java @@ -25,16 +25,18 @@ import static org.junit.jupiter.api.Assertions.assertThrows; /** - * Small, focused tests for the public {@link SessionProvider} contract implemented by {@link BlobSessionProvider}. + * Small, focused tests for the public {@link SessionProvider} contract implemented by + * {@link TokenCredentialSessionProvider}. *

- * These verify that {@link BlobSessionProvider#getSessionAsync(SessionRequestContext)} and - * {@link BlobSessionProvider#getSession(SessionRequestContext)} route the CreateSession REST call to the + * These verify that {@link TokenCredentialSessionProvider#getSessionAsync(SessionRequestContext)} and + * {@link TokenCredentialSessionProvider#getSession(SessionRequestContext)} route the CreateSession REST call to the * container named on the {@link SessionRequestContext}, proving out the "per-request container" seam that * backs the BYO {@link SessionProvider} extension point, and that a context missing a container * name is rejected rather than silently falling back to some default. This complements (and does not * duplicate) {@code BlobSessionClientTests}, which exercises these same paths against the live service, and - * {@code BlobSessionProviderCacheTest}, which fakes the transport wholesale to test per-container cache timing - * behavior. Here {@link BlobSessionProvider} is real and only the transport is faked, so the container name + * {@code TokenCredentialSessionProviderCacheTest}, which fakes the transport wholesale to test per-container cache + * timing behavior. Here {@link TokenCredentialSessionProvider} is real and only the transport is faked, so the + * container name * actually placed on the wire is what's being verified. */ public class SessionProviderTests { @@ -45,7 +47,7 @@ public class SessionProviderTests { @Test public void getSessionAsyncUsesContainerFromContext() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); @@ -62,7 +64,7 @@ public void getSessionAsyncUsesContainerFromContext() { @Test public void getSessionSyncUsesContainerFromContext() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext().setContainerName(CONTEXT_CONTAINER).setAccountName(ACCOUNT_NAME); @@ -78,7 +80,7 @@ public void getSessionSyncUsesContainerFromContext() { @Test public void missingContextContainerThrowsSync() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(requestedContainer); // There is no constructor-supplied fallback container: a context with no container name must be // rejected rather than silently degrading to some default. @@ -90,17 +92,17 @@ public void missingContextContainerThrowsSync() { @Test public void missingContextContainerThrowsAsync() { AtomicReference requestedContainer = new AtomicReference<>(); - BlobSessionProvider sessionProvider = createSessionProvider(requestedContainer); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(requestedContainer); SessionRequestContext context = new SessionRequestContext(); StepVerifier.create(sessionProvider.getSessionAsync(context)).verifyError(IllegalArgumentException.class); } - private static BlobSessionProvider createSessionProvider(AtomicReference requestedContainer) { + private static TokenCredentialSessionProvider createSessionProvider(AtomicReference requestedContainer) { HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new CreateSessionMockClient(requestedContainer)).build(); - return new BlobSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + return new TokenCredentialSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", BlobServiceVersion.getLatest(), ACCOUNT_NAME); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java similarity index 94% rename from sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java rename to sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java index 7c5037270346..f13f9eaaaeaf 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionProviderCacheTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java @@ -40,17 +40,17 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Deterministic, network-free tests for {@link BlobSessionProvider}'s time-based, per-container caching + * Deterministic, network-free tests for {@link TokenCredentialSessionProvider}'s time-based, per-container caching * behavior. *

- * These tests drive {@link BlobSessionProvider} with an injectable {@link Clock} and a fake HTTP transport + * These tests drive {@link TokenCredentialSessionProvider} with an injectable {@link Clock} and a fake HTTP transport * ({@link ControllableHttpClient}) so the expiry, proactive-refresh, and per-container independence logic * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is * covered separately by {@code SessionTokenCredentialPolicyTest}. */ -public class BlobSessionProviderCacheTest { +public class TokenCredentialSessionProviderCacheTest { private static final String ACCOUNT_NAME = "myaccount"; private static final String CONTAINER_A = "container-a"; @@ -72,7 +72,7 @@ public void expiredByTimeOnSecondRequestCreatesNewSession() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); // First request: cold cache mints a good token and uses it. SessionCredential firstRequest = provider.getSession(contextFor(CONTAINER_A)); @@ -99,7 +99,7 @@ public void automaticBackgroundRefreshFiresWithoutServiceHint() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); // First request: cold cache mints the initial token. assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); @@ -134,7 +134,7 @@ public void independentContainersRefreshIndependently() { httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, "refreshed-a", now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); httpClient.enqueue(CONTAINER_B, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); // Mint an initial session for each container. assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); @@ -166,7 +166,7 @@ public void noRefreshBeforeJitterWindowWithoutServiceHint() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); // First request mints the token. assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); @@ -191,7 +191,7 @@ public void coldCacheCreatesValueAsync() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) @@ -209,7 +209,7 @@ public void cachedValueIsReusedOnLaterAsyncRequests() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) @@ -235,7 +235,7 @@ public void creationFailurePropagatesAndAllowsRetryAsync() { ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueueFailure(CONTAINER_A); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); @@ -256,7 +256,7 @@ public void containerNameLookupIsCaseInsensitive() { MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); ControllableHttpClient httpClient = new ControllableHttpClient(); httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); assertEquals(FIRST_TOKEN, @@ -272,7 +272,7 @@ public void refreshAndInvalidationAreOwnedByProvider() { httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); httpClient.enqueue(CONTAINER_A, "third-session-token", now(clock).plus(SESSION_LIFETIME)); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); SessionRequestContext context = contextFor(CONTAINER_A); SessionCredential first = provider.getSession(context); @@ -295,7 +295,7 @@ public void concurrentAsyncRequestsShareASingleInFlightCreation() { ControllableHttpClient httpClient = new ControllableHttpClient(); // A pending response models a CreateSession call that is still outstanding. Sinks.One pendingResponse = httpClient.preparePendingResponse(CONTAINER_A); - BlobSessionProvider provider = createProvider(httpClient, clock); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); Mono first = provider.getSessionAsync(contextFor(CONTAINER_A)); Mono second = provider.getSessionAsync(contextFor(CONTAINER_A)); @@ -371,9 +371,9 @@ private static void sleepBriefly() { } } - private static BlobSessionProvider createProvider(HttpClient httpClient, Clock clock) { + private static TokenCredentialSessionProvider createProvider(HttpClient httpClient, Clock clock) { HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); - return new BlobSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + return new TokenCredentialSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock); }