diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 222d2cf55571..508260353e4a 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_018b1a065c" + "Tag": "java/storage/azure-storage-blob_49f6817d98" } 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..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 @@ -189,18 +189,20 @@ 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; + } + + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, null, serviceVersion); } /** @@ -650,4 +652,5 @@ public BlobClientBuilder audience(BlobAudience audience) { this.audience = audience; 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 6f3a12dd90a5..6aea5e0d6730 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 @@ -1846,11 +1846,4 @@ 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; - // } } 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..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 @@ -133,7 +133,7 @@ public BlobContainerClient buildClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobContainerClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); @@ -174,18 +174,19 @@ public BlobContainerAsyncClient buildAsyncClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobContainerAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } - 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; + } + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, null, serviceVersion); } /** @@ -606,4 +607,5 @@ public BlobContainerClientBuilder audience(BlobAudience audience) { this.audience = audience; 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..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 @@ -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)}. 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. + */ + public BlobServiceClientBuilder sessionOptions(SessionOptions 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/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index af54bda3cb4b..a51d83a5d876 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 @@ -48,6 +48,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; @@ -1042,6 +1044,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); } /** @@ -7659,4 +7681,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/BlobsDownloadHeaders.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/BlobsDownloadHeaders.java index 1d409a5a4cdd..7dd424fe666c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/BlobsDownloadHeaders.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/BlobsDownloadHeaders.java @@ -295,6 +295,30 @@ public final class BlobsDownloadHeaders { @Generated private Long xMsStructuredContentLength; + /* + * The x-ms-access-tier property. + */ + @Generated + private String xMsAccessTier; + + /* + * The x-ms-access-tier-inferred property. + */ + @Generated + private Boolean xMsAccessTierInferred; + + /* + * The x-ms-access-tier-change-time property. + */ + @Generated + private DateTimeRfc1123 xMsAccessTierChangeTime; + + /* + * The x-ms-smart-access-tier property. + */ + @Generated + private String xMsSmartAccessTier; + /* * The x-ms-content-crc64 property. */ @@ -367,6 +391,16 @@ public final class BlobsDownloadHeaders { private static final HttpHeaderName X_MS_STRUCTURED_CONTENT_LENGTH = HttpHeaderName.fromString("x-ms-structured-content-length"); + private static final HttpHeaderName X_MS_ACCESS_TIER = HttpHeaderName.fromString("x-ms-access-tier"); + + private static final HttpHeaderName X_MS_ACCESS_TIER_INFERRED + = HttpHeaderName.fromString("x-ms-access-tier-inferred"); + + private static final HttpHeaderName X_MS_ACCESS_TIER_CHANGE_TIME + = HttpHeaderName.fromString("x-ms-access-tier-change-time"); + + private static final HttpHeaderName X_MS_SMART_ACCESS_TIER = HttpHeaderName.fromString("x-ms-smart-access-tier"); + private static final HttpHeaderName X_MS_CONTENT_CRC64 = HttpHeaderName.fromString("x-ms-content-crc64"); // HttpHeaders containing the raw property values. @@ -529,6 +563,20 @@ public BlobsDownloadHeaders(HttpHeaders rawHeaders) { } else { this.xMsStructuredContentLength = null; } + this.xMsAccessTier = rawHeaders.getValue(X_MS_ACCESS_TIER); + String xMsAccessTierInferred = rawHeaders.getValue(X_MS_ACCESS_TIER_INFERRED); + if (xMsAccessTierInferred != null) { + this.xMsAccessTierInferred = Boolean.parseBoolean(xMsAccessTierInferred); + } else { + this.xMsAccessTierInferred = null; + } + String xMsAccessTierChangeTime = rawHeaders.getValue(X_MS_ACCESS_TIER_CHANGE_TIME); + if (xMsAccessTierChangeTime != null) { + this.xMsAccessTierChangeTime = new DateTimeRfc1123(xMsAccessTierChangeTime); + } else { + this.xMsAccessTierChangeTime = null; + } + this.xMsSmartAccessTier = rawHeaders.getValue(X_MS_SMART_ACCESS_TIER); String xMsContentCrc64 = rawHeaders.getValue(X_MS_CONTENT_CRC64); if (xMsContentCrc64 != null) { this.xMsContentCrc64 = Base64.getDecoder().decode(xMsContentCrc64); @@ -1584,6 +1632,101 @@ public BlobsDownloadHeaders setXMsStructuredContentLength(Long xMsStructuredCont return this; } + /** + * Get the xMsAccessTier property: The x-ms-access-tier property. + * + * @return the xMsAccessTier value. + */ + @Generated + public String getXMsAccessTier() { + return this.xMsAccessTier; + } + + /** + * Set the xMsAccessTier property: The x-ms-access-tier property. + * + * @param xMsAccessTier the xMsAccessTier value to set. + * @return the BlobsDownloadHeaders object itself. + */ + @Generated + public BlobsDownloadHeaders setXMsAccessTier(String xMsAccessTier) { + this.xMsAccessTier = xMsAccessTier; + return this; + } + + /** + * Get the xMsAccessTierInferred property: The x-ms-access-tier-inferred property. + * + * @return the xMsAccessTierInferred value. + */ + @Generated + public Boolean isXMsAccessTierInferred() { + return this.xMsAccessTierInferred; + } + + /** + * Set the xMsAccessTierInferred property: The x-ms-access-tier-inferred property. + * + * @param xMsAccessTierInferred the xMsAccessTierInferred value to set. + * @return the BlobsDownloadHeaders object itself. + */ + @Generated + public BlobsDownloadHeaders setXMsAccessTierInferred(Boolean xMsAccessTierInferred) { + this.xMsAccessTierInferred = xMsAccessTierInferred; + return this; + } + + /** + * Get the xMsAccessTierChangeTime property: The x-ms-access-tier-change-time property. + * + * @return the xMsAccessTierChangeTime value. + */ + @Generated + public OffsetDateTime getXMsAccessTierChangeTime() { + if (this.xMsAccessTierChangeTime == null) { + return null; + } + return this.xMsAccessTierChangeTime.getDateTime(); + } + + /** + * Set the xMsAccessTierChangeTime property: The x-ms-access-tier-change-time property. + * + * @param xMsAccessTierChangeTime the xMsAccessTierChangeTime value to set. + * @return the BlobsDownloadHeaders object itself. + */ + @Generated + public BlobsDownloadHeaders setXMsAccessTierChangeTime(OffsetDateTime xMsAccessTierChangeTime) { + if (xMsAccessTierChangeTime == null) { + this.xMsAccessTierChangeTime = null; + } else { + this.xMsAccessTierChangeTime = new DateTimeRfc1123(xMsAccessTierChangeTime); + } + return this; + } + + /** + * Get the xMsSmartAccessTier property: The x-ms-smart-access-tier property. + * + * @return the xMsSmartAccessTier value. + */ + @Generated + public String getXMsSmartAccessTier() { + return this.xMsSmartAccessTier; + } + + /** + * Set the xMsSmartAccessTier property: The x-ms-smart-access-tier property. + * + * @param xMsSmartAccessTier the xMsSmartAccessTier value to set. + * @return the BlobsDownloadHeaders object itself. + */ + @Generated + public BlobsDownloadHeaders setXMsSmartAccessTier(String xMsSmartAccessTier) { + this.xMsSmartAccessTier = xMsSmartAccessTier; + return this; + } + /** * Get the xMsContentCrc64 property: The x-ms-content-crc64 property. * 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/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 4d5da7f03116..bf28158cfec6 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,17 @@ 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.blob.models.SessionProvider; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; @@ -66,7 +71,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. @@ -83,6 +89,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, @@ -90,7 +98,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); @@ -124,12 +132,46 @@ 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); + + // Tail of every pipeline: the policies between the auth policy and the transport. Also reused by the + // session creation pipeline so CreateSession takes the same network path as the data requests its + // credential signs. + List postAuthenticationPolicies = new ArrayList<>(perRetryPolicies); + HttpPolicyProviders.addAfterRetryPolicies(postAuthenticationPolicies); + postAuthenticationPolicies.add(getResponseValidationPolicy()); + postAuthenticationPolicies.add(new HttpLoggingPolicy(logOptions)); + postAuthenticationPolicies.add(new ScrubEtagPolicy()); + + // 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. 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); + + if (sessionOptions == null || sessionOptions.getSessionMode() == SessionMode.DISABLED) { + policies.add(bearerPolicy); + } else { + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + SessionProvider sessionProvider = sessionOptions.getSessionProvider(); + if (sessionProvider == null) { + sessionProvider = createDefaultSessionProvider(policies, bearerPolicy, postAuthenticationPolicies, + effectiveHttpClient, clientOptions, endpoint, effectiveServiceVersion, + sessionOptions.getAccountName()); + } + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, sessionOptions)); + } } if (azureSasCredential != null) { @@ -138,16 +180,45 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new AzureSasCredentialPolicy(new AzureSasCredential(sasToken), false)); } - policies.addAll(perRetryPolicies); + policies.addAll(postAuthenticationPolicies); - HttpPolicyProviders.addAfterRetryPolicies(policies); + return createPipeline(policies, effectiveHttpClient, clientOptions); + } + + /** + * Creates the default {@link SessionProvider}, backed by a bearer-only {@link HttpPipeline} used for + * CreateSession calls. That pipeline mirrors the data pipeline - the same pre-auth policies, bearer token policy, + * post-auth policies and transport - but has no session policy, because session credentials are bound to the + * network context of the CreateSession call. + */ + private static SessionProvider createDefaultSessionProvider(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + List postAuthenticationPolicies, HttpClient httpClient, ClientOptions clientOptions, + String endpoint, BlobServiceVersion serviceVersion, String accountName) { + List bearerPolicies = new ArrayList<>(preAuthPolicies); + bearerPolicies.add(bearerPolicy); + bearerPolicies.addAll(postAuthenticationPolicies); - policies.add(getResponseValidationPolicy()); + return new TokenCredentialSessionProvider(createPipeline(bearerPolicies, httpClient, clientOptions), endpoint, + serviceVersion, accountName); + } - policies.add(new HttpLoggingPolicy(logOptions)); + private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) { + if (httpClient != null) { + return httpClient; + } - policies.add(new ScrubEtagPolicy()); + return clientOptions instanceof HttpClientOptions + ? HttpClient.createDefault((HttpClientOptions) clientOptions) + : HttpClient.createDefault(); + } + /** + * Creates an {@link HttpPipeline} from an ordered policy list, applying the transport, client options and tracer + * configuration shared by every pipeline this helper builds. + */ + private static HttpPipeline createPipeline(List policies, HttpClient httpClient, + ClientOptions clientOptions) { return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(clientOptions) @@ -237,4 +308,5 @@ 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); } + } 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..642b053673ad --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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; +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.core.util.DateTimeRfc1123; +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.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; + +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; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 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. + *

+ * If session authentication cannot be used against an account, either because session acquisition failed with + * HTTP 400, 403, or 5xx, or because the service rejected session-signed requests with HTTP 401 three times in + * a row, the account is placed in a five minute cooldown during which requests go straight to bearer + * authentication. Cooldown state is held by this policy instance, so it is scoped to a single client pipeline. + * Acquisition failures that do not carry one of those status codes fall back to bearer for that request only + * and do not start a cooldown. + */ +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_COOLDOWN = Duration.ofMinutes(5); + private static final int MAX_CONSECUTIVE_SESSION_REJECTIONS = 3; + + private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private final SessionProvider sessionProvider; + private final SessionOptions sessionOptions; + private final Clock clock; + private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>(); + private final ConcurrentHashMap accountRejections = new ConcurrentHashMap<>(); + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + SessionProvider sessionProvider, SessionOptions sessionOptions) { + this(bearerPolicy, sessionProvider, sessionOptions, Clock.systemUTC()); + } + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + 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.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.process(context, next); + } + if (isAccountInCooldown(requestContext.getAccountName())) { + return bearerPolicy.process(context, next); + } + + HttpPipelineNextPolicy retryNext = next.clone(); + Mono sessionMono; + try { + sessionMono = sessionProvider.getSessionAsync(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + return bearerPolicy.process(context, next); + } + + return sessionMono.onErrorResume(error -> { + handleSessionAcquisitionFailure(requestContext, error); + 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 + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.processSync(context, next); + } + if (isAccountInCooldown(requestContext.getAccountName())) { + return bearerPolicy.processSync(context, next); + } + + HttpPipelineNextSyncPolicy retryNext = next.clone(); + SessionCredential session; + try { + session = sessionProvider.getSession(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + return bearerPolicy.processSync(context, next); + } + signRequest(context, session); + + HttpResponse response = next.processSync(); + return handleSessionResponseSync(context, response, session, requestContext, retryNext); + } + + private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext context) { + if (sessionOptions.getSessionMode() == SessionMode.DISABLED + || context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return null; + } + + 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; + } + + 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. + 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. + */ + private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextPolicy retryNext) { + + handleSessionExpiringHeader(response, requestContext); + + if (response.getStatusCode() == 401) { + handleSessionRejection(requestContext, session); + } else { + recordSessionAccepted(requestContext); + } + + 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, + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextSyncPolicy retryNext) { + + handleSessionExpiringHeader(response, requestContext); + + if (response.getStatusCode() == 401) { + handleSessionRejection(requestContext, session); + } else { + recordSessionAccepted(requestContext); + } + + 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; + } + + private void signRequest(HttpPipelineCallContext context, SessionCredential 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); + } + + /** + * Handles a session credential being rejected by the service. The rejected credential is invalidated so it is not + * reused, and the rejection is counted. Because invalidation causes the next request to create a brand new + * session, an environment that cannot use sessions at all would otherwise create and lose one session per + * request indefinitely. After {@value #MAX_CONSECUTIVE_SESSION_REJECTIONS} consecutive rejections the account is + * placed in cooldown so requests fall straight through to bearer. + */ + private void handleSessionRejection(SessionRequestContext requestContext, SessionCredential session) { + logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); + + int consecutiveRejections = accountRejections + .computeIfAbsent(normalize(requestContext.getAccountName()), ignored -> new AtomicInteger()) + .incrementAndGet(); + + if (consecutiveRejections >= MAX_CONSECUTIVE_SESSION_REJECTIONS + && beginAccountCooldown(requestContext.getAccountName())) { + LOGGER.warning( + "Session authentication was rejected {} times in a row for container '{}'. Suppressing session " + + "authentication for this account for five minutes and using bearer token.", + consecutiveRejections, requestContext.getContainerName()); + } + } + + /** + * Clears the consecutive rejection count once the service accepts a session credential. Any response other than + * 401 means the session authenticated successfully, so an account where sessions work never reaches the + * rejection threshold. + */ + private void recordSessionAccepted(SessionRequestContext requestContext) { + accountRejections.remove(normalize(requestContext.getAccountName())); + } + + private void handleSessionExpiringHeader(HttpResponse response, SessionRequestContext requestContext) { + String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); + if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { + 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()); + } + } + + /** + * 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; + } + + int statusCode = response.getStatusCode(); + return statusCode == 400 || statusCode == 401; + } + + /** + * Handles a failure to obtain a session credential. When the failure carries an HTTP 400, 403, or 5xx response + * the account is placed in cooldown so following requests skip session acquisition entirely. Any other failure + * is logged and falls back to bearer for the current request only. + */ + private void handleSessionAcquisitionFailure(SessionRequestContext requestContext, Throwable error) { + Throwable current = error; + while (current != null && !(current instanceof HttpResponseException)) { + current = current.getCause(); + } + + if (current != null && ((HttpResponseException) current).getResponse() != null) { + HttpResponse response = ((HttpResponseException) current).getResponse(); + int statusCode = response.getStatusCode(); + if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) { + if (beginAccountCooldown(requestContext.getAccountName())) { + LOGGER.warning( + "Session acquisition failed with HTTP {}. Suppressing session authentication for this account " + + "for five minutes and using bearer token.", + statusCode); + } + return; + } + } + + 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_COOLDOWN); + AtomicBoolean cooldownStarted = new AtomicBoolean(); + accountCooldowns.compute(key, (ignored, currentExpirationTime) -> { + if (currentExpirationTime != null && now.isBefore(currentExpirationTime)) { + return currentExpirationTime; + } + + cooldownStarted.set(true); + return cooldownUntil; + }); + + if (cooldownStarted.get()) { + // Reset the count so the account gets a fresh set of attempts once the cooldown lapses. + accountRejections.remove(key); + } + + return cooldownStarted.get(); + } + + private static String normalize(String accountName) { + return CoreUtils.isNullOrEmpty(accountName) ? "" : accountName.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java new file mode 100644 index 000000000000..b6dc4844f9b6 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java @@ -0,0 +1,240 @@ +// 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.CoreUtils; +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 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; + +/** + * 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 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. + * + *

Invalidation

+ *

+ * {@link #invalidateSession} delegates to the cache's compare-and-swap: it succeeds only for the + * caller presenting the credential that is still cached. A caller presenting a credential that a + * concurrent refresh has already replaced gets {@code false} and the newer credential is left in + * place, so a stale 401 cannot evict a healthy session. + * + *

Background refresh

+ *

+ * {@link #refreshSession} forces an immediate background refresh even when the client's own + * jittered refresh timer has not yet elapsed, so the service's + * {@code x-ms-auth-info: session_expiring} hint is acted on promptly. The one exception is that the + * refresh is suppressed while the cache is backing off from a recent session creation failure, which + * stops a failing service from being retried once per request. + * + *

+ * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}: + * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an + * {@link AzureBlobStorageImpl} internally. + */ +final class TokenCredentialSessionProvider implements SessionProvider { + + static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5; + + 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); + + private final AzureBlobStorageImpl azureBlobStorage; + private final String accountName; + private final Clock clock; + private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>(); + + 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. */ + TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String accountName, Clock clock) { + this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) + .url(url) + .version(serviceVersion.getVersion()) + .buildClient(); + this.accountName = accountName; + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + @Override + public Mono getSessionAsync(SessionRequestContext context) { + return Mono.defer(() -> { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + return updateCache(container, resolvedAccount).cache.getValidValueAsync(); + }); + } + + @Override + public SessionCredential getSession(SessionRequestContext context) { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + return updateCache(container, resolvedAccount).cache.getValidValueSync(); + } + + @Override + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + if (context == null) { + return false; + } + ContainerSessionCache containerSessionCache = containerSessionCaches.get(normalize(context.getContainerName())); + return containerSessionCache != null && containerSessionCache.cache.invalidateValue(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)); + } + + 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 SessionCredential toCredential(Response response, String resolvedAccountName) { + 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.")); + } + + 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 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(TokenCredentialSessionProvider provider, + Clock clock, String containerName, String resolvedAccountName) { + AutoRefreshingCache.ValueProvider valueProvider + = new AutoRefreshingCache.ValueProvider() { + @Override + public Mono createAsync() { + return provider.createSessionAsync(containerName, resolvedAccountName); + } + + @Override + public SessionCredential createSync() { + 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/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 new file mode 100644 index 000000000000..afcb0bf84c08 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * 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 #ENABLED} + * {@link #DISABLED} + */ +public enum SessionMode { + + /** + * The SDK creates a session on the first eligible request and, when using the built-in session provider, + * keeps an active session until it receives no requests for 5 minutes. This is the default. If a session + * cannot be created, or the service answers a session-signed request with HTTP 400 or 401, the SDK + * transparently falls back to bearer token authentication for that request. Repeated failures stop the + * SDK from using sessions for that account for five minutes; during that window its requests are + * authenticated with bearer tokens without attempting to create a session. + */ + ENABLED, + + /** + * Always use bearer token authentication. No session tokens are ever created or used. + */ + 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 new file mode 100644 index 000000000000..0a060daae4c8 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -0,0 +1,120 @@ +// 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 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. + * + * @see SessionMode + */ +public final class SessionOptions { + + private SessionMode sessionMode = SessionMode.ENABLED; + private String containerName; + private String accountName; + private SessionProvider sessionProvider; + + /** + * Creates a new {@link SessionOptions} instance with default values. + * 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() { + } + + /** + * Gets the session mode. + * + * @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#ENABLED}. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode == null ? SessionMode.ENABLED : sessionMode; + return this; + } + + /** + * 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. + */ + public String getContainerName() { + return containerName; + } + + /** + * 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. + */ + 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; + } + + /** + * 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. 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, of choosing between session and bearer authentication, and of pausing session use + * for a storage account when sessions repeatedly fail against it, as described on {@link SessionProvider}. + * The same provider instance may be supplied to multiple service client builders to share its cache; that + * pause, however, is tracked per client pipeline and is not shared by those clients. + * 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. + */ + 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..6d6f1a7b1ed4 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import reactor.core.publisher.Mono; + +/** + * Provides and manages cached {@link SessionCredential session credentials} for storage containers. + *

+ * 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 + * relying on the SDK to sign requests and to fall back to bearer authentication when sessions fail. 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). + * + *

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.
  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. + *
+ * + *

Division of responsibility

+ *

+ * A {@link SessionProvider} produces, invalidates, and refreshes credentials. Everything else stays with the + * SDK: signing each request with the session's HMAC key, and choosing between session and bearer + * authentication. The SDK authenticates a request with a bearer token rather than a session when the request + * is not session-eligible, when no session credential could be obtained, and when the service answers a + * session-signed request with HTTP 400 or 401. + *

+ * The SDK also stops using sessions for a storage account when they repeatedly fail against it. When a call + * to this provider fails with an HTTP 400, 403, or 5xx error, or the service rejects three session-signed + * requests in a row with HTTP 401, the SDK stops requesting sessions for that account for five minutes and + * authenticates its requests with bearer tokens instead; {@link #getSession} and {@link #getSessionAsync} are + * not called at all during that window. The pause covers every container in the account, not only the + * container whose request failed, and a provider failure that carries no HTTP response does not start it - + * that request simply falls back to bearer. Each client tracks the pause on its own HTTP pipeline, so clients + * pause independently even when they share one {@link SessionProvider} instance. + * + *

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. 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 + * @see SessionOptions + */ +public interface SessionProvider { + + /** + * 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}. + */ + Mono getSessionAsync(SessionRequestContext context); + + /** + * 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}. + */ + 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). + * + * @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. + */ + boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential); + + /** + * 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. + * + * @param context the request-scoped parameters (container, account) identifying the session scope. + */ + void refreshSession(SessionRequestContext context); +} 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/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 44a32b6a420f..99cb97ecedbd 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; @@ -74,6 +77,7 @@ 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; +import com.azure.storage.common.test.shared.http.WireTapHttpClient; import com.azure.storage.common.test.shared.policy.MockFailureResponsePolicy; import com.azure.storage.common.test.shared.policy.MockRetryRangeResponsePolicy; import com.azure.storage.common.test.shared.policy.TransientFailureInjectingHttpPipelinePolicy; @@ -81,6 +85,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 +125,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; @@ -3201,6 +3207,51 @@ 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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 + = getOAuthServiceClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerClient(cc.getBlobContainerName()) + .getBlobClient(blobName); + + 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..c2616f97bcc0 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; @@ -68,6 +71,7 @@ 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; +import com.azure.storage.common.test.shared.http.WireTapHttpClient; import com.azure.storage.common.test.shared.policy.MockFailureResponsePolicy; import com.azure.storage.common.test.shared.policy.MockRetryRangeResponsePolicy; import com.azure.storage.common.test.shared.policy.TransientFailureInjectingHttpPipelinePolicy; @@ -76,6 +80,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 +2970,51 @@ 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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 + = getOAuthServiceAsyncClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()) + .getBlobAsyncClient(blobName); + + 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 71345472c393..0e3c4923378c 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 @@ -52,6 +52,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; @@ -166,6 +167,15 @@ public class BlobTestBase extends TestProxyTestBase { protected static final String GARBAGE_LEASE_ID = CoreUtils.randomUuid().toString(); + /* + The values below are shared fixtures for session authentication tests. They are never sent to the service. + */ + public static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + + public static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + + public static final String TEST_SESSION_ACCOUNT_NAME = "testaccount"; + protected BlobServiceClient primaryBlobServiceClient; protected BlobServiceAsyncClient primaryBlobServiceAsyncClient; protected BlobServiceClient alternateBlobServiceClient; @@ -205,7 +215,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 @@ -417,21 +431,85 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien } protected BlobServiceClient getOAuthServiceClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceClient(new SessionOptions()); + } - instrument(builder); + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) { + return getOAuthServiceClientBuilder(sessionOptions, null, policies).buildClient(); + } - return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient(); + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpClient httpClient) { + return getOAuthServiceClientBuilder(sessionOptions, httpClient).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) { + return getOAuthServiceClientBuilder(sessionOptions, null, policies).buildAsyncClient(); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, HttpClient httpClient) { + return getOAuthServiceClientBuilder(sessionOptions, httpClient).buildAsyncClient(); + } + + /** + * Builds an OAuth service client builder. When {@code httpClient} is supplied it replaces the transport that + * {@code instrument} installed, which lets a test observe requests as they go on the wire. + * + * @param sessionOptions The session options to configure. + * @param httpClient The transport to send requests with, or null to keep the instrumented one. + * @param policies Additional policies to add to the pipeline. + * @return The configured builder. + */ + private BlobServiceClientBuilder getOAuthServiceClientBuilder(SessionOptions sessionOptions, HttpClient httpClient, + HttpPipelinePolicy... policies) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); - return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient(); + if (httpClient != null) { + builder.httpClient(httpClient); + } + + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)); + } + + /** + * Creates a session expiration time in the future. + * + * @return A valid session expiration time. + */ + public static OffsetDateTime createValidSessionExpiration() { + return OffsetDateTime.now().plusHours(1); + } + + /** + * Creates a session expiration time in the past. + * + * @return An expired session expiration time. + */ + public static OffsetDateTime createExpiredSessionExpiration() { + return OffsetDateTime.now().minusMinutes(5); } protected BlobServiceClient getServiceClient(String endpoint) { 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..e7950ff8fb24 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 @@ -6,12 +6,14 @@ import com.azure.core.credential.AzureSasCredential; import com.azure.core.http.HttpClient; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipeline; import com.azure.core.http.HttpRequest; import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.FixedDelayOptions; import com.azure.core.http.policy.HttpLogOptions; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.policy.RetryOptions; import com.azure.core.test.http.MockHttpResponse; import com.azure.core.test.http.NoOpHttpClient; @@ -22,6 +24,11 @@ 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.SessionMode; +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.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -40,15 +47,22 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.time.OffsetDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import java.util.stream.Stream; 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.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; @@ -61,6 +75,12 @@ public class BuilderHelperTests { = new RequestRetryOptions(RetryPolicyType.FIXED, 2, 2, 1000L, 4000L, null); private static final RetryOptions CORE_RETRY_OPTIONS = new RetryOptions(new FixedDelayOptions(1, Duration.ofSeconds(2))); + private static final String CREATE_SESSION_RESPONSE_BODY = "" + + "session-id" + OffsetDateTime.now().plusHours(1) + + "HMAC" + + "session-token" + + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==" + + ""; private static HttpRequest request(String url) { return new HttpRequest(HttpMethod.HEAD, url).setBody(Flux.empty()) @@ -72,10 +92,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 +196,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 +325,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 +700,189 @@ public Mono send(HttpRequest request) { return Mono.just(new MockHttpResponse(request, 200)); } } + + // region buildPipeline session tests + + @ParameterizedTest(name = "{0}") + @MethodSource("pipelinesWithoutSessionsSupplier") + public void pipelinesWithoutSessionsDoNotContainSessionPolicy(String scenario, + Supplier pipelineSupplier, boolean expectsBearerPolicy) { + HttpPipeline pipeline = pipelineSupplier.get(); + + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + scenario + " should not contain SessionTokenCredentialPolicy"); + assertEquals(expectsBearerPolicy, hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy"), + scenario + " bearer policy expectation mismatch"); + } + + @Test + public void serviceBuilderUsesBuiltInSessionProviderByDefault() { + BlobServiceClient client = new BlobServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + assertTrue(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy")); + } + + /** + * Session credentials are bound to the network context of the CreateSession call, so the session creation + * pipeline must run the same post-authentication policies as the data pipeline. + */ + @Test + public void perRetryPoliciesAreAppliedToCreateSessionRequests() { + List observedMethods = Collections.synchronizedList(new ArrayList<>()); + HttpPipelinePolicy perRetryPolicy = (context, next) -> { + observedMethods.add(context.getHttpRequest().getHttpMethod()); + return next.process(); + }; + + HttpClient sessionClient = request -> { + if (request.getHttpMethod() == HttpMethod.POST) { + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/xml"); + return Mono.just(new MockHttpResponse(request, 201, headers, + CREATE_SESSION_RESPONSE_BODY.getBytes(StandardCharsets.UTF_8))); + } + + return Mono.just(new MockHttpResponse(request, 200)); + }; + + HttpPipeline pipeline = BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), sessionClient, + new ArrayList<>(), Collections.singletonList(perRetryPolicy), null, null, + new ClientLogger(BuilderHelperTests.class), new SessionOptions(), BlobServiceVersion.getLatest()); + + StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"))) + .assertNext(response -> assertEquals(200, response.getStatusCode())) + .verifyComplete(); + + assertTrue(observedMethods.contains(HttpMethod.POST), + "Per-retry policies must run for CreateSession requests, saw " + observedMethods); + assertTrue(observedMethods.contains(HttpMethod.GET), + "Per-retry policies must run for data requests, saw " + observedMethods); + } + + @Test + public void customSessionProviderIsWiredIntoPipelineWithResolvedRequestContext() { + AtomicReference capturedContext = new AtomicReference<>(); + SessionProvider provider = createCapturingSessionProvider(capturedContext); + + HttpPipeline pipeline = buildPipelineWithSessionProvider(provider); + HttpRequest request = new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"); + + StepVerifier.create(pipeline.send(request)).expectNextCount(1).verifyComplete(); + + assertNotNull(capturedContext.get(), "Custom session provider should have been invoked by the pipeline"); + assertEquals("container", capturedContext.get().getContainerName()); + assertEquals("account", capturedContext.get().getAccountName()); + } + + private SessionProvider createCapturingSessionProvider(AtomicReference capture) { + SessionCredential credential = new SessionCredential("session-token", + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==", OffsetDateTime.now().plusMinutes(5), "account"); + + return new SessionProvider() { + @Override + public Mono getSessionAsync(SessionRequestContext context) { + capture.set(context); + return Mono.just(credential); + } + + @Override + public SessionCredential getSession(SessionRequestContext context) { + capture.set(context); + return credential; + } + + @Override + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + return false; + } + + @Override + public void refreshSession(SessionRequestContext context) { + } + }; + } + + private HttpPipeline buildPipelineWithSessionProvider(SessionProvider provider) { + SessionOptions options = new SessionOptions().setSessionProvider(provider); + HttpClient mockClient = request -> { + MockHttpResponse response = new MockHttpResponse(request, 200); + return Mono.just(response); + }; + + return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, + null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), mockClient, new ArrayList<>(), + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), options, null); + } + + private static Stream pipelinesWithoutSessionsSupplier() { + return Stream.of( + Arguments.of("null session options", (Supplier) () -> buildTokenPipeline(null), true), + Arguments.of("sessions disabled", + (Supplier) () -> buildTokenPipeline( + new SessionOptions().setSessionMode(SessionMode.DISABLED)), + true), + Arguments.of("shared key credential", (Supplier) BuilderHelperTests::buildSharedKeyPipeline, + false), + Arguments.of("standalone BlobClientBuilder", + (Supplier) () -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient() + .getHttpPipeline(), + true), + Arguments.of("standalone BlobContainerClientBuilder", + (Supplier) () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient() + .getHttpPipeline(), + true), + Arguments.of("standalone SpecializedBlobClientBuilder", + (Supplier) () -> new SpecializedBlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildBlockBlobClient() + .getHttpPipeline(), + true)); + } + + /** + * Helper to build a pipeline with bearer token auth and the given session options. + */ + private static HttpPipeline buildTokenPipeline(SessionOptions sessionOptions) { + 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), sessionOptions, 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; + } + } 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 5bdf5cbd0c0e..c9c05e7e2274 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 @@ -3,7 +3,17 @@ package com.azure.storage.blob; +import com.azure.core.http.HttpClient; 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; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; @@ -31,8 +41,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.StorageResponseSerializationFormat; import com.azure.storage.blob.models.TaggedBlobItem; @@ -51,10 +64,12 @@ 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; +import com.azure.storage.common.test.shared.http.WireTapHttpClient; import com.azure.storage.common.test.shared.policy.InvalidServiceVersionPipelinePolicy; 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; @@ -67,11 +82,15 @@ import com.azure.storage.blob.implementation.util.ModelHelper; import com.azure.storage.blob.models.ListBlobsIncludeItem; import com.azure.core.http.rest.ResponseBase; +import reactor.core.publisher.Mono; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.util.ArrayList; +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; @@ -86,6 +105,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; @@ -2475,4 +2495,324 @@ public void listBlobsByHierarchyArrowPagination() { assertEquals(4, allItems.size()); } + @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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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); + } + + @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) { + return getOAuthServiceClient(sessionEnabledOptions(), policies) + .getBlobContainerClient(cc.getBlobContainerName()); + } + + private BlobContainerClient sessionEnabledContainerClient(HttpClient httpClient) { + return getOAuthServiceClient(sessionEnabledOptions(), httpClient) + .getBlobContainerClient(cc.getBlobContainerName()); + } + + private SessionOptions sessionEnabledOptions() { + return new SessionOptions().setSessionMode(SessionMode.ENABLED) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + } + } 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 568603acdb9a..d2f336817e7c 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 @@ -3,12 +3,16 @@ package com.azure.storage.blob; +import com.azure.core.http.HttpClient; 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.FluxUtil; import com.azure.core.util.polling.PollerFlux; @@ -34,11 +38,13 @@ 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; +import com.azure.storage.common.test.shared.http.WireTapHttpClient; import com.azure.storage.common.test.shared.policy.InvalidServiceVersionPipelinePolicy; import org.junit.jupiter.api.Assertions; 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; @@ -48,8 +54,11 @@ import reactor.test.StepVerifier; import reactor.util.function.Tuple2; +import java.io.File; +import java.io.IOException; import java.net.URL; import java.io.ByteArrayInputStream; +import java.nio.file.Files; import java.time.Duration; import java.time.OffsetDateTime; import java.util.*; @@ -2507,4 +2516,139 @@ public void listBlobsArrowWithTags() { assertEquals("tagvalue", item.getTags().get("tagkey")); }).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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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<>()); + WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), 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) { + return getOAuthServiceAsyncClient(sessionEnabledOptions(), policies) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + } + + private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpClient httpClient) { + return getOAuthServiceAsyncClient(sessionEnabledOptions(), httpClient) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + } + + private SessionOptions sessionEnabledOptions() { + return new SessionOptions().setSessionMode(SessionMode.ENABLED) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + } + } 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..64ae247f39f3 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.storage.blob.BlobTestBase; +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(createCredential(BlobTestBase.createExpiredSessionExpiration()).isExpired()); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + assertFalse(createCredential(BlobTestBase.createValidSessionExpiration()).isExpired()); + } + + @Test + public void constructorRejectsNullExpiration() { + assertThrows(NullPointerException.class, () -> new SessionCredential(BlobTestBase.TEST_SESSION_TOKEN, + BlobTestBase.TEST_SESSION_KEY, null, BlobTestBase.TEST_SESSION_ACCOUNT_NAME)); + } + + private static SessionCredential createCredential(java.time.OffsetDateTime expiration) { + return new SessionCredential(BlobTestBase.TEST_SESSION_TOKEN, BlobTestBase.TEST_SESSION_KEY, expiration, + BlobTestBase.TEST_SESSION_ACCOUNT_NAME); + } +} 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 new file mode 100644 index 000000000000..6bd6b6ef94ff --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java @@ -0,0 +1,54 @@ +// 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.HttpPipelineBuilder; +import com.azure.core.test.http.NoOpHttpClient; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Small, focused tests for the public {@link SessionProvider} contract implemented by + * {@link TokenCredentialSessionProvider}. + *

+ * These verify that a context missing a container name is rejected rather than silently falling back to some default. + * The successful sync and async routing paths are covered by {@code TokenCredentialSessionProviderTests} against the + * live service, while {@code TokenCredentialSessionProviderCacheTest} fakes the transport to test cache timing. + */ +public class SessionProviderTests { + + @Test + public void missingContextContainerThrowsSync() { + TokenCredentialSessionProvider sessionProvider = createSessionProvider(); + + // 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, () -> sessionProvider.getSession(context)); + } + + @Test + public void missingContextContainerThrowsAsync() { + TokenCredentialSessionProvider sessionProvider = createSessionProvider(); + + SessionRequestContext context = new SessionRequestContext(); + + StepVerifier.create(sessionProvider.getSessionAsync(context)).verifyError(IllegalArgumentException.class); + } + + private static TokenCredentialSessionProvider createSessionProvider() { + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()).build(); + return new TokenCredentialSessionProvider(pipeline, + "https://" + BlobTestBase.TEST_SESSION_ACCOUNT_NAME + ".blob.core.windows.net", + BlobServiceVersion.getLatest(), BlobTestBase.TEST_SESSION_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 new file mode 100644 index 000000000000..1f42d16b2d92 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -0,0 +1,557 @@ +// 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.HttpClient; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +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.core.test.http.MockHttpResponse; +import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.blob.models.BlobStorageException; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import com.azure.storage.common.test.shared.http.WireTapHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +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 java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +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; + +public class SessionTokenCredentialPolicyTest { + + private static final String FIRST_TOKEN = "first-session-token"; + + private SessionProvider sessionProvider; + private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private SessionTokenCredentialPolicy policy; + + @BeforeEach + public void beforeEach() { + sessionProvider = mock(SessionProvider.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(); + } + + @Test + public void sessionAcquisitionServerFailureStartsAccountCooldown() { + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)); + + HttpClient transport = successTransport(); + HttpPipeline pipeline = buildPipeline(transport); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Session acquisition is attempted only once; the cooldown suppresses the second attempt. + verify(sessionProvider, times(1)).getSessionAsync(any()); + } + + @Test + public void sessionAcquisitionCooldownExpiresAfterFiveMinutes() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + policy = createPolicy(clock); + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); + + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)) // first call: acquisition fails + .thenReturn(Mono.just(credentialWithToken())); // third call: cooldown expired + + HttpClient transport = successTransport(); + HttpPipeline pipeline = buildPipeline(transport); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + clock.advance(Duration.ofMinutes(5)); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + verify(sessionProvider, times(2)).getSessionAsync(any()); + } + + @Test + public void policySignsRequestWithSessionCredential() { + HttpRequest request = blobGetRequest(); + HttpClient transport = successTransport(); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + assertTrue(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION).startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + } + + /** + * Verifies that a 401 from the service invalidates the cached session and retries the request + * using bearer authentication. No WWW-Authenticate header is required to trigger this fallback; + * any 401 from a session-authenticated request unconditionally falls back to bearer. + */ + @Test + public void policyInvalidatesSessionAndFallsBackToBearerAsync() { + HttpRequest request = blobGetRequest(); + WireTapHttpClient transport = bearerFallbackTransport(401); + + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Session auth was stripped before the bearer retry. + assertNull(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + // Transport received two dispatches: one for session auth, one for bearer retry. + assertEquals(2, transport.getRequestCount()); + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, times(1)).invalidateSession(any(), any()); + verify(bearerPolicy, times(1)).process(any(), any()); + } + + /** + * Invalidating a rejected session means the next request creates a brand new one. Where sessions cannot work at + * all, that would repeat forever, so consecutive rejections must eventually suppress session authentication. + */ + @Test + public void repeatedSessionRejectionStartsAccountCooldown() { + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + WireTapHttpClient transport = bearerFallbackTransport(401); + HttpPipeline pipeline = buildPipeline(transport); + + for (int i = 0; i < 4; i++) { + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + } + + // Three rejections trip the cooldown, so the fourth request never acquires a session. + verify(sessionProvider, times(3)).getSessionAsync(any()); + assertEquals(7, transport.getRequestCount()); + } + + @Test + public void acceptedSessionResetsRejectionCount() { + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + WireTapHttpClient transport = sessionRejectionTransportWithAcceptedSecondRequest(); + HttpPipeline pipeline = buildPipeline(transport); + + for (int i = 0; i < 5; i++) { + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + } + + // Four rejections total, but the accepted session reset the run, so the threshold is never reached. + verify(sessionProvider, times(5)).getSessionAsync(any()); + } + + @Test + public void sessionRejectionCooldownExpiresAfterFiveMinutes() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + policy = createPolicy(clock); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + WireTapHttpClient transport = bearerFallbackTransport(401); + HttpPipeline pipeline = buildPipeline(transport); + + for (int i = 0; i < 4; i++) { + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + } + verify(sessionProvider, times(3)).getSessionAsync(any()); + + clock.advance(Duration.ofMinutes(5)); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + verify(sessionProvider, times(4)).getSessionAsync(any()); + } + + @Test + public void policyReturns403WithoutRetry() { + HttpRequest request = blobGetRequest(); + WireTapHttpClient transport = new WireTapHttpClient(statusCodeTransport(403)); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(403, r.getStatusCode())) + .verifyComplete(); + + assertEquals(1, transport.getRequestCount()); + verify(bearerPolicy, times(0)).process(any(), any()); + } + + @Test + public void policyReturnsDataRequest503WithoutBearerFallbackAsync() { + HttpRequest request = blobGetRequest(); + WireTapHttpClient transport = new WireTapHttpClient(statusCodeTransport(503)); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(503, r.getStatusCode())) + .verifyComplete(); + + // 503 is not a bearer-fallback trigger; the response is returned as-is. + assertEquals(1, transport.getRequestCount()); + verify(bearerPolicy, times(0)).process(any(), any()); + } + + @Test + public void policyFallsToBearerOn400Async() { + HttpRequest request = blobGetRequest(); + WireTapHttpClient transport = bearerFallbackTransport(400); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + assertEquals(2, transport.getRequestCount()); + verify(bearerPolicy, times(1)).process(any(), any()); + String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + + @Test + public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { + HttpRequest request = blobGetRequest(); + HttpHeaders responseHeaders + = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-auth-info"), "session_expiring"); + HttpClient transport = responseTransport(200, responseHeaders); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // 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 + public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { + HttpRequest request = blobGetRequest(); + HttpClient transport = successTransport(); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Without the hint and with a fresh session, only the initial get is made and no refresh occurs. + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, never()).refreshSession(any()); + } + + @Test + public void getBlobRequestProducesWellFormedSessionAuthHeader() { + SessionCredential cred = credentialWithToken(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://testaccount.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"); + + HttpClient transport = successTransport(); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(cred)); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // The policy adapts Shared Key signing to the Session authorization scheme. + String actual = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + 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); + } + + // Sync tests use a minimal mock next-policy because the real pipeline doesn't expose sync invocation. + + @Test + 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(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(initialResponse); + when(retryNext.processSync()).thenReturn(retriedResponse); + when(initialResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(retriedResponse, actualResponse); + assertNull(context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + verify(sessionProvider, times(1)).invalidateSession(any(), any()); + } + } + + @Test + public void policyReturnsDataRequest503WithoutBearerFallbackSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(unavailableResponse); + when(unavailableResponse.getStatusCode()).thenReturn(503); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(unavailableResponse, actualResponse); + verify(unavailableResponse, times(0)).close(); + verify(bearerPolicy, times(0)).processSync(any(), any()); + verify(retryNext, times(0)).processSync(); + } + } + + @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(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + 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(HttpHeaderName.AUTHORIZATION); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void repeatedSessionRejectionStartsAccountCooldownSync() { + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + + for (int i = 0; i < 3; i++) { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse rejectedResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(rejectedResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(rejectedResponse.getStatusCode()).thenReturn(401); + when(bearerResponse.getStatusCode()).thenReturn(200); + + policy.processSync(context, next).close(); + } + + verify(sessionProvider, times(3)).getSession(any()); + + // The cooldown is now active, so this request goes straight to bearer without acquiring a session. + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + when(next.processSync()).thenReturn(bearerResponse); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(sessionProvider, times(3)).getSession(any()); + verify(next, times(0)).clone(); + } + } + + // Helpers + + private HttpPipeline buildPipeline(HttpClient transport) { + return new HttpPipelineBuilder().httpClient(transport).policies(policy).build(); + } + + private static HttpClient successTransport() { + return statusCodeTransport(200); + } + + private static HttpClient statusCodeTransport(int statusCode) { + return request -> Mono.just(new MockHttpResponse(request, statusCode)); + } + + private static HttpClient responseTransport(int statusCode, HttpHeaders headers) { + return request -> Mono.just(new MockHttpResponse(request, statusCode, headers)); + } + + private static WireTapHttpClient bearerFallbackTransport(int sessionResponseStatusCode) { + return new WireTapHttpClient(request -> Mono + .just(new MockHttpResponse(request, isSessionAuthenticated(request) ? sessionResponseStatusCode : 200))); + } + + private static WireTapHttpClient sessionRejectionTransportWithAcceptedSecondRequest() { + AtomicInteger sessionRequestCount = new AtomicInteger(); + return new WireTapHttpClient(request -> Mono.just(new MockHttpResponse(request, + !isSessionAuthenticated(request) || sessionRequestCount.incrementAndGet() == 2 ? 200 : 401))); + } + + private static boolean isSessionAuthenticated(HttpRequest request) { + String authorization = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + return authorization != null && authorization.startsWith("Session "); + } + + private static HttpRequest blobGetRequest() { + return new HttpRequest(HttpMethod.GET, "https://testaccount.blob.core.windows.net/mycontainer/myblob"); + } + + private SessionTokenCredentialPolicy createPolicy() { + return createPolicy(Clock.systemUTC()); + } + + private SessionTokenCredentialPolicy createPolicy(Clock clock) { + SessionOptions options = new SessionOptions().setContainerName("mycontainer"); + return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, options, clock); + } + + private static SessionCredential credentialWithToken() { + return credentialWithToken(OffsetDateTime.now().plusHours(1)); + } + + private static SessionCredential credentialWithToken(OffsetDateTime expiration) { + return new SessionCredential(FIRST_TOKEN, BlobTestBase.TEST_SESSION_KEY, expiration, + BlobTestBase.TEST_SESSION_ACCOUNT_NAME); + } + + private static HttpPipelineCallContext createContext() { + return createContextForRequest( + new HttpRequest(HttpMethod.GET, "https://testaccount.blob.core.windows.net/mycontainer/myblob")); + } + + 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; + } + + 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); + } + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java new file mode 100644 index 000000000000..453a1a395e72 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.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.BeforeEach; +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.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +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 TokenCredentialSessionProvider}'s time-based, per-container caching + * behavior. + *

+ * These tests drive {@link TokenCredentialSessionProvider} with an injectable {@link Clock} and a fake HTTP transport + * ({@link CreateSessionTransport}) 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 TokenCredentialSessionProviderCacheTest { + + private static final String ACCOUNT_NAME = "testaccount"; + 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); + private static final Instant TEST_START = Instant.parse("2026-06-19T00:00:00Z"); + + private MutableClock clock; + private CreateSessionTransport httpClient; + private TokenCredentialSessionProvider provider; + + @BeforeEach + public void setup() { + clock = new MutableClock(TEST_START); + httpClient = new CreateSessionTransport(); + + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); + provider = new TokenCredentialSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock); + } + + /** + * 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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2))); + + // First request: cold cache mints a good token and uses it. + SessionCredential firstRequest = provider.getSession(contextFor(CONTAINER_A)); + assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); + assertEquals(1, httpClient.getRequestCount(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 = provider.getSession(contextFor(CONTAINER_A)); + assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); + assertEquals(2, httpClient.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2))); + + // First request: cold cache mints the initial token. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(1, httpClient.getRequestCount(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, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(2, httpClient.getRequestCount(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(() -> provider.getSession(contextFor(CONTAINER_A))).getSessionToken()); + // Still only one inline creation and one background refresh overall (no over-eager churn). + assertEquals(2, httpClient.getRequestCount(CONTAINER_A)); + } + + /** + * Invalidation must evict the live credential and cause the next request to mint a replacement. + */ + @Test + public void invalidateSessionEvictsTheLiveCredential() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2))); + SessionRequestContext context = contextFor(CONTAINER_A); + + SessionCredential credential = provider.getSession(context); + assertTrue(provider.invalidateSession(context, credential)); + + SessionCredential replacement = provider.getSession(context); + assertEquals(SECOND_TOKEN, replacement.getSessionToken()); + assertEquals(2, httpClient.getRequestCount(CONTAINER_A)); + } + + /** + * A stale rejection must not evict a credential that a background refresh has already replaced. + */ + @Test + public void invalidateSessionIgnoresACredentialAlreadyReplacedByRefresh() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2))); + SessionRequestContext context = contextFor(CONTAINER_A); + + SessionCredential firstCredential = provider.getSession(context); + + // Before the shadow copy was removed, this late rejection incorrectly reported success even though the + // background refresh had already replaced the cached credential. + clock.advance(SESSION_LIFETIME.minusSeconds(2)); + assertEquals(FIRST_TOKEN, provider.getSession(context).getSessionToken()); + assertEquals(2, httpClient.getRequestCount(CONTAINER_A)); + assertEquals(SECOND_TOKEN, waitForToken(() -> provider.getSession(context)).getSessionToken()); + assertEquals(2, httpClient.getRequestCount(CONTAINER_A)); + + assertFalse(provider.invalidateSession(context, firstCredential)); + assertEquals(SECOND_TOKEN, provider.getSession(context).getSessionToken()); + assertEquals(2, httpClient.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, "refreshed-a", now().plus(SESSION_LIFETIME.multipliedBy(2))); + enqueueSessionResponse(CONTAINER_B, SECOND_TOKEN, now().plus(SESSION_LIFETIME)); + + // Mint an initial session for each container. + 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 + // 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, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + waitForRequestCount(CONTAINER_A, 2); + assertEquals(2, httpClient.getRequestCount(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.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + + // First request mints the token. + 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, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + } + + assertEquals(1, httpClient.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + + 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(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getRequestCount(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() { + enqueueFailure(CONTAINER_A); + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); + + // The failure left no cached value behind, so the retry mints a fresh one. + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(2, httpClient.getRequestCount(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() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, + provider.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken()); + + assertEquals(1, httpClient.getRequestCount(CONTAINER_A)); + } + + @Test + public void refreshAndInvalidationAreOwnedByProvider() { + enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME)); + enqueueSessionResponse(CONTAINER_A, "third-session-token", now().plus(SESSION_LIFETIME)); + SessionRequestContext context = contextFor(CONTAINER_A); + + SessionCredential first = provider.getSession(context); + provider.refreshSession(context); + SessionCredential second = waitForToken(() -> provider.getSession(context)); + + 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. + */ + @Test + public void concurrentAsyncRequestsShareASingleInFlightCreation() { + // A response that is never completed inline models a CreateSession call that is still outstanding. + Sinks.One pendingResponse = Sinks.one(); + AtomicReference pendingRequest = new AtomicReference<>(); + httpClient.enqueueResponse(CONTAINER_A, request -> { + pendingRequest.set(request); + return pendingResponse.asMono(); + }); + + Mono first = provider.getSessionAsync(contextFor(CONTAINER_A)); + Mono second = provider.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.getRequestCount(CONTAINER_A)); + + pendingResponse.tryEmitValue(buildResponse(pendingRequest.get(), FIRST_TOKEN, now().plus(SESSION_LIFETIME))); + + awaitLatch(firstLatch); + awaitLatch(secondLatch); + + assertEquals(FIRST_TOKEN, firstResult.get().getSessionToken()); + assertEquals(FIRST_TOKEN, secondResult.get().getSessionToken()); + assertEquals(1, httpClient.getRequestCount(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) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + SessionCredential last; + do { + last = supplier.get(); + if (SECOND_TOKEN.equals(last.getSessionToken())) { + return last; + } + sleepBriefly(); + } while (System.nanoTime() < deadline); + return last; + } + + private void waitForRequestCount(String container, int expectedCount) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (httpClient.getRequestCount(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 SessionRequestContext contextFor(String containerName) { + return new SessionRequestContext().setContainerName(containerName).setAccountName(ACCOUNT_NAME); + } + + private OffsetDateTime now() { + return OffsetDateTime.now(clock); + } + + private void enqueueSessionResponse(String container, String token, OffsetDateTime expiresAt) { + httpClient.enqueueResponse(container, request -> Mono.just(buildResponse(request, token, expiresAt))); + } + + private void enqueueFailure(String container) { + httpClient.enqueueResponse(container, + request -> Mono.error(new IllegalStateException("CreateSession failed."))); + } + + private static String containerFromPath(HttpRequest request) { + String path = request.getUrl().getPath(); + return path.startsWith("/") ? path.substring(1) : path; + } + + 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"); + } + + /** + * Serves a per-container FIFO queue of CreateSession responses, keyed off the container name in the request + * path, and counts the requests each container received. + */ + private static final class CreateSessionTransport implements HttpClient { + private final ConcurrentHashMap>>> responsesByContainer + = new ConcurrentHashMap<>(); + private final ConcurrentHashMap requestCountsByContainer = new ConcurrentHashMap<>(); + + void enqueueResponse(String container, Function> response) { + responsesByContainer.computeIfAbsent(container, ignored -> new ConcurrentLinkedQueue<>()).add(response); + } + + int getRequestCount(String container) { + AtomicInteger count = requestCountsByContainer.get(container); + return count == null ? 0 : count.get(); + } + + @Override + public Mono send(HttpRequest request) { + String container = containerFromPath(request); + requestCountsByContainer.computeIfAbsent(container, ignored -> new AtomicInteger()).incrementAndGet(); + + ConcurrentLinkedQueue>> responses + = responsesByContainer.get(container); + Function> response = responses == null ? null : responses.poll(); + return response == null + ? Mono.error(new IllegalStateException("No CreateSession response for " + container)) + : response.apply(request); + } + } + + /** + * 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/TokenCredentialSessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java new file mode 100644 index 000000000000..9aa55963cfbb --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java @@ -0,0 +1,193 @@ +// 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.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.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; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TokenCredentialSessionProviderTests extends BlobTestBase { + + @Test + public void createSessionReturnsTokenAndKey() { + AtomicReference requestPath = new AtomicReference<>(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(new AtomicInteger(), requestPath), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertEquals("/" + cc.getBlobContainerName(), requestPath.get()); + } + + @Test + public void createSessionAsyncReturnsTokenAndKey() { + AtomicReference requestPath = new AtomicReference<>(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(new AtomicInteger(), requestPath), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + }) + .verifyComplete(); + assertEquals("/" + ccAsync.getBlobContainerName(), requestPath.get()); + } + + @Test + public void createSessionSyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertEquals(1, policyInvocationCount.get()); + } + + @Test + public void createSessionAsyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .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()); + } + + @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(); + + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertFalse(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(); + + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertFalse(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) { + return createOAuthPipeline(policyInvocationCount, null); + } + + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount, AtomicReference requestPath) { + HttpPipelinePolicy policy = (context, next) -> { + policyInvocationCount.incrementAndGet(); + if (requestPath != null) { + requestPath.set(context.getHttpRequest().getUrl().getPath()); + } + 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/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md index 0019d026a0d0..ad2f4c16f6dc 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/nickliu-msft/azure-rest-api-specs/f85584d452061985a5fc21a67b8fc0b46b75188a/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json +input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/7c058345a1ef9a85676c955ddbe790ce3f90faed/specification/storage/data-plane/Microsoft.BlobStorage/stable/2027-03-07/blob.json java: true output-folder: ../ namespace: com.azure.storage.blob @@ -725,4 +725,3 @@ directive: } ]; ``` - diff --git a/sdk/storage/azure-storage-common/pom.xml b/sdk/storage/azure-storage-common/pom.xml index 97d6fcc863ce..a2bd7bb54d72 100644 --- a/sdk/storage/azure-storage-common/pom.xml +++ b/sdk/storage/azure-storage-common/pom.xml @@ -90,6 +90,26 @@ 1.18.4 test + + org.mockito + mockito-core + 4.11.0 + test + + + + + net.bytebuddy + byte-buddy + 1.18.11 + test + + + net.bytebuddy + byte-buddy-agent + 1.18.11 + test + 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 new file mode 100644 index 000000000000..eb2ada50b549 --- /dev/null +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.common.implementation.util; + +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +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 { + Mono createAsync(); + + T createSync(); + } + + private static final ClientLogger LOGGER = new ClientLogger(AutoRefreshingCache.class); + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final Duration REFRESH_RETRY_DELAY = Duration.ofSeconds(30); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + + private final ValueProvider valueProvider; + private final Function expirationExtractor; + private final Clock clock; + // Doubles as the "a creation is in flight" flag and the latch that wakes callers waiting on that + // creation. The thread that wins the compare-and-set owns the creation and must terminate the sink + // and clear this reference. Clearing happens before the value is delivered downstream, so a caller + // that reacts inside onNext sees no creation in flight and can start a fresh one. + private final AtomicReference> wip = new AtomicReference<>(); + private final AtomicReference value = new AtomicReference<>(); + private volatile OffsetDateTime nextRefreshTime; + // Throttles background refresh retries after creation failures so a failing provider is not retried + // once per caller request. Foreground creation remains intentionally unthrottled. + private volatile OffsetDateTime retryNotBefore; + + public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor) { + this(valueProvider, expirationExtractor, Clock.systemUTC()); + } + + 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."); + } + + public Mono getValidValueAsync() { + return Mono.defer(() -> { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value.get(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshValueInBackground(); + } + return Mono.just(current); + } + + return createOrJoinAsync(); + }); + } + + public T getValidValueSync() { + while (true) { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value.get(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshValueInBackground(); + } + return current; + } + + Sinks.One latch = Sinks.one(); + if (wip.compareAndSet(null, latch)) { + T created; + try { + // Re-check under ownership: another caller may have published a value between the + // check at the top of this loop and the compare-and-set above. + created = value.get(); + if (!isUsable(created, OffsetDateTime.now(clock))) { + created = valueProvider.createSync(); + setActiveValue(created); + } + } catch (RuntimeException e) { + armRetryBackoff(); + wip.compareAndSet(latch, null); + latch.tryEmitError(e); + throw LOGGER.logExceptionAsError(e); + } + // Clear ownership before waking waiters and before returning, so a caller reacting to + // this value sees no creation in flight. + wip.compareAndSet(latch, null); + latch.tryEmitValue(created); + return created; + } + + Sinks.One inFlight = wip.get(); + if (inFlight != null) { + // Join the in-flight creation rather than minting a duplicate. Blocking here is the + // same exposure the previous implementation had. Return what the owner published + // rather than re-testing it, so a value that is already expired on arrival is + // surfaced once instead of sending this loop back for another attempt. + T joined = inFlight.asMono().block(); + if (joined != null) { + return joined; + } + } + } + } + + /** + * Clears the cached value, but only if it is still the value the caller is rejecting. + * + * @param target The value the caller believes is cached. + * @return true if {@code target} was still the cached value and has been cleared; false if it had + * already been replaced or removed, in which case the cache is left untouched. + */ + public boolean invalidateValue(T target) { + boolean invalidated = target != null && value.compareAndSet(target, null); + if (invalidated) { + nextRefreshTime = null; + } + wip.set(null); + return invalidated; + } + + public void refreshValueInBackground() { + OffsetDateTime now = OffsetDateTime.now(clock); + if (!isUsable(value.get(), now) || !isRefreshDue(now) || wip.get() != null || isRetryBackoffActive(now)) { + return; + } + + createOrJoinAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + public void forceRefreshValueInBackground() { + OffsetDateTime now = OffsetDateTime.now(clock); + if (isUsable(value.get(), now)) { + nextRefreshTime = now; + } + + refreshValueInBackground(); + } + + private Mono createOrJoinAsync() { + return Mono.defer(() -> { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value.get(); + if (isUsable(current, now) && !isRefreshDue(now)) { + return Mono.just(current); + } + + Sinks.One latch = Sinks.one(); + if (wip.compareAndSet(null, latch)) { + return Mono.using(() -> latch, ignored -> valueProvider.createAsync().doOnNext(created -> { + setActiveValue(created); + // Clear ownership before waking waiters so a caller reacting to this value + // sees no creation in flight. + wip.compareAndSet(latch, null); + latch.tryEmitValue(created); + }).doOnError(error -> { + armRetryBackoff(); + wip.compareAndSet(latch, null); + latch.tryEmitError(error); + }), owned -> { + wip.compareAndSet(owned, null); + // No-op when the creation already emitted. On cancellation it releases anyone + // waiting on the latch so they retry instead of hanging. + owned.tryEmitEmpty(); + }).cache(); + } + + Sinks.One inFlight = wip.get(); + if (inFlight == null) { + return createOrJoinAsync(); + } + + return inFlight.asMono().switchIfEmpty(Mono.defer(this::createOrJoinAsync)); + }); + } + + private void setActiveValue(T newValue) { + value.set(newValue); + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), expirationExtractor.apply(newValue)); + retryNotBefore = null; + } + + private void armRetryBackoff() { + retryNotBefore = OffsetDateTime.now(clock).plus(REFRESH_RETRY_DELAY); + } + + private boolean isUsable(T value, OffsetDateTime now) { + return value != null && !now.isAfter(expirationExtractor.apply(value)); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private boolean isRetryBackoffActive(OffsetDateTime now) { + OffsetDateTime notBefore = retryNotBefore; + return notBefore != null && now.isBefore(notBefore); + } + + 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-common/src/main/java/module-info.java b/sdk/storage/azure-storage-common/src/main/java/module-info.java index 412a11fe41d6..13b44ef9c126 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,7 +25,7 @@ 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.contentvalidation to // FIXME this should not be a long-term solution com.azure.storage.blob, com.azure.storage.file.share, com.azure.storage.file.datalake; + exports com.azure.storage.common.implementation.util to com.azure.storage.blob; //FIXME this should not be a long-term solution } diff --git a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java index cfe97c625e7f..10661c681fb0 100644 --- a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java +++ b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java @@ -9,34 +9,63 @@ import com.azure.core.util.Context; import reactor.core.publisher.Mono; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + public class WireTapHttpClient implements HttpClient { private final HttpClient delegate; + private final AtomicInteger requestCount = new AtomicInteger(); + private Consumer requestInspector; private volatile HttpRequest lastRequest; public WireTapHttpClient(HttpClient delegate) { this.delegate = delegate; } + /** + * Creates a wire tap that hands every request to {@code requestInspector} as it goes on the wire, letting a test + * observe the final headers set by the pipeline's policies. + * + * @param delegate The client that actually sends the request. + * @param requestInspector Invoked with each request before it is sent. + */ + public WireTapHttpClient(HttpClient delegate, Consumer requestInspector) { + this(delegate); + this.requestInspector = requestInspector; + } + @Override public Mono send(HttpRequest request) { - lastRequest = request; + inspect(request); return delegate.send(request); } @Override public Mono send(HttpRequest request, Context context) { - lastRequest = request; + inspect(request); return delegate.send(request, context); } @Override public HttpResponse sendSync(HttpRequest request, Context context) { - lastRequest = request; + inspect(request); return delegate.sendSync(request, context); } public HttpRequest getLastRequest() { return lastRequest; } + + public int getRequestCount() { + return requestCount.get(); + } + + private void inspect(HttpRequest request) { + lastRequest = request; + requestCount.incrementAndGet(); + if (requestInspector != null) { + requestInspector.accept(request); + } + } } 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); + } } diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java new file mode 100644 index 000000000000..1981f5af130f --- /dev/null +++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java @@ -0,0 +1,483 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.common.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 java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +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. + */ +@SuppressWarnings("unchecked") +public class AutoRefreshingCacheTests { + private static final String FIRST_VALUE = "first-value"; + private static final String SECOND_VALUE = "second-value"; + private static final Duration VALUE_LIFETIME = Duration.ofMinutes(5); + + @Test + public void expiredByTimeOnSecondRequestCreatesNewValue() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + OffsetDateTime expiration = now(clock).plus(VALUE_LIFETIME); + when(provider.createSync()).thenReturn(value(FIRST_VALUE, expiration)) + .thenReturn(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createSync(); + verify(provider, never()).createAsync(); + + clock.advance(VALUE_LIFETIME.plusSeconds(1)); + + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(2)).createSync(); + verify(provider, never()).createAsync(); + } + + @Test + public void automaticBackgroundRefreshFiresWithoutHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))); + when(provider.createAsync()) + .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createSync(); + verify(provider, never()).createAsync(); + + clock.advance(VALUE_LIFETIME.minusSeconds(2)); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createSync(); + verify(provider, times(1)).createAsync(); + } + + @Test + public void failedBackgroundRefreshIsThrottled() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))); + when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom"))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + clock.advance(VALUE_LIFETIME.minusSeconds(2)); + + for (int i = 0; i < 3; i++) { + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + } + + verify(provider, times(1)).createSync(); + verify(provider, times(1)).createAsync(); + } + + @Test + public void throttledRefreshRetriesAfterBackoffElapses() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))); + when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom"))) + .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + cache.forceRefreshValueInBackground(); + + verify(provider, times(1)).createAsync(); + + // One second before the backoff elapses the retry must still be suppressed. + clock.advance(Duration.ofSeconds(29)); + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + + // Once it elapses the retry proceeds and the new value is adopted. + clock.advance(Duration.ofSeconds(1)); + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(2)).createAsync(); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + } + + @Test + public void forcedRefreshRespectsFailureBackoff() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))); + when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom"))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + clock.advance(VALUE_LIFETIME.minusSeconds(2)); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + + cache.forceRefreshValueInBackground(); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + } + + @Test + public void expiredValueIsStillCreatedDuringBackoff() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))) + .thenReturn(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))); + when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom"))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + clock.advance(VALUE_LIFETIME.minusSeconds(2)); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + + clock.advance(Duration.ofSeconds(3)); + + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(2)).createSync(); + verify(provider, times(1)).createAsync(); + } + + @Test + public void successfulCreationClearsFailureBackoff() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + // The replacement is deliberately short-lived so its own refresh window opens while the failure + // backoff armed by the first value would still have been active. + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))) + .thenReturn(value(SECOND_VALUE, now(clock).plus(Duration.ofSeconds(321)))); + when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom"))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + + // T+298s: inside the first value's refresh window. The background refresh fails and arms the + // backoff until T+328s. + clock.advance(VALUE_LIFETIME.minusSeconds(2)); + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(1)).createAsync(); + + // T+301s: the first value has expired, so the unthrottled foreground path mints a replacement. + clock.advance(Duration.ofSeconds(3)); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(2)).createSync(); + + // T+317s: inside the replacement's refresh window but still before T+328s, so this refresh can + // only happen because the successful creation cleared the backoff. + clock.advance(Duration.ofSeconds(16)); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + verify(provider, times(2)).createAsync(); + } + + @Test + public void forcedRefreshFromWithinOnNextStartsNewCreation() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createAsync()).thenReturn(Mono.just(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)))) + .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))))); + + // Mirrors the production pipeline: a downstream subscriber inspects the response for the + // "session expiring" hint and forces a refresh from inside onNext, which runs before the + // creation Mono has reached its terminal signal. + TestExpiringValue delivered + = cache.getValidValueAsync().doOnNext(ignored -> cache.forceRefreshValueInBackground()).block(); + + assertEquals(FIRST_VALUE, delivered.getValue()); + // The reentrant force must start a brand new creation rather than handing back the creation + // that is still mid-delivery. + verify(provider, times(2)).createAsync(); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + } + + @Test + public void forcedRefreshFromWithinJoinerOnNextStartsNewCreation() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createAsync()) + // The first creation is delayed so a joiner can attach. + .thenReturn( + Mono.just(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))).delayElement(Duration.ofMillis(500))) + .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))))); + + Mono owner = cache.getValidValueAsync(); + Mono joiner + = cache.getValidValueAsync().doOnNext(ignored -> cache.forceRefreshValueInBackground()); + + // Run both owner and joiner pipelines. The joiner will be notified when the owner completes. + reactor.core.publisher.Mono.when(owner, joiner).block(); + + // The reentrant force from the joiner must start a brand new creation. + verify(provider, times(2)).createAsync(); + assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue()); + } + + @Test + public void noRefreshBeforeJitterWindowWithoutHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))); + + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + clock.advance(Duration.ofSeconds(30)); + + for (int i = 0; i < 3; i++) { + assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue()); + } + + verify(provider, times(1)).createSync(); + verify(provider, never()).createAsync(); + } + + @Test + public void invalidateValueClearsMatchingValue() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + TestExpiringValue first = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)); + TestExpiringValue second = value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))); + when(provider.createSync()).thenReturn(first).thenReturn(second); + + assertSame(first, cache.getValidValueSync()); + + assertTrue(cache.invalidateValue(first), "Invalidating the live value should report success."); + + // The rejected value was the live one, so the next call must mint a replacement. + assertSame(second, cache.getValidValueSync()); + verify(provider, times(2)).createSync(); + } + + @Test + public void invalidateValueIgnoresStaleTarget() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + TestExpiringValue live = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)); + when(provider.createSync()).thenReturn(live); + + assertSame(live, cache.getValidValueSync()); + + // A late rejection naming a value that has already been replaced must not evict the live one. + assertFalse(cache.invalidateValue(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME))), + "Invalidating a value that is not the cached one should report failure."); + + assertSame(live, cache.getValidValueSync()); + verify(provider, times(1)).createSync(); + } + + @Test + public void concurrentSyncCallersShareASingleCreation() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + TestExpiringValue created = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)); + CountDownLatch creationEntered = new CountDownLatch(1); + CountDownLatch releaseCreation = new CountDownLatch(1); + when(provider.createSync()).thenAnswer(invocation -> { + creationEntered.countDown(); + assertTrue(releaseCreation.await(10, TimeUnit.SECONDS)); + return created; + }); + + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future owner = pool.submit(cache::getValidValueSync); + // Only start the second caller once the first genuinely owns the in-flight creation. + assertTrue(creationEntered.await(10, TimeUnit.SECONDS)); + Future joiner = pool.submit(cache::getValidValueSync); + + releaseCreation.countDown(); + + assertSame(created, owner.get(10, TimeUnit.SECONDS)); + assertSame(created, joiner.get(10, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + + // The joiner must have reused the owner's creation rather than minting a duplicate. + verify(provider, times(1)).createSync(); + } + + @Test + public void valueThatIsAlreadyExpiredOnArrivalIsReturnedNotRecreated() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + // Clock skew can make a freshly minted value look expired the moment it arrives. It must be + // handed back once rather than sending the caller into another creation. + TestExpiringValue stillborn = value(FIRST_VALUE, now(clock).minusSeconds(1)); + when(provider.createSync()).thenReturn(stillborn); + + assertSame(stillborn, cache.getValidValueSync()); + verify(provider, times(1)).createSync(); + } + + @Test + public void syncJoinerReturnsAnAlreadyExpiredValueWithoutRecreating() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class); + AutoRefreshingCache cache + = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock); + + TestExpiringValue stillborn = value(FIRST_VALUE, now(clock).minusSeconds(1)); + CountDownLatch creationEntered = new CountDownLatch(1); + CountDownLatch releaseCreation = new CountDownLatch(1); + when(provider.createSync()).thenAnswer(invocation -> { + creationEntered.countDown(); + assertTrue(releaseCreation.await(10, TimeUnit.SECONDS)); + return stillborn; + }); + + AtomicReference joinerThread = new AtomicReference<>(); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future owner = pool.submit(cache::getValidValueSync); + assertTrue(creationEntered.await(10, TimeUnit.SECONDS)); + + Future joiner = pool.submit(() -> { + joinerThread.set(Thread.currentThread()); + return cache.getValidValueSync(); + }); + + // Wait until the joiner has actually parked on the in-flight creation's latch. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + Thread running = joinerThread.get(); + while (System.nanoTime() < deadline + && (running == null + || running.getState() == Thread.State.RUNNABLE + || running.getState() == Thread.State.NEW)) { + Thread.yield(); + running = joinerThread.get(); + } + + releaseCreation.countDown(); + + assertSame(stillborn, owner.get(10, TimeUnit.SECONDS)); + // The joiner must hand back what the owner published even though it is already expired, + // rather than looping and minting a second value. + assertSame(stillborn, joiner.get(10, TimeUnit.SECONDS)); + } finally { + pool.shutdownNow(); + } + + verify(provider, times(1)).createSync(); + } + + private static OffsetDateTime now(Clock clock) { + return OffsetDateTime.now(clock); + } + + private static TestExpiringValue value(String value, OffsetDateTime expiration) { + return new TestExpiringValue(value, expiration); + } + + private static final class TestExpiringValue { + private final String value; + private final OffsetDateTime expiration; + + private TestExpiringValue(String value, OffsetDateTime expiration) { + this.value = value; + this.expiration = expiration; + } + + public OffsetDateTime getExpiration() { + return expiration; + } + + private String getValue() { + return value; + } + } + + 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); + } + } +} diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java index 088b981fc19e..bd019e1a374a 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java @@ -28,6 +28,7 @@ import com.azure.storage.blob.BlobServiceClientBuilder; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +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; @@ -89,6 +90,7 @@ public class DataLakeServiceClientBuilder 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, and are only effective for accounts with a + * hierarchical namespace (HNS) enabled. Session mode is enabled by default whenever this builder is + * configured with a {@link com.azure.core.credential.TokenCredential}; requests that are not eligible for + * session authentication, or for which session negotiation fails (for example, because HNS is not enabled), + * transparently fall back to bearer token authentication. + * + * @param sessionOptions The session options for the HTTP pipeline. + * @return the updated DataLakeServiceClientBuilder object. + */ + public DataLakeServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); + blobServiceClientBuilder.sessionOptions(this.sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java new file mode 100644 index 000000000000..11880685b265 --- /dev/null +++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.file.datalake; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.test.http.NoOpHttpClient; +import com.azure.core.test.utils.MockTokenCredential; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.StorageSharedKeyCredential; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class DataLakeServiceClientBuilderTests { + + private static final String ENDPOINT = "https://account.blob.core.windows.net/"; + + @Test + public void defaultTokenCredentialClientsUseSessionPolicy() { + DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + assertTrue(hasPolicyOfType(client.blobServiceClient.getHttpPipeline(), "SessionTokenCredentialPolicy")); + } + + @Test + public void disablingSessionsRemovesSessionPolicyButKeepsBearerPolicy() { + DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.DISABLED)) + .buildClient(); + + HttpPipeline pipeline = client.blobServiceClient.getHttpPipeline(); + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy")); + assertTrue(hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy")); + } + + @Test + public void fileSystemClientsReuseTheServiceSessionPipeline() { + DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + DataLakeFileSystemClient fileSystemClient = client.getFileSystemClient("filesystem"); + + assertTrue(hasPolicyOfType(fileSystemClient.getBlobContainerClient().getHttpPipeline(), + "SessionTokenCredentialPolicy")); + } + + @Test + public void sharedKeyCredentialDoesNotUseBearerOrSessionPolicies() { + DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT) + .credential(new StorageSharedKeyCredential("account", "accountKey")) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + HttpPipeline pipeline = client.blobServiceClient.getHttpPipeline(); + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy")); + assertFalse(hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy")); + } + + @Test + public void nullSessionOptionsBehaveLikeDefaultSessions() { + DataLakeServiceClient client = assertDoesNotThrow(() -> new DataLakeServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(null) + .buildClient()); + + assertTrue(hasPolicyOfType(client.blobServiceClient.getHttpPipeline(), "SessionTokenCredentialPolicy")); + } + + 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; + } +} diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java index e24e61c41b6d..c2cc8d69bd2c 100644 --- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java +++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java @@ -19,6 +19,7 @@ import com.azure.core.test.models.TestProxySanitizerType; import com.azure.core.util.CoreUtils; import com.azure.storage.blob.models.BlobErrorCode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.Utility; import com.azure.storage.common.implementation.Constants; @@ -135,10 +136,14 @@ public void beforeTest() { prefix = StorageCommonTestUtils.getCrc32(testContextManager.getTestPlaybackRecordingName()); if (getTestMode() != TestMode.LIVE) { - interceptorManager.addSanitizers(Arrays.asList( - new TestProxySanitizer("sig=(.*)", "REDACTED", TestProxySanitizerType.URL), - new TestProxySanitizer("x-ms-encryption-key", ".*", "REDACTED", TestProxySanitizerType.HEADER), - new TestProxySanitizer("x-ms-rename-source", "sig=(.*)", "REDACTED", TestProxySanitizerType.HEADER))); + interceptorManager + .addSanitizers(Arrays.asList(new TestProxySanitizer("sig=(.*)", "REDACTED", TestProxySanitizerType.URL), + new TestProxySanitizer("x-ms-encryption-key", ".*", "REDACTED", TestProxySanitizerType.HEADER), + new TestProxySanitizer("x-ms-rename-source", "sig=(.*)", "REDACTED", TestProxySanitizerType.HEADER), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"))); // Remove `id` and `name` sanitizers from the list of common sanitizers. interceptorManager.removeSanitizers("AZSDK3430", "AZSDK3493"); } @@ -207,6 +212,43 @@ protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient() { return getOAuthServiceClientBuilder().buildAsyncClient(); } + protected DataLakeServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected DataLakeServiceClient getOAuthServiceClient(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { + return getOAuthServiceClientBuilder(sessionOptions, policies).buildClient(); + } + + protected DataLakeServiceClientBuilder getOAuthServiceClientBuilder(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { + DataLakeServiceClientBuilder builder + = new DataLakeServiceClientBuilder().endpoint(ENVIRONMENT.getDataLakeAccount().getDataLakeEndpoint()) + .sessionOptions(sessionOptions); + + instrument(builder); + + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)); + } + + protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) { + return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { + return getOAuthServiceClientBuilder(sessionOptions, policies).buildAsyncClient(); + } + protected DataLakeServiceClient getServiceClient(TestAccount account) { return getServiceClient(account.getCredential(), account.getDataLakeEndpoint()); } diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java index c97ed9007c4f..5518189cc698 100644 --- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java +++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java @@ -3,13 +3,18 @@ package com.azure.storage.file.datalake; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; +import com.azure.core.test.utils.TestUtils; import com.azure.core.util.Context; import com.azure.core.util.CoreUtils; import com.azure.identity.DefaultAzureCredentialBuilder; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobErrorCode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.test.shared.TestHttpClientType; import com.azure.storage.common.test.shared.extensions.LiveOnly; import com.azure.storage.common.test.shared.extensions.PlaybackOnly; @@ -39,17 +44,20 @@ import com.azure.storage.file.datalake.options.FileScheduleDeletionOptions; import com.azure.storage.file.datalake.options.FileSystemEncryptionScopeOptions; 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; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import java.io.ByteArrayOutputStream; import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -2518,4 +2526,49 @@ private void setupDirectoryForListing(DataLakeDirectoryClient client) { // // assertThrows(DataLakeStorageException.class, () -> dataLakeFileSystemClient.rename(generateFileSystemName())); // } + + // Session credentials are bound to the network context of the CreateSession call, so any TLS-terminating + // intermediary (including the test proxy) causes the service to reject the session-signed reads. + @Test + @LiveOnly + @ResourceLock("DataLakeSessionAuth") + public void readFileOverSessionAuth() { + int fileCount = 5; + List fileNames = new ArrayList<>(); + for (int i = 0; i < fileCount; i++) { + String fileName = generatePathName(); + dataLakeFileSystemClient.getFileClient(fileName).upload(DATA.getDefaultBinaryData()); + fileNames.add(fileName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + HttpPipelinePolicy inspect = (context, next) -> { + HttpRequest req = context.getHttpRequest(); + 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 && req.getHttpMethod() == HttpMethod.GET && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + return next.process(); + }; + + DataLakeFileSystemClient sessionFileSystemClient = sessionEnabledFileSystemClient(inspect); + + for (String fileName : fileNames) { + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + sessionFileSystemClient.getFileClient(fileName).read(outStream); + TestUtils.assertArraysEqual(DATA.getDefaultBytes(), outStream.toByteArray()); + } + + assertTrue(downloadAuthSchemes.size() >= fileCount, + "Expected to observe at least one download request per file; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all file downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + private DataLakeFileSystemClient sessionEnabledFileSystemClient(HttpPipelinePolicy... policies) { + return getOAuthServiceClient(new SessionOptions(), policies) + .getFileSystemClient(dataLakeFileSystemClient.getFileSystemName()); + } } diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java index 504200c89c7c..0a6bc752139e 100644 --- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java +++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java @@ -3,12 +3,18 @@ package com.azure.storage.file.datalake; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; +import com.azure.core.test.utils.TestUtils; import com.azure.core.util.CoreUtils; +import com.azure.core.util.FluxUtil; import com.azure.identity.DefaultAzureCredentialBuilder; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobErrorCode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.test.shared.TestHttpClientType; import com.azure.storage.common.test.shared.extensions.LiveOnly; import com.azure.storage.common.test.shared.extensions.PlaybackOnly; @@ -38,6 +44,7 @@ import com.azure.storage.file.datalake.options.FileSystemEncryptionScopeOptions; import org.junit.jupiter.api.Assertions; 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; @@ -51,6 +58,7 @@ import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -2605,4 +2613,53 @@ private Mono setupDirectoryForListing(DataLakeDire .flatMap(foo2 -> foo2.createSubdirectory("bar")) .then(baz.createSubdirectory("bar/foo"))); } + + // Session credentials are bound to the network context of the CreateSession call, so any TLS-terminating + // intermediary (including the test proxy) causes the service to reject the session-signed reads. + @Test + @LiveOnly + @ResourceLock("DataLakeSessionAuth") + public void readFileOverSessionAuth() { + int fileCount = 5; + List fileNames = new ArrayList<>(); + for (int i = 0; i < fileCount; i++) { + String fileName = generatePathName(); + dataLakeFileSystemAsyncClient.getFileAsyncClient(fileName) + .upload(DATA.getDefaultBinaryData(), null) + .block(); + fileNames.add(fileName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + HttpPipelinePolicy inspect = (context, next) -> { + HttpRequest req = context.getHttpRequest(); + 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 && req.getHttpMethod() == HttpMethod.GET && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + return next.process(); + }; + + DataLakeFileSystemAsyncClient sessionFileSystemAsyncClient = sessionEnabledFileSystemAsyncClient(inspect); + + for (String fileName : fileNames) { + StepVerifier + .create(FluxUtil + .collectBytesInByteBufferStream(sessionFileSystemAsyncClient.getFileAsyncClient(fileName).read())) + .assertNext(bytes -> TestUtils.assertArraysEqual(DATA.getDefaultBytes(), bytes)) + .verifyComplete(); + } + + assertTrue(downloadAuthSchemes.size() >= fileCount, + "Expected to observe at least one download request per file; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all file downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + private DataLakeFileSystemAsyncClient sessionEnabledFileSystemAsyncClient(HttpPipelinePolicy... policies) { + return getOAuthServiceAsyncClient(new SessionOptions(), policies) + .getFileSystemAsyncClient(dataLakeFileSystemAsyncClient.getFileSystemName()); + } }