diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml
index a9109cf51803..5c5fe54c957d 100644
--- a/eng/common/pipelines/templates/steps/verify-agent-os.yml
+++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml
@@ -14,5 +14,3 @@ steps:
filePath: ${{ parameters.ScriptDirectory }}/Verify-AgentOS.ps1
arguments: >
-AgentImage "${{ parameters.AgentImage }}"
-
- - template: /eng/common/pipelines/templates/steps/bypass-local-dns.yml
diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json
index 8cad139f33ff..92108b8c51bb 100644
--- a/sdk/storage/azure-storage-blob/assets.json
+++ b/sdk/storage/azure-storage-blob/assets.json
@@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "java",
"TagPrefix": "java/storage/azure-storage-blob",
- "Tag": "java/storage/azure-storage-blob_47f4243e59"
+ "Tag": "java/storage/azure-storage-blob_dbe8c45320"
}
diff --git a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml
index 90d7f65ae375..a5ed026c13b5 100644
--- a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml
+++ b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml
@@ -6,6 +6,7 @@
+
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java
index 1d0ac36d4ce8..a099f0aab5c5 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java
@@ -31,6 +31,8 @@
import com.azure.storage.blob.models.BlobAudience;
import com.azure.storage.blob.models.CpkInfo;
import com.azure.storage.blob.models.CustomerProvidedKey;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.StorageSharedKeyCredential;
import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings;
import com.azure.storage.common.implementation.connectionstring.StorageConnectionString;
@@ -92,6 +94,7 @@ public final class BlobClientBuilder
private Configuration configuration;
private BlobServiceVersion version;
private BlobAudience audience;
+ private SessionOptions sessionOptions = new SessionOptions();
/**
* Creates a builder instance that is able to configure and construct {@link BlobClient BlobClients} and {@link
@@ -133,6 +136,8 @@ public BlobClient buildClient() {
new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set"));
}
+ BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER);
+
/*
Implicit and explicit root container access are functionally equivalent, but explicit references are easier
to read and debug.
@@ -189,18 +194,27 @@ public BlobAsyncClient buildAsyncClient() {
BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest();
- HttpPipeline pipeline = constructPipeline();
+ HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion);
return new BlobAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, blobName,
snapshot, customerProvidedKey, encryptionScope, versionId);
}
- private HttpPipeline constructPipeline() {
- return (httpPipeline != null)
- ? httpPipeline
- : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
- endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
- perRetryPolicies, configuration, audience, LOGGER);
+ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion serviceVersion) {
+ if (httpPipeline != null) {
+ return httpPipeline;
+ }
+
+ if (containerName != null) {
+ sessionOptions.setContainerName(containerName);
+ }
+ if (sessionOptions.getAccountName() == null) {
+ sessionOptions.setAccountName(accountName);
+ }
+
+ return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
+ endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
+ perRetryPolicies, configuration, audience, LOGGER, sessionOptions, serviceVersion);
}
/**
@@ -650,4 +664,20 @@ public BlobClientBuilder audience(BlobAudience audience) {
this.audience = audience;
return this;
}
+
+ /**
+ * Sets the {@link SessionOptions} that controls how the SDK manages session-based authentication for this blob.
+ *
+ * Sessions amortize authentication and authorization cost across many requests by signing them with a lightweight
+ * HMAC key instead of a full bearer token. When the session mode within the options is set to a value other than
+ * {@link SessionMode#NONE}, this builder's configured container name is used when the options don't specify one.
+ *
+ * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO}
+ * when identity-based authentication (bearer token) is configured.
+ * @return the updated BlobClientBuilder object.
+ */
+ public BlobClientBuilder sessionOptions(SessionOptions sessionOptions) {
+ this.sessionOptions = SessionOptions.orDefault(sessionOptions);
+ return this;
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java
index b86fe4e76b2f..d79a68c24a96 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java
@@ -28,6 +28,9 @@
import com.azure.storage.blob.implementation.models.EncryptionScope;
import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse;
import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse;
+import com.azure.storage.blob.implementation.models.AuthenticationType;
+import com.azure.storage.blob.implementation.models.CreateSessionConfiguration;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
import com.azure.storage.blob.implementation.util.BlobConstants;
import com.azure.storage.blob.implementation.util.BlobSasImplUtil;
import com.azure.storage.blob.implementation.util.ModelHelper;
@@ -1691,11 +1694,39 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV
.generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context);
}
- // private boolean validateNoTime(BlobRequestConditions modifiedRequestConditions) {
- // if (modifiedRequestConditions == null) {
- // return true;
- // }
- // return modifiedRequestConditions.getIfModifiedSince() == null
- // && modifiedRequestConditions.getIfUnmodifiedSince() == null;
- // }
+ /**
+ * Creates a session scoped to this container. The session provides temporary credentials (a session token and
+ * session key) that can be used to sign subsequent requests using the Shared Key protocol.
+ *
+ * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ Mono createSession() {
+ return createSessionWithResponse().flatMap(FluxUtil::toMono);
+ }
+
+ /**
+ * Creates a session scoped to this container. The session provides temporary credentials (a session token and
+ * session key) that can be used to sign subsequent requests using the Shared Key protocol.
+ *
+ * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ Mono> createSessionWithResponse() {
+ try {
+ return withContext(this::createSessionWithResponse);
+ } catch (RuntimeException ex) {
+ return monoError(LOGGER, ex);
+ }
+ }
+
+ Mono> createSessionWithResponse(Context context) {
+ context = context == null ? Context.NONE : context;
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+ return this.azureBlobStorage.getContainers()
+ .createSessionWithResponseAsync(containerName, config, null, null, context)
+ .map(response -> new SimpleResponse<>(response, response.getValue()));
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java
index 64de81617f9c..426e38c46b76 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java
@@ -31,6 +31,9 @@
import com.azure.storage.blob.implementation.models.FilterBlobSegment;
import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse;
import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse;
+import com.azure.storage.blob.implementation.models.AuthenticationType;
+import com.azure.storage.blob.implementation.models.CreateSessionConfiguration;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
import com.azure.storage.blob.implementation.util.BlobConstants;
import com.azure.storage.blob.implementation.util.BlobSasImplUtil;
import com.azure.storage.blob.implementation.util.ModelHelper;
@@ -1509,4 +1512,37 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV
.generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context);
}
+ /**
+ * Creates a session scoped to this container. The session provides temporary credentials (a session token and
+ * session key) that can be used to sign subsequent requests using the Shared Key protocol.
+ *
+ * @return The {@link CreateSessionResponse} with session credentials.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ CreateSessionResponse createSession() {
+ return createSessionWithResponse(null, Context.NONE).getValue();
+ }
+
+ /**
+ * Creates a session scoped to this container. The session provides temporary credentials (a session token and
+ * session key) that can be used to sign subsequent requests using the Shared Key protocol.
+ *
+ * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised.
+ * @param context Additional context that is passed through the Http pipeline during the service call.
+ * @return A {@link Response} containing the {@link CreateSessionResponse}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ Response createSessionWithResponse(Duration timeout, Context context) {
+ Context finalContext = context == null ? Context.NONE : context;
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+
+ Callable> operation = () -> {
+ Response response = this.azureBlobStorage.getContainers()
+ .createSessionWithResponse(containerName, config, null, null, finalContext);
+ return new SimpleResponse<>(response, response.getValue());
+ };
+
+ return sendRequest(operation, timeout, BlobStorageException.class);
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java
index 5ca6281bb1fb..1f0b003f01cc 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java
@@ -32,6 +32,8 @@
import com.azure.storage.blob.models.BlobContainerEncryptionScope;
import com.azure.storage.blob.models.CpkInfo;
import com.azure.storage.blob.models.CustomerProvidedKey;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.blob.models.SessionMode;
import com.azure.storage.common.StorageSharedKeyCredential;
import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings;
import com.azure.storage.common.implementation.connectionstring.StorageConnectionString;
@@ -91,6 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait
+ * Sessions amortize authentication and authorization cost across many requests by signing them
+ * with a lightweight HMAC key instead of a full bearer token. When the session mode within the options
+ * is set to a value other than {@link SessionMode#NONE},
+ * {@link #containerName(String) containerName} must also be set.
+ *
+ * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO}
+ * when identity-based authentication (bearer token) is configured.
+ * @return the updated BlobContainerClientBuilder object.
+ */
+ public BlobContainerClientBuilder sessionOptions(SessionOptions sessionOptions) {
+ this.sessionOptions = SessionOptions.orDefault(sessionOptions);
+ return this;
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java
index 3ad51c9a9b5f..641c40b1a0a0 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java
@@ -147,6 +147,7 @@ public BlobContainerClient getBlobContainerClient(String containerName) {
if (CoreUtils.isNullOrEmpty(containerName)) {
containerName = BlobContainerClient.ROOT_CONTAINER_NAME;
}
+
return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(),
containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope);
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java
index 5fb46965824f..3cefa0395364 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java
@@ -30,6 +30,8 @@
import com.azure.core.util.logging.ClientLogger;
import com.azure.storage.blob.implementation.models.EncryptionScope;
import com.azure.storage.blob.implementation.util.BuilderHelper;
+import com.azure.storage.blob.implementation.util.SessionTokenCredentialPolicy;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.models.BlobAudience;
import com.azure.storage.blob.models.BlobContainerEncryptionScope;
import com.azure.storage.blob.models.CpkInfo;
@@ -93,6 +95,7 @@ public final class BlobServiceClientBuilder implements TokenCredentialTrait
+ * Sessions amortize authentication and authorization cost across many requests by signing them
+ * with a lightweight HMAC key instead of a full bearer token. This setting is passed to container
+ * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}.
+ *
+ * @param sessionOptions The session options for the HTTP pipeline.
+ * @return the updated BlobServiceClientBuilder object.
+ */
+ public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) {
+ this.sessionOptions = SessionOptions.orDefault(sessionOptions);
+ return this;
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java
index 7fd2af96e4df..8bc27e750abc 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java
@@ -46,6 +46,8 @@
import com.azure.storage.blob.implementation.models.ContainersSetAccessPolicyHeaders;
import com.azure.storage.blob.implementation.models.ContainersSetMetadataHeaders;
import com.azure.storage.blob.implementation.models.ContainersSubmitBatchHeaders;
+import com.azure.storage.blob.implementation.models.CreateSessionConfiguration;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
import com.azure.storage.blob.implementation.models.FilterBlobSegment;
import com.azure.storage.blob.implementation.models.FilterBlobsIncludeItem;
import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse;
@@ -938,6 +940,26 @@ Response getAccountInfoNoCustomHeadersSync(@HostParam("url") String url,
@QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout,
@HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId,
@HeaderParam("Accept") String accept, Context context);
+
+ @Post("/{containerName}")
+ @ExpectedResponses({ 201 })
+ @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class)
+ Mono> createSession(@HostParam("url") String url,
+ @PathParam("containerName") String containerName, @QueryParam("restype") String restype,
+ @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout,
+ @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId,
+ @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration,
+ @HeaderParam("Accept") String accept, Context context);
+
+ @Post("/{containerName}")
+ @ExpectedResponses({ 201 })
+ @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class)
+ Response createSessionSync(@HostParam("url") String url,
+ @PathParam("containerName") String containerName, @QueryParam("restype") String restype,
+ @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout,
+ @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId,
+ @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration,
+ @HeaderParam("Accept") String accept, Context context);
}
/**
@@ -6707,4 +6729,159 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container
throw ModelHelper.mapToBlobStorageException(internalException);
}
}
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response body along with {@link Response} on successful completion of {@link Mono}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public Mono> createSessionWithResponseAsync(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) {
+ return FluxUtil
+ .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout,
+ requestId, context))
+ .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException);
+ }
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @param context The context to associate with this operation.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response body along with {@link Response} on successful completion of {@link Mono}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public Mono> createSessionWithResponseAsync(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) {
+ final String restype = "container";
+ final String comp = "session";
+ final String accept = "application/xml";
+ return service
+ .createSession(this.client.getUrl(), containerName, restype, comp, timeout, this.client.getVersion(),
+ requestId, createSessionConfiguration, accept, context)
+ .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException);
+ }
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response body on successful completion of {@link Mono}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public Mono createSessionAsync(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) {
+ return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId)
+ .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException)
+ .flatMap(res -> Mono.justOrEmpty(res.getValue()));
+ }
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @param context The context to associate with this operation.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response body on successful completion of {@link Mono}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public Mono createSessionAsync(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) {
+ return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId, context)
+ .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException)
+ .flatMap(res -> Mono.justOrEmpty(res.getValue()));
+ }
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @param context The context to associate with this operation.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response body along with {@link Response}.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public Response createSessionWithResponse(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) {
+ try {
+ final String restype = "container";
+ final String comp = "session";
+ final String accept = "application/xml";
+ return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, timeout,
+ this.client.getVersion(), requestId, createSessionConfiguration, accept, context);
+ } catch (BlobStorageExceptionInternal internalException) {
+ throw ModelHelper.mapToBlobStorageException(internalException);
+ }
+ }
+
+ /**
+ * The Create Session operation enables users to create a session scoped to a container.
+ *
+ * @param containerName The container name.
+ * @param createSessionConfiguration The createSessionConfiguration parameter.
+ * @param timeout The timeout parameter is expressed in seconds. For more information, see <a
+ * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting
+ * Timeouts for Blob Service Operations.</a>.
+ * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the
+ * analytics logs when storage analytics logging is enabled.
+ * @throws IllegalArgumentException thrown if parameters fail the validation.
+ * @throws BlobStorageExceptionInternal thrown if the request is rejected by server.
+ * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent.
+ * @return the response.
+ */
+ @ServiceMethod(returns = ReturnType.SINGLE)
+ public CreateSessionResponse createSession(String containerName,
+ CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) {
+ try {
+ return createSessionWithResponse(containerName, createSessionConfiguration, timeout, requestId,
+ Context.NONE).getValue();
+ } catch (BlobStorageExceptionInternal internalException) {
+ throw ModelHelper.mapToBlobStorageException(internalException);
+ }
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java
new file mode 100644
index 000000000000..76a92bba45e3
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+// Code generated by Microsoft (R) AutoRest Code Generator.
+
+package com.azure.storage.blob.implementation.models;
+
+import com.azure.core.annotation.Generated;
+import com.azure.core.util.ExpandableStringEnum;
+import java.util.Collection;
+
+/**
+ * The type of authentication required to create the session. The only type currently supported is HMAC.
+ */
+public final class AuthenticationType extends ExpandableStringEnum {
+ /**
+ * Static value HMAC for AuthenticationType.
+ */
+ @Generated
+ public static final AuthenticationType HMAC = fromString("HMAC");
+
+ /**
+ * Creates a new instance of AuthenticationType value.
+ *
+ * @deprecated Use the {@link #fromString(String)} factory method.
+ */
+ @Generated
+ @Deprecated
+ public AuthenticationType() {
+ }
+
+ /**
+ * Creates or finds a AuthenticationType from its string representation.
+ *
+ * @param name a name to look for.
+ * @return the corresponding AuthenticationType.
+ */
+ @Generated
+ public static AuthenticationType fromString(String name) {
+ return fromString(name, AuthenticationType.class);
+ }
+
+ /**
+ * Gets known AuthenticationType values.
+ *
+ * @return known AuthenticationType values.
+ */
+ @Generated
+ public static Collection values() {
+ return values(AuthenticationType.class);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java
new file mode 100644
index 000000000000..b52e86f169cd
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java
@@ -0,0 +1,119 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+// Code generated by Microsoft (R) AutoRest Code Generator.
+
+package com.azure.storage.blob.implementation.models;
+
+import com.azure.core.annotation.Fluent;
+import com.azure.core.annotation.Generated;
+import com.azure.xml.XmlReader;
+import com.azure.xml.XmlSerializable;
+import com.azure.xml.XmlToken;
+import com.azure.xml.XmlWriter;
+import javax.xml.namespace.QName;
+import javax.xml.stream.XMLStreamException;
+
+/**
+ * The CreateSessionConfiguration model.
+ */
+@Fluent
+public final class CreateSessionConfiguration implements XmlSerializable {
+ /*
+ * The type of authentication required to create the session. The only type currently supported is HMAC.
+ */
+ @Generated
+ private AuthenticationType authenticationType;
+
+ /**
+ * Creates an instance of CreateSessionConfiguration class.
+ */
+ @Generated
+ public CreateSessionConfiguration() {
+ }
+
+ /**
+ * Get the authenticationType property: The type of authentication required to create the session. The only type
+ * currently supported is HMAC.
+ *
+ * @return the authenticationType value.
+ */
+ @Generated
+ public AuthenticationType getAuthenticationType() {
+ return this.authenticationType;
+ }
+
+ /**
+ * Set the authenticationType property: The type of authentication required to create the session. The only type
+ * currently supported is HMAC.
+ *
+ * @param authenticationType the authenticationType value to set.
+ * @return the CreateSessionConfiguration object itself.
+ */
+ @Generated
+ public CreateSessionConfiguration setAuthenticationType(AuthenticationType authenticationType) {
+ this.authenticationType = authenticationType;
+ return this;
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException {
+ return toXml(xmlWriter, null);
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException {
+ rootElementName
+ = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName;
+ xmlWriter.writeStartElement(rootElementName);
+ xmlWriter.writeStringElement("AuthenticationType",
+ this.authenticationType == null ? null : this.authenticationType.toString());
+ return xmlWriter.writeEndElement();
+ }
+
+ /**
+ * Reads an instance of CreateSessionConfiguration from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if
+ * it was pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration.
+ */
+ @Generated
+ public static CreateSessionConfiguration fromXml(XmlReader xmlReader) throws XMLStreamException {
+ return fromXml(xmlReader, null);
+ }
+
+ /**
+ * Reads an instance of CreateSessionConfiguration from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @param rootElementName Optional root element name to override the default defined by the model. Used to support
+ * cases where the model can deserialize from different root element names.
+ * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if
+ * it was pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration.
+ */
+ @Generated
+ public static CreateSessionConfiguration fromXml(XmlReader xmlReader, String rootElementName)
+ throws XMLStreamException {
+ String finalRootElementName
+ = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName;
+ return xmlReader.readObject(finalRootElementName, reader -> {
+ CreateSessionConfiguration deserializedCreateSessionConfiguration = new CreateSessionConfiguration();
+ while (reader.nextElement() != XmlToken.END_ELEMENT) {
+ QName elementName = reader.getElementName();
+
+ if ("AuthenticationType".equals(elementName.getLocalPart())) {
+ deserializedCreateSessionConfiguration.authenticationType
+ = AuthenticationType.fromString(reader.getStringElement());
+ } else {
+ reader.skipElement();
+ }
+ }
+
+ return deserializedCreateSessionConfiguration;
+ });
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java
new file mode 100644
index 000000000000..610080c98fd4
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java
@@ -0,0 +1,221 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+// Code generated by Microsoft (R) AutoRest Code Generator.
+
+package com.azure.storage.blob.implementation.models;
+
+import com.azure.core.annotation.Fluent;
+import com.azure.core.annotation.Generated;
+import com.azure.core.util.DateTimeRfc1123;
+import com.azure.xml.XmlReader;
+import com.azure.xml.XmlSerializable;
+import com.azure.xml.XmlToken;
+import com.azure.xml.XmlWriter;
+import java.time.OffsetDateTime;
+import java.util.Objects;
+import javax.xml.namespace.QName;
+import javax.xml.stream.XMLStreamException;
+
+/**
+ * The CreateSessionResponse model.
+ */
+@Fluent
+public final class CreateSessionResponse implements XmlSerializable {
+ /*
+ * A unique identifier for the created session.
+ */
+ @Generated
+ private String id;
+
+ /*
+ * The time when the session will expire. The format follows RFC 1123.
+ */
+ @Generated
+ private DateTimeRfc1123 expiration;
+
+ /*
+ * The type of authentication required to create the session. The only type currently supported is HMAC.
+ */
+ @Generated
+ private AuthenticationType authenticationType;
+
+ /*
+ * The Credentials property.
+ */
+ @Generated
+ private SessionCredentials credentials;
+
+ /**
+ * Creates an instance of CreateSessionResponse class.
+ */
+ @Generated
+ public CreateSessionResponse() {
+ }
+
+ /**
+ * Get the id property: A unique identifier for the created session.
+ *
+ * @return the id value.
+ */
+ @Generated
+ public String getId() {
+ return this.id;
+ }
+
+ /**
+ * Set the id property: A unique identifier for the created session.
+ *
+ * @param id the id value to set.
+ * @return the CreateSessionResponse object itself.
+ */
+ @Generated
+ public CreateSessionResponse setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Get the expiration property: The time when the session will expire. The format follows RFC 1123.
+ *
+ * @return the expiration value.
+ */
+ @Generated
+ public OffsetDateTime getExpiration() {
+ if (this.expiration == null) {
+ return null;
+ }
+ return this.expiration.getDateTime();
+ }
+
+ /**
+ * Set the expiration property: The time when the session will expire. The format follows RFC 1123.
+ *
+ * @param expiration the expiration value to set.
+ * @return the CreateSessionResponse object itself.
+ */
+ @Generated
+ public CreateSessionResponse setExpiration(OffsetDateTime expiration) {
+ if (expiration == null) {
+ this.expiration = null;
+ } else {
+ this.expiration = new DateTimeRfc1123(expiration);
+ }
+ return this;
+ }
+
+ /**
+ * Get the authenticationType property: The type of authentication required to create the session. The only type
+ * currently supported is HMAC.
+ *
+ * @return the authenticationType value.
+ */
+ @Generated
+ public AuthenticationType getAuthenticationType() {
+ return this.authenticationType;
+ }
+
+ /**
+ * Set the authenticationType property: The type of authentication required to create the session. The only type
+ * currently supported is HMAC.
+ *
+ * @param authenticationType the authenticationType value to set.
+ * @return the CreateSessionResponse object itself.
+ */
+ @Generated
+ public CreateSessionResponse setAuthenticationType(AuthenticationType authenticationType) {
+ this.authenticationType = authenticationType;
+ return this;
+ }
+
+ /**
+ * Get the credentials property: The Credentials property.
+ *
+ * @return the credentials value.
+ */
+ @Generated
+ public SessionCredentials getCredentials() {
+ return this.credentials;
+ }
+
+ /**
+ * Set the credentials property: The Credentials property.
+ *
+ * @param credentials the credentials value to set.
+ * @return the CreateSessionResponse object itself.
+ */
+ @Generated
+ public CreateSessionResponse setCredentials(SessionCredentials credentials) {
+ this.credentials = credentials;
+ return this;
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException {
+ return toXml(xmlWriter, null);
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException {
+ rootElementName
+ = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName;
+ xmlWriter.writeStartElement(rootElementName);
+ xmlWriter.writeStringElement("Id", this.id);
+ xmlWriter.writeStringElement("Expiration", Objects.toString(this.expiration, null));
+ xmlWriter.writeStringElement("AuthenticationType",
+ this.authenticationType == null ? null : this.authenticationType.toString());
+ xmlWriter.writeXml(this.credentials, "Credentials");
+ return xmlWriter.writeEndElement();
+ }
+
+ /**
+ * Reads an instance of CreateSessionResponse from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it
+ * was pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse.
+ */
+ @Generated
+ public static CreateSessionResponse fromXml(XmlReader xmlReader) throws XMLStreamException {
+ return fromXml(xmlReader, null);
+ }
+
+ /**
+ * Reads an instance of CreateSessionResponse from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @param rootElementName Optional root element name to override the default defined by the model. Used to support
+ * cases where the model can deserialize from different root element names.
+ * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it
+ * was pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse.
+ */
+ @Generated
+ public static CreateSessionResponse fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException {
+ String finalRootElementName
+ = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName;
+ return xmlReader.readObject(finalRootElementName, reader -> {
+ CreateSessionResponse deserializedCreateSessionResponse = new CreateSessionResponse();
+ while (reader.nextElement() != XmlToken.END_ELEMENT) {
+ QName elementName = reader.getElementName();
+
+ if ("Id".equals(elementName.getLocalPart())) {
+ deserializedCreateSessionResponse.id = reader.getStringElement();
+ } else if ("Expiration".equals(elementName.getLocalPart())) {
+ deserializedCreateSessionResponse.expiration = reader.getNullableElement(DateTimeRfc1123::new);
+ } else if ("AuthenticationType".equals(elementName.getLocalPart())) {
+ deserializedCreateSessionResponse.authenticationType
+ = AuthenticationType.fromString(reader.getStringElement());
+ } else if ("Credentials".equals(elementName.getLocalPart())) {
+ deserializedCreateSessionResponse.credentials = SessionCredentials.fromXml(reader, "Credentials");
+ } else {
+ reader.skipElement();
+ }
+ }
+
+ return deserializedCreateSessionResponse;
+ });
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java
new file mode 100644
index 000000000000..ed427a221aa7
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java
@@ -0,0 +1,149 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+// Code generated by Microsoft (R) AutoRest Code Generator.
+
+package com.azure.storage.blob.implementation.models;
+
+import com.azure.core.annotation.Fluent;
+import com.azure.core.annotation.Generated;
+import com.azure.xml.XmlReader;
+import com.azure.xml.XmlSerializable;
+import com.azure.xml.XmlToken;
+import com.azure.xml.XmlWriter;
+import javax.xml.namespace.QName;
+import javax.xml.stream.XMLStreamException;
+
+/**
+ * The SessionCredentials model.
+ */
+@Fluent
+public final class SessionCredentials implements XmlSerializable {
+ /*
+ * An opaque token used to authorize subsequent requests in the session. Must be treated as a security credential.
+ */
+ @Generated
+ private String sessionToken;
+
+ /*
+ * Only returned when AuthenticationType is HMAC. A symmetric encryption key used to sign requests in the session
+ * using the Shared Key protocol.
+ */
+ @Generated
+ private String sessionKey;
+
+ /**
+ * Creates an instance of SessionCredentials class.
+ */
+ @Generated
+ public SessionCredentials() {
+ }
+
+ /**
+ * Get the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be
+ * treated as a security credential.
+ *
+ * @return the sessionToken value.
+ */
+ @Generated
+ public String getSessionToken() {
+ return this.sessionToken;
+ }
+
+ /**
+ * Set the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be
+ * treated as a security credential.
+ *
+ * @param sessionToken the sessionToken value to set.
+ * @return the SessionCredentials object itself.
+ */
+ @Generated
+ public SessionCredentials setSessionToken(String sessionToken) {
+ this.sessionToken = sessionToken;
+ return this;
+ }
+
+ /**
+ * Get the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to
+ * sign requests in the session using the Shared Key protocol.
+ *
+ * @return the sessionKey value.
+ */
+ @Generated
+ public String getSessionKey() {
+ return this.sessionKey;
+ }
+
+ /**
+ * Set the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to
+ * sign requests in the session using the Shared Key protocol.
+ *
+ * @param sessionKey the sessionKey value to set.
+ * @return the SessionCredentials object itself.
+ */
+ @Generated
+ public SessionCredentials setSessionKey(String sessionKey) {
+ this.sessionKey = sessionKey;
+ return this;
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException {
+ return toXml(xmlWriter, null);
+ }
+
+ @Generated
+ @Override
+ public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException {
+ rootElementName = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName;
+ xmlWriter.writeStartElement(rootElementName);
+ xmlWriter.writeStringElement("SessionToken", this.sessionToken);
+ xmlWriter.writeStringElement("SessionKey", this.sessionKey);
+ return xmlWriter.writeEndElement();
+ }
+
+ /**
+ * Reads an instance of SessionCredentials from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was
+ * pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the SessionCredentials.
+ */
+ @Generated
+ public static SessionCredentials fromXml(XmlReader xmlReader) throws XMLStreamException {
+ return fromXml(xmlReader, null);
+ }
+
+ /**
+ * Reads an instance of SessionCredentials from the XmlReader.
+ *
+ * @param xmlReader The XmlReader being read.
+ * @param rootElementName Optional root element name to override the default defined by the model. Used to support
+ * cases where the model can deserialize from different root element names.
+ * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was
+ * pointing to XML null.
+ * @throws XMLStreamException If an error occurs while reading the SessionCredentials.
+ */
+ @Generated
+ public static SessionCredentials fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException {
+ String finalRootElementName
+ = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName;
+ return xmlReader.readObject(finalRootElementName, reader -> {
+ SessionCredentials deserializedSessionCredentials = new SessionCredentials();
+ while (reader.nextElement() != XmlToken.END_ELEMENT) {
+ QName elementName = reader.getElementName();
+
+ if ("SessionToken".equals(elementName.getLocalPart())) {
+ deserializedSessionCredentials.sessionToken = reader.getStringElement();
+ } else if ("SessionKey".equals(elementName.getLocalPart())) {
+ deserializedSessionCredentials.sessionKey = reader.getStringElement();
+ } else {
+ reader.skipElement();
+ }
+ }
+
+ return deserializedSessionCredentials;
+ });
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java
new file mode 100644
index 000000000000..00b3e376b826
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java
@@ -0,0 +1,75 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.rest.Response;
+import com.azure.core.util.Context;
+import com.azure.core.util.logging.ClientLogger;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.implementation.AzureBlobStorageImpl;
+import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder;
+import com.azure.storage.blob.implementation.models.AuthenticationType;
+import com.azure.storage.blob.implementation.models.CreateSessionConfiguration;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
+import com.azure.storage.blob.implementation.models.SessionCredentials;
+import reactor.core.publisher.Mono;
+
+/**
+ * Package-private client for creating sessions via the CreateSession REST API.
+ * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}:
+ * takes an {@link HttpPipeline} (bearer-only, no SessionPolicy) and builds an
+ * {@link AzureBlobStorageImpl} internally.
+ */
+final class BlobSessionClient {
+
+ private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class);
+ private final AzureBlobStorageImpl azureBlobStorage;
+ private final String accountName;
+ private final String containerName;
+
+ BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName,
+ String containerName) {
+ this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline)
+ .url(url)
+ .version(serviceVersion.getVersion())
+ .buildClient();
+ this.accountName = accountName;
+ this.containerName = containerName;
+ }
+
+ Mono createSessionAsync() {
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+
+ return azureBlobStorage.getContainers()
+ .createSessionWithResponseAsync(containerName, config, null, null)
+ .map(this::toCredential);
+ }
+
+ StorageSessionCredential createSessionSync() {
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+
+ Response response = azureBlobStorage.getContainers()
+ .createSessionWithResponse(containerName, config, null, null, Context.NONE);
+ return toCredential(response);
+ }
+
+ private StorageSessionCredential toCredential(Response response) {
+ CreateSessionResponse session = response.getValue();
+ if (session == null) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalStateException("CreateSession response did not contain a session payload."));
+ }
+
+ SessionCredentials creds = session.getCredentials();
+ if (creds == null) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalStateException("CreateSession response did not contain HMAC session credentials."));
+ }
+ return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(),
+ accountName);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java
index 0866d310981c..914794f2bde1 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java
@@ -24,12 +24,16 @@
import com.azure.core.util.ClientOptions;
import com.azure.core.util.Configuration;
import com.azure.core.util.CoreUtils;
+import com.azure.core.util.HttpClientOptions;
import com.azure.core.util.TracingOptions;
import com.azure.core.util.logging.ClientLogger;
import com.azure.core.util.tracing.Tracer;
import com.azure.core.util.tracing.TracerProvider;
+import com.azure.storage.blob.BlobServiceVersion;
import com.azure.storage.blob.BlobUrlParts;
import com.azure.storage.blob.models.BlobAudience;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.StorageSharedKeyCredential;
import com.azure.storage.common.implementation.BuilderUtils;
import com.azure.storage.common.implementation.Constants;
@@ -64,7 +68,8 @@ public final class BuilderHelper {
}
/**
- * Constructs a {@link HttpPipeline} from values passed from a builder.
+ * Constructs a {@link HttpPipeline} from values passed from a builder, with optional session-based
+ * authentication support.
*
* @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present.
* @param tokenCredential {@link TokenCredential} if present.
@@ -81,6 +86,8 @@ public final class BuilderHelper {
* @param configuration Configuration store contain environment settings.
* @param logger {@link ClientLogger} used to log any exception.
* @param audience {@link BlobAudience} used to determine the audience of the blob.
+ * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication.
+ * @param serviceVersion The service version for session creation. Required when session is active.
* @return A new {@link HttpPipeline} from the passed values.
*/
public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential,
@@ -88,7 +95,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare
RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions,
ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies,
List perRetryPolicies, Configuration configuration, BlobAudience audience,
- ClientLogger logger) {
+ ClientLogger logger, SessionOptions sessionOptions, BlobServiceVersion serviceVersion) {
CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential,
azureSasCredential, sasToken, logger);
@@ -119,12 +126,40 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare
policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential));
}
+ // Session credentials are bound to the client's network context. When the caller doesn't provide an
+ // HttpClient, create one default instance and share it between CreateSession and data requests instead of
+ // letting each pipeline create its own transport.
+ HttpClient effectiveHttpClient
+ = tokenCredential == null ? httpClient : getOrCreateHttpClient(httpClient, clientOptions);
+
+ // When sessionOptions is non-null and the resolved session mode is not SessionMode.NONE, and a tokenCredential is
+ // present, a single SessionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer
+ // token policy internally and delegates to it for non-session-eligible requests. When sessions are not active,
+ // the bearer token policy is added directly.
if (tokenCredential != null) {
httpsValidation(tokenCredential, "bearer token", endpoint, logger);
String scope = audience != null
? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default"))
: Constants.STORAGE_SCOPE;
- policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope));
+ StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy
+ = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope);
+
+ SessionOptions effectiveSessionOptions = SessionOptions.orDefault(sessionOptions);
+
+ BlobServiceVersion effectiveServiceVersion
+ = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest();
+
+ HttpPipeline bearerPipeline
+ = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions);
+ BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion,
+ effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName());
+
+ if (effectiveSessionOptions.getSessionMode() == SessionMode.NONE) {
+ policies.add(bearerPolicy);
+ } else {
+ policies.add(new SessionTokenCredentialPolicy(bearerPolicy,
+ new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions));
+ }
}
if (azureSasCredential != null) {
@@ -144,12 +179,38 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare
policies.add(new ScrubEtagPolicy());
return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0]))
+ .httpClient(effectiveHttpClient)
+ .clientOptions(clientOptions)
+ .tracer(createTracer(clientOptions))
+ .build();
+ }
+
+ /**
+ * Builds a bearer-only {@link HttpPipeline} for CreateSession calls. This pipeline contains
+ * all pre-auth policies plus the bearer token policy, but no session policy.
+ */
+ private static HttpPipeline buildBearerPipeline(List preAuthPolicies,
+ StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient,
+ ClientOptions clientOptions) {
+ List bearerPolicies = new ArrayList<>(preAuthPolicies);
+ bearerPolicies.add(bearerPolicy);
+ return new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0]))
.httpClient(httpClient)
.clientOptions(clientOptions)
.tracer(createTracer(clientOptions))
.build();
}
+ private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) {
+ if (httpClient != null) {
+ return httpClient;
+ }
+
+ return clientOptions instanceof HttpClientOptions
+ ? HttpClient.createDefault((HttpClientOptions) clientOptions)
+ : HttpClient.createDefault();
+ }
+
/**
* Gets the default http log option for Storage Blob.
*
@@ -232,4 +293,11 @@ public static Tracer createTracer(ClientOptions clientOptions) {
public static void logCredentialChange(ClientLogger logger, String newCredentialType) {
logger.info("Credential set to '{}' when it was previously configured.", newCredentialType);
}
+
+ public static void validateSessionMode(SessionOptions sessionOptions, String containerName, ClientLogger logger) {
+ if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) {
+ throw logger.logExceptionAsError(new IllegalArgumentException(
+ "containerName must be set when using SessionMode." + sessionOptions.getSessionMode()));
+ }
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java
new file mode 100644
index 000000000000..e7d875389856
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java
@@ -0,0 +1,284 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.core.util.CoreUtils;
+import com.azure.storage.blob.BlobUrlParts;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy;
+import reactor.core.publisher.Mono;
+
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * A pipeline policy that selects between session token and bearer token authentication.
+ *
+ * This policy occupies the authentication policy slot in the pipeline, wrapping the
+ * {@link StorageBearerTokenChallengeAuthorizationPolicy}. For eligible blob GET requests,
+ * the policy authenticates with a session token. For all other requests, it delegates to the
+ * wrapped bearer token policy.
+ *
+ * Request analysis is performed by {@link #analyzeRequest(HttpPipelineCallContext)} which returns
+ * an {@link AuthStrategy} indicating the authentication approach to use.
+ */
+public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy {
+ private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried";
+ private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info");
+ private static final String SESSION_EXPIRING = "session_expiring";
+ private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable";
+
+ private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy;
+ private final StorageSessionCredentialCache sessionCredentialCache;
+ private final SessionOptions sessionOptions;
+
+ /**
+ * Authentication strategy determined by {@link #analyzeRequest(HttpPipelineCallContext)}.
+ */
+ enum AuthStrategy {
+ /** Delegate to the wrapped bearer token policy. */
+ USE_BEARER_TOKEN,
+ /** Acquire a session token and sign the request. */
+ USE_SESSION_TOKEN
+ }
+
+ SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy,
+ StorageSessionCredentialCache sessionCredentialCache, SessionOptions sessionOptions) {
+ this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null.");
+ this.sessionCredentialCache
+ = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null.");
+ this.sessionOptions = SessionOptions.orDefault(sessionOptions);
+
+ if (this.sessionOptions.getSessionMode().resolve() == SessionMode.SINGLE_SPECIFIED_CONTAINER
+ && CoreUtils.isNullOrEmpty(this.sessionOptions.getContainerName())) {
+ throw new IllegalArgumentException(
+ "Container name must be specified when using SINGLE_SPECIFIED_CONTAINER session mode.");
+ }
+ }
+
+ /**
+ * Returns the wrapped bearer token policy. Used when constructing per-container pipelines from a service
+ * pipeline so that the bearer policy can be reused without scanning the pipeline.
+ */
+ StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() {
+ return bearerPolicy;
+ }
+
+ @Override
+ public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
+ if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) {
+ return bearerPolicy.process(context, next);
+ }
+
+ HttpPipelineNextPolicy retryNext = next.clone();
+ return getValidSessionAsync().flatMap(session -> {
+ signRequest(context, session);
+ return next.process().flatMap(response -> handleSessionResponse(context, response, session, retryNext));
+ });
+ }
+
+ @Override
+ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) {
+ if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) {
+ return bearerPolicy.processSync(context, next);
+ }
+
+ HttpPipelineNextSyncPolicy retryNext = next.clone();
+ StorageSessionCredential session = getValidSessionSync();
+ signRequest(context, session);
+
+ HttpResponse response = next.processSync();
+ return handleSessionResponseSync(context, response, session, retryNext);
+ }
+
+ /**
+ * Analyzes the request to determine whether a session token or bearer token should be used.
+ * Session tokens are only used for blob GET operations in
+ * {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode targeting the configured container.
+ *
+ * @param context the pipeline call context for the request being analyzed.
+ * @return {@link AuthStrategy#USE_SESSION_TOKEN} if the request is eligible for session-token
+ * authentication (a GET against a blob in the configured container, with no {@code comp} query
+ * parameter, while in {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode);
+ * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise.
+ */
+ AuthStrategy analyzeRequest(HttpPipelineCallContext context) {
+ SessionMode effectiveMode = sessionOptions.getSessionMode().resolve();
+
+ if (effectiveMode == SessionMode.NONE) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl());
+
+ // If Service-level request (no container in path)
+ if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName())
+ && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ // If Container level request (container in path but no blob)
+ if (CoreUtils.isNullOrEmpty(parts.getBlobName())) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth.
+ Map queryParams = parts.getUnparsedParameters();
+ if (queryParams.containsKey("comp")) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ if (parts.getBlobContainerName().compareToIgnoreCase(sessionOptions.getContainerName()) != 0) {
+ return AuthStrategy.USE_BEARER_TOKEN;
+ }
+
+ return AuthStrategy.USE_SESSION_TOKEN;
+ }
+
+ /**
+ * Handles the response after a session-authenticated async request. Inspects for
+ * session-expiring hints, retryable failures, and fallback conditions.
+ */
+ private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response,
+ StorageSessionCredential session, HttpPipelineNextPolicy retryNext) {
+
+ handleSessionExpiringHeader(response);
+
+ if (isUnauthorizedResponse(response)) {
+ invalidateSession(session);
+ }
+
+ if (shouldRetryRequest(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+ return getValidSessionAsync().flatMap(refreshed -> {
+ signRequest(context, refreshed);
+ return retryNext.process();
+ });
+ }
+
+ if (shouldFallBackToBearer(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+ context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION);
+ return bearerPolicy.process(context, retryNext);
+ }
+
+ return Mono.just(response);
+ }
+
+ /**
+ * Handles the response after a session-authenticated sync request. Inspects for
+ * session-expiring hints, retryable failures, and fallback conditions.
+ */
+ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response,
+ StorageSessionCredential session, HttpPipelineNextSyncPolicy retryNext) {
+
+ handleSessionExpiringHeader(response);
+
+ if (isUnauthorizedResponse(response)) {
+ invalidateSession(session);
+ }
+
+ if (shouldRetryRequest(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+
+ StorageSessionCredential refreshed = getValidSessionSync();
+ signRequest(context, refreshed);
+ return retryNext.processSync();
+ }
+
+ if (shouldFallBackToBearer(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+ context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION);
+ return bearerPolicy.processSync(context, retryNext);
+ }
+
+ return response;
+ }
+
+ Mono getValidSessionAsync() {
+ return sessionCredentialCache.getValidSessionAsync();
+ }
+
+ StorageSessionCredential getValidSessionSync() {
+ return sessionCredentialCache.getValidSessionSync();
+ }
+
+ void invalidateSession(StorageSessionCredential target) {
+ sessionCredentialCache.invalidateSession(target);
+ }
+
+ private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) {
+ cred.signRequest(context.getHttpRequest());
+ }
+
+ private void handleSessionExpiringHeader(HttpResponse response) {
+ String authInfo = response.getHeaderValue(X_MS_AUTH_INFO);
+ if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) {
+ sessionCredentialCache.refreshSessionInBackground();
+ }
+ }
+
+ /**
+ * Returns true when the session-authenticated request was rejected as unauthorized.
+ * Used to decide whether to invalidate the cached session.
+ */
+ private static boolean isUnauthorizedResponse(HttpResponse response) {
+ return response.getStatusCode() == 401;
+ }
+
+ /**
+ * Returns true for 401 responses where the request should be retried once with a refreshed session.
+ */
+ private static boolean isRetryableSessionFailure(HttpResponse response) {
+ return response.getStatusCode() == 401;
+ }
+
+ private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpResponse response) {
+ if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) {
+ return false;
+ }
+
+ return isRetryableSessionFailure(response);
+ }
+
+ /**
+ * Returns true for responses where retrying with bearer authentication can preserve
+ * request compatibility when session authentication is unavailable or rejected.
+ */
+ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) {
+ if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) {
+ return false;
+ }
+
+ return isBadRequest(response) || isSessionUnavailable(response);
+ }
+
+ private static boolean isBadRequest(HttpResponse response) {
+ return response.getStatusCode() == 400;
+ }
+
+ private static boolean isSessionUnavailable(HttpResponse response) {
+ if (response.getStatusCode() != 503) {
+ return false;
+ }
+ String errorCode = response.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"));
+ return SESSION_OPS_UNAVAILABLE.equals(errorCode);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java
new file mode 100644
index 000000000000..d74a4ebc5792
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java
@@ -0,0 +1,173 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpHeader;
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.util.CoreUtils;
+import com.azure.core.util.DateTimeRfc1123;
+import com.azure.storage.common.StorageSharedKeyCredential;
+import com.azure.storage.common.Utility;
+
+import java.net.URL;
+import java.text.Collator;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.TreeMap;
+
+/**
+ * Holds session credentials and signs requests using the Shared Key string-to-sign with the
+ * Session scheme prefix.
+ */
+final class StorageSessionCredential {
+
+ private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date");
+ private static final String SESSION_PREFIX = "Session ";
+
+ private final String sessionToken;
+ private final String sessionKey;
+ private final OffsetDateTime expiration;
+ private final String accountName;
+ private final StorageSharedKeyCredential sharedKey;
+
+ StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, String accountName) {
+ this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null.");
+ this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null.");
+ this.expiration = expiration != null ? expiration : OffsetDateTime.now().plusMinutes(5L);
+ this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null.");
+ this.sharedKey = new StorageSharedKeyCredential(accountName, sessionKey);
+ }
+
+ void signRequest(HttpRequest request) {
+ // Pin x-ms-date so the value we sign matches what is on the wire (AddDatePolicy only sets Date).
+ // Honor any pre-set x-ms-date so callers (e.g., tests, retries) can pin a deterministic value.
+ if (request.getHeaders().getValue(X_MS_DATE) == null) {
+ request.setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now()));
+ }
+
+ String stringToSign = buildStringToSign(request);
+ String signature = sharedKey.computeHmac256(stringToSign);
+ request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature);
+ }
+
+ // Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for
+ // Content-Length. The Session protocol signs the literal value the wire carries.
+ //
+ // We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in
+ // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/
+ // implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls
+ // `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including
+ // GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization
+ // then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it
+ // sees on the wire, so delegating produces a signature mismatch.
+ //
+ // TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE,
+ // delete this method and delegate to sharedKey.generateAuthorizationHeader(...).
+ // This matches what happens in dotnet:
+ // https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99
+ private String buildStringToSign(HttpRequest request) {
+ HttpHeaders headers = request.getHeaders();
+ Collator collator = Collator.getInstance(Locale.ROOT);
+
+ String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH);
+ // If x-ms-date is present, the Date slot is empty.
+ String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE);
+
+ return String.join("\n", request.getHttpMethod().toString(),
+ getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_ENCODING),
+ getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength,
+ getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_MD5),
+ getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_TYPE), dateHeader,
+ getHeaderOrEmpty(headers, HttpHeaderName.IF_MODIFIED_SINCE),
+ getHeaderOrEmpty(headers, HttpHeaderName.IF_MATCH), getHeaderOrEmpty(headers, HttpHeaderName.IF_NONE_MATCH),
+ getHeaderOrEmpty(headers, HttpHeaderName.IF_UNMODIFIED_SINCE),
+ getHeaderOrEmpty(headers, HttpHeaderName.RANGE), canonicalizedXmsHeaders(headers, collator),
+ canonicalizedResource(request.getUrl(), collator));
+ }
+
+ private static String getHeaderOrEmpty(HttpHeaders headers, HttpHeaderName name) {
+ String value = headers.getValue(name);
+ return value == null ? "" : value;
+ }
+
+ private static String canonicalizedXmsHeaders(HttpHeaders headers, Collator collator) {
+ List xmsHeaders = new ArrayList<>();
+ for (HttpHeader header : headers) {
+ if ("x-ms-".regionMatches(true, 0, header.getName(), 0, 5)) {
+ xmsHeaders.add(header);
+ }
+ }
+ if (xmsHeaders.isEmpty()) {
+ return "";
+ }
+ xmsHeaders.sort((a, b) -> collator.compare(a.getName(), b.getName()));
+ StringBuilder sb = new StringBuilder();
+ for (HttpHeader h : xmsHeaders) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(h.getName().toLowerCase(Locale.ROOT)).append(':').append(h.getValue());
+ }
+ return sb.toString();
+ }
+
+ private String canonicalizedResource(URL url, Collator collator) {
+ String path = url.getPath();
+ if (CoreUtils.isNullOrEmpty(path)) {
+ path = "/";
+ }
+ String query = url.getQuery();
+ if (CoreUtils.isNullOrEmpty(query)) {
+ return "/" + accountName + path;
+ }
+
+ // Sort query parameters with locale-insensitive collation, lower-cased keys.
+ // Values must be URL-decoded (and split on commas) to match the canonicalization that the
+ // service performs; otherwise percent-encoded characters (e.g., %3A in a snapshot timestamp)
+ // would produce a different HMAC than Shared Key.
+ TreeMap> params = new TreeMap<>(collator);
+ for (String pair : query.split("&")) {
+ int eq = pair.indexOf('=');
+ String key = Utility.urlDecode(eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT);
+ String rawValue = eq < 0 ? "" : pair.substring(eq + 1);
+ List decoded = params.computeIfAbsent(key, k -> new ArrayList<>());
+ for (String v : rawValue.split(",")) {
+ decoded.add(Utility.urlDecode(v));
+ }
+ }
+
+ StringBuilder sb = new StringBuilder("/").append(accountName).append(path);
+ for (java.util.Map.Entry> entry : params.entrySet()) {
+ List values = entry.getValue();
+ java.util.Collections.sort(values);
+ sb.append('\n').append(entry.getKey()).append(':').append(String.join(",", values));
+ }
+ return sb.toString();
+ }
+
+ String getSessionToken() {
+ return sessionToken;
+ }
+
+ String getSessionKey() {
+ return sessionKey;
+ }
+
+ OffsetDateTime getExpiration() {
+ return expiration;
+ }
+
+ String getAccountName() {
+ return accountName;
+ }
+
+ boolean isExpired() {
+ return OffsetDateTime.now().isAfter(expiration);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java
new file mode 100644
index 000000000000..594f976207df
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java
@@ -0,0 +1,159 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.util.logging.ClientLogger;
+import reactor.core.publisher.Mono;
+
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Objects;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Cache for container-scoped storage session credentials.
+ */
+final class StorageSessionCredentialCache {
+ private static final ClientLogger LOGGER = new ClientLogger(StorageSessionCredentialCache.class);
+ private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5);
+ private static final double JITTER_WINDOW_START_RATIO = 0.8d;
+
+ private final BlobSessionClient sessionClient;
+ private final Object creationLock = new Object();
+ private volatile StorageSessionCredential credential;
+ private volatile OffsetDateTime nextRefreshTime;
+ private volatile boolean refreshing;
+ private volatile Mono inflightCreation;
+
+ StorageSessionCredentialCache(BlobSessionClient sessionClient) {
+ this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null.");
+ }
+
+ Mono getValidSessionAsync() {
+ OffsetDateTime now = OffsetDateTime.now();
+ StorageSessionCredential current = credential;
+ if (isUsable(current, now)) {
+ if (isRefreshDue(now)) {
+ refreshSessionInBackground();
+ }
+ return Mono.just(current);
+ }
+
+ return startSessionCreationAsync();
+ }
+
+ StorageSessionCredential getValidSessionSync() {
+ OffsetDateTime now = OffsetDateTime.now();
+ StorageSessionCredential current = credential;
+ if (isUsable(current, now)) {
+ if (isRefreshDue(now)) {
+ refreshSessionInBackground();
+ }
+ return current;
+ }
+
+ // Join in-flight async creation outside the lock to avoid deadlock with doOnNext.
+ Mono inFlight = inflightCreation;
+ if (inFlight != null) {
+ StorageSessionCredential refreshed = inFlight.block();
+ if (refreshed != null) {
+ return refreshed;
+ }
+ }
+
+ synchronized (creationLock) {
+ current = credential;
+ now = OffsetDateTime.now();
+ if (isUsable(current, now)) {
+ if (isRefreshDue(now)) {
+ refreshSessionInBackground();
+ }
+ return current;
+ }
+
+ StorageSessionCredential created = sessionClient.createSessionSync();
+ setActiveCredential(created);
+ return created;
+ }
+ }
+
+ void invalidateSession(StorageSessionCredential target) {
+ synchronized (creationLock) {
+ if (credential == target) {
+ credential = null;
+ nextRefreshTime = null;
+ refreshing = false;
+ }
+ inflightCreation = null;
+ }
+ }
+
+ void refreshSessionInBackground() {
+ synchronized (creationLock) {
+ OffsetDateTime now = OffsetDateTime.now();
+ if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) {
+ return;
+ }
+ refreshing = true;
+ }
+
+ startSessionCreationAsync().subscribe(ignored -> {
+ }, error -> LOGGER.warning("Background session refresh failed.", error));
+ }
+
+ private Mono startSessionCreationAsync() {
+ synchronized (creationLock) {
+ OffsetDateTime now = OffsetDateTime.now();
+ StorageSessionCredential current = credential;
+ if (isUsable(current, now) && !isRefreshDue(now)) {
+ return Mono.just(current);
+ }
+
+ if (inflightCreation != null) {
+ return inflightCreation;
+ }
+
+ refreshing = true;
+
+ inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> {
+ synchronized (creationLock) {
+ setActiveCredential(cred);
+ }
+ }).doFinally(ignored -> {
+ synchronized (creationLock) {
+ inflightCreation = null;
+ refreshing = false;
+ }
+ }).cache();
+
+ return inflightCreation;
+ }
+ }
+
+ private void setActiveCredential(StorageSessionCredential newCredential) {
+ credential = newCredential;
+ nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration());
+ refreshing = false;
+ }
+
+ private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) {
+ return cred != null && !now.isAfter(cred.getExpiration());
+ }
+
+ private boolean isRefreshDue(OffsetDateTime now) {
+ OffsetDateTime refresh = nextRefreshTime;
+ return refresh != null && !now.isBefore(refresh);
+ }
+
+ private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) {
+ long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis();
+ if (availableMillis <= 0) {
+ return now;
+ }
+
+ double refreshPoint
+ = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble();
+ return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint)));
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java
new file mode 100644
index 000000000000..1a87ea5845fe
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Defines the session management strategy used by the SDK when sending requests to a container.
+ *
+ * A session is a temporary security context scoped to a container that amortizes authentication
+ * and authorization cost across many requests by signing them with a lightweight HMAC key instead
+ * of a full bearer token.
+ * {@link #NONE}
+ * {@link #SINGLE_SPECIFIED_CONTAINER}
+ * {@link #AUTO}
+ */
+public enum SessionMode {
+
+ /**
+ * Always use bearer token authentication. No session tokens are used.
+ */
+ NONE,
+
+ /**
+ * Default behavior. This is currently equivalent to {@link #NONE}
+ */
+ AUTO,
+
+ /**
+ * The SDK creates a session on the first request and keeps an active session until it
+ * receives no requests for 5 minutes.
+ */
+ SINGLE_SPECIFIED_CONTAINER;
+
+ /**
+ * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to
+ * {@link #NONE}; this may change in a future release without breaking callers that
+ * use {@code resolve()} consistently.
+ * @return returns the effective session mode, never {@code AUTO}
+ */
+ public SessionMode resolve() {
+ return this == AUTO ? NONE : this;
+ }
+
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java
new file mode 100644
index 000000000000..b70e682db554
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java
@@ -0,0 +1,101 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Options bag that configures session-based authentication on blob storage builders.
+ *
+ * Sessions amortize authentication and authorization cost across many requests by signing them
+ * with a lightweight HMAC key instead of a full bearer token.
+ *
+ * @see SessionMode
+ */
+public final class SessionOptions {
+
+ private SessionMode sessionMode = SessionMode.AUTO;
+ private String containerName;
+ private String accountName;
+
+ /**
+ * Creates a new {@link SessionOptions} instance with default values.
+ * Note: This currently only applies when using TokenCredential for GET Blob operations.
+ */
+ public SessionOptions() {
+ }
+
+ /**
+ * Returns {@code options} if non-null, otherwise a freshly constructed {@link SessionOptions}
+ * with default values. Use this helper instead of inlining {@code opts != null ? opts : new SessionOptions()}
+ * so default construction stays in one place.
+ *
+ * @param options the options instance to validate; may be {@code null}.
+ * @return {@code options} if non-null; a new default {@link SessionOptions} otherwise.
+ */
+ public static SessionOptions orDefault(SessionOptions options) {
+ return options != null ? options : new SessionOptions();
+ }
+
+ /**
+ * Gets the session mode.
+ *
+ * @return the {@link SessionMode}; defaults to {@link SessionMode#AUTO}.
+ */
+ public SessionMode getSessionMode() {
+ return sessionMode;
+ }
+
+ /**
+ * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#AUTO}.
+ *
+ * @param sessionMode the {@link SessionMode} to set.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setSessionMode(SessionMode sessionMode) {
+ this.sessionMode = sessionMode == null ? SessionMode.AUTO : sessionMode;
+ return this;
+ }
+
+ /**
+ * Gets the container name that the session is scoped to.
+ *
+ * @return the container name, or {@code null} if not set.
+ */
+ public String getContainerName() {
+ return containerName;
+ }
+
+ /**
+ * Sets the container name that the session is scoped to. This is required when the session mode
+ * is not {@link SessionMode#NONE}.
+ *
+ * @param containerName the container name.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setContainerName(String containerName) {
+ this.containerName = containerName;
+ return this;
+ }
+
+ /**
+ * Gets the storage account name used for session HMAC signing.
+ *
+ * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL).
+ */
+ public String getAccountName() {
+ return accountName;
+ }
+
+ /**
+ * Sets the storage account name used for session HMAC signing. When set, this takes precedence
+ * over the account name parsed from the endpoint URL. This is useful for custom domain URLs
+ * where the account name cannot be inferred from the hostname.
+ *
+ * @param accountName the storage account name.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setAccountName(String accountName) {
+ this.accountName = accountName;
+ return this;
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
index 54fd3682e72c..42e12896bc4b 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
@@ -242,7 +242,7 @@ private HttpPipeline getHttpPipeline() {
? httpPipeline
: BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
- perRetryPolicies, configuration, audience, LOGGER);
+ perRetryPolicies, configuration, audience, LOGGER, null, null);
}
private BlobServiceVersion getServiceVersion() {
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
index 50a9eb63ef21..b0fac6bbc9d6 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
@@ -6,6 +6,7 @@
import com.azure.core.http.HttpAuthorization;
import com.azure.core.http.HttpHeaderName;
import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
import com.azure.core.http.RequestConditions;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.Response;
@@ -50,6 +51,8 @@
import com.azure.storage.blob.models.ObjectReplicationStatus;
import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.models.StorageAccountInfo;
import com.azure.storage.blob.models.SyncCopyStatusType;
import com.azure.storage.blob.options.BlobBeginCopyOptions;
@@ -81,6 +84,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
@@ -120,6 +124,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -3177,6 +3182,50 @@ public void audienceFromString() {
assertTrue(aadBlob.exists());
}
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobClient blobClient = cc.getBlobClient(blobName);
+ blobClient.getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length);
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect)
+ .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER))
+ .buildClient();
+
+ File outFile = new File(prefix + "-session-download.tmp");
+ createdFiles.add(outFile);
+ Files.deleteIfExists(outFile.toPath());
+
+ sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null,
+ false, null, null);
+
+ assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ }
+
@RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2025-07-05")
@Test
@LiveOnly
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
index 049e4254e92a..e8a74ac57729 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
@@ -7,6 +7,7 @@
import com.azure.core.http.HttpAuthorization;
import com.azure.core.http.HttpHeaderName;
import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.Response;
import com.azure.core.test.TestMode;
@@ -48,6 +49,8 @@
import com.azure.storage.blob.models.ObjectReplicationStatus;
import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.options.BlobBeginCopyOptions;
import com.azure.storage.blob.options.BlobCopyFromUrlOptions;
import com.azure.storage.blob.options.BlobDownloadToFileOptions;
@@ -76,6 +79,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
@@ -2965,6 +2969,50 @@ public void audienceErrorBearerChallengeRetry() {
StepVerifier.create(aadBlob.getProperties()).assertNext(Assertions::assertNotNull).verifyComplete();
}
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName);
+ blobClient.upload(BinaryData.fromBytes(data), true).block();
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobAsyncClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect)
+ .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER))
+ .buildAsyncClient();
+
+ File outFile = new File(prefix + "-session-download.tmp");
+ createdFiles.add(outFile);
+ Files.deleteIfExists(outFile.toPath());
+
+ StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null,
+ false)).expectNextCount(1).verifyComplete();
+
+ Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ }
+
@Test
public void audienceFromString() {
String url = String.format("https://%s.blob.core.windows.net/", ccAsync.getAccountName());
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
index 99d925bff60c..b96e458ab8e7 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
@@ -49,6 +49,7 @@
import com.azure.storage.blob.models.LeaseStateType;
import com.azure.storage.blob.models.ListBlobContainersOptions;
import com.azure.storage.blob.models.PublicAccessType;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.options.BlobBreakLeaseOptions;
import com.azure.storage.blob.sas.BlobSasPermission;
import com.azure.storage.blob.specialized.BlobAsyncClientBase;
@@ -196,7 +197,11 @@ public void beforeTest() {
TestProxySanitizerType.HEADER),
new TestProxySanitizer("x-ms-rename-source", "((?<=http://|https://)([^/?]+)|sig=(.*))", "REDACTED",
TestProxySanitizerType.HEADER),
- new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL)));
+ new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret")));
}
// Ignore changes to the order of query parameters and wholly ignore the 'sv' (service version) query parameter
@@ -408,20 +413,53 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien
}
protected BlobServiceClient getOAuthServiceClient() {
- BlobServiceClientBuilder builder
- = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
+ return getOAuthServiceClient(new SessionOptions());
+ }
+
+ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) {
+ return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) {
+ BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions)
+ .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
instrument(builder);
+ if (policies != null) {
+ for (HttpPipelinePolicy policy : policies) {
+ if (policy != null) {
+ builder.addPolicy(policy);
+ }
+ }
+ }
+
return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient();
}
protected BlobServiceAsyncClient getOAuthServiceAsyncClient() {
- BlobServiceClientBuilder builder
- = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
+ return getOAuthServiceAsyncClient(new SessionOptions());
+ }
+
+ protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) {
+ return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions,
+ HttpPipelinePolicy... policies) {
+ BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions)
+ .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
instrument(builder);
+ if (policies != null) {
+ for (HttpPipelinePolicy policy : policies) {
+ if (policy != null) {
+ builder.addPolicy(policy);
+ }
+ }
+ }
+
return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient();
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
index 0af01b5fe437..370b33e88424 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
@@ -22,6 +22,8 @@
import com.azure.core.util.Header;
import com.azure.core.util.logging.ClientLogger;
import com.azure.storage.blob.implementation.util.BuilderHelper;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.blob.models.SessionMode;
import com.azure.storage.blob.specialized.AppendBlobClient;
import com.azure.storage.blob.specialized.BlockBlobClient;
import com.azure.storage.blob.specialized.PageBlobClient;
@@ -48,6 +50,7 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -72,10 +75,10 @@ private static HttpRequest request(String url) {
*/
@Test
public void freshDateAppliedOnRetry() {
- HttpPipeline pipeline
- = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null,
- BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new FreshDateTestClient(),
- new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
+ REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(),
+ new FreshDateTestClient(), new ArrayList<>(), new ArrayList<>(), null, null,
+ new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -176,7 +179,7 @@ public void customApplicationIdInUAString(String logOptionsUA, String clientOpti
HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
new RequestRetryOptions(), null, new HttpLogOptions().setApplicationId(logOptionsUA),
new ClientOptions().setApplicationId(clientOptionsUA), new ApplicationIdUAStringTestClient(expectedUA),
- new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -305,7 +308,7 @@ public void customHeadersClientOptions() {
HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(),
new ClientOptions().setHeaders(headers), new ClientOptionsHeadersTestClient(headers), new ArrayList<>(),
- new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -680,4 +683,155 @@ public Mono send(HttpRequest request) {
return Mono.just(new MockHttpResponse(request, 200));
}
}
+
+ // region buildPipeline session tests
+
+ @Test
+ public void buildPipelineWithTokenCredentialAlwaysHasSessionPolicy() {
+ HttpPipeline pipeline = buildBearerPipeline();
+
+ assertTrue(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"),
+ "Pipeline with tokenCredential should always contain SessionTokenCredentialPolicy");
+ }
+
+ @Test
+ public void buildPipelineWithSharedKeyDoesNotHaveSessionPolicy() {
+ HttpPipeline pipeline = buildSharedKeyPipeline();
+
+ assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"),
+ "Pipeline with shared key should not contain SessionTokenCredentialPolicy");
+ }
+
+ /**
+ * Helper to build a pipeline with bearer token auth.
+ */
+ private static HttpPipeline buildBearerPipeline() {
+ return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT,
+ new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(),
+ new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null,
+ new ClientLogger(BuilderHelperTests.class), null, BlobServiceVersion.getLatest());
+ }
+
+ /**
+ * Helper to build a pipeline without bearer token auth (shared key only).
+ */
+ private static HttpPipeline buildSharedKeyPipeline() {
+ return BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null,
+ BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(),
+ new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
+ }
+
+ /**
+ * Checks whether the pipeline contains a policy whose simple class name matches the given name.
+ */
+ private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) {
+ for (int i = 0; i < pipeline.getPolicyCount(); i++) {
+ if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns the index of the first policy whose simple class name matches, or -1 if not found.
+ */
+ private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) {
+ for (int i = 0; i < pipeline.getPolicyCount(); i++) {
+ if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ // endregion
+
+ // region BlobClientBuilder sessionOptions tests
+
+ @Test
+ public void blobBuilderWithSingleSpecifiedContainerSessionBuilds() {
+ SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER);
+
+ assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .blobName("myblob")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(options)
+ .buildClient());
+ }
+
+ @Test
+ public void blobBuilderWithSingleSpecifiedContainerSessionAndNoContainerNameThrows() {
+ SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new BlobClientBuilder().endpoint(ENDPOINT)
+ .blobName("myblob")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(options)
+ .buildClient());
+ }
+
+ @Test
+ public void blobBuilderWithoutSessionOptionsBuilds() {
+ assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .blobName("myblob")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient());
+ }
+
+ // endregion
+
+ // region BlobContainerClientBuilder sessionOptions tests
+
+ @Test
+ public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() {
+ SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER);
+
+ assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(options)
+ .buildClient());
+ }
+
+ @Test
+ public void containerBuilderWithSessionOptionsAlwaysAndNoContainerNameThrows() {
+ SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER);
+
+ assertThrows(IllegalArgumentException.class,
+ () -> new BlobContainerClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(options)
+ .buildClient());
+ }
+
+ @Test
+ public void containerBuilderWithSessionOptionsNoneAndNoContainerNameSucceeds() {
+ SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE);
+
+ assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(options)
+ .buildClient());
+ }
+
+ @Test
+ public void containerBuilderWithNoSessionOptionsSucceeds() {
+ assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient());
+ }
+
+ // endregion
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
index f46116acdbb5..76195bb37083 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
@@ -4,12 +4,16 @@
package com.azure.storage.blob;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.core.util.BinaryData;
import com.azure.core.http.rest.PagedIterable;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
import com.azure.core.test.utils.MockTokenCredential;
import com.azure.core.util.Context;
import com.azure.identity.DefaultAzureCredentialBuilder;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
import com.azure.storage.blob.models.AccessTier;
import com.azure.storage.blob.models.AppendBlobItem;
import com.azure.storage.blob.models.BlobAccessPolicy;
@@ -31,8 +35,11 @@
import com.azure.storage.blob.models.ListBlobsOptions;
import com.azure.storage.blob.models.ObjectReplicationPolicy;
import com.azure.storage.blob.models.ObjectReplicationStatus;
+import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.PublicAccessType;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.models.StorageAccountInfo;
import com.azure.storage.blob.models.TaggedBlobItem;
import com.azure.storage.blob.options.BlobContainerCreateOptions;
@@ -53,16 +60,21 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
import java.net.URL;
+import java.nio.file.Files;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Base64;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@@ -74,6 +86,7 @@
import java.util.stream.Stream;
import static com.azure.storage.common.implementation.StorageImplUtils.INVALID_VERSION_HEADER_MESSAGE;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -2128,4 +2141,154 @@ public void getBlobContainerUrlEncodesContainerName() {
// then:
// assertThrows(BlobStorageException.class, () ->
// }
+
+ // Need to create a container client test here to test that sessions have been enabled and used
+
+ @Test
+ @ResourceLock("BlobSessionAuth")
+ public void createSession() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName());
+ CreateSessionResponse response = oauthCc.createSession();
+
+ assertNotNull(response);
+ assertNotNull(response.getId());
+ assertNotNull(response.getExpiration());
+ assertNotNull(response.getCredentials());
+ assertNotNull(response.getCredentials().getSessionToken());
+ assertNotNull(response.getCredentials().getSessionKey());
+ }
+
+ @Test
+ @ResourceLock("BlobSessionAuth")
+ public void createSessionWithResponse() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName());
+ Response response = oauthCc.createSessionWithResponse(null, Context.NONE);
+
+ assertResponseStatusCode(response, 201);
+ CreateSessionResponse sessionResponse = response.getValue();
+ assertNotNull(sessionResponse);
+ assertNotNull(sessionResponse.getId());
+ assertNotNull(sessionResponse.getExpiration());
+ assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now()));
+ assertNotNull(sessionResponse.getCredentials());
+ assertNotNull(sessionResponse.getCredentials().getSessionToken());
+ assertNotNull(sessionResponse.getCredentials().getSessionKey());
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobOverSessionAuth() {
+ int blobCount = 5;
+ List blobNames = new ArrayList<>();
+ for (int i = 0; i < blobCount; i++) {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName)
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ blobNames.add(blobName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect);
+
+ for (String blobName : blobNames) {
+ BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent();
+ assertEquals(DATA.getDefaultText(), downloaded.toString());
+ }
+
+ // Greater than or equal to because there might be a retry that has a Session token as well if test is run with
+ // listBlobsOverSessionEnabledClient()
+ assertTrue(downloadAuthSchemes.size() >= blobCount,
+ "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length);
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName);
+ File outFile = File.createTempFile(prefix, ".tmp");
+ outFile.deleteOnExit();
+ Files.deleteIfExists(outFile.toPath());
+
+ try {
+ sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null,
+ null, false, null, null);
+
+ assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ } finally {
+ Files.deleteIfExists(outFile.toPath());
+ }
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // This test validates that listing blobs with a session-enabled client uses Bearer authorization because
+ // List Blobs is a container-level GET request, not a blob-level GET request so it users Bearer tokens instead of session tokens.
+ public void listBlobsOverSessionEnabledClient() {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ List listAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String query = req.getUrl().getQuery();
+ if (auth != null && query != null && query.contains("comp=list")) {
+ listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect);
+
+ assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName)));
+
+ assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request");
+ assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals),
+ "Container list operation must use Bearer authorization; saw " + listAuthSchemes);
+ }
+
+ private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) {
+ SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)
+ .setContainerName(cc.getBlobContainerName())
+ .setAccountName(cc.getAccountName());
+ return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName());
+ }
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
index 04ebc06dc2b6..ee3579271283 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
@@ -4,14 +4,18 @@
package com.azure.storage.blob;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.PagedFlux;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
import com.azure.core.test.TestMode;
import com.azure.core.test.utils.MockTokenCredential;
+import com.azure.core.util.BinaryData;
import com.azure.core.util.Context;
import com.azure.core.util.polling.PollerFlux;
import com.azure.identity.DefaultAzureCredentialBuilder;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
import com.azure.storage.blob.models.*;
import com.azure.storage.blob.options.BlobContainerCreateOptions;
import com.azure.storage.blob.options.BlobParallelUploadOptions;
@@ -23,6 +27,7 @@
import com.azure.storage.blob.specialized.BlockBlobAsyncClient;
import com.azure.storage.blob.specialized.PageBlobAsyncClient;
import com.azure.storage.common.test.shared.TestHttpClientType;
+import com.azure.storage.common.implementation.Constants;
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
@@ -31,6 +36,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -40,7 +46,10 @@
import reactor.test.StepVerifier;
import reactor.util.function.Tuple2;
+import java.io.File;
+import java.io.IOException;
import java.net.URL;
+import java.nio.file.Files;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.*;
@@ -2142,4 +2151,164 @@ public void getBlobContainerUrlEncodesContainerName() {
assertTrue(containerClient.getBlobContainerUrl().contains("my%20container"));
}
+
+ @Test
+ @ResourceLock("BlobSessionAuth")
+ public void createSession() {
+ BlobContainerAsyncClient oauthCcAsync
+ = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ StepVerifier.create(oauthCcAsync.createSession()).assertNext(response -> {
+ assertNotNull(response);
+ assertNotNull(response.getId());
+ assertNotNull(response.getExpiration());
+ assertNotNull(response.getCredentials());
+ assertNotNull(response.getCredentials().getSessionToken());
+ assertNotNull(response.getCredentials().getSessionKey());
+ }).verifyComplete();
+ }
+
+ @Test
+ @ResourceLock("BlobSessionAuth")
+ public void createSessionWithResponse() {
+ BlobContainerAsyncClient oauthCcAsync
+ = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> {
+ assertResponseStatusCode(response, 201);
+ CreateSessionResponse sessionResponse = response.getValue();
+ assertNotNull(sessionResponse);
+ assertNotNull(sessionResponse.getId());
+ assertNotNull(sessionResponse.getExpiration());
+ assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now()));
+ assertNotNull(sessionResponse.getCredentials());
+ assertNotNull(sessionResponse.getCredentials().getSessionToken());
+ assertNotNull(sessionResponse.getCredentials().getSessionKey());
+ }).verifyComplete();
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobOverSessionAuth() {
+ int blobCount = 5;
+ List blobNames = new ArrayList<>();
+ for (int i = 0; i < blobCount; i++) {
+ String blobName = generateBlobName();
+ ccAsync.getBlobAsyncClient(blobName)
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .block();
+ blobNames.add(blobName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect);
+
+ for (String blobName : blobNames) {
+ StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent())
+ .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString()))
+ .verifyComplete();
+ }
+
+ // Greater than or equal to because there might be a retry that has a Session token as well if test is run with
+ // listBlobsOverSessionEnabledClient()
+ assertTrue(downloadAuthSchemes.size() >= blobCount,
+ "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName);
+ blobClient.upload(BinaryData.fromBytes(data), true).block();
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobAsyncClient sessionBlob = sessionEnabledContainerAsyncClient(inspect).getBlobAsyncClient(blobName);
+ File outFile = File.createTempFile(prefix, ".tmp");
+ outFile.deleteOnExit();
+ Files.deleteIfExists(outFile.toPath());
+
+ try {
+ StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null,
+ null, false)).expectNextCount(1).verifyComplete();
+
+ Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ } finally {
+ Files.deleteIfExists(outFile.toPath());
+ }
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // This test validates that listing blobs with a session-enabled client uses Bearer authorization because
+ // List Blobs is a container-level GET request, not a blob-level GET request.
+ public void listBlobsOverSessionEnabledClient() {
+ String blobName = generateBlobName();
+ ccAsync.getBlobAsyncClient(blobName)
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .block();
+
+ List listAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String query = req.getUrl().getQuery();
+ if (auth != null && query != null && query.contains("comp=list")) {
+ listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect);
+
+ StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements())
+ .expectNext(true)
+ .verifyComplete();
+
+ assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request");
+ assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals),
+ "Container list operation must use Bearer authorization; saw " + listAuthSchemes);
+ }
+
+ private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) {
+ SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)
+ .setContainerName(ccAsync.getBlobContainerName())
+ .setAccountName(ccAsync.getAccountName());
+ return getOAuthServiceAsyncClient(sessionOptions, policies)
+ .getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java
new file mode 100644
index 000000000000..99d0b115a622
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.azure.storage.blob;
+
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpPipelinePosition;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import reactor.core.publisher.Mono;
+
+import java.util.function.Consumer;
+
+/**
+ * Test-only pipeline policy that lets a test peek at every {@link HttpRequest} as it
+ * goes on the wire. Registers at {@link HttpPipelinePosition#PER_RETRY} so it sees
+ * the {@code Authorization} header that the auth policies set.
+ *
+ * Used by the session-auth live tests as a wire-level sanity check (e.g. to assert
+ * which authentication scheme was applied to a given request).
+ */
+public final class RequestInspectionPolicy implements HttpPipelinePolicy {
+ private final Consumer inspector;
+
+ public RequestInspectionPolicy(Consumer inspector) {
+ this.inspector = inspector;
+ }
+
+ @Override
+ public HttpPipelinePosition getPipelinePosition() {
+ return HttpPipelinePosition.PER_RETRY;
+ }
+
+ @Override
+ public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
+ if (inspector != null) {
+ inspector.accept(context.getHttpRequest());
+ }
+ return next.process();
+ }
+
+ @Override
+ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) {
+ if (inspector != null) {
+ inspector.accept(context.getHttpRequest());
+ }
+ return next.processSync();
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java
new file mode 100644
index 000000000000..154ef16cb7bd
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java
@@ -0,0 +1,167 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.storage.blob.BlobContainerAsyncClient;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobContainerClientBuilder;
+import com.azure.storage.blob.BlobServiceClientBuilder;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.BlobTestBase;
+import com.azure.storage.blob.sas.BlobContainerSasPermission;
+import com.azure.storage.blob.sas.BlobServiceSasSignatureValues;
+import com.azure.storage.common.test.shared.StorageCommonTestUtils;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class BlobSessionClientTests extends BlobTestBase {
+
+ @Test
+ public void createSessionReturnsTokenAndKey() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName());
+ BlobSessionClient sessionClient
+ = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(),
+ BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName());
+
+ StorageSessionCredential credential = sessionClient.createSessionSync();
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ }
+
+ @Test
+ public void createSessionAsyncReturnsTokenAndKey() {
+ BlobContainerAsyncClient oauthCc
+ = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName());
+
+ StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ }).verifyComplete();
+ }
+
+ @Test
+ public void createSessionSyncUsesProvidedHttpPipeline() {
+ AtomicInteger policyInvocationCount = new AtomicInteger();
+ BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName());
+
+ StorageSessionCredential credential = sessionClient.createSessionSync();
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ assertEquals(1, policyInvocationCount.get());
+ }
+
+ @Test
+ public void createSessionAsyncUsesProvidedHttpPipeline() {
+ AtomicInteger policyInvocationCount = new AtomicInteger();
+ BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName());
+
+ StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType());
+ }).verifyComplete();
+
+ assertEquals(1, policyInvocationCount.get());
+ }
+
+ @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType")
+ @Test
+ public void createSessionWithUserDelegationSas() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName());
+
+ String sas = generateUserDelegationContainerSas(oauthCc);
+
+ BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl());
+
+ BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient();
+
+ BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName());
+
+ StorageSessionCredential credential = sessionClient.createSessionSync();
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ assertEquals(false, credential.isExpired());
+ }
+
+ @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType")
+ @Test
+ public void createSessionAsyncWithUserDelegationSas() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName());
+
+ String sas = generateUserDelegationContainerSas(oauthCc);
+
+ BlobContainerClient sasCc
+ = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas))
+ .buildClient();
+
+ BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName());
+
+ StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiration());
+ assertEquals(false, credential.isExpired());
+ }).verifyComplete();
+ }
+
+ private String generateUserDelegationContainerSas(BlobContainerClient containerClient) {
+ BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true)
+ .setWritePermission(true)
+ .setCreatePermission(true)
+ .setListPermission(true);
+ BlobServiceSasSignatureValues sasValues
+ = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions);
+
+ return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient()
+ .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1)));
+ }
+
+ private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) {
+ HttpPipelinePolicy policy = (context, next) -> {
+ policyInvocationCount.incrementAndGet();
+ return next.process();
+ };
+
+ BlobServiceClientBuilder builder
+ = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint())
+ .credential(StorageCommonTestUtils.getTokenCredential(interceptorManager))
+ .addPolicy(policy);
+
+ instrument(builder);
+ return builder.buildClient().getHttpPipeline();
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java
new file mode 100644
index 000000000000..592fc5f22241
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Shared test constants and factories for session-based auth tests.
+ */
+final class SessionTestHelper {
+
+ // A valid Base64-encoded 32-byte key for testing
+ static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==";
+ static final String TEST_SESSION_TOKEN = "test-session-token-abc123";
+ static final String TEST_ACCOUNT_NAME = "myaccount";
+ static final String TEST_CONTAINER_NAME = "testcontainer";
+
+ static StorageSessionCredential createCredential(OffsetDateTime expiration) {
+ return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, TEST_ACCOUNT_NAME);
+ }
+
+ static StorageSessionCredential createCredential(OffsetDateTime expiration, String accountName) {
+ return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, accountName);
+ }
+
+ static StorageSessionCredential createValidCredential() {
+ return createCredential(OffsetDateTime.now().plusHours(1));
+ }
+
+ static StorageSessionCredential createExpiredCredential() {
+ return createCredential(OffsetDateTime.now().minusMinutes(5));
+ }
+
+ private SessionTestHelper() {
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java
new file mode 100644
index 000000000000..020284e56f78
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java
@@ -0,0 +1,811 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class SessionTokenCredentialPolicyTest {
+
+ private static final String FIRST_TOKEN = "first-session-token";
+ private static final String SECOND_TOKEN = "second-session-token";
+ HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION;
+
+ private BlobSessionClient sessionClient;
+ private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy;
+ private SessionTokenCredentialPolicy policy;
+
+ @BeforeEach
+ public void beforeEach() {
+ sessionClient = mock(BlobSessionClient.class);
+ bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class);
+
+ // Default mock behavior: bearer policy delegates to next policy in the pipeline.
+ when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> {
+ HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1);
+ return nextPolicy.process();
+ });
+ when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> {
+ HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1);
+ return nextPolicy.processSync();
+ });
+
+ policy = createPolicy(SessionMode.SINGLE_SPECIFIED_CONTAINER);
+ }
+
+ @Test
+ public void policyCreatesSessionOnFirstAsyncAccess() {
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+
+ StorageSessionCredential credential = policy.getValidSessionAsync().block();
+
+ assertNotNull(credential);
+ assertEquals(FIRST_TOKEN, credential.getSessionToken());
+ verify(sessionClient, times(1)).createSessionAsync();
+ }
+
+ @Test
+ public void policyReturnsCachedSessionOnConcurrentAsyncAccess() {
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)))
+ .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN)));
+
+ List results
+ = Flux.range(0, 5).flatMap(ignored -> policy.getValidSessionAsync()).collectList().block();
+
+ assertNotNull(results);
+ assertEquals(5, results.size());
+ results.forEach(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken()));
+ verify(sessionClient, times(1)).createSessionAsync();
+ }
+
+ @Test
+ public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() {
+ StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2));
+ StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN);
+
+ when(sessionClient.createSessionSync()).thenReturn(nearExpiry);
+ // This is a Reactor quirk where Mono.just() emits synchronously on subscribe, so the refresh happens
+ // immediately when the cache determines the credential is near expiry
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed));
+
+ // Cold call to getValidSessionSync triggers session creation via createSessionSync
+ StorageSessionCredential initial = policy.getValidSessionSync();
+ // Trigger refresh, which uses sessionClient.createSessionAsync() to get the refreshed session
+ StorageSessionCredential duringRefresh = policy.getValidSessionSync();
+ StorageSessionCredential afterRefresh = policy.getValidSessionSync();
+
+ assertEquals(FIRST_TOKEN, initial.getSessionToken());
+ assertEquals(FIRST_TOKEN, duringRefresh.getSessionToken());
+ assertEquals(SECOND_TOKEN, afterRefresh.getSessionToken());
+ verify(sessionClient, times(1)).createSessionSync();
+ verify(sessionClient, times(1)).createSessionAsync();
+ }
+
+ @Test
+ public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception {
+ when(sessionClient.createSessionSync()).thenAnswer(invocation -> {
+ Thread.sleep(100);
+ return credentialWithToken(FIRST_TOKEN);
+ }).thenReturn(credentialWithToken(SECOND_TOKEN));
+
+ int threadCount = 5;
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ try {
+ List> tasks = IntStream.range(0, threadCount)
+ .mapToObj(i -> (Callable) policy::getValidSessionSync)
+ .collect(Collectors.toList());
+
+ List> futures = executor.invokeAll(tasks);
+ for (Future future : futures) {
+ assertEquals(FIRST_TOKEN, future.get().getSessionToken());
+ }
+
+ verify(sessionClient, times(1)).createSessionSync();
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void policySignsRequestWithSessionCredential() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(response, actualResponse);
+ assertTrue(
+ context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN),
+ "Expected request to be signed with a session credential.");
+ verify(next, times(1)).process();
+ }
+ }
+
+ @Test
+ public void policyInvalidatesSessionAndRetriesOnceAsync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse initialResponse = mock(HttpResponse.class);
+ HttpResponse retriedResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)))
+ .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(initialResponse));
+ when(retryNext.process()).thenReturn(Mono.just(retriedResponse));
+ when(initialResponse.getStatusCode()).thenReturn(401);
+ when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE))
+ .thenReturn("Session error=session_expired");
+ when(retriedResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(retriedResponse, actualResponse);
+ assertTrue(
+ context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN));
+ verify(initialResponse, times(1)).close();
+ verify(next, times(1)).process();
+ verify(retryNext, times(1)).process();
+ verify(sessionClient, times(2)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void policyInvalidatesSessionAndRetriesOnceSync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse initialResponse = mock(HttpResponse.class);
+ HttpResponse retriedResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN))
+ .thenReturn(credentialWithToken(SECOND_TOKEN));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(initialResponse);
+ when(retryNext.processSync()).thenReturn(retriedResponse);
+ when(initialResponse.getStatusCode()).thenReturn(401);
+ when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE))
+ .thenReturn("Session error=session_expired");
+ when(retriedResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(retriedResponse, actualResponse);
+ assertTrue(
+ context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN));
+ verify(initialResponse, times(1)).close();
+ verify(next, times(1)).processSync();
+ verify(retryNext, times(1)).processSync();
+ }
+ }
+
+ @Test
+ public void policyOnlyRetriesOncePerRequest() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse initialResponse = mock(HttpResponse.class);
+ HttpResponse retriedResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)))
+ .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(initialResponse));
+ when(retryNext.process()).thenReturn(Mono.just(retriedResponse));
+ when(initialResponse.getStatusCode()).thenReturn(401);
+ when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE))
+ .thenReturn("Session error=session_expired");
+ when(retriedResponse.getStatusCode()).thenReturn(401);
+ when(retriedResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE))
+ .thenReturn("Session error=session_expired");
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(retriedResponse, actualResponse);
+ verify(retryNext, times(1)).process();
+ verify(sessionClient, times(2)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void policyReturns403WithoutRetry() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse forbiddenResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(forbiddenResponse));
+ when(forbiddenResponse.getStatusCode()).thenReturn(403);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(forbiddenResponse, actualResponse);
+ verify(next, times(1)).process();
+ verify(retryNext, times(0)).process();
+ verify(forbiddenResponse, times(0)).close();
+ verify(sessionClient, times(1)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void policyRetriesAny401WithNewSession() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse unauthorizedResponse = mock(HttpResponse.class);
+ HttpResponse retriedResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)))
+ .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(unauthorizedResponse));
+ when(retryNext.process()).thenReturn(Mono.just(retriedResponse));
+ when(unauthorizedResponse.getStatusCode()).thenReturn(401);
+ when(retriedResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(retriedResponse, actualResponse);
+ assertTrue(
+ context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN));
+ verify(unauthorizedResponse, times(1)).close();
+ verify(next, times(1)).process();
+ verify(retryNext, times(1)).process();
+ verify(sessionClient, times(2)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void policyFallsToBearerOn503SessionUnavailableAsync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse unavailableResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(unavailableResponse));
+ when(retryNext.process()).thenReturn(Mono.just(bearerResponse));
+ when(unavailableResponse.getStatusCode()).thenReturn(503);
+ when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code")))
+ .thenReturn("SessionOperationsTemporarilyUnavailable");
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(unavailableResponse, times(1)).close();
+ // Verify that the bearer policy was invoked for fallback
+ verify(bearerPolicy, times(1)).process(any(), any());
+ // Authorization header should have been stripped so bearer policy can add its own
+ String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization");
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+ }
+
+ @Test
+ public void policyFallsToBearerOn400Async() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse badRequestResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(badRequestResponse));
+ when(retryNext.process()).thenReturn(Mono.just(bearerResponse));
+ when(badRequestResponse.getStatusCode()).thenReturn(400);
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(badRequestResponse, times(1)).close();
+ verify(bearerPolicy, times(1)).process(any(), any());
+ String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization");
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+ }
+
+ @Test
+ public void policyFallsToBearerOn503SessionUnavailableSync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse unavailableResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(unavailableResponse);
+ when(retryNext.processSync()).thenReturn(bearerResponse);
+ when(unavailableResponse.getStatusCode()).thenReturn(503);
+ when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code")))
+ .thenReturn("SessionOperationsTemporarilyUnavailable");
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(unavailableResponse, times(1)).close();
+ // Verify that the bearer policy was invoked for fallback
+ verify(bearerPolicy, times(1)).processSync(any(), any());
+ String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization");
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+ }
+
+ @Test
+ public void policyFallsToBearerOn400Sync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse badRequestResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(badRequestResponse);
+ when(retryNext.processSync()).thenReturn(bearerResponse);
+ when(badRequestResponse.getStatusCode()).thenReturn(400);
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(badRequestResponse, times(1)).close();
+ verify(bearerPolicy, times(1)).processSync(any(), any());
+ String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization");
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+ }
+
+ @Test
+ public void policyReturns503ServerBusyWithoutBearerFallback() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class);
+ HttpResponse busyResponse = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(retryNext);
+ when(next.process()).thenReturn(Mono.just(busyResponse));
+ when(busyResponse.getStatusCode()).thenReturn(503);
+ when(busyResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))).thenReturn("ServerBusy");
+
+ try (HttpResponse actualResponse = policy.process(context, next).block()) {
+ // ServerBusy 503 is not session-specific — return as-is for retry policy to handle
+ assertEquals(busyResponse, actualResponse);
+ verify(retryNext, times(0)).process();
+ verify(busyResponse, times(0)).close();
+ }
+ }
+
+ @Test
+ public void noneModeAlwaysPassesThrough() {
+ SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE);
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) {
+ assertEquals(response, actualResponse);
+ // Verify bearer policy was invoked (session delegates to bearer in NONE mode)
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void noneModeSyncAlwaysPassesThrough() {
+ SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE);
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.processSync()).thenReturn(response);
+ when(response.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) {
+ assertEquals(response, actualResponse);
+ // Verify bearer policy was invoked (session delegates to bearer in NONE mode)
+ verify(bearerPolicy, times(1)).processSync(any(), any());
+ verify(sessionClient, times(0)).createSessionSync();
+ }
+ }
+
+ @Test
+ public void alwaysModeSignsFirstRequest() {
+ // The default `policy` in setUp is ALWAYS — verify it signs the very first request
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ policy.process(context, next).block().close();
+
+ assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "));
+ verify(sessionClient, times(1)).createSessionAsync();
+ }
+
+ @Test
+ public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() {
+ SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(response.getStatusCode()).thenReturn(200);
+
+ // AUTO resolves to NONE, so all requests should delegate to bearer
+ HttpPipelineCallContext context1 = createContext();
+ HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class);
+ when(next1.process()).thenReturn(Mono.just(response));
+
+ try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) {
+ assertEquals(response, actual1);
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ // Second GetBlob also delegates to bearer (AUTO == NONE, no session ever)
+ HttpPipelineCallContext context2 = createContext();
+ HttpPipelineNextPolicy next2 = mock(HttpPipelineNextPolicy.class);
+ when(next2.process()).thenReturn(Mono.just(response));
+
+ try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) {
+ assertEquals(response, actual2);
+ verify(bearerPolicy, times(2)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+ }
+
+ @Test
+ public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() {
+ SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(response.getStatusCode()).thenReturn(200);
+
+ // AUTO resolves to NONE, so all requests should delegate to bearer
+ HttpPipelineCallContext context1 = createContext();
+ HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class);
+ when(next1.processSync()).thenReturn(response);
+
+ try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) {
+ assertEquals(response, actual1);
+ verify(bearerPolicy, times(1)).processSync(any(), any());
+ verify(sessionClient, times(0)).createSessionSync();
+ }
+
+ HttpPipelineCallContext context2 = createContext();
+ HttpPipelineNextSyncPolicy next2 = mock(HttpPipelineNextSyncPolicy.class);
+ when(next2.processSync()).thenReturn(response);
+
+ try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) {
+ assertEquals(response, actual2);
+ verify(bearerPolicy, times(2)).processSync(any(), any());
+ verify(sessionClient, times(0)).createSessionSync();
+ }
+ }
+
+ private SessionTokenCredentialPolicy createPolicy(SessionMode mode) {
+ SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer");
+ return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient),
+ options);
+ }
+
+ private static StorageSessionCredential credentialWithToken(String token) {
+ return credentialWithToken(token, OffsetDateTime.now().plusHours(1));
+ }
+
+ private static StorageSessionCredential credentialWithToken(String token, OffsetDateTime expiration) {
+ return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration,
+ SessionTestHelper.TEST_ACCOUNT_NAME);
+ }
+
+ private static HttpPipelineCallContext createContext() {
+ return createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ }
+
+ private static HttpPipelineCallContext createContextForUrl(String url) {
+ return createContextForRequest(new HttpRequest(HttpMethod.GET, url));
+ }
+
+ private static HttpPipelineCallContext createContextForRequest(HttpRequest request) {
+ HttpPipelineCallContext context = mock(HttpPipelineCallContext.class);
+ Map data = new ConcurrentHashMap<>();
+
+ when(context.getHttpRequest()).thenReturn(request);
+ when(context.getData(anyString()))
+ .thenAnswer(invocation -> Optional.ofNullable(data.get(invocation.getArgument(0))));
+ doAnswer(invocation -> {
+ data.put(invocation.getArgument(0), invocation.getArgument(1));
+ return null;
+ }).when(context).setData(anyString(), org.mockito.ArgumentMatchers.any());
+
+ return context;
+ }
+
+ @Test
+ public void getBlobRequestUsesSessionAuth() {
+ HttpPipelineCallContext context
+ = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ policy.process(context, next).block().close();
+
+ assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "),
+ "GetBlob request should be signed with session auth");
+ }
+
+ @Test
+ public void getBlobRequestProducesWellFormedSessionAuthHeader() {
+ StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN);
+ HttpRequest request
+ = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ request.getHeaders()
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555")
+ .set(HttpHeaderName.RANGE, "bytes=0-1023");
+
+ HttpPipelineCallContext context = createContextForRequest(request);
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(cred));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ policy.process(context, next).block().close();
+
+ // The policy must delegate signing to StorageSessionCredential, producing a Session-scheme
+ // Authorization header of the form `Session :`. End-to-end signature
+ // correctness against the live service is covered by ContainerApiTests.downloadBlobOverSessionAuth.
+ String actual = request.getHeaders().getValue(authHeaderName);
+ assertNotNull(actual, "Authorization header should be set by the policy");
+ assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"),
+ "Authorization should use the Session scheme with the cached session token, but was: " + actual);
+ String actualSignature = actual.substring(actual.indexOf(':') + 1);
+ assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"),
+ "Signature must be base64-encoded, but was: " + actualSignature);
+ }
+
+ /**
+ * Guards the workaround in {@link StorageSessionCredential#buildStringToSign}: the Session
+ * protocol signs the literal {@code Content-Length} value rather than normalizing
+ * {@code "0" -> ""} like SharedKey does. This is required today because azure-core's
+ * {@code RestProxyBase} unconditionally adds {@code Content-Length: 0} to body-less GET
+ * requests. Once that is fixed in azure-core, the buildStringToSign workaround can be removed
+ * and this test should be updated (or deleted) to reflect the new behavior.
+ */
+ @Test
+ public void contentLengthZeroIsIncludedInSessionSignature() {
+ String pinnedDate = "Wed, 22 Apr 2026 20:00:00 GMT";
+
+ HttpRequest withCl0
+ = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ withCl0.getHeaders()
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555")
+ .set(HttpHeaderName.RANGE, "bytes=0-1023")
+ .set(HttpHeaderName.CONTENT_LENGTH, "0")
+ .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate);
+ credentialWithToken(FIRST_TOKEN).signRequest(withCl0);
+ String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName));
+
+ HttpRequest withoutCl
+ = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ withoutCl.getHeaders()
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555")
+ .set(HttpHeaderName.RANGE, "bytes=0-1023")
+ .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate);
+ credentialWithToken(FIRST_TOKEN).signRequest(withoutCl);
+ String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName));
+
+ assertTrue(!sigWithCl0.equals(sigWithoutCl),
+ "Session signature must include literal Content-Length value: signing with "
+ + "Content-Length: 0 must differ from signing without Content-Length");
+ }
+
+ private static String extractSignature(String authHeader) {
+ return authHeader.substring(authHeader.indexOf(':') + 1);
+ }
+
+ @Test
+ public void putBlobRequestSkipsSessionAuth() {
+ HttpPipelineCallContext context = createContextForRequest(
+ new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob"));
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+
+ policy.process(context, next).block().close();
+
+ // Non-GetBlob requests delegate to bearer policy instead of session auth
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void listBlobsRequestSkipsSessionAuth() {
+ HttpPipelineCallContext context
+ = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container&comp=list");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+
+ policy.process(context, next).block().close();
+
+ // ListBlobs requests delegate to bearer policy instead of session auth
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void getBlobPropertiesRequestSkipsSessionAuth() {
+ HttpPipelineCallContext context
+ = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob?comp=metadata");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+
+ policy.process(context, next).block().close();
+
+ // GetBlobProperties (comp=metadata) delegates to bearer policy instead of session auth
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void getBlobWithSnapshotUsesSessionAuth() {
+ HttpPipelineCallContext context = createContextForUrl(
+ "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2021-01-01T00:00:00Z");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ policy.process(context, next).block().close();
+
+ assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "),
+ "GetBlob with snapshot should still use session auth");
+ }
+
+ @Test
+ public void containerLevelGetRequestSkipsSessionAuth() {
+ HttpPipelineCallContext context
+ = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+
+ policy.process(context, next).block().close();
+
+ // Container-level GET (restype=container) delegates to bearer policy instead of session auth
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void autoModeAlwaysDelegatesToBearerEvenForGetBlobRequests() {
+ SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO);
+ HttpResponse response = mock(HttpResponse.class);
+ when(response.getStatusCode()).thenReturn(200);
+
+ // PUT request — delegates to bearer (AUTO == NONE)
+ HttpPipelineCallContext putContext = createContextForRequest(
+ new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob"));
+ HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class);
+ when(putNext.process()).thenReturn(Mono.just(response));
+ autoPolicy.process(putContext, putNext).block().close();
+
+ // GET blob — also delegates to bearer (AUTO == NONE)
+ HttpPipelineCallContext getContext
+ = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob");
+ HttpPipelineNextPolicy getNext = mock(HttpPipelineNextPolicy.class);
+ when(getNext.process()).thenReturn(Mono.just(response));
+ Objects.requireNonNull(autoPolicy.process(getContext, getNext).block()).close();
+
+ verify(bearerPolicy, times(2)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void singleSpecifiedContainerModeNonGetBlobSkipsSession() {
+ HttpPipelineCallContext context = createContextForRequest(
+ new HttpRequest(HttpMethod.DELETE, "https://myaccount.blob.core.windows.net/mycontainer/myblob"));
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(next.process()).thenReturn(Mono.just(response));
+
+ Objects.requireNonNull(policy.process(context, next).block()).close();
+
+ // SINGLE_SPECIFIED_CONTAINER mode non-GetBlob requests delegate to bearer instead of session auth
+ verify(bearerPolicy, times(1)).process(any(), any());
+ verify(sessionClient, times(0)).createSessionAsync();
+ }
+
+ @Test
+ public void ipStyleEndpointGetBlobUsesSessionAuth() {
+ HttpPipelineCallContext context
+ = createContextForUrl("https://127.0.0.1:10000/devstoreaccount1/mycontainer/myblob");
+ HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class);
+ HttpResponse response = mock(HttpResponse.class);
+
+ when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN)));
+ when(next.clone()).thenReturn(next);
+ when(next.process()).thenReturn(Mono.just(response));
+ when(response.getStatusCode()).thenReturn(200);
+
+ Objects.requireNonNull(policy.process(context, next).block()).close();
+
+ assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "),
+ "GetBlob on IP-style endpoint should use session auth");
+ }
+
+ // endregion
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java
new file mode 100644
index 000000000000..2215af27c1a0
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java
@@ -0,0 +1,127 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpRequest;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.common.StorageSharedKeyCredential;
+import org.junit.jupiter.api.Test;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.time.OffsetDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class StorageSessionCredentialTest {
+
+ @Test
+ public void signRequestUsesSessionScheme() throws MalformedURLException {
+ StorageSessionCredential credential = SessionTestHelper.createValidCredential();
+ HttpRequest request
+ = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob"));
+
+ credential.signRequest(request);
+
+ String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ assertNotNull(authHeader);
+ assertTrue(authHeader.startsWith("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":"),
+ "Authorization header should start with 'Session :' but was: " + authHeader);
+ String signaturePart = authHeader.substring(authHeader.indexOf(':') + 1);
+ assertFalse(signaturePart.isEmpty(), "Signature should not be empty");
+ }
+
+ @Test
+ public void signRequestSetsXmsDateHeader() throws MalformedURLException {
+ StorageSessionCredential credential = SessionTestHelper.createValidCredential();
+ HttpRequest request
+ = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob"));
+
+ assertNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")));
+
+ credential.signRequest(request);
+
+ assertNotNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")),
+ "signRequest must set x-ms-date so the signed value matches what is sent on the wire");
+ }
+
+ // Regression guard for the URL-decode fix in StorageSessionCredential.canonicalizedResource:
+ // verifies Session and SharedKey produce the same HMAC for a well-formed GET with an
+ // encoded query string (e.g. snapshot=...%3A...).
+ //
+ // Scope is intentionally narrow. Session and SharedKey legitimately diverge on:
+ // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits "")
+ // - Content-Length "0" on GETs (SharedKey normalizes to ""; Session preserves "0" to match
+ // what azure-core's RestProxyBase puts on the wire — see the comment on
+ // StorageSessionCredential.buildStringToSign).
+ // Content-Length is pinned to a realistic non-zero value to bypass both quirks.
+ //
+ // DELETE this test once azure-core stops setting Content-Length: 0 on GETs and
+ // StorageSessionCredential.buildStringToSign is removed in favor of delegating to
+ // sharedKey.generateAuthorizationHeader(...). At that point this assertion becomes
+ // tautological (SharedKey vs. SharedKey).
+ @Test
+ public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException {
+ StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential();
+ StorageSharedKeyCredential sharedKeyCred
+ = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY);
+
+ HttpRequest request = new HttpRequest(HttpMethod.GET,
+ new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot="
+ + "2025-03-31T00%3A00%3A00.0000000Z"));
+ request.getHeaders()
+ .set(HttpHeaderName.fromString("x-ms-version"), BlobServiceVersion.getLatest().getVersion())
+ .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555")
+ .set(HttpHeaderName.RANGE, "bytes=0-1023")
+ .set(HttpHeaderName.CONTENT_LENGTH, "1024");
+
+ sessionCred.signRequest(request);
+
+ String sessionAuth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1);
+
+ HttpHeaders headersForSharedKey = request.getHeaders();
+ headersForSharedKey.remove(HttpHeaderName.AUTHORIZATION);
+ String sharedKeyAuth
+ = sharedKeyCred.generateAuthorizationHeader(request.getUrl(), "GET", headersForSharedKey, false);
+ String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1);
+
+ assertEquals(sharedKeySignature, sessionSignature,
+ "Session HMAC must match Shared Key HMAC for the same URL/method/headers");
+ }
+
+ @Test
+ public void isExpiredReturnsTrueWhenPastExpiration() {
+ assertTrue(SessionTestHelper.createExpiredCredential().isExpired(),
+ "Credential should be expired when expiration is in the past");
+ }
+
+ @Test
+ public void isExpiredReturnsFalseWhenBeforeExpiration() {
+ assertFalse(SessionTestHelper.createValidCredential().isExpired(),
+ "Credential should not be expired when expiration is in the future");
+ }
+
+ @Test
+ public void getExpirationDefaultsWhenConstructedWithNull() {
+ OffsetDateTime before = OffsetDateTime.now();
+ StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN,
+ SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME);
+ OffsetDateTime after = OffsetDateTime.now();
+
+ OffsetDateTime expiration = credential.getExpiration();
+ assertNotNull(expiration);
+ assertTrue(
+ !expiration.isBefore(before.plusMinutes(5L).minusSeconds(1))
+ && !expiration.isAfter(after.plusMinutes(5L).plusSeconds(1)),
+ "Default expiration should be ~5 minutes from construction time, but was " + expiration);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md
index 292d2f7c231d..e3f3f4ead37b 100644
--- a/sdk/storage/azure-storage-blob/swagger/README.md
+++ b/sdk/storage/azure-storage-blob/swagger/README.md
@@ -16,7 +16,7 @@ autorest
### Code generation settings
``` yaml
use: '@autorest/java@4.1.63'
-input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/15d7f54a5389d5906ffb4e56bb2f38fe5525c0d3/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-06-06/blob.json
+input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/013866b01623e6f2cc6c313b44c9c6460de3e91e/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json
java: true
output-folder: ../
namespace: com.azure.storage.blob
diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
index dfe6de66b555..ab8f7aa109d0 100644
--- a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
+++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
@@ -3,15 +3,21 @@
package com.azure.storage.common;
import com.azure.core.credential.AzureNamedKeyCredential;
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
import com.azure.core.util.CoreUtils;
import com.azure.storage.common.implementation.StorageImplUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import java.net.MalformedURLException;
+import java.net.URL;
+
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class StorageSharedKeyCredentialTests {
@Test
@@ -74,4 +80,32 @@ public void cannotParseInvalidConnectionString(String connectionString) {
assertThrows(IllegalArgumentException.class,
() -> StorageSharedKeyCredential.fromConnectionString(connectionString));
}
+
+ @Test
+ public void ipStyleUrlCanonicalizedResourceIncludesAccountNameTwice() throws MalformedURLException {
+ // For IP-style URLs (e.g., Azurite), the account name appears in the URL path.
+ // The canonicalized resource prepends / to the absolute path,
+ // so the account name correctly appears twice: ///container/blob
+ String accountName = "myaccount";
+ String accountKey = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==";
+
+ StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey);
+
+ URL url = new URL("http://127.0.0.1:10000/myaccount/mycontainer/myblob");
+ HttpHeaders headers
+ = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT")
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.CONTENT_LENGTH, "0");
+
+ String authHeader = credential.generateAuthorizationHeader(url, "GET", headers, false);
+
+ // Verify the signature matches a string-to-sign with account name appearing twice
+ String stringToSign = "GET\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:Mon, 31 Mar 2025 00:00:00 GMT\n"
+ + "x-ms-version:2025-01-05\n" + "/myaccount/myaccount/mycontainer/myblob";
+ String expectedSignature = credential.computeHmac256(stringToSign);
+
+ assertTrue(authHeader.startsWith("SharedKey myaccount:"),
+ "Authorization header should start with 'SharedKey myaccount:' but was: " + authHeader);
+ assertEquals("SharedKey myaccount:" + expectedSignature, authHeader);
+ }
}