policies, HttpClient httpClient,
+ ClientOptions clientOptions) {
return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0]))
.httpClient(httpClient)
.clientOptions(clientOptions)
@@ -237,4 +308,5 @@ public static Tracer createTracer(ClientOptions clientOptions) {
public static void logCredentialChange(ClientLogger logger, String newCredentialType) {
logger.info("Credential set to '{}' when it was previously configured.", newCredentialType);
}
+
}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java
new file mode 100644
index 000000000000..642b053673ad
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java
@@ -0,0 +1,378 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.exception.HttpResponseException;
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.core.util.CoreUtils;
+import com.azure.core.util.DateTimeRfc1123;
+import com.azure.core.util.logging.ClientLogger;
+import com.azure.storage.blob.BlobUrlParts;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.blob.models.SessionProvider;
+import com.azure.storage.blob.models.SessionRequestContext;
+import com.azure.storage.common.StorageSharedKeyCredential;
+import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy;
+import reactor.core.publisher.Mono;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A pipeline policy that selects between session token and bearer token authentication.
+ *
+ * This policy occupies the authentication policy slot in the pipeline, wrapping the
+ * {@link StorageBearerTokenChallengeAuthorizationPolicy}. For eligible blob GET requests,
+ * the policy authenticates with a session token. For all other requests, it delegates to the
+ * wrapped bearer token policy.
+ *
+ * If session authentication cannot be used against an account, either because session acquisition failed with
+ * HTTP 400, 403, or 5xx, or because the service rejected session-signed requests with HTTP 401 three times in
+ * a row, the account is placed in a five minute cooldown during which requests go straight to bearer
+ * authentication. Cooldown state is held by this policy instance, so it is scoped to a single client pipeline.
+ * Acquisition failures that do not carry one of those status codes fall back to bearer for that request only
+ * and do not start a cooldown.
+ */
+public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy {
+ private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class);
+ private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried";
+ private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info");
+ private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date");
+ private static final String SESSION_EXPIRING = "session_expiring";
+ private static final String SESSION_PREFIX = "Session ";
+ private static final Duration SESSION_COOLDOWN = Duration.ofMinutes(5);
+ private static final int MAX_CONSECUTIVE_SESSION_REJECTIONS = 3;
+
+ private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy;
+ private final SessionProvider sessionProvider;
+ private final SessionOptions sessionOptions;
+ private final Clock clock;
+ private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap accountRejections = new ConcurrentHashMap<>();
+
+ SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy,
+ SessionProvider sessionProvider, SessionOptions sessionOptions) {
+ this(bearerPolicy, sessionProvider, sessionOptions, Clock.systemUTC());
+ }
+
+ SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy,
+ SessionProvider sessionProvider, SessionOptions sessionOptions, Clock clock) {
+ this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null.");
+ this.sessionProvider = Objects.requireNonNull(sessionProvider, "'sessionProvider' cannot be null.");
+ this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null.");
+ this.clock = Objects.requireNonNull(clock, "'clock' cannot be null.");
+ }
+
+ @Override
+ public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
+ SessionRequestContext requestContext = resolveSessionRequest(context);
+ if (requestContext == null) {
+ return bearerPolicy.process(context, next);
+ }
+ if (isAccountInCooldown(requestContext.getAccountName())) {
+ return bearerPolicy.process(context, next);
+ }
+
+ HttpPipelineNextPolicy retryNext = next.clone();
+ Mono sessionMono;
+ try {
+ sessionMono = sessionProvider.getSessionAsync(requestContext);
+ } catch (RuntimeException ex) {
+ handleSessionAcquisitionFailure(requestContext, ex);
+ return bearerPolicy.process(context, next);
+ }
+
+ return sessionMono.onErrorResume(error -> {
+ handleSessionAcquisitionFailure(requestContext, error);
+ return Mono.empty();
+ }).flatMap(session -> {
+ signRequest(context, session);
+ return next.process()
+ .flatMap(response -> handleSessionResponse(context, response, session, requestContext, retryNext));
+ }).switchIfEmpty(Mono.defer(() -> bearerPolicy.process(context, next)));
+ }
+
+ @Override
+ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) {
+ SessionRequestContext requestContext = resolveSessionRequest(context);
+ if (requestContext == null) {
+ return bearerPolicy.processSync(context, next);
+ }
+ if (isAccountInCooldown(requestContext.getAccountName())) {
+ return bearerPolicy.processSync(context, next);
+ }
+
+ HttpPipelineNextSyncPolicy retryNext = next.clone();
+ SessionCredential session;
+ try {
+ session = sessionProvider.getSession(requestContext);
+ } catch (RuntimeException ex) {
+ handleSessionAcquisitionFailure(requestContext, ex);
+ return bearerPolicy.processSync(context, next);
+ }
+ signRequest(context, session);
+
+ HttpResponse response = next.processSync();
+ return handleSessionResponseSync(context, response, session, requestContext, retryNext);
+ }
+
+ private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext context) {
+ if (sessionOptions.getSessionMode() == SessionMode.DISABLED
+ || context.getHttpRequest().getHttpMethod() != HttpMethod.GET) {
+ return null;
+ }
+
+ BlobUrlParts parts;
+ try {
+ parts = BlobUrlParts.parse(context.getHttpRequest().getUrl());
+ } catch (RuntimeException ex) {
+ LOGGER.warning("Unable to resolve session authentication context from request URL. Using bearer token.",
+ ex);
+ return null;
+ }
+
+ String containerName = getOverrideOrDefault(sessionOptions.getContainerName(), parts.getBlobContainerName());
+ String accountName = getOverrideOrDefault(sessionOptions.getAccountName(), parts.getAccountName());
+
+ // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth.
+ if (CoreUtils.isNullOrEmpty(containerName)
+ || CoreUtils.isNullOrEmpty(parts.getBlobName())
+ || parts.getUnparsedParameters().containsKey("comp")) {
+ return null;
+ }
+
+ return new SessionRequestContext().setContainerName(containerName).setAccountName(accountName);
+ }
+
+ private static String getOverrideOrDefault(String override, String defaultValue) {
+ return CoreUtils.isNullOrEmpty(override) ? defaultValue : override;
+ }
+
+ /**
+ * Handles the response after a session-authenticated async request. Inspects for
+ * session-expiring hints, retryable failures, and fallback conditions.
+ */
+ private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response,
+ SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextPolicy retryNext) {
+
+ handleSessionExpiringHeader(response, requestContext);
+
+ if (response.getStatusCode() == 401) {
+ handleSessionRejection(requestContext, session);
+ } else {
+ recordSessionAccepted(requestContext);
+ }
+
+ if (shouldFallBackToBearer(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+ context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION);
+ return bearerPolicy.process(context, retryNext);
+ }
+
+ return Mono.just(response);
+ }
+
+ /**
+ * Handles the response after a session-authenticated sync request. Inspects for
+ * session-expiring hints, retryable failures, and fallback conditions.
+ */
+ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response,
+ SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextSyncPolicy retryNext) {
+
+ handleSessionExpiringHeader(response, requestContext);
+
+ if (response.getStatusCode() == 401) {
+ handleSessionRejection(requestContext, session);
+ } else {
+ recordSessionAccepted(requestContext);
+ }
+
+ if (shouldFallBackToBearer(context, response)) {
+ response.close();
+ context.setData(RETRY_CONTEXT_KEY, true);
+ context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION);
+ return bearerPolicy.processSync(context, retryNext);
+ }
+
+ return response;
+ }
+
+ private void signRequest(HttpPipelineCallContext context, SessionCredential credential) {
+ if (context.getHttpRequest().getHeaders().getValue(X_MS_DATE) == null) {
+ context.getHttpRequest().setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now()));
+ }
+
+ StorageSharedKeyCredential sharedKey
+ = new StorageSharedKeyCredential(credential.getAccountName(), credential.getSessionKey());
+ boolean contentLengthMissing
+ = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_LENGTH) == null;
+ if (contentLengthMissing) {
+ context.getHttpRequest().setHeader(HttpHeaderName.CONTENT_LENGTH, "0");
+ }
+
+ String sharedKeyAuthorization;
+ try {
+ sharedKeyAuthorization = sharedKey.generateAuthorizationHeader(context.getHttpRequest().getUrl(),
+ context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders(), false);
+ } finally {
+ if (contentLengthMissing) {
+ context.getHttpRequest().getHeaders().remove(HttpHeaderName.CONTENT_LENGTH);
+ }
+ }
+ String signature = sharedKeyAuthorization.substring(sharedKeyAuthorization.indexOf(':') + 1);
+ context.getHttpRequest()
+ .setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + credential.getSessionToken() + ":" + signature);
+ }
+
+ /**
+ * Handles a session credential being rejected by the service. The rejected credential is invalidated so it is not
+ * reused, and the rejection is counted. Because invalidation causes the next request to create a brand new
+ * session, an environment that cannot use sessions at all would otherwise create and lose one session per
+ * request indefinitely. After {@value #MAX_CONSECUTIVE_SESSION_REJECTIONS} consecutive rejections the account is
+ * placed in cooldown so requests fall straight through to bearer.
+ */
+ private void handleSessionRejection(SessionRequestContext requestContext, SessionCredential session) {
+ logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session));
+
+ int consecutiveRejections = accountRejections
+ .computeIfAbsent(normalize(requestContext.getAccountName()), ignored -> new AtomicInteger())
+ .incrementAndGet();
+
+ if (consecutiveRejections >= MAX_CONSECUTIVE_SESSION_REJECTIONS
+ && beginAccountCooldown(requestContext.getAccountName())) {
+ LOGGER.warning(
+ "Session authentication was rejected {} times in a row for container '{}'. Suppressing session "
+ + "authentication for this account for five minutes and using bearer token.",
+ consecutiveRejections, requestContext.getContainerName());
+ }
+ }
+
+ /**
+ * Clears the consecutive rejection count once the service accepts a session credential. Any response other than
+ * 401 means the session authenticated successfully, so an account where sessions work never reaches the
+ * rejection threshold.
+ */
+ private void recordSessionAccepted(SessionRequestContext requestContext) {
+ accountRejections.remove(normalize(requestContext.getAccountName()));
+ }
+
+ private void handleSessionExpiringHeader(HttpResponse response, SessionRequestContext requestContext) {
+ String authInfo = response.getHeaderValue(X_MS_AUTH_INFO);
+ if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) {
+ sessionProvider.refreshSession(requestContext);
+ }
+ }
+
+ private static void logSessionInvalidation(SessionRequestContext requestContext, boolean invalidated) {
+ if (invalidated) {
+ LOGGER.warning(
+ "Session authentication was rejected with HTTP 401 for container '{}'. "
+ + "The cached session was invalidated and the request will proceed using bearer token.",
+ requestContext.getContainerName());
+ } else {
+ LOGGER.verbose(
+ "Session authentication was rejected with HTTP 401 for container '{}', but the cached "
+ + "session was already invalidated. The request will proceed using bearer token.",
+ requestContext.getContainerName());
+ }
+ }
+
+ /**
+ * Returns true for responses where retrying with bearer authentication can preserve
+ * request compatibility when session authentication is unavailable or rejected.
+ */
+ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) {
+ if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) {
+ return false;
+ }
+
+ int statusCode = response.getStatusCode();
+ return statusCode == 400 || statusCode == 401;
+ }
+
+ /**
+ * Handles a failure to obtain a session credential. When the failure carries an HTTP 400, 403, or 5xx response
+ * the account is placed in cooldown so following requests skip session acquisition entirely. Any other failure
+ * is logged and falls back to bearer for the current request only.
+ */
+ private void handleSessionAcquisitionFailure(SessionRequestContext requestContext, Throwable error) {
+ Throwable current = error;
+ while (current != null && !(current instanceof HttpResponseException)) {
+ current = current.getCause();
+ }
+
+ if (current != null && ((HttpResponseException) current).getResponse() != null) {
+ HttpResponse response = ((HttpResponseException) current).getResponse();
+ int statusCode = response.getStatusCode();
+ if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) {
+ if (beginAccountCooldown(requestContext.getAccountName())) {
+ LOGGER.warning(
+ "Session acquisition failed with HTTP {}. Suppressing session authentication for this account "
+ + "for five minutes and using bearer token.",
+ statusCode);
+ }
+ return;
+ }
+ }
+
+ LOGGER.warning("Unable to obtain a session credential. Using bearer token.", error);
+ }
+
+ private boolean isAccountInCooldown(String accountName) {
+ String key = normalize(accountName);
+ OffsetDateTime cooldownUntil = accountCooldowns.get(key);
+ if (cooldownUntil == null) {
+ return false;
+ }
+
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ if (now.isBefore(cooldownUntil)) {
+ return true;
+ }
+
+ accountCooldowns.remove(key, cooldownUntil);
+ return false;
+ }
+
+ private boolean beginAccountCooldown(String accountName) {
+ String key = normalize(accountName);
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ OffsetDateTime cooldownUntil = now.plus(SESSION_COOLDOWN);
+ AtomicBoolean cooldownStarted = new AtomicBoolean();
+ accountCooldowns.compute(key, (ignored, currentExpirationTime) -> {
+ if (currentExpirationTime != null && now.isBefore(currentExpirationTime)) {
+ return currentExpirationTime;
+ }
+
+ cooldownStarted.set(true);
+ return cooldownUntil;
+ });
+
+ if (cooldownStarted.get()) {
+ // Reset the count so the account gets a fresh set of attempts once the cooldown lapses.
+ accountRejections.remove(key);
+ }
+
+ return cooldownStarted.get();
+ }
+
+ private static String normalize(String accountName) {
+ return CoreUtils.isNullOrEmpty(accountName) ? "" : accountName.trim().toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java
new file mode 100644
index 000000000000..b6dc4844f9b6
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java
@@ -0,0 +1,240 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.rest.Response;
+import com.azure.core.util.Context;
+import com.azure.core.util.CoreUtils;
+import com.azure.core.util.logging.ClientLogger;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.implementation.AzureBlobStorageImpl;
+import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder;
+import com.azure.storage.blob.implementation.models.AuthenticationType;
+import com.azure.storage.blob.implementation.models.CreateSessionConfiguration;
+import com.azure.storage.blob.implementation.models.CreateSessionResponse;
+import com.azure.storage.blob.implementation.models.SessionCredentials;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionProvider;
+import com.azure.storage.blob.models.SessionRequestContext;
+import com.azure.storage.common.implementation.util.AutoRefreshingCache;
+import reactor.core.publisher.Mono;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Built-in {@link SessionProvider} implementation that creates sessions via the CreateSession REST
+ * API and manages their lifecycle: per-container caching, proactive background refresh, idle
+ * eviction, and compare-and-invalidate race safety.
+ *
+ * Caching model
+ *
+ * One {@link AutoRefreshingCache} of {@link SessionCredential} per container (keyed by a
+ * lowercase-normalized name) is maintained, allowing a single {@link TokenCredentialSessionProvider} to serve
+ * many containers without creating a new session for every request. Entries are opportunistically
+ * evicted once they have not been accessed for {@value #IDLE_EVICTION_THRESHOLD_MINUTES} minutes.
+ *
+ *
Invalidation
+ *
+ * {@link #invalidateSession} delegates to the cache's compare-and-swap: it succeeds only for the
+ * caller presenting the credential that is still cached. A caller presenting a credential that a
+ * concurrent refresh has already replaced gets {@code false} and the newer credential is left in
+ * place, so a stale 401 cannot evict a healthy session.
+ *
+ *
Background refresh
+ *
+ * {@link #refreshSession} forces an immediate background refresh even when the client's own
+ * jittered refresh timer has not yet elapsed, so the service's
+ * {@code x-ms-auth-info: session_expiring} hint is acted on promptly. The one exception is that the
+ * refresh is suppressed while the cache is backing off from a recent session creation failure, which
+ * stops a failing service from being retried once per request.
+ *
+ *
+ * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}:
+ * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an
+ * {@link AzureBlobStorageImpl} internally.
+ */
+final class TokenCredentialSessionProvider implements SessionProvider {
+
+ static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5;
+
+ private static final ClientLogger LOGGER = new ClientLogger(TokenCredentialSessionProvider.class);
+ private static final Duration IDLE_EVICTION_THRESHOLD = Duration.ofMinutes(IDLE_EVICTION_THRESHOLD_MINUTES);
+ // Defensive fallback expiration for a malformed/absent service response.
+ private static final Duration DEFAULT_EXPIRATION_OFFSET = Duration.ofMinutes(5L);
+
+ private final AzureBlobStorageImpl azureBlobStorage;
+ private final String accountName;
+ private final Clock clock;
+ private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>();
+
+ TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion,
+ String accountName) {
+ this(bearerPipeline, url, serviceVersion, accountName, Clock.systemUTC());
+ }
+
+ /** Package-private constructor that accepts an injectable clock for deterministic testing. */
+ TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion,
+ String accountName, Clock clock) {
+ this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline)
+ .url(url)
+ .version(serviceVersion.getVersion())
+ .buildClient();
+ this.accountName = accountName;
+ this.clock = Objects.requireNonNull(clock, "'clock' cannot be null.");
+ }
+
+ @Override
+ public Mono getSessionAsync(SessionRequestContext context) {
+ return Mono.defer(() -> {
+ String container = requireContainerName(context);
+ String resolvedAccount = resolveAccountName(context);
+ return updateCache(container, resolvedAccount).cache.getValidValueAsync();
+ });
+ }
+
+ @Override
+ public SessionCredential getSession(SessionRequestContext context) {
+ String container = requireContainerName(context);
+ String resolvedAccount = resolveAccountName(context);
+ return updateCache(container, resolvedAccount).cache.getValidValueSync();
+ }
+
+ @Override
+ public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) {
+ if (context == null) {
+ return false;
+ }
+ ContainerSessionCache containerSessionCache = containerSessionCaches.get(normalize(context.getContainerName()));
+ return containerSessionCache != null && containerSessionCache.cache.invalidateValue(rejectedCredential);
+ }
+
+ @Override
+ public void refreshSession(SessionRequestContext context) {
+ if (context == null) {
+ return;
+ }
+ String key = normalize(context.getContainerName());
+ ContainerSessionCache containerSessionCache = containerSessionCaches.get(key);
+ if (containerSessionCache != null) {
+ containerSessionCache.cache.forceRefreshValueInBackground();
+ }
+ }
+
+ private String requireContainerName(SessionRequestContext context) {
+ String containerName = context == null ? null : context.getContainerName();
+ if (CoreUtils.isNullOrEmpty(containerName)) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalArgumentException("'context.getContainerName()' cannot be null or empty."));
+ }
+ return containerName;
+ }
+
+ private String resolveAccountName(SessionRequestContext context) {
+ String contextAccountName = context == null ? null : context.getAccountName();
+ String resolvedAccountName = CoreUtils.isNullOrEmpty(accountName) ? contextAccountName : accountName;
+ if (CoreUtils.isNullOrEmpty(resolvedAccountName)) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalArgumentException("The account name could not be resolved from the request URL."));
+ }
+ return resolvedAccountName;
+ }
+
+ private ContainerSessionCache updateCache(String containerName, String resolvedAccountName) {
+ String key = normalize(containerName);
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ ContainerSessionCache containerSessionCache = containerSessionCaches.compute(key, (k, existing) -> {
+ if (existing == null) {
+ return new ContainerSessionCache(this, clock, containerName, resolvedAccountName, now);
+ }
+ existing.lastAccess = now;
+ return existing;
+ });
+ evictStaleCaches();
+ return containerSessionCache;
+ }
+
+ private void evictStaleCaches() {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ containerSessionCaches.forEach((key, cache) -> {
+ if (Duration.between(cache.lastAccess, now).compareTo(IDLE_EVICTION_THRESHOLD) >= 0) {
+ containerSessionCaches.remove(key, cache);
+ }
+ });
+ }
+
+ private Mono createSessionAsync(String container, String resolvedAccountName) {
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+ return azureBlobStorage.getContainers()
+ .createSessionWithResponseAsync(container, config, null, null)
+ .map(response -> toCredential(response, resolvedAccountName));
+ }
+
+ private SessionCredential createSessionSync(String container, String resolvedAccountName) {
+ CreateSessionConfiguration config
+ = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC);
+ Response response
+ = azureBlobStorage.getContainers().createSessionWithResponse(container, config, null, null, Context.NONE);
+ return toCredential(response, resolvedAccountName);
+ }
+
+ private SessionCredential toCredential(Response response, String resolvedAccountName) {
+ CreateSessionResponse session = response.getValue();
+ if (session == null) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalStateException("CreateSession response did not contain a session payload."));
+ }
+
+ SessionCredentials creds = session.getCredentials();
+ if (creds == null) {
+ throw LOGGER.logExceptionAsError(
+ new IllegalStateException("CreateSession response did not contain HMAC session credentials."));
+ }
+
+ OffsetDateTime expiration = session.getExpiration();
+ if (expiration == null) {
+ expiration = OffsetDateTime.now().plus(DEFAULT_EXPIRATION_OFFSET);
+ }
+ return new SessionCredential(creds.getSessionToken(), creds.getSessionKey(), expiration, resolvedAccountName);
+ }
+
+ private static String normalize(String name) {
+ return CoreUtils.isNullOrEmpty(name) ? "" : name.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static final class ContainerSessionCache {
+ final AutoRefreshingCache cache;
+ volatile OffsetDateTime lastAccess;
+
+ private ContainerSessionCache(TokenCredentialSessionProvider provider, Clock clock, String containerName,
+ String resolvedAccountName, OffsetDateTime lastAccess) {
+ this.cache = createCache(provider, clock, containerName, resolvedAccountName);
+ this.lastAccess = lastAccess;
+ }
+
+ private static AutoRefreshingCache createCache(TokenCredentialSessionProvider provider,
+ Clock clock, String containerName, String resolvedAccountName) {
+ AutoRefreshingCache.ValueProvider valueProvider
+ = new AutoRefreshingCache.ValueProvider() {
+ @Override
+ public Mono createAsync() {
+ return provider.createSessionAsync(containerName, resolvedAccountName);
+ }
+
+ @Override
+ public SessionCredential createSync() {
+ return provider.createSessionSync(containerName, resolvedAccountName);
+ }
+ };
+ return new AutoRefreshingCache<>(valueProvider, SessionCredential::getExpiresAt, clock);
+ }
+
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java
new file mode 100644
index 000000000000..9258b2e7ffe7
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+import java.time.OffsetDateTime;
+import java.util.Objects;
+
+/**
+ * Represents a session credential used to authenticate blob storage requests via the lightweight,
+ * per-container session authentication scheme.
+ *
+ * This is modeled after {@code com.azure.core.credential.AccessToken}: a small, immutable holder for the
+ * session token, session key, and expiration returned by the storage service's CreateSession operation (or
+ * by a customer-supplied {@link SessionProvider}). Actual request signing is performed internally using the
+ * fixed HMAC scheme the service defines for session authentication; this type only carries the data needed
+ * to do so.
+ *
+ * @see SessionProvider
+ */
+public final class SessionCredential {
+
+ private final String sessionToken;
+ private final String sessionKey;
+ private final OffsetDateTime expiresAt;
+ private final String accountName;
+
+ /**
+ * Creates a new {@link SessionCredential}.
+ *
+ * @param sessionToken the session token issued by the service (or a custom {@link SessionProvider}).
+ * @param sessionKey the Base64-encoded session key used to sign requests.
+ * @param expiresAt the instant at which this session credential expires.
+ * @param accountName the storage account name this session credential is scoped to.
+ * @throws NullPointerException if {@code sessionToken}, {@code sessionKey}, or {@code accountName} is
+ * {@code null}.
+ */
+ public SessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiresAt, String accountName) {
+ this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null.");
+ this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null.");
+ this.expiresAt = Objects.requireNonNull(expiresAt, "'expiresAt' cannot be null.");
+ this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null.");
+ }
+
+ /**
+ * Gets the session token.
+ *
+ * @return the session token.
+ */
+ public String getSessionToken() {
+ return sessionToken;
+ }
+
+ /**
+ * Gets the Base64-encoded session key used to sign requests.
+ *
+ * @return the session key.
+ */
+ public String getSessionKey() {
+ return sessionKey;
+ }
+
+ /**
+ * Gets the instant at which this session credential expires.
+ *
+ * @return the expiration instant.
+ */
+ public OffsetDateTime getExpiresAt() {
+ return expiresAt;
+ }
+
+ /**
+ * Gets the storage account name this session credential is scoped to.
+ *
+ * @return the account name.
+ */
+ public String getAccountName() {
+ return accountName;
+ }
+
+ /**
+ * Gets whether this session credential is expired.
+ *
+ * @return {@code true} if the current time is after {@link #getExpiresAt()}; {@code false} otherwise.
+ */
+ public boolean isExpired() {
+ return OffsetDateTime.now().isAfter(expiresAt);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java
new file mode 100644
index 000000000000..afcb0bf84c08
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java
@@ -0,0 +1,32 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Defines whether the SDK uses session-based authentication when sending requests to a container.
+ *
+ * A session is a temporary security context scoped to a container that amortizes authentication
+ * and authorization cost across many requests by signing them with a lightweight HMAC key instead
+ * of a full bearer token.
+ * {@link #ENABLED}
+ * {@link #DISABLED}
+ */
+public enum SessionMode {
+
+ /**
+ * The SDK creates a session on the first eligible request and, when using the built-in session provider,
+ * keeps an active session until it receives no requests for 5 minutes. This is the default. If a session
+ * cannot be created, or the service answers a session-signed request with HTTP 400 or 401, the SDK
+ * transparently falls back to bearer token authentication for that request. Repeated failures stop the
+ * SDK from using sessions for that account for five minutes; during that window its requests are
+ * authenticated with bearer tokens without attempting to create a session.
+ */
+ ENABLED,
+
+ /**
+ * Always use bearer token authentication. No session tokens are ever created or used.
+ */
+ DISABLED
+
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java
new file mode 100644
index 000000000000..0a060daae4c8
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java
@@ -0,0 +1,120 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Options bag that configures session-based authentication for a
+ * {@link com.azure.storage.blob.BlobServiceClientBuilder}.
+ *
+ * Sessions amortize authentication and authorization cost across many requests by signing them
+ * with a lightweight HMAC key instead of a full bearer token.
+ *
+ * @see SessionMode
+ */
+public final class SessionOptions {
+
+ private SessionMode sessionMode = SessionMode.ENABLED;
+ private String containerName;
+ private String accountName;
+ private SessionProvider sessionProvider;
+
+ /**
+ * Creates a new {@link SessionOptions} instance with default values.
+ * This only applies to clients created from a {@link com.azure.storage.blob.BlobServiceClientBuilder}
+ * configured with a TokenCredential, and to eligible GET Blob operations made by clients derived from
+ * that service client.
+ */
+ public SessionOptions() {
+ }
+
+ /**
+ * Gets the session mode.
+ *
+ * @return the {@link SessionMode}; defaults to {@link SessionMode#ENABLED}.
+ */
+ public SessionMode getSessionMode() {
+ return sessionMode;
+ }
+
+ /**
+ * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#ENABLED}.
+ *
+ * @param sessionMode the {@link SessionMode} to set.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setSessionMode(SessionMode sessionMode) {
+ this.sessionMode = sessionMode == null ? SessionMode.ENABLED : sessionMode;
+ return this;
+ }
+
+ /**
+ * Gets the container name override used when it cannot be resolved from the request URL.
+ *
+ * @return the container name, or {@code null} if not set.
+ */
+ public String getContainerName() {
+ return containerName;
+ }
+
+ /**
+ * Sets the container name override used when it cannot be resolved from the request URL.
+ *
+ * @param containerName the container name.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setContainerName(String containerName) {
+ this.containerName = containerName;
+ return this;
+ }
+
+ /**
+ * Gets the storage account name used for session HMAC signing.
+ *
+ * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL).
+ */
+ public String getAccountName() {
+ return accountName;
+ }
+
+ /**
+ * Sets the storage account name used for session HMAC signing. When set, this takes precedence
+ * over the account name parsed from the endpoint URL. This is useful for custom domain URLs
+ * where the account name cannot be inferred from the hostname.
+ *
+ * @param accountName the storage account name.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setAccountName(String accountName) {
+ this.accountName = accountName;
+ return this;
+ }
+
+ /**
+ * Gets the custom provider used to obtain session credentials.
+ *
+ * @return the custom {@link SessionProvider}, or {@code null} to use the built-in provider.
+ */
+ public SessionProvider getSessionProvider() {
+ return sessionProvider;
+ }
+
+ /**
+ * Sets the custom provider used to obtain session credentials. When set, the provider is called directly
+ * for each eligible request: the SDK does not layer additional caching on top of a custom provider, so
+ * the provider is responsible for its own caching and refresh strategy. The SDK retains ownership of
+ * HMAC request signing, of choosing between session and bearer authentication, and of pausing session use
+ * for a storage account when sessions repeatedly fail against it, as described on {@link SessionProvider}.
+ * The same provider instance may be supplied to multiple service client builders to share its cache; that
+ * pause, however, is tracked per client pipeline and is not shared by those clients.
+ * When {@code null}, the built-in provider is used, which calls the storage service's CreateSession REST
+ * API and manages per-container credential caching, proactive refresh, and idle eviction automatically.
+ *
+ * @param sessionProvider the custom {@link SessionProvider}, or {@code null} to use the built-in provider.
+ * @return the updated {@link SessionOptions} object.
+ */
+ public SessionOptions setSessionProvider(SessionProvider sessionProvider) {
+ this.sessionProvider = sessionProvider;
+ return this;
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java
new file mode 100644
index 000000000000..6d6f1a7b1ed4
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java
@@ -0,0 +1,122 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * Provides and manages cached {@link SessionCredential session credentials} for storage containers.
+ *
+ * Implement this interface to bring your own session creation and caching logic - for example, proxying
+ * CreateSession calls through another service or sharing a credential cache across clients - while still
+ * relying on the SDK to sign requests and to fall back to bearer authentication when sessions fail. Set an
+ * instance via {@link SessionOptions#setSessionProvider(SessionProvider)}, then pass those options to
+ * {@link com.azure.storage.blob.BlobServiceClientBuilder#sessionOptions(SessionOptions)}, to have it used in
+ * place of the default, built-in provider (which calls the storage service's CreateSession REST API directly
+ * and manages its own per-container caching).
+ *
+ *
Lifecycle
+ *
+ * A {@link SessionProvider} implementation is expected to support the full session lifecycle:
+ *
+ * - Retrieve - {@link #getSessionAsync} / {@link #getSession} return a usable
+ * {@link SessionCredential} for the container described by the request context, minting or refreshing one
+ * as needed.
+ * - Invalidate - {@link #invalidateSession} is called when the service rejects a
+ * previously-issued credential with HTTP 401, giving the implementation the opportunity to evict it so the
+ * next retrieval mints a fresh one.
+ * - Refresh - {@link #refreshSession} is called when the service signals (via an
+ * {@code x-ms-auth-info: session_expiring} response header) that the current session is about to stop being
+ * honored, giving the implementation the opportunity to proactively refresh it in the background.
+ *
+ *
+ * Division of responsibility
+ *
+ * A {@link SessionProvider} produces, invalidates, and refreshes credentials. Everything else stays with the
+ * SDK: signing each request with the session's HMAC key, and choosing between session and bearer
+ * authentication. The SDK authenticates a request with a bearer token rather than a session when the request
+ * is not session-eligible, when no session credential could be obtained, and when the service answers a
+ * session-signed request with HTTP 400 or 401.
+ *
+ * The SDK also stops using sessions for a storage account when they repeatedly fail against it. When a call
+ * to this provider fails with an HTTP 400, 403, or 5xx error, or the service rejects three session-signed
+ * requests in a row with HTTP 401, the SDK stops requesting sessions for that account for five minutes and
+ * authenticates its requests with bearer tokens instead; {@link #getSession} and {@link #getSessionAsync} are
+ * not called at all during that window. The pause covers every container in the account, not only the
+ * container whose request failed, and a provider failure that carries no HTTP response does not start it -
+ * that request simply falls back to bearer. Each client tracks the pause on its own HTTP pipeline, so clients
+ * pause independently even when they share one {@link SessionProvider} instance.
+ *
+ *
Thread safety
+ *
+ * Implementations must be thread-safe: {@link #getSessionAsync}, {@link #getSession},
+ * {@link #invalidateSession}, and {@link #refreshSession} may all be invoked concurrently from multiple
+ * pipeline threads. In particular, {@link #invalidateSession} must perform its compare-and-invalidate as a
+ * single atomic operation (see its documentation for details), and {@link #refreshSession} must not block.
+ *
+ *
Scoping
+ *
+ * A single {@link SessionProvider} instance may be asked to serve many different containers (and, in
+ * principle, multiple accounts) over its lifetime; the {@link SessionRequestContext} passed to each method
+ * call identifies which container (and account) the call applies to. Applications may reuse one provider
+ * instance across service clients when they intentionally want those clients to share the provider's cache.
+ *
+ * @see SessionCredential
+ * @see SessionRequestContext
+ * @see SessionOptions
+ */
+public interface SessionProvider {
+
+ /**
+ * Asynchronously returns a valid cached {@link SessionCredential} for the container described by
+ * {@code context}, creating or refreshing the credential when needed.
+ *
+ * @param context the request-scoped parameters (e.g. container name) the session should be created for.
+ * @return a {@link Mono} that emits the resulting {@link SessionCredential}.
+ */
+ Mono getSessionAsync(SessionRequestContext context);
+
+ /**
+ * Synchronously returns a valid cached {@link SessionCredential} for the container described by
+ * {@code context}, creating or refreshing the credential when needed.
+ *
+ * @param context the request-scoped parameters (e.g. container name) the session should be created for.
+ * @return the resulting {@link SessionCredential}.
+ */
+ SessionCredential getSession(SessionRequestContext context);
+
+ /**
+ * Attempts a compare-and-invalidate on the credential currently held for the container described by
+ * {@code context}: if {@code rejectedCredential} is still the active credential, it is atomically
+ * replaced so the next call to {@link #getSession} or {@link #getSessionAsync} returns a fresh one.
+ *
+ * Thread safety: Implementations must treat the compare and the invalidate as a
+ * single atomic operation. Exactly one thread presenting the same {@code rejectedCredential} should
+ * succeed in invalidating it; all later threads presenting the same instance must return {@code false}.
+ *
+ * Warning semantics: The SDK logs a one-time warning when this returns {@code true}
+ * (the first invalidation for a given rejected credential) and a verbose message when it returns
+ * {@code false} (already replaced).
+ *
+ * @param context the request-scoped parameters (container, account) identifying the session scope.
+ * @param rejectedCredential the credential the service rejected with HTTP 401.
+ * @return {@code true} if this call invalidated the credential (first invalidator wins);
+ * {@code false} if the credential was already replaced.
+ */
+ boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential);
+
+ /**
+ * Non-blocking hint that the service has indicated the current session for the container described by
+ * {@code context} is about to expire (signalled via an {@code x-ms-auth-info: session_expiring}
+ * response header). Implementations should trigger a proactive background refresh immediately so the
+ * next request uses a fresh session without an inline latency penalty.
+ *
+ * Non-blocking contract: This method is called from both synchronous and
+ * asynchronous response-processing paths and must return immediately without waiting for the
+ * refresh to complete. It must not throw.
+ *
+ * @param context the request-scoped parameters (container, account) identifying the session scope.
+ */
+ void refreshSession(SessionRequestContext context);
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java
new file mode 100644
index 000000000000..081595413c25
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java
@@ -0,0 +1,73 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.models;
+
+/**
+ * Carries the request-scoped parameters needed to obtain a {@link SessionCredential}, such as the target
+ * container and account.
+ *
+ * Both {@code containerName} and {@code accountName} are optional: they are resolved automatically from the
+ * request URL in the common case, and are only needed here when that automatic resolution isn't possible or
+ * isn't correct - for example, a custom domain URL that a {@link SessionProvider} implementation cannot
+ * parse the account name from, or a proxying scenario where the effective container differs from the one on
+ * the wire. A {@link SessionProvider} implementation should treat either value as a hint that may be absent
+ * rather than something it can always rely on.
+ *
+ * This exists so a single {@link SessionProvider} instance can be asked for a session that is scoped to a
+ * specific container at call time, rather than being permanently bound to one container at construction
+ * time - allowing one provider to serve sessions for many containers.
+ *
+ * @see SessionProvider
+ */
+public final class SessionRequestContext {
+
+ private String containerName;
+ private String accountName;
+
+ /**
+ * Creates a new {@link SessionRequestContext}.
+ */
+ public SessionRequestContext() {
+ }
+
+ /**
+ * Gets the name of the container the session should be scoped to, if known.
+ *
+ * @return the container name, or {@code null} if not resolved/known for this request.
+ */
+ public String getContainerName() {
+ return containerName;
+ }
+
+ /**
+ * Sets the name of the container the session should be scoped to.
+ *
+ * @param containerName the container name.
+ * @return the updated {@link SessionRequestContext} object.
+ */
+ public SessionRequestContext setContainerName(String containerName) {
+ this.containerName = containerName;
+ return this;
+ }
+
+ /**
+ * Gets the name of the storage account the session should be scoped to, if known.
+ *
+ * @return the account name, or {@code null} if not resolved/known for this request.
+ */
+ public String getAccountName() {
+ return accountName;
+ }
+
+ /**
+ * Sets the name of the storage account the session should be scoped to.
+ *
+ * @param accountName the account name.
+ * @return the updated {@link SessionRequestContext} object.
+ */
+ public SessionRequestContext setAccountName(String accountName) {
+ this.accountName = accountName;
+ return this;
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
index 54fd3682e72c..42e12896bc4b 100644
--- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
+++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java
@@ -242,7 +242,7 @@ private HttpPipeline getHttpPipeline() {
? httpPipeline
: BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
- perRetryPolicies, configuration, audience, LOGGER);
+ perRetryPolicies, configuration, audience, LOGGER, null, null);
}
private BlobServiceVersion getServiceVersion() {
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
index 44a32b6a420f..99cb97ecedbd 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java
@@ -6,6 +6,7 @@
import com.azure.core.http.HttpAuthorization;
import com.azure.core.http.HttpHeaderName;
import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
import com.azure.core.http.RequestConditions;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.Response;
@@ -50,6 +51,8 @@
import com.azure.storage.blob.models.ObjectReplicationStatus;
import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.models.StorageAccountInfo;
import com.azure.storage.blob.models.SyncCopyStatusType;
import com.azure.storage.blob.options.BlobBeginCopyOptions;
@@ -74,6 +77,7 @@
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
+import com.azure.storage.common.test.shared.http.WireTapHttpClient;
import com.azure.storage.common.test.shared.policy.MockFailureResponsePolicy;
import com.azure.storage.common.test.shared.policy.MockRetryRangeResponsePolicy;
import com.azure.storage.common.test.shared.policy.TransientFailureInjectingHttpPipelinePolicy;
@@ -81,6 +85,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
@@ -120,6 +125,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -3201,6 +3207,51 @@ public void audienceFromString() {
assertTrue(aadBlob.exists());
}
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobClient blobClient = cc.getBlobClient(blobName);
+ blobClient.getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length);
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobClient sessionBlob
+ = getOAuthServiceClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect)
+ .getBlobContainerClient(cc.getBlobContainerName())
+ .getBlobClient(blobName);
+
+ File outFile = new File(prefix + "-session-download.tmp");
+ createdFiles.add(outFile);
+ Files.deleteIfExists(outFile.toPath());
+
+ sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null,
+ false, null, null);
+
+ assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ }
+
@RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2025-07-05")
@Test
@LiveOnly
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
index 049e4254e92a..c2616f97bcc0 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java
@@ -7,6 +7,7 @@
import com.azure.core.http.HttpAuthorization;
import com.azure.core.http.HttpHeaderName;
import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.Response;
import com.azure.core.test.TestMode;
@@ -48,6 +49,8 @@
import com.azure.storage.blob.models.ObjectReplicationStatus;
import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.options.BlobBeginCopyOptions;
import com.azure.storage.blob.options.BlobCopyFromUrlOptions;
import com.azure.storage.blob.options.BlobDownloadToFileOptions;
@@ -68,6 +71,7 @@
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
+import com.azure.storage.common.test.shared.http.WireTapHttpClient;
import com.azure.storage.common.test.shared.policy.MockFailureResponsePolicy;
import com.azure.storage.common.test.shared.policy.MockRetryRangeResponsePolicy;
import com.azure.storage.common.test.shared.policy.TransientFailureInjectingHttpPipelinePolicy;
@@ -76,6 +80,7 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
@@ -2965,6 +2970,51 @@ public void audienceErrorBearerChallengeRetry() {
StepVerifier.create(aadBlob.getProperties()).assertNext(Assertions::assertNotNull).verifyComplete();
}
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName);
+ blobClient.upload(BinaryData.fromBytes(data), true).block();
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobAsyncClient sessionBlob
+ = getOAuthServiceAsyncClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect)
+ .getBlobContainerAsyncClient(ccAsync.getBlobContainerName())
+ .getBlobAsyncClient(blobName);
+
+ File outFile = new File(prefix + "-session-download.tmp");
+ createdFiles.add(outFile);
+ Files.deleteIfExists(outFile.toPath());
+
+ StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null,
+ false)).expectNextCount(1).verifyComplete();
+
+ Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ }
+
@Test
public void audienceFromString() {
String url = String.format("https://%s.blob.core.windows.net/", ccAsync.getAccountName());
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
index 71345472c393..0e3c4923378c 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java
@@ -52,6 +52,7 @@
import com.azure.storage.blob.models.LeaseStateType;
import com.azure.storage.blob.models.ListBlobContainersOptions;
import com.azure.storage.blob.models.PublicAccessType;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.options.BlobBreakLeaseOptions;
import com.azure.storage.blob.sas.BlobSasPermission;
import com.azure.storage.blob.specialized.BlobAsyncClientBase;
@@ -166,6 +167,15 @@ public class BlobTestBase extends TestProxyTestBase {
protected static final String GARBAGE_LEASE_ID = CoreUtils.randomUuid().toString();
+ /*
+ The values below are shared fixtures for session authentication tests. They are never sent to the service.
+ */
+ public static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==";
+
+ public static final String TEST_SESSION_TOKEN = "test-session-token-abc123";
+
+ public static final String TEST_SESSION_ACCOUNT_NAME = "testaccount";
+
protected BlobServiceClient primaryBlobServiceClient;
protected BlobServiceAsyncClient primaryBlobServiceAsyncClient;
protected BlobServiceClient alternateBlobServiceClient;
@@ -205,7 +215,11 @@ public void beforeTest() {
TestProxySanitizerType.HEADER),
new TestProxySanitizer("x-ms-rename-source", "((?<=http://|https://)([^/?]+)|sig=(.*))", "REDACTED",
TestProxySanitizerType.HEADER),
- new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL)));
+ new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret")));
}
// Ignore changes to the order of query parameters and wholly ignore the 'sv' (service version) query parameter
@@ -417,21 +431,85 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien
}
protected BlobServiceClient getOAuthServiceClient() {
- BlobServiceClientBuilder builder
- = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
+ return getOAuthServiceClient(new SessionOptions());
+ }
- instrument(builder);
+ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) {
+ return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) {
+ return getOAuthServiceClientBuilder(sessionOptions, null, policies).buildClient();
+ }
- return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient();
+ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpClient httpClient) {
+ return getOAuthServiceClientBuilder(sessionOptions, httpClient).buildClient();
}
protected BlobServiceAsyncClient getOAuthServiceAsyncClient() {
- BlobServiceClientBuilder builder
- = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
+ return getOAuthServiceAsyncClient(new SessionOptions());
+ }
+
+ protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) {
+ return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions,
+ HttpPipelinePolicy... policies) {
+ return getOAuthServiceClientBuilder(sessionOptions, null, policies).buildAsyncClient();
+ }
+
+ protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, HttpClient httpClient) {
+ return getOAuthServiceClientBuilder(sessionOptions, httpClient).buildAsyncClient();
+ }
+
+ /**
+ * Builds an OAuth service client builder. When {@code httpClient} is supplied it replaces the transport that
+ * {@code instrument} installed, which lets a test observe requests as they go on the wire.
+ *
+ * @param sessionOptions The session options to configure.
+ * @param httpClient The transport to send requests with, or null to keep the instrumented one.
+ * @param policies Additional policies to add to the pipeline.
+ * @return The configured builder.
+ */
+ private BlobServiceClientBuilder getOAuthServiceClientBuilder(SessionOptions sessionOptions, HttpClient httpClient,
+ HttpPipelinePolicy... policies) {
+ BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions)
+ .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint());
instrument(builder);
- return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient();
+ if (httpClient != null) {
+ builder.httpClient(httpClient);
+ }
+
+ if (policies != null) {
+ for (HttpPipelinePolicy policy : policies) {
+ if (policy != null) {
+ builder.addPolicy(policy);
+ }
+ }
+ }
+
+ return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager));
+ }
+
+ /**
+ * Creates a session expiration time in the future.
+ *
+ * @return A valid session expiration time.
+ */
+ public static OffsetDateTime createValidSessionExpiration() {
+ return OffsetDateTime.now().plusHours(1);
+ }
+
+ /**
+ * Creates a session expiration time in the past.
+ *
+ * @return An expired session expiration time.
+ */
+ public static OffsetDateTime createExpiredSessionExpiration() {
+ return OffsetDateTime.now().minusMinutes(5);
}
protected BlobServiceClient getServiceClient(String endpoint) {
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
index 0af01b5fe437..e7950ff8fb24 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java
@@ -6,12 +6,14 @@
import com.azure.core.credential.AzureSasCredential;
import com.azure.core.http.HttpClient;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
import com.azure.core.http.HttpMethod;
import com.azure.core.http.HttpPipeline;
import com.azure.core.http.HttpRequest;
import com.azure.core.http.HttpResponse;
import com.azure.core.http.policy.FixedDelayOptions;
import com.azure.core.http.policy.HttpLogOptions;
+import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.policy.RetryOptions;
import com.azure.core.test.http.MockHttpResponse;
import com.azure.core.test.http.NoOpHttpClient;
@@ -22,6 +24,11 @@
import com.azure.core.util.Header;
import com.azure.core.util.logging.ClientLogger;
import com.azure.storage.blob.implementation.util.BuilderHelper;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.blob.models.SessionProvider;
+import com.azure.storage.blob.models.SessionRequestContext;
import com.azure.storage.blob.specialized.AppendBlobClient;
import com.azure.storage.blob.specialized.BlockBlobClient;
import com.azure.storage.blob.specialized.PageBlobClient;
@@ -40,15 +47,22 @@
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.time.OffsetDateTime;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -61,6 +75,12 @@ public class BuilderHelperTests {
= new RequestRetryOptions(RetryPolicyType.FIXED, 2, 2, 1000L, 4000L, null);
private static final RetryOptions CORE_RETRY_OPTIONS
= new RetryOptions(new FixedDelayOptions(1, Duration.ofSeconds(2)));
+ private static final String CREATE_SESSION_RESPONSE_BODY = ""
+ + "session-id" + OffsetDateTime.now().plusHours(1)
+ + "HMAC"
+ + "session-token"
+ + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="
+ + "";
private static HttpRequest request(String url) {
return new HttpRequest(HttpMethod.HEAD, url).setBody(Flux.empty())
@@ -72,10 +92,10 @@ private static HttpRequest request(String url) {
*/
@Test
public void freshDateAppliedOnRetry() {
- HttpPipeline pipeline
- = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null,
- BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new FreshDateTestClient(),
- new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
+ REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(),
+ new FreshDateTestClient(), new ArrayList<>(), new ArrayList<>(), null, null,
+ new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -176,7 +196,7 @@ public void customApplicationIdInUAString(String logOptionsUA, String clientOpti
HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
new RequestRetryOptions(), null, new HttpLogOptions().setApplicationId(logOptionsUA),
new ClientOptions().setApplicationId(clientOptionsUA), new ApplicationIdUAStringTestClient(expectedUA),
- new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -305,7 +325,7 @@ public void customHeadersClientOptions() {
HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT,
new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(),
new ClientOptions().setHeaders(headers), new ClientOptionsHeadersTestClient(headers), new ArrayList<>(),
- new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class));
+ new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
StepVerifier.create(pipeline.send(request(ENDPOINT)))
.assertNext(it -> assertEquals(200, it.getStatusCode()))
@@ -680,4 +700,189 @@ public Mono send(HttpRequest request) {
return Mono.just(new MockHttpResponse(request, 200));
}
}
+
+ // region buildPipeline session tests
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("pipelinesWithoutSessionsSupplier")
+ public void pipelinesWithoutSessionsDoNotContainSessionPolicy(String scenario,
+ Supplier pipelineSupplier, boolean expectsBearerPolicy) {
+ HttpPipeline pipeline = pipelineSupplier.get();
+
+ assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"),
+ scenario + " should not contain SessionTokenCredentialPolicy");
+ assertEquals(expectsBearerPolicy, hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy"),
+ scenario + " bearer policy expectation mismatch");
+ }
+
+ @Test
+ public void serviceBuilderUsesBuiltInSessionProviderByDefault() {
+ BlobServiceClient client = new BlobServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient();
+
+ assertTrue(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy"));
+ }
+
+ /**
+ * Session credentials are bound to the network context of the CreateSession call, so the session creation
+ * pipeline must run the same post-authentication policies as the data pipeline.
+ */
+ @Test
+ public void perRetryPoliciesAreAppliedToCreateSessionRequests() {
+ List observedMethods = Collections.synchronizedList(new ArrayList<>());
+ HttpPipelinePolicy perRetryPolicy = (context, next) -> {
+ observedMethods.add(context.getHttpRequest().getHttpMethod());
+ return next.process();
+ };
+
+ HttpClient sessionClient = request -> {
+ if (request.getHttpMethod() == HttpMethod.POST) {
+ HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/xml");
+ return Mono.just(new MockHttpResponse(request, 201, headers,
+ CREATE_SESSION_RESPONSE_BODY.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ return Mono.just(new MockHttpResponse(request, 200));
+ };
+
+ HttpPipeline pipeline = BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT,
+ REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), sessionClient,
+ new ArrayList<>(), Collections.singletonList(perRetryPolicy), null, null,
+ new ClientLogger(BuilderHelperTests.class), new SessionOptions(), BlobServiceVersion.getLatest());
+
+ StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob")))
+ .assertNext(response -> assertEquals(200, response.getStatusCode()))
+ .verifyComplete();
+
+ assertTrue(observedMethods.contains(HttpMethod.POST),
+ "Per-retry policies must run for CreateSession requests, saw " + observedMethods);
+ assertTrue(observedMethods.contains(HttpMethod.GET),
+ "Per-retry policies must run for data requests, saw " + observedMethods);
+ }
+
+ @Test
+ public void customSessionProviderIsWiredIntoPipelineWithResolvedRequestContext() {
+ AtomicReference capturedContext = new AtomicReference<>();
+ SessionProvider provider = createCapturingSessionProvider(capturedContext);
+
+ HttpPipeline pipeline = buildPipelineWithSessionProvider(provider);
+ HttpRequest request = new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob");
+
+ StepVerifier.create(pipeline.send(request)).expectNextCount(1).verifyComplete();
+
+ assertNotNull(capturedContext.get(), "Custom session provider should have been invoked by the pipeline");
+ assertEquals("container", capturedContext.get().getContainerName());
+ assertEquals("account", capturedContext.get().getAccountName());
+ }
+
+ private SessionProvider createCapturingSessionProvider(AtomicReference capture) {
+ SessionCredential credential = new SessionCredential("session-token",
+ "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==", OffsetDateTime.now().plusMinutes(5), "account");
+
+ return new SessionProvider() {
+ @Override
+ public Mono getSessionAsync(SessionRequestContext context) {
+ capture.set(context);
+ return Mono.just(credential);
+ }
+
+ @Override
+ public SessionCredential getSession(SessionRequestContext context) {
+ capture.set(context);
+ return credential;
+ }
+
+ @Override
+ public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) {
+ return false;
+ }
+
+ @Override
+ public void refreshSession(SessionRequestContext context) {
+ }
+ };
+ }
+
+ private HttpPipeline buildPipelineWithSessionProvider(SessionProvider provider) {
+ SessionOptions options = new SessionOptions().setSessionProvider(provider);
+ HttpClient mockClient = request -> {
+ MockHttpResponse response = new MockHttpResponse(request, 200);
+ return Mono.just(response);
+ };
+
+ return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, REQUEST_RETRY_OPTIONS,
+ null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), mockClient, new ArrayList<>(),
+ new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), options, null);
+ }
+
+ private static Stream pipelinesWithoutSessionsSupplier() {
+ return Stream.of(
+ Arguments.of("null session options", (Supplier) () -> buildTokenPipeline(null), true),
+ Arguments.of("sessions disabled",
+ (Supplier) () -> buildTokenPipeline(
+ new SessionOptions().setSessionMode(SessionMode.DISABLED)),
+ true),
+ Arguments.of("shared key credential", (Supplier) BuilderHelperTests::buildSharedKeyPipeline,
+ false),
+ Arguments.of("standalone BlobClientBuilder",
+ (Supplier) () -> new BlobClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .blobName("myblob")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient()
+ .getHttpPipeline(),
+ true),
+ Arguments.of("standalone BlobContainerClientBuilder",
+ (Supplier) () -> new BlobContainerClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient()
+ .getHttpPipeline(),
+ true),
+ Arguments.of("standalone SpecializedBlobClientBuilder",
+ (Supplier) () -> new SpecializedBlobClientBuilder().endpoint(ENDPOINT)
+ .containerName("mycontainer")
+ .blobName("myblob")
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildBlockBlobClient()
+ .getHttpPipeline(),
+ true));
+ }
+
+ /**
+ * Helper to build a pipeline with bearer token auth and the given session options.
+ */
+ private static HttpPipeline buildTokenPipeline(SessionOptions sessionOptions) {
+ return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT,
+ new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(),
+ new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null,
+ new ClientLogger(BuilderHelperTests.class), sessionOptions, BlobServiceVersion.getLatest());
+ }
+
+ /**
+ * Helper to build a pipeline without bearer token auth (shared key only).
+ */
+ private static HttpPipeline buildSharedKeyPipeline() {
+ return BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null,
+ BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(),
+ new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null);
+ }
+
+ /**
+ * Checks whether the pipeline contains a policy whose simple class name matches the given name.
+ */
+ private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) {
+ for (int i = 0; i < pipeline.getPolicyCount(); i++) {
+ if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
index 5bdf5cbd0c0e..c9c05e7e2274 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java
@@ -3,7 +3,17 @@
package com.azure.storage.blob;
+import com.azure.core.http.HttpClient;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpPipelinePosition;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.core.util.BinaryData;
import com.azure.core.http.rest.PagedIterable;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
@@ -31,8 +41,11 @@
import com.azure.storage.blob.models.ListBlobsOptions;
import com.azure.storage.blob.models.ObjectReplicationPolicy;
import com.azure.storage.blob.models.ObjectReplicationStatus;
+import com.azure.storage.blob.models.ParallelTransferOptions;
import com.azure.storage.blob.models.PublicAccessType;
import com.azure.storage.blob.models.RehydratePriority;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.blob.models.StorageAccountInfo;
import com.azure.storage.blob.models.StorageResponseSerializationFormat;
import com.azure.storage.blob.models.TaggedBlobItem;
@@ -51,10 +64,12 @@
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
+import com.azure.storage.common.test.shared.http.WireTapHttpClient;
import com.azure.storage.common.test.shared.policy.InvalidServiceVersionPipelinePolicy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -67,11 +82,15 @@
import com.azure.storage.blob.implementation.util.ModelHelper;
import com.azure.storage.blob.models.ListBlobsIncludeItem;
import com.azure.core.http.rest.ResponseBase;
+import reactor.core.publisher.Mono;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.ArrayList;
+import java.io.File;
+import java.io.IOException;
import java.net.URL;
+import java.nio.file.Files;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.Base64;
@@ -86,6 +105,7 @@
import java.util.stream.Stream;
import static com.azure.storage.common.implementation.StorageImplUtils.INVALID_VERSION_HEADER_MESSAGE;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -2475,4 +2495,324 @@ public void listBlobsByHierarchyArrowPagination() {
assertEquals(4, allItems.size());
}
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobOverSessionAuth() {
+ int blobCount = 5;
+ List blobNames = new ArrayList<>();
+ for (int i = 0; i < blobCount; i++) {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName)
+ .getBlockBlobClient()
+ .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+ blobNames.add(blobName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect);
+
+ for (String blobName : blobNames) {
+ BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent();
+ assertEquals(DATA.getDefaultText(), downloaded.toString());
+ }
+
+ // Greater than or equal to because there might be a retry that has a Session token as well if test is run with
+ // listBlobsOverSessionEnabledClient()
+ assertTrue(downloadAuthSchemes.size() >= blobCount,
+ "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length);
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName);
+ File outFile = File.createTempFile(prefix, ".tmp");
+ outFile.deleteOnExit();
+ Files.deleteIfExists(outFile.toPath());
+
+ try {
+ sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null,
+ null, false, null, null);
+
+ assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ } finally {
+ Files.deleteIfExists(outFile.toPath());
+ }
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // This test validates that listing blobs with a session-enabled client uses Bearer authorization because
+ // List Blobs is a container-level GET request, not a blob-level GET request so it users Bearer tokens instead of session tokens.
+ public void listBlobsOverSessionEnabledClient() {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ List listAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String query = req.getUrl().getQuery();
+ if (auth != null && query != null && query.contains("comp=list")) {
+ listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect);
+
+ assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName)));
+
+ assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request");
+ assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals),
+ "Container list operation must use Bearer authorization; saw " + listAuthSchemes);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // Verifies that the cached session token rotates on its own while a client keeps issuing blob GET requests.
+ // A session credential is short-lived (~5 minutes) and the credential cache fetches a fresh one in the
+ // background before the current one expires. This test keeps issuing small GETs across more than one session
+ // lifetime and asserts that the session token observed on the wire changes at least once (rotation happened).
+ public void sessionTokenRotates() {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName);
+ BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName);
+
+ // Continuously issue small GET requests for slightly longer than one session lifetime so we are
+ // guaranteed to cross at least one background rotation boundary while requests are in flight.
+ long testDurationMillis = 6 * 60 * 1000L;
+ long pollIntervalMillis = 10 * 1000L;
+ long deadline = System.currentTimeMillis() + testDurationMillis;
+ int getCount = 0;
+
+ while (System.currentTimeMillis() < deadline) {
+ // Each GET is small (the default test data) and must succeed with the expected content.
+ assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString());
+ getCount++;
+ sleepIfRunningAgainstService(pollIntervalMillis);
+ }
+
+ assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window");
+ assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens");
+
+ Set distinctTokens = new HashSet<>(inspect.getSessionTokens());
+ assertTrue(distinctTokens.size() >= 2,
+ "Expected the session token to rotate at least once over the test window; only saw " + distinctTokens);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // Verifies that, while a client hammers the service with small, rapid, back-to-back blob GET requests, the
+ // cached session token rotates and every download still succeeds. The service legitimately returns transient
+ // "session_token_invalid" (401, network-context-mismatch) responses while it rotates a session's binding; the
+ // SDK recovers from those by invalidating the session, creating a fresh one, and retrying, so the caller's GET
+ // never fails. We therefore assert the contract the SDK can actually honor - every download returns the
+ // correct content (no invalid-token failure ever surfaces to the caller) and the token rotates at least once -
+ // rather than asserting the wire never carries a 401, which the service does not guarantee. (Recovered
+ // invalid-token responses are still recorded and surfaced in the failure message below for diagnostics.)
+ public void sessionTokenRotatesWithoutInvalidTokenGets() {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName);
+ BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName);
+
+ // Continuously issue small GET requests, back-to-back with no delay, for slightly longer than one
+ // session lifetime so we are guaranteed to cross at least one rotation boundary while a high volume of
+ // requests are in flight.
+ long testDurationMillis = 6 * 60 * 1000L;
+ long deadline = System.currentTimeMillis() + testDurationMillis;
+ int getCount = 0;
+
+ while (System.currentTimeMillis() < deadline) {
+ // Each GET must succeed with the expected content. If a transient invalid-token 401 reaches the wire,
+ // the SDK's retry transparently recovers it, so this download still returns the blob - the caller
+ // never observes a failure.
+ assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString());
+ getCount++;
+ }
+
+ assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window");
+ assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens");
+
+ Set distinctTokens = new HashSet<>(inspect.getSessionTokens());
+ assertTrue(distinctTokens.size() >= 2,
+ "Expected the session token to rotate at least once over the test window; saw tokens " + distinctTokens
+ + " and transparently-recovered invalid-token responses " + inspect.getInvalidAuthStatuses());
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // Simulates a slow-polling client that issues a single small blob GET roughly every 30 seconds. Because the
+ // requests are sparse, the client can go a long time between responses and may miss the service's proactive
+ // "x-ms-auth-info: session_expiring" hint window entirely - so it can end up signing a request with a token
+ // that has expired purely due to the passage of time. This verifies the SDK handles that gracefully: every
+ // download still returns the correct content (the cache rotates to a fresh session - proactively via its own
+ // refresh timer when it can, or via the one-shot 401 retry as a backstop when an expired token slips onto the
+ // wire) and the session token observed on the wire rotates at least once over the multi-lifetime window.
+ public void sessionTokenRotatesWithSparsePolling() {
+ String blobName = generateBlobName();
+ cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize());
+
+ SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName);
+ BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName);
+
+ // Poll once every ~30s for longer than two session lifetimes (~5 min each) so we are guaranteed to cross
+ // multiple expiry boundaries. The wide gap between requests is what makes it possible to land a request
+ // on an already-expired token: the proactive refresh point can come due during the idle gap, and the
+ // first request after it - 30s later - may be signed just after the token has lapsed.
+ long testDurationMillis = 11 * 60 * 1000L;
+ long pollIntervalMillis = 30 * 1000L;
+ long deadline = System.currentTimeMillis() + testDurationMillis;
+ int getCount = 0;
+
+ while (System.currentTimeMillis() < deadline) {
+ // The caller must never observe a failure: each sparse GET returns the expected content, whether the
+ // cached token was still valid, was proactively rotated, or had to be re-acquired after a 401. Any
+ // expired-token use is recovered transparently by the policy's single retry with a fresh session.
+ assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString());
+ getCount++;
+ sleepIfRunningAgainstService(pollIntervalMillis);
+ }
+
+ assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the sparse-polling window");
+ assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens");
+
+ Set distinctTokens = new HashSet<>(inspect.getSessionTokens());
+ assertTrue(distinctTokens.size() >= 2,
+ "Expected the session token to rotate at least once over the sparse-polling window; only saw "
+ + distinctTokens);
+ }
+
+ /**
+ * Test-only pipeline policy that watches blob-level GET requests for a single blob and records, at the wire
+ * level (PER_RETRY), the Session token used to sign each request and any invalid-token (401/403) responses
+ * those requests receive. Used to assert that session tokens rotate over time without any request ever being
+ * signed with an expired/invalid token.
+ */
+ private static final class SessionGetInspectionPolicy implements HttpPipelinePolicy {
+ private final String blobName;
+ private final List sessionTokens = Collections.synchronizedList(new ArrayList<>());
+ private final List invalidAuthStatuses = Collections.synchronizedList(new ArrayList<>());
+
+ SessionGetInspectionPolicy(String blobName) {
+ this.blobName = blobName;
+ }
+
+ private boolean isBlobGet(HttpRequest request) {
+ String path = request.getUrl().getPath();
+ String query = request.getUrl().getQuery();
+ return request.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="));
+ }
+
+ private void onRequest(HttpRequest request) {
+ if (!isBlobGet(request)) {
+ return;
+ }
+ String auth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ if (auth != null && auth.startsWith("Session ")) {
+ // Header form is "Session :" - extract just the token.
+ int tokenStart = "Session ".length();
+ int sigSeparator = auth.indexOf(':', tokenStart);
+ sessionTokens
+ .add(sigSeparator < 0 ? auth.substring(tokenStart) : auth.substring(tokenStart, sigSeparator));
+ }
+ }
+
+ private void onResponse(HttpRequest request, int statusCode) {
+ if (isBlobGet(request) && (statusCode == 401 || statusCode == 403)) {
+ invalidAuthStatuses.add(statusCode);
+ }
+ }
+
+ @Override
+ public HttpPipelinePosition getPipelinePosition() {
+ return HttpPipelinePosition.PER_RETRY;
+ }
+
+ @Override
+ public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) {
+ onRequest(context.getHttpRequest());
+ return next.process().doOnNext(response -> onResponse(context.getHttpRequest(), response.getStatusCode()));
+ }
+
+ @Override
+ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) {
+ onRequest(context.getHttpRequest());
+ HttpResponse response = next.processSync();
+ onResponse(context.getHttpRequest(), response.getStatusCode());
+ return response;
+ }
+
+ List getSessionTokens() {
+ return sessionTokens;
+ }
+
+ List getInvalidAuthStatuses() {
+ return invalidAuthStatuses;
+ }
+ }
+
+ private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) {
+ return getOAuthServiceClient(sessionEnabledOptions(), policies)
+ .getBlobContainerClient(cc.getBlobContainerName());
+ }
+
+ private BlobContainerClient sessionEnabledContainerClient(HttpClient httpClient) {
+ return getOAuthServiceClient(sessionEnabledOptions(), httpClient)
+ .getBlobContainerClient(cc.getBlobContainerName());
+ }
+
+ private SessionOptions sessionEnabledOptions() {
+ return new SessionOptions().setSessionMode(SessionMode.ENABLED)
+ .setContainerName(cc.getBlobContainerName())
+ .setAccountName(cc.getAccountName());
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
index 568603acdb9a..d2f336817e7c 100644
--- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java
@@ -3,12 +3,16 @@
package com.azure.storage.blob;
+import com.azure.core.http.HttpClient;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.PagedFlux;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
import com.azure.core.test.TestMode;
import com.azure.core.test.utils.MockTokenCredential;
+import com.azure.core.util.BinaryData;
import com.azure.core.util.Context;
import com.azure.core.util.FluxUtil;
import com.azure.core.util.polling.PollerFlux;
@@ -34,11 +38,13 @@
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion;
+import com.azure.storage.common.test.shared.http.WireTapHttpClient;
import com.azure.storage.common.test.shared.policy.InvalidServiceVersionPipelinePolicy;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
@@ -48,8 +54,11 @@
import reactor.test.StepVerifier;
import reactor.util.function.Tuple2;
+import java.io.File;
+import java.io.IOException;
import java.net.URL;
import java.io.ByteArrayInputStream;
+import java.nio.file.Files;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.*;
@@ -2507,4 +2516,139 @@ public void listBlobsArrowWithTags() {
assertEquals("tagvalue", item.getTags().get("tagkey"));
}).verifyComplete();
}
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobOverSessionAuth() {
+ int blobCount = 5;
+ List blobNames = new ArrayList<>();
+ for (int i = 0; i < blobCount; i++) {
+ String blobName = generateBlobName();
+ ccAsync.getBlobAsyncClient(blobName)
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .block();
+ blobNames.add(blobName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect);
+
+ for (String blobName : blobNames) {
+ StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent())
+ .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString()))
+ .verifyComplete();
+ }
+
+ // Greater than or equal to because there might be a retry that has a Session token as well if test is run with
+ // listBlobsOverSessionEnabledClient()
+ assertTrue(downloadAuthSchemes.size() >= blobCount,
+ "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException {
+ String blobName = generateBlobName();
+ byte[] data = getRandomByteArray(4 * Constants.KB + 17);
+ int downloadBlockSize = Constants.KB;
+
+ BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName);
+ blobClient.upload(BinaryData.fromBytes(data), true).block();
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String query = req.getUrl().getQuery();
+ if (auth != null
+ && req.getHttpMethod() == HttpMethod.GET
+ && path != null
+ && path.endsWith("/" + blobName)
+ && (query == null || !query.contains("comp="))) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobAsyncClient sessionBlob = sessionEnabledContainerAsyncClient(inspect).getBlobAsyncClient(blobName);
+ File outFile = File.createTempFile(prefix, ".tmp");
+ outFile.deleteOnExit();
+ Files.deleteIfExists(outFile.toPath());
+
+ try {
+ StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null,
+ new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null,
+ null, false)).expectNextCount(1).verifyComplete();
+
+ Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath()));
+ assertTrue(downloadAuthSchemes.size() > 1,
+ "Expected multiple chunked download requests; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes);
+ } finally {
+ Files.deleteIfExists(outFile.toPath());
+ }
+ }
+
+ @Test
+ @LiveOnly
+ @ResourceLock("BlobSessionAuth")
+ // This test validates that listing blobs with a session-enabled client uses Bearer authorization because
+ // List Blobs is a container-level GET request, not a blob-level GET request.
+ public void listBlobsOverSessionEnabledClient() {
+ String blobName = generateBlobName();
+ ccAsync.getBlobAsyncClient(blobName)
+ .getBlockBlobAsyncClient()
+ .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize())
+ .block();
+
+ List listAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ WireTapHttpClient inspect = new WireTapHttpClient(getHttpClient(), req -> {
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String query = req.getUrl().getQuery();
+ if (auth != null && query != null && query.contains("comp=list")) {
+ listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ });
+
+ BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect);
+
+ StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements())
+ .expectNext(true)
+ .verifyComplete();
+
+ assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request");
+ assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals),
+ "Container list operation must use Bearer authorization; saw " + listAuthSchemes);
+ }
+
+ private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) {
+ return getOAuthServiceAsyncClient(sessionEnabledOptions(), policies)
+ .getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ }
+
+ private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpClient httpClient) {
+ return getOAuthServiceAsyncClient(sessionEnabledOptions(), httpClient)
+ .getBlobContainerAsyncClient(ccAsync.getBlobContainerName());
+ }
+
+ private SessionOptions sessionEnabledOptions() {
+ return new SessionOptions().setSessionMode(SessionMode.ENABLED)
+ .setContainerName(ccAsync.getBlobContainerName())
+ .setAccountName(ccAsync.getAccountName());
+ }
+
}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java
new file mode 100644
index 000000000000..64ae247f39f3
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.storage.blob.BlobTestBase;
+import com.azure.storage.blob.models.SessionCredential;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class SessionCredentialTest {
+
+ @Test
+ public void isExpiredReturnsTrueWhenPastExpiration() {
+ assertTrue(createCredential(BlobTestBase.createExpiredSessionExpiration()).isExpired());
+ }
+
+ @Test
+ public void isExpiredReturnsFalseWhenBeforeExpiration() {
+ assertFalse(createCredential(BlobTestBase.createValidSessionExpiration()).isExpired());
+ }
+
+ @Test
+ public void constructorRejectsNullExpiration() {
+ assertThrows(NullPointerException.class, () -> new SessionCredential(BlobTestBase.TEST_SESSION_TOKEN,
+ BlobTestBase.TEST_SESSION_KEY, null, BlobTestBase.TEST_SESSION_ACCOUNT_NAME));
+ }
+
+ private static SessionCredential createCredential(java.time.OffsetDateTime expiration) {
+ return new SessionCredential(BlobTestBase.TEST_SESSION_TOKEN, BlobTestBase.TEST_SESSION_KEY, expiration,
+ BlobTestBase.TEST_SESSION_ACCOUNT_NAME);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java
new file mode 100644
index 000000000000..6bd6b6ef94ff
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java
@@ -0,0 +1,54 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.HttpPipelineBuilder;
+import com.azure.core.test.http.NoOpHttpClient;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.BlobTestBase;
+import com.azure.storage.blob.models.SessionProvider;
+import com.azure.storage.blob.models.SessionRequestContext;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Small, focused tests for the public {@link SessionProvider} contract implemented by
+ * {@link TokenCredentialSessionProvider}.
+ *
+ * These verify that a context missing a container name is rejected rather than silently falling back to some default.
+ * The successful sync and async routing paths are covered by {@code TokenCredentialSessionProviderTests} against the
+ * live service, while {@code TokenCredentialSessionProviderCacheTest} fakes the transport to test cache timing.
+ */
+public class SessionProviderTests {
+
+ @Test
+ public void missingContextContainerThrowsSync() {
+ TokenCredentialSessionProvider sessionProvider = createSessionProvider();
+
+ // There is no constructor-supplied fallback container: a context with no container name must be
+ // rejected rather than silently degrading to some default.
+ SessionRequestContext context = new SessionRequestContext();
+
+ assertThrows(IllegalArgumentException.class, () -> sessionProvider.getSession(context));
+ }
+
+ @Test
+ public void missingContextContainerThrowsAsync() {
+ TokenCredentialSessionProvider sessionProvider = createSessionProvider();
+
+ SessionRequestContext context = new SessionRequestContext();
+
+ StepVerifier.create(sessionProvider.getSessionAsync(context)).verifyError(IllegalArgumentException.class);
+ }
+
+ private static TokenCredentialSessionProvider createSessionProvider() {
+ HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(new NoOpHttpClient()).build();
+ return new TokenCredentialSessionProvider(pipeline,
+ "https://" + BlobTestBase.TEST_SESSION_ACCOUNT_NAME + ".blob.core.windows.net",
+ BlobServiceVersion.getLatest(), BlobTestBase.TEST_SESSION_ACCOUNT_NAME);
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java
new file mode 100644
index 000000000000..1f42d16b2d92
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java
@@ -0,0 +1,557 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpClient;
+import com.azure.core.http.HttpHeaders;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.HttpPipelineBuilder;
+import com.azure.core.http.HttpPipelineCallContext;
+import com.azure.core.http.HttpPipelineNextPolicy;
+import com.azure.core.http.HttpPipelineNextSyncPolicy;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.test.http.MockHttpResponse;
+import com.azure.storage.blob.BlobTestBase;
+import com.azure.storage.blob.models.BlobStorageException;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.blob.models.SessionProvider;
+import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy;
+import com.azure.storage.common.test.shared.http.WireTapHttpClient;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class SessionTokenCredentialPolicyTest {
+
+ private static final String FIRST_TOKEN = "first-session-token";
+
+ private SessionProvider sessionProvider;
+ private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy;
+ private SessionTokenCredentialPolicy policy;
+
+ @BeforeEach
+ public void beforeEach() {
+ sessionProvider = mock(SessionProvider.class);
+ bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class);
+
+ // Default mock behavior: bearer policy delegates to next policy in the pipeline.
+ when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> {
+ HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1);
+ return nextPolicy.process();
+ });
+ when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> {
+ HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1);
+ return nextPolicy.processSync();
+ });
+
+ policy = createPolicy();
+ }
+
+ @Test
+ public void sessionAcquisitionServerFailureStartsAccountCooldown() {
+ BlobStorageException serverFailure
+ = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null);
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure));
+
+ HttpClient transport = successTransport();
+ HttpPipeline pipeline = buildPipeline(transport);
+
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ // Session acquisition is attempted only once; the cooldown suppresses the second attempt.
+ verify(sessionProvider, times(1)).getSessionAsync(any());
+ }
+
+ @Test
+ public void sessionAcquisitionCooldownExpiresAfterFiveMinutes() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ policy = createPolicy(clock);
+ BlobStorageException serverFailure
+ = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null);
+
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)) // first call: acquisition fails
+ .thenReturn(Mono.just(credentialWithToken())); // third call: cooldown expired
+
+ HttpClient transport = successTransport();
+ HttpPipeline pipeline = buildPipeline(transport);
+
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ clock.advance(Duration.ofMinutes(5));
+
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ verify(sessionProvider, times(2)).getSessionAsync(any());
+ }
+
+ @Test
+ public void policySignsRequestWithSessionCredential() {
+ HttpRequest request = blobGetRequest();
+ HttpClient transport = successTransport();
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ assertTrue(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION).startsWith("Session " + FIRST_TOKEN),
+ "Expected request to be signed with a session credential.");
+ }
+
+ /**
+ * Verifies that a 401 from the service invalidates the cached session and retries the request
+ * using bearer authentication. No WWW-Authenticate header is required to trigger this fallback;
+ * any 401 from a session-authenticated request unconditionally falls back to bearer.
+ */
+ @Test
+ public void policyInvalidatesSessionAndFallsBackToBearerAsync() {
+ HttpRequest request = blobGetRequest();
+ WireTapHttpClient transport = bearerFallbackTransport(401);
+
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ // Session auth was stripped before the bearer retry.
+ assertNull(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION));
+ // Transport received two dispatches: one for session auth, one for bearer retry.
+ assertEquals(2, transport.getRequestCount());
+ verify(sessionProvider, times(1)).getSessionAsync(any());
+ verify(sessionProvider, times(1)).invalidateSession(any(), any());
+ verify(bearerPolicy, times(1)).process(any(), any());
+ }
+
+ /**
+ * Invalidating a rejected session means the next request creates a brand new one. Where sessions cannot work at
+ * all, that would repeat forever, so consecutive rejections must eventually suppress session authentication.
+ */
+ @Test
+ public void repeatedSessionRejectionStartsAccountCooldown() {
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ WireTapHttpClient transport = bearerFallbackTransport(401);
+ HttpPipeline pipeline = buildPipeline(transport);
+
+ for (int i = 0; i < 4; i++) {
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+ }
+
+ // Three rejections trip the cooldown, so the fourth request never acquires a session.
+ verify(sessionProvider, times(3)).getSessionAsync(any());
+ assertEquals(7, transport.getRequestCount());
+ }
+
+ @Test
+ public void acceptedSessionResetsRejectionCount() {
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ WireTapHttpClient transport = sessionRejectionTransportWithAcceptedSecondRequest();
+ HttpPipeline pipeline = buildPipeline(transport);
+
+ for (int i = 0; i < 5; i++) {
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+ }
+
+ // Four rejections total, but the accepted session reset the run, so the threshold is never reached.
+ verify(sessionProvider, times(5)).getSessionAsync(any());
+ }
+
+ @Test
+ public void sessionRejectionCooldownExpiresAfterFiveMinutes() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ policy = createPolicy(clock);
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ WireTapHttpClient transport = bearerFallbackTransport(401);
+ HttpPipeline pipeline = buildPipeline(transport);
+
+ for (int i = 0; i < 4; i++) {
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+ }
+ verify(sessionProvider, times(3)).getSessionAsync(any());
+
+ clock.advance(Duration.ofMinutes(5));
+
+ StepVerifier.create(pipeline.send(blobGetRequest()))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ verify(sessionProvider, times(4)).getSessionAsync(any());
+ }
+
+ @Test
+ public void policyReturns403WithoutRetry() {
+ HttpRequest request = blobGetRequest();
+ WireTapHttpClient transport = new WireTapHttpClient(statusCodeTransport(403));
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(403, r.getStatusCode()))
+ .verifyComplete();
+
+ assertEquals(1, transport.getRequestCount());
+ verify(bearerPolicy, times(0)).process(any(), any());
+ }
+
+ @Test
+ public void policyReturnsDataRequest503WithoutBearerFallbackAsync() {
+ HttpRequest request = blobGetRequest();
+ WireTapHttpClient transport = new WireTapHttpClient(statusCodeTransport(503));
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(503, r.getStatusCode()))
+ .verifyComplete();
+
+ // 503 is not a bearer-fallback trigger; the response is returned as-is.
+ assertEquals(1, transport.getRequestCount());
+ verify(bearerPolicy, times(0)).process(any(), any());
+ }
+
+ @Test
+ public void policyFallsToBearerOn400Async() {
+ HttpRequest request = blobGetRequest();
+ WireTapHttpClient transport = bearerFallbackTransport(400);
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ assertEquals(2, transport.getRequestCount());
+ verify(bearerPolicy, times(1)).process(any(), any());
+ String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+
+ @Test
+ public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() {
+ HttpRequest request = blobGetRequest();
+ HttpHeaders responseHeaders
+ = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-auth-info"), "session_expiring");
+ HttpClient transport = responseTransport(200, responseHeaders);
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ // The service hint must trigger a proactive background refresh call, even though the client's
+ // own refresh timer had not yet elapsed. Dropping the hint here is what previously let the session
+ // be used past the rotation boundary, surfacing as a 401 "session_token_invalid" (network context
+ // mismatch). The refresh itself is delegated to the provider via refreshSession, distinct from the
+ // single getSessionAsync call used to obtain the credential for this request.
+ verify(sessionProvider, times(1)).getSessionAsync(any());
+ verify(sessionProvider, times(1)).refreshSession(any());
+ }
+
+ @Test
+ public void noSessionExpiringHintDoesNotForceBackgroundRefresh() {
+ HttpRequest request = blobGetRequest();
+ HttpClient transport = successTransport();
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken()));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ // Without the hint and with a fresh session, only the initial get is made and no refresh occurs.
+ verify(sessionProvider, times(1)).getSessionAsync(any());
+ verify(sessionProvider, never()).refreshSession(any());
+ }
+
+ @Test
+ public void getBlobRequestProducesWellFormedSessionAuthHeader() {
+ SessionCredential cred = credentialWithToken();
+ HttpRequest request
+ = new HttpRequest(HttpMethod.GET, "https://testaccount.blob.core.windows.net/mycontainer/myblob");
+ request.getHeaders()
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555")
+ .set(HttpHeaderName.RANGE, "bytes=0-1023");
+
+ HttpClient transport = successTransport();
+ when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(cred));
+
+ StepVerifier.create(buildPipeline(transport).send(request))
+ .assertNext(r -> assertEquals(200, r.getStatusCode()))
+ .verifyComplete();
+
+ // The policy adapts Shared Key signing to the Session authorization scheme.
+ String actual = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ assertNotNull(actual, "Authorization header should be set by the policy");
+ assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"),
+ "Authorization should use the Session scheme with the cached session token, but was: " + actual);
+ String actualSignature = actual.substring(actual.indexOf(':') + 1);
+ assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"),
+ "Signature must be base64-encoded, but was: " + actualSignature);
+ }
+
+ // Sync tests use a minimal mock next-policy because the real pipeline doesn't expose sync invocation.
+
+ @Test
+ public void policyInvalidatesSessionAndFallsBackToBearerSync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse initialResponse = mock(HttpResponse.class);
+ HttpResponse retriedResponse = mock(HttpResponse.class);
+
+ when(sessionProvider.getSession(any())).thenReturn(credentialWithToken());
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(initialResponse);
+ when(retryNext.processSync()).thenReturn(retriedResponse);
+ when(initialResponse.getStatusCode()).thenReturn(401);
+ when(retriedResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(retriedResponse, actualResponse);
+ assertNull(context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION));
+ verify(initialResponse, times(1)).close();
+ verify(next, times(1)).processSync();
+ verify(retryNext, times(1)).processSync();
+ verify(sessionProvider, times(1)).invalidateSession(any(), any());
+ }
+ }
+
+ @Test
+ public void policyReturnsDataRequest503WithoutBearerFallbackSync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse unavailableResponse = mock(HttpResponse.class);
+
+ when(sessionProvider.getSession(any())).thenReturn(credentialWithToken());
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(unavailableResponse);
+ when(unavailableResponse.getStatusCode()).thenReturn(503);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(unavailableResponse, actualResponse);
+ verify(unavailableResponse, times(0)).close();
+ verify(bearerPolicy, times(0)).processSync(any(), any());
+ verify(retryNext, times(0)).processSync();
+ }
+ }
+
+ @Test
+ public void policyFallsToBearerOn400Sync() {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse badRequestResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(sessionProvider.getSession(any())).thenReturn(credentialWithToken());
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(badRequestResponse);
+ when(retryNext.processSync()).thenReturn(bearerResponse);
+ when(badRequestResponse.getStatusCode()).thenReturn(400);
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(badRequestResponse, times(1)).close();
+ verify(bearerPolicy, times(1)).processSync(any(), any());
+ String authHeader = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ assertTrue(authHeader == null || !authHeader.startsWith("Session"),
+ "Session auth should have been stripped but was: " + authHeader);
+ }
+ }
+
+ @Test
+ public void repeatedSessionRejectionStartsAccountCooldownSync() {
+ when(sessionProvider.getSession(any())).thenReturn(credentialWithToken());
+
+ for (int i = 0; i < 3; i++) {
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse rejectedResponse = mock(HttpResponse.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+
+ when(next.clone()).thenReturn(retryNext);
+ when(next.processSync()).thenReturn(rejectedResponse);
+ when(retryNext.processSync()).thenReturn(bearerResponse);
+ when(rejectedResponse.getStatusCode()).thenReturn(401);
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ policy.processSync(context, next).close();
+ }
+
+ verify(sessionProvider, times(3)).getSession(any());
+
+ // The cooldown is now active, so this request goes straight to bearer without acquiring a session.
+ HttpPipelineCallContext context = createContext();
+ HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class);
+ HttpResponse bearerResponse = mock(HttpResponse.class);
+ when(next.processSync()).thenReturn(bearerResponse);
+ when(bearerResponse.getStatusCode()).thenReturn(200);
+
+ try (HttpResponse actualResponse = policy.processSync(context, next)) {
+ assertEquals(bearerResponse, actualResponse);
+ verify(sessionProvider, times(3)).getSession(any());
+ verify(next, times(0)).clone();
+ }
+ }
+
+ // Helpers
+
+ private HttpPipeline buildPipeline(HttpClient transport) {
+ return new HttpPipelineBuilder().httpClient(transport).policies(policy).build();
+ }
+
+ private static HttpClient successTransport() {
+ return statusCodeTransport(200);
+ }
+
+ private static HttpClient statusCodeTransport(int statusCode) {
+ return request -> Mono.just(new MockHttpResponse(request, statusCode));
+ }
+
+ private static HttpClient responseTransport(int statusCode, HttpHeaders headers) {
+ return request -> Mono.just(new MockHttpResponse(request, statusCode, headers));
+ }
+
+ private static WireTapHttpClient bearerFallbackTransport(int sessionResponseStatusCode) {
+ return new WireTapHttpClient(request -> Mono
+ .just(new MockHttpResponse(request, isSessionAuthenticated(request) ? sessionResponseStatusCode : 200)));
+ }
+
+ private static WireTapHttpClient sessionRejectionTransportWithAcceptedSecondRequest() {
+ AtomicInteger sessionRequestCount = new AtomicInteger();
+ return new WireTapHttpClient(request -> Mono.just(new MockHttpResponse(request,
+ !isSessionAuthenticated(request) || sessionRequestCount.incrementAndGet() == 2 ? 200 : 401)));
+ }
+
+ private static boolean isSessionAuthenticated(HttpRequest request) {
+ String authorization = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ return authorization != null && authorization.startsWith("Session ");
+ }
+
+ private static HttpRequest blobGetRequest() {
+ return new HttpRequest(HttpMethod.GET, "https://testaccount.blob.core.windows.net/mycontainer/myblob");
+ }
+
+ private SessionTokenCredentialPolicy createPolicy() {
+ return createPolicy(Clock.systemUTC());
+ }
+
+ private SessionTokenCredentialPolicy createPolicy(Clock clock) {
+ SessionOptions options = new SessionOptions().setContainerName("mycontainer");
+ return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, options, clock);
+ }
+
+ private static SessionCredential credentialWithToken() {
+ return credentialWithToken(OffsetDateTime.now().plusHours(1));
+ }
+
+ private static SessionCredential credentialWithToken(OffsetDateTime expiration) {
+ return new SessionCredential(FIRST_TOKEN, BlobTestBase.TEST_SESSION_KEY, expiration,
+ BlobTestBase.TEST_SESSION_ACCOUNT_NAME);
+ }
+
+ private static HttpPipelineCallContext createContext() {
+ return createContextForRequest(
+ new HttpRequest(HttpMethod.GET, "https://testaccount.blob.core.windows.net/mycontainer/myblob"));
+ }
+
+ private static HttpPipelineCallContext createContextForRequest(HttpRequest request) {
+ HttpPipelineCallContext context = mock(HttpPipelineCallContext.class);
+ Map data = new ConcurrentHashMap<>();
+
+ when(context.getHttpRequest()).thenReturn(request);
+ when(context.getData(anyString()))
+ .thenAnswer(invocation -> Optional.ofNullable(data.get(invocation.getArgument(0))));
+ doAnswer(invocation -> {
+ data.put(invocation.getArgument(0), invocation.getArgument(1));
+ return null;
+ }).when(context).setData(anyString(), org.mockito.ArgumentMatchers.any());
+
+ return context;
+ }
+
+ private static final class MutableClock extends Clock {
+ private final ZoneId zone;
+ private Instant instant;
+
+ private MutableClock(Instant instant) {
+ this(instant, ZoneOffset.UTC);
+ }
+
+ private MutableClock(Instant instant, ZoneId zone) {
+ this.instant = instant;
+ this.zone = zone;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public Clock withZone(ZoneId newZone) {
+ return new MutableClock(instant, newZone);
+ }
+
+ @Override
+ public Instant instant() {
+ return instant;
+ }
+
+ private void advance(Duration duration) {
+ instant = instant.plus(duration);
+ }
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java
new file mode 100644
index 000000000000..453a1a395e72
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java
@@ -0,0 +1,506 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpClient;
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.HttpPipelineBuilder;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.HttpResponse;
+import com.azure.core.test.http.MockHttpResponse;
+import com.azure.core.util.DateTimeRfc1123;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionRequestContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+import reactor.test.StepVerifier;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.Locale;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Deterministic, network-free tests for {@link TokenCredentialSessionProvider}'s time-based, per-container caching
+ * behavior.
+ *
+ * These tests drive {@link TokenCredentialSessionProvider} with an injectable {@link Clock} and a fake HTTP transport
+ * ({@link CreateSessionTransport}) so the expiry, proactive-refresh, and per-container independence logic
+ * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which
+ * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which
+ * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is
+ * covered separately by {@code SessionTokenCredentialPolicyTest}.
+ */
+public class TokenCredentialSessionProviderCacheTest {
+
+ private static final String ACCOUNT_NAME = "testaccount";
+ private static final String CONTAINER_A = "container-a";
+ private static final String CONTAINER_B = "container-b";
+ private static final String FIRST_TOKEN = "first-session-token";
+ private static final String SECOND_TOKEN = "second-session-token";
+
+ // A session's usable lifetime in these tests (the service issues ~5 minute sessions).
+ private static final Duration SESSION_LIFETIME = Duration.ofMinutes(5);
+ private static final Instant TEST_START = Instant.parse("2026-06-19T00:00:00Z");
+
+ private MutableClock clock;
+ private CreateSessionTransport httpClient;
+ private TokenCredentialSessionProvider provider;
+
+ @BeforeEach
+ public void setup() {
+ clock = new MutableClock(TEST_START);
+ httpClient = new CreateSessionTransport();
+
+ HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build();
+ provider = new TokenCredentialSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net",
+ BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock);
+ }
+
+ /**
+ * A request returns a good (valid) token. The clock then advances past the token's expiration. The next
+ * request must detect that the cached token is expired purely due to the passage of time and request a
+ * brand-new session rather than reuse or send the expired one.
+ */
+ @Test
+ public void expiredByTimeOnSecondRequestCreatesNewSession() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2)));
+
+ // First request: cold cache mints a good token and uses it.
+ SessionCredential firstRequest = provider.getSession(contextFor(CONTAINER_A));
+ assertEquals(FIRST_TOKEN, firstRequest.getSessionToken());
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+
+ // Time advances past the first token's expiration with no traffic in between.
+ clock.advance(SESSION_LIFETIME.plusSeconds(1));
+
+ // Second request: the cached token is expired by time, so a new session is created instead of reused.
+ SessionCredential secondRequest = provider.getSession(contextFor(CONTAINER_A));
+ assertEquals(SECOND_TOKEN, secondRequest.getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * When the service has NOT sent a {@code session_expiring} hint, the cache must still refresh
+ * automatically once its own jittered timer elapses (while the current token is still usable), serving
+ * the current token until the refreshed one is ready.
+ */
+ @Test
+ public void automaticBackgroundRefreshFiresWithoutServiceHint() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2)));
+
+ // First request: cold cache mints the initial token.
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+
+ // Advance to a point guaranteed to be past the jittered refresh time (80-100% of lifetime minus the
+ // 5s safety buffer => at most lifetime-5s) but still before hard expiry, so the token remains usable.
+ clock.advance(SESSION_LIFETIME.minusSeconds(2));
+
+ // Second request: token still usable, refresh timer elapsed, no service hint => automatic background
+ // refresh. The current token is served while the refresh happens.
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+
+ // Third request: the background refresh has swapped in the new token, which is now served. The
+ // refresh runs on a background subscription, so poll briefly rather than asserting immediately.
+ assertEquals(SECOND_TOKEN, waitForToken(() -> provider.getSession(contextFor(CONTAINER_A))).getSessionToken());
+ // Still only one inline creation and one background refresh overall (no over-eager churn).
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * Invalidation must evict the live credential and cause the next request to mint a replacement.
+ */
+ @Test
+ public void invalidateSessionEvictsTheLiveCredential() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2)));
+ SessionRequestContext context = contextFor(CONTAINER_A);
+
+ SessionCredential credential = provider.getSession(context);
+ assertTrue(provider.invalidateSession(context, credential));
+
+ SessionCredential replacement = provider.getSession(context);
+ assertEquals(SECOND_TOKEN, replacement.getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * A stale rejection must not evict a credential that a background refresh has already replaced.
+ */
+ @Test
+ public void invalidateSessionIgnoresACredentialAlreadyReplacedByRefresh() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME.multipliedBy(2)));
+ SessionRequestContext context = contextFor(CONTAINER_A);
+
+ SessionCredential firstCredential = provider.getSession(context);
+
+ // Before the shadow copy was removed, this late rejection incorrectly reported success even though the
+ // background refresh had already replaced the cached credential.
+ clock.advance(SESSION_LIFETIME.minusSeconds(2));
+ assertEquals(FIRST_TOKEN, provider.getSession(context).getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ assertEquals(SECOND_TOKEN, waitForToken(() -> provider.getSession(context)).getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+
+ assertFalse(provider.invalidateSession(context, firstCredential));
+ assertEquals(SECOND_TOKEN, provider.getSession(context).getSessionToken());
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * Two different containers must refresh completely independently: advancing the clock past one
+ * container's jittered refresh point must trigger a background refresh for that container only, leaving
+ * the other container's still-fresh session untouched.
+ */
+ @Test
+ public void independentContainersRefreshIndependently() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, "refreshed-a", now().plus(SESSION_LIFETIME.multipliedBy(2)));
+ enqueueSessionResponse(CONTAINER_B, SECOND_TOKEN, now().plus(SESSION_LIFETIME));
+
+ // Mint an initial session for each container.
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ assertEquals(SECOND_TOKEN, provider.getSession(contextFor(CONTAINER_B)).getSessionToken());
+
+ // Advance past container A's jittered refresh window (both containers were minted at the same time,
+ // so this is also past B's refresh window by clock time - but B must only refresh once *it* is
+ // accessed, not merely because time passed).
+ clock.advance(SESSION_LIFETIME.minusSeconds(2));
+
+ // Touching container A triggers its background refresh. The refresh runs on a background
+ // subscription, so poll briefly rather than asserting the call count immediately.
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ waitForRequestCount(CONTAINER_A, 2);
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+
+ // Container B has not been touched since the clock advanced, so it must not have refreshed - proving
+ // the two containers' caches operate independently rather than sharing one refresh timer.
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_B));
+ }
+
+ /**
+ * Guards against over-eager refreshing: while the token is comfortably before its jittered refresh point
+ * and no service hint has arrived, repeated requests must reuse the same cached token and never trigger a
+ * refresh.
+ */
+ @Test
+ public void noRefreshBeforeJitterWindowWithoutServiceHint() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+
+ // First request mints the token.
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+
+ // Advance only slightly - well before the earliest jittered refresh point (80% of lifetime).
+ clock.advance(Duration.ofSeconds(30));
+
+ // Several more requests reuse the same token; no refresh is triggered.
+ for (int i = 0; i < 3; i++) {
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ }
+
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * The async path on a cold cache must mint a value through the async CreateSession call and emit exactly
+ * one element before completing.
+ */
+ @Test
+ public void coldCacheCreatesValueAsync() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+
+ StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A)))
+ .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken()))
+ .verifyComplete();
+
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * Once the async path has cached a usable value, later async requests made before the jittered refresh
+ * window must replay that cached value rather than creating a second one.
+ */
+ @Test
+ public void cachedValueIsReusedOnLaterAsyncRequests() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+
+ StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A)))
+ .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken()))
+ .verifyComplete();
+
+ // Advance well short of the earliest jittered refresh point (80% of lifetime).
+ clock.advance(Duration.ofSeconds(30));
+
+ StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A)))
+ .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken()))
+ .verifyComplete();
+
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * A failed creation must surface to the caller as an error signal rather than an empty completion, and it
+ * must not poison the cache: the in-flight creation is cleared so a later request can retry successfully.
+ */
+ @Test
+ public void creationFailurePropagatesAndAllowsRetryAsync() {
+ enqueueFailure(CONTAINER_A);
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+
+ StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))).verifyError();
+
+ // The failure left no cached value behind, so the retry mints a fresh one.
+ StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A)))
+ .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken()))
+ .verifyComplete();
+
+ assertEquals(2, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ /**
+ * Container names must be matched case-insensitively: a container looked up with different casing must
+ * reuse the same cache entry rather than minting a duplicate session.
+ */
+ @Test
+ public void containerNameLookupIsCaseInsensitive() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+
+ assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken());
+ assertEquals(FIRST_TOKEN,
+ provider.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken());
+
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ @Test
+ public void refreshAndInvalidationAreOwnedByProvider() {
+ enqueueSessionResponse(CONTAINER_A, FIRST_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, SECOND_TOKEN, now().plus(SESSION_LIFETIME));
+ enqueueSessionResponse(CONTAINER_A, "third-session-token", now().plus(SESSION_LIFETIME));
+ SessionRequestContext context = contextFor(CONTAINER_A);
+
+ SessionCredential first = provider.getSession(context);
+ provider.refreshSession(context);
+ SessionCredential second = waitForToken(() -> provider.getSession(context));
+
+ assertFalse(provider.invalidateSession(context, first));
+ assertTrue(provider.invalidateSession(context, second));
+ assertFalse(provider.invalidateSession(context, second));
+ assertEquals("third-session-token", provider.getSession(context).getSessionToken());
+ }
+
+ /**
+ * Concurrent async callers arriving while a creation is still in flight must join that single in-flight
+ * creation instead of each triggering their own, and all of them must observe the same value.
+ */
+ @Test
+ public void concurrentAsyncRequestsShareASingleInFlightCreation() {
+ // A response that is never completed inline models a CreateSession call that is still outstanding.
+ Sinks.One pendingResponse = Sinks.one();
+ AtomicReference pendingRequest = new AtomicReference<>();
+ httpClient.enqueueResponse(CONTAINER_A, request -> {
+ pendingRequest.set(request);
+ return pendingResponse.asMono();
+ });
+
+ Mono first = provider.getSessionAsync(contextFor(CONTAINER_A));
+ Mono second = provider.getSessionAsync(contextFor(CONTAINER_A));
+
+ AtomicReference firstResult = new AtomicReference<>();
+ AtomicReference secondResult = new AtomicReference<>();
+ CountDownLatch firstLatch = new CountDownLatch(1);
+ CountDownLatch secondLatch = new CountDownLatch(1);
+ first.subscribe(cred -> {
+ firstResult.set(cred);
+ firstLatch.countDown();
+ });
+ second.subscribe(cred -> {
+ secondResult.set(cred);
+ secondLatch.countDown();
+ });
+
+ // Only one CreateSession call was made even though two callers subscribed.
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+
+ pendingResponse.tryEmitValue(buildResponse(pendingRequest.get(), FIRST_TOKEN, now().plus(SESSION_LIFETIME)));
+
+ awaitLatch(firstLatch);
+ awaitLatch(secondLatch);
+
+ assertEquals(FIRST_TOKEN, firstResult.get().getSessionToken());
+ assertEquals(FIRST_TOKEN, secondResult.get().getSessionToken());
+ assertEquals(1, httpClient.getRequestCount(CONTAINER_A));
+ }
+
+ private static void awaitLatch(CountDownLatch latch) {
+ try {
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for async result.");
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * Repeatedly invokes {@code supplier} (which triggers a synchronous cache lookup that may itself kick
+ * off a background refresh subscription) until it observes {@code expectedToken} or a timeout elapses.
+ * Background refreshes complete on a separate subscription from the caller that triggered them, so
+ * asserting on the very next call without allowing for that latency would be flaky.
+ */
+ private static SessionCredential waitForToken(Supplier supplier) {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ SessionCredential last;
+ do {
+ last = supplier.get();
+ if (SECOND_TOKEN.equals(last.getSessionToken())) {
+ return last;
+ }
+ sleepBriefly();
+ } while (System.nanoTime() < deadline);
+ return last;
+ }
+
+ private void waitForRequestCount(String container, int expectedCount) {
+ long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
+ while (httpClient.getRequestCount(container) < expectedCount && System.nanoTime() < deadline) {
+ sleepBriefly();
+ }
+ }
+
+ private static void sleepBriefly() {
+ try {
+ Thread.sleep(10);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static SessionRequestContext contextFor(String containerName) {
+ return new SessionRequestContext().setContainerName(containerName).setAccountName(ACCOUNT_NAME);
+ }
+
+ private OffsetDateTime now() {
+ return OffsetDateTime.now(clock);
+ }
+
+ private void enqueueSessionResponse(String container, String token, OffsetDateTime expiresAt) {
+ httpClient.enqueueResponse(container, request -> Mono.just(buildResponse(request, token, expiresAt)));
+ }
+
+ private void enqueueFailure(String container) {
+ httpClient.enqueueResponse(container,
+ request -> Mono.error(new IllegalStateException("CreateSession failed.")));
+ }
+
+ private static String containerFromPath(HttpRequest request) {
+ String path = request.getUrl().getPath();
+ return path.startsWith("/") ? path.substring(1) : path;
+ }
+
+ private static HttpResponse buildResponse(HttpRequest request, String token, OffsetDateTime expiresAt) {
+ String expiration = new DateTimeRfc1123(expiresAt).toString();
+ String body = "" + ""
+ + "test-session-id" + "" + expiration + ""
+ + "HMAC" + "" + "" + token
+ + "" + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="
+ + "" + "";
+
+ return new MockHttpResponse(request, 201, body.getBytes(StandardCharsets.UTF_8)).addHeader("Content-Type",
+ "application/xml");
+ }
+
+ /**
+ * Serves a per-container FIFO queue of CreateSession responses, keyed off the container name in the request
+ * path, and counts the requests each container received.
+ */
+ private static final class CreateSessionTransport implements HttpClient {
+ private final ConcurrentHashMap>>> responsesByContainer
+ = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap requestCountsByContainer = new ConcurrentHashMap<>();
+
+ void enqueueResponse(String container, Function> response) {
+ responsesByContainer.computeIfAbsent(container, ignored -> new ConcurrentLinkedQueue<>()).add(response);
+ }
+
+ int getRequestCount(String container) {
+ AtomicInteger count = requestCountsByContainer.get(container);
+ return count == null ? 0 : count.get();
+ }
+
+ @Override
+ public Mono send(HttpRequest request) {
+ String container = containerFromPath(request);
+ requestCountsByContainer.computeIfAbsent(container, ignored -> new AtomicInteger()).incrementAndGet();
+
+ ConcurrentLinkedQueue>> responses
+ = responsesByContainer.get(container);
+ Function> response = responses == null ? null : responses.poll();
+ return response == null
+ ? Mono.error(new IllegalStateException("No CreateSession response for " + container))
+ : response.apply(request);
+ }
+ }
+
+ /**
+ * A {@link Clock} whose instant can be advanced, allowing deterministic control of the cache's notion
+ * of "now" without sleeping.
+ */
+ private static final class MutableClock extends Clock {
+ private final ZoneId zone;
+ private Instant instant;
+
+ MutableClock(Instant instant) {
+ this(instant, ZoneOffset.UTC);
+ }
+
+ private MutableClock(Instant instant, ZoneId zone) {
+ this.instant = instant;
+ this.zone = zone;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public Clock withZone(ZoneId newZone) {
+ return new MutableClock(instant, newZone);
+ }
+
+ @Override
+ public Instant instant() {
+ return instant;
+ }
+
+ void advance(Duration duration) {
+ instant = instant.plus(duration);
+ }
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java
new file mode 100644
index 000000000000..9aa55963cfbb
--- /dev/null
+++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java
@@ -0,0 +1,193 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.blob.implementation.util;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.http.policy.HttpPipelinePolicy;
+import com.azure.storage.blob.BlobContainerClient;
+import com.azure.storage.blob.BlobContainerClientBuilder;
+import com.azure.storage.blob.BlobServiceClientBuilder;
+import com.azure.storage.blob.BlobServiceVersion;
+import com.azure.storage.blob.BlobTestBase;
+import com.azure.storage.blob.models.SessionCredential;
+import com.azure.storage.blob.models.SessionRequestContext;
+import com.azure.storage.blob.sas.BlobContainerSasPermission;
+import com.azure.storage.blob.sas.BlobServiceSasSignatureValues;
+import com.azure.storage.common.test.shared.StorageCommonTestUtils;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+public class TokenCredentialSessionProviderTests extends BlobTestBase {
+
+ @Test
+ public void createSessionReturnsTokenAndKey() {
+ AtomicReference requestPath = new AtomicReference<>();
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(
+ createOAuthPipeline(new AtomicInteger(), requestPath), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(),
+ BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName());
+
+ SessionCredential credential
+ = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName()));
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ assertEquals("/" + cc.getBlobContainerName(), requestPath.get());
+ }
+
+ @Test
+ public void createSessionAsyncReturnsTokenAndKey() {
+ AtomicReference requestPath = new AtomicReference<>();
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(
+ createOAuthPipeline(new AtomicInteger(), requestPath), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(),
+ BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName());
+
+ StepVerifier
+ .create(sessionProvider
+ .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName())))
+ .assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ })
+ .verifyComplete();
+ assertEquals("/" + ccAsync.getBlobContainerName(), requestPath.get());
+ }
+
+ @Test
+ public void createSessionSyncUsesProvidedHttpPipeline() {
+ AtomicInteger policyInvocationCount = new AtomicInteger();
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(
+ createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(),
+ BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName());
+
+ SessionCredential credential
+ = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName()));
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ assertEquals(1, policyInvocationCount.get());
+ }
+
+ @Test
+ public void createSessionAsyncUsesProvidedHttpPipeline() {
+ AtomicInteger policyInvocationCount = new AtomicInteger();
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(
+ createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(),
+ BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName());
+
+ StepVerifier
+ .create(sessionProvider
+ .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName())))
+ .assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType());
+ })
+ .verifyComplete();
+
+ assertEquals(1, policyInvocationCount.get());
+ }
+
+ @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType")
+ @Test
+ public void createSessionWithUserDelegationSas() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName());
+
+ String sas = generateUserDelegationContainerSas(oauthCc);
+
+ BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl());
+
+ BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient();
+
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName());
+
+ SessionCredential credential
+ = sessionProvider.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName()));
+
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ assertFalse(credential.isExpired());
+ }
+
+ @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType")
+ @Test
+ public void createSessionAsyncWithUserDelegationSas() {
+ BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName());
+
+ String sas = generateUserDelegationContainerSas(oauthCc);
+
+ BlobContainerClient sasCc
+ = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas))
+ .buildClient();
+
+ TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(),
+ ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(),
+ ENVIRONMENT.getPrimaryAccount().getName());
+
+ StepVerifier
+ .create(sessionProvider
+ .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName())))
+ .assertNext(credential -> {
+ assertNotNull(credential);
+ assertNotNull(credential.getSessionToken());
+ assertNotNull(credential.getSessionKey());
+ assertNotNull(credential.getExpiresAt());
+ assertFalse(credential.isExpired());
+ })
+ .verifyComplete();
+ }
+
+ private String generateUserDelegationContainerSas(BlobContainerClient containerClient) {
+ BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true)
+ .setWritePermission(true)
+ .setCreatePermission(true)
+ .setListPermission(true);
+ BlobServiceSasSignatureValues sasValues
+ = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions);
+
+ return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient()
+ .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1)));
+ }
+
+ private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) {
+ return createOAuthPipeline(policyInvocationCount, null);
+ }
+
+ private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount, AtomicReference requestPath) {
+ HttpPipelinePolicy policy = (context, next) -> {
+ policyInvocationCount.incrementAndGet();
+ if (requestPath != null) {
+ requestPath.set(context.getHttpRequest().getUrl().getPath());
+ }
+ return next.process();
+ };
+
+ BlobServiceClientBuilder builder
+ = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint())
+ .credential(StorageCommonTestUtils.getTokenCredential(interceptorManager))
+ .addPolicy(policy);
+
+ instrument(builder);
+ return builder.buildClient().getHttpPipeline();
+ }
+}
diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md
index 0019d026a0d0..ad2f4c16f6dc 100644
--- a/sdk/storage/azure-storage-blob/swagger/README.md
+++ b/sdk/storage/azure-storage-blob/swagger/README.md
@@ -16,7 +16,7 @@ autorest
### Code generation settings
``` yaml
use: '@autorest/java@4.1.63'
-input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/f85584d452061985a5fc21a67b8fc0b46b75188a/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json
+input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/7c058345a1ef9a85676c955ddbe790ce3f90faed/specification/storage/data-plane/Microsoft.BlobStorage/stable/2027-03-07/blob.json
java: true
output-folder: ../
namespace: com.azure.storage.blob
@@ -725,4 +725,3 @@ directive:
}
];
```
-
diff --git a/sdk/storage/azure-storage-common/pom.xml b/sdk/storage/azure-storage-common/pom.xml
index 97d6fcc863ce..a2bd7bb54d72 100644
--- a/sdk/storage/azure-storage-common/pom.xml
+++ b/sdk/storage/azure-storage-common/pom.xml
@@ -90,6 +90,26 @@
1.18.4
test
+
+ org.mockito
+ mockito-core
+ 4.11.0
+ test
+
+
+
+
+ net.bytebuddy
+ byte-buddy
+ 1.18.11
+ test
+
+
+ net.bytebuddy
+ byte-buddy-agent
+ 1.18.11
+ test
+
diff --git a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java
new file mode 100644
index 000000000000..eb2ada50b549
--- /dev/null
+++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java
@@ -0,0 +1,232 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.common.implementation.util;
+
+import com.azure.core.util.logging.ClientLogger;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Objects;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+
+/**
+ * Cache for container-scoped storage session credentials.
+ *
+ * {@code T} is not required to implement any particular interface; the caller supplies a
+ * {@link Function} that extracts the expiration instant from a value, decoupling this cache from any
+ * specific credential shape.
+ */
+public final class AutoRefreshingCache {
+ public interface ValueProvider {
+ Mono createAsync();
+
+ T createSync();
+ }
+
+ private static final ClientLogger LOGGER = new ClientLogger(AutoRefreshingCache.class);
+ private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5);
+ private static final Duration REFRESH_RETRY_DELAY = Duration.ofSeconds(30);
+ private static final double JITTER_WINDOW_START_RATIO = 0.8d;
+
+ private final ValueProvider valueProvider;
+ private final Function expirationExtractor;
+ private final Clock clock;
+ // Doubles as the "a creation is in flight" flag and the latch that wakes callers waiting on that
+ // creation. The thread that wins the compare-and-set owns the creation and must terminate the sink
+ // and clear this reference. Clearing happens before the value is delivered downstream, so a caller
+ // that reacts inside onNext sees no creation in flight and can start a fresh one.
+ private final AtomicReference> wip = new AtomicReference<>();
+ private final AtomicReference value = new AtomicReference<>();
+ private volatile OffsetDateTime nextRefreshTime;
+ // Throttles background refresh retries after creation failures so a failing provider is not retried
+ // once per caller request. Foreground creation remains intentionally unthrottled.
+ private volatile OffsetDateTime retryNotBefore;
+
+ public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor) {
+ this(valueProvider, expirationExtractor, Clock.systemUTC());
+ }
+
+ public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor,
+ Clock clock) {
+ this.valueProvider = Objects.requireNonNull(valueProvider, "'valueProvider' cannot be null.");
+ this.expirationExtractor = Objects.requireNonNull(expirationExtractor, "'expirationExtractor' cannot be null.");
+ this.clock = Objects.requireNonNull(clock, "'clock' cannot be null.");
+ }
+
+ public Mono getValidValueAsync() {
+ return Mono.defer(() -> {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ T current = value.get();
+ if (isUsable(current, now)) {
+ if (isRefreshDue(now)) {
+ refreshValueInBackground();
+ }
+ return Mono.just(current);
+ }
+
+ return createOrJoinAsync();
+ });
+ }
+
+ public T getValidValueSync() {
+ while (true) {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ T current = value.get();
+ if (isUsable(current, now)) {
+ if (isRefreshDue(now)) {
+ refreshValueInBackground();
+ }
+ return current;
+ }
+
+ Sinks.One latch = Sinks.one();
+ if (wip.compareAndSet(null, latch)) {
+ T created;
+ try {
+ // Re-check under ownership: another caller may have published a value between the
+ // check at the top of this loop and the compare-and-set above.
+ created = value.get();
+ if (!isUsable(created, OffsetDateTime.now(clock))) {
+ created = valueProvider.createSync();
+ setActiveValue(created);
+ }
+ } catch (RuntimeException e) {
+ armRetryBackoff();
+ wip.compareAndSet(latch, null);
+ latch.tryEmitError(e);
+ throw LOGGER.logExceptionAsError(e);
+ }
+ // Clear ownership before waking waiters and before returning, so a caller reacting to
+ // this value sees no creation in flight.
+ wip.compareAndSet(latch, null);
+ latch.tryEmitValue(created);
+ return created;
+ }
+
+ Sinks.One inFlight = wip.get();
+ if (inFlight != null) {
+ // Join the in-flight creation rather than minting a duplicate. Blocking here is the
+ // same exposure the previous implementation had. Return what the owner published
+ // rather than re-testing it, so a value that is already expired on arrival is
+ // surfaced once instead of sending this loop back for another attempt.
+ T joined = inFlight.asMono().block();
+ if (joined != null) {
+ return joined;
+ }
+ }
+ }
+ }
+
+ /**
+ * Clears the cached value, but only if it is still the value the caller is rejecting.
+ *
+ * @param target The value the caller believes is cached.
+ * @return true if {@code target} was still the cached value and has been cleared; false if it had
+ * already been replaced or removed, in which case the cache is left untouched.
+ */
+ public boolean invalidateValue(T target) {
+ boolean invalidated = target != null && value.compareAndSet(target, null);
+ if (invalidated) {
+ nextRefreshTime = null;
+ }
+ wip.set(null);
+ return invalidated;
+ }
+
+ public void refreshValueInBackground() {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ if (!isUsable(value.get(), now) || !isRefreshDue(now) || wip.get() != null || isRetryBackoffActive(now)) {
+ return;
+ }
+
+ createOrJoinAsync().subscribe(ignored -> {
+ }, error -> LOGGER.warning("Background session refresh failed.", error));
+ }
+
+ public void forceRefreshValueInBackground() {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ if (isUsable(value.get(), now)) {
+ nextRefreshTime = now;
+ }
+
+ refreshValueInBackground();
+ }
+
+ private Mono createOrJoinAsync() {
+ return Mono.defer(() -> {
+ OffsetDateTime now = OffsetDateTime.now(clock);
+ T current = value.get();
+ if (isUsable(current, now) && !isRefreshDue(now)) {
+ return Mono.just(current);
+ }
+
+ Sinks.One latch = Sinks.one();
+ if (wip.compareAndSet(null, latch)) {
+ return Mono.using(() -> latch, ignored -> valueProvider.createAsync().doOnNext(created -> {
+ setActiveValue(created);
+ // Clear ownership before waking waiters so a caller reacting to this value
+ // sees no creation in flight.
+ wip.compareAndSet(latch, null);
+ latch.tryEmitValue(created);
+ }).doOnError(error -> {
+ armRetryBackoff();
+ wip.compareAndSet(latch, null);
+ latch.tryEmitError(error);
+ }), owned -> {
+ wip.compareAndSet(owned, null);
+ // No-op when the creation already emitted. On cancellation it releases anyone
+ // waiting on the latch so they retry instead of hanging.
+ owned.tryEmitEmpty();
+ }).cache();
+ }
+
+ Sinks.One inFlight = wip.get();
+ if (inFlight == null) {
+ return createOrJoinAsync();
+ }
+
+ return inFlight.asMono().switchIfEmpty(Mono.defer(this::createOrJoinAsync));
+ });
+ }
+
+ private void setActiveValue(T newValue) {
+ value.set(newValue);
+ nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), expirationExtractor.apply(newValue));
+ retryNotBefore = null;
+ }
+
+ private void armRetryBackoff() {
+ retryNotBefore = OffsetDateTime.now(clock).plus(REFRESH_RETRY_DELAY);
+ }
+
+ private boolean isUsable(T value, OffsetDateTime now) {
+ return value != null && !now.isAfter(expirationExtractor.apply(value));
+ }
+
+ private boolean isRefreshDue(OffsetDateTime now) {
+ OffsetDateTime refresh = nextRefreshTime;
+ return refresh != null && !now.isBefore(refresh);
+ }
+
+ private boolean isRetryBackoffActive(OffsetDateTime now) {
+ OffsetDateTime notBefore = retryNotBefore;
+ return notBefore != null && now.isBefore(notBefore);
+ }
+
+ private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) {
+ long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis();
+ if (availableMillis <= 0) {
+ return now;
+ }
+
+ double refreshPoint
+ = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble();
+ return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint)));
+ }
+}
diff --git a/sdk/storage/azure-storage-common/src/main/java/module-info.java b/sdk/storage/azure-storage-common/src/main/java/module-info.java
index 412a11fe41d6..13b44ef9c126 100644
--- a/sdk/storage/azure-storage-common/src/main/java/module-info.java
+++ b/sdk/storage/azure-storage-common/src/main/java/module-info.java
@@ -25,7 +25,7 @@
exports com.azure.storage.common.implementation.connectionstring to // FIXME this should not be a long-term solution
com.azure.data.tables, com.azure.storage.blob, com.azure.storage.blob.cryptography,
com.azure.storage.file.share, com.azure.storage.file.datalake, com.azure.storage.queue;
-
exports com.azure.storage.common.implementation.contentvalidation to // FIXME this should not be a long-term solution
com.azure.storage.blob, com.azure.storage.file.share, com.azure.storage.file.datalake;
+ exports com.azure.storage.common.implementation.util to com.azure.storage.blob; //FIXME this should not be a long-term solution
}
diff --git a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java
index cfe97c625e7f..10661c681fb0 100644
--- a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java
+++ b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/http/WireTapHttpClient.java
@@ -9,34 +9,63 @@
import com.azure.core.util.Context;
import reactor.core.publisher.Mono;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
public class WireTapHttpClient implements HttpClient {
private final HttpClient delegate;
+ private final AtomicInteger requestCount = new AtomicInteger();
+ private Consumer requestInspector;
private volatile HttpRequest lastRequest;
public WireTapHttpClient(HttpClient delegate) {
this.delegate = delegate;
}
+ /**
+ * Creates a wire tap that hands every request to {@code requestInspector} as it goes on the wire, letting a test
+ * observe the final headers set by the pipeline's policies.
+ *
+ * @param delegate The client that actually sends the request.
+ * @param requestInspector Invoked with each request before it is sent.
+ */
+ public WireTapHttpClient(HttpClient delegate, Consumer requestInspector) {
+ this(delegate);
+ this.requestInspector = requestInspector;
+ }
+
@Override
public Mono send(HttpRequest request) {
- lastRequest = request;
+ inspect(request);
return delegate.send(request);
}
@Override
public Mono send(HttpRequest request, Context context) {
- lastRequest = request;
+ inspect(request);
return delegate.send(request, context);
}
@Override
public HttpResponse sendSync(HttpRequest request, Context context) {
- lastRequest = request;
+ inspect(request);
return delegate.sendSync(request, context);
}
public HttpRequest getLastRequest() {
return lastRequest;
}
+
+ public int getRequestCount() {
+ return requestCount.get();
+ }
+
+ private void inspect(HttpRequest request) {
+ lastRequest = request;
+ requestCount.incrementAndGet();
+ if (requestInspector != null) {
+ requestInspector.accept(request);
+ }
+ }
}
diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
index dfe6de66b555..ab8f7aa109d0 100644
--- a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
+++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java
@@ -3,15 +3,21 @@
package com.azure.storage.common;
import com.azure.core.credential.AzureNamedKeyCredential;
+import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpHeaders;
import com.azure.core.util.CoreUtils;
import com.azure.storage.common.implementation.StorageImplUtils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import java.net.MalformedURLException;
+import java.net.URL;
+
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
public class StorageSharedKeyCredentialTests {
@Test
@@ -74,4 +80,32 @@ public void cannotParseInvalidConnectionString(String connectionString) {
assertThrows(IllegalArgumentException.class,
() -> StorageSharedKeyCredential.fromConnectionString(connectionString));
}
+
+ @Test
+ public void ipStyleUrlCanonicalizedResourceIncludesAccountNameTwice() throws MalformedURLException {
+ // For IP-style URLs (e.g., Azurite), the account name appears in the URL path.
+ // The canonicalized resource prepends / to the absolute path,
+ // so the account name correctly appears twice: ///container/blob
+ String accountName = "myaccount";
+ String accountKey = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==";
+
+ StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey);
+
+ URL url = new URL("http://127.0.0.1:10000/myaccount/mycontainer/myblob");
+ HttpHeaders headers
+ = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT")
+ .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05")
+ .set(HttpHeaderName.CONTENT_LENGTH, "0");
+
+ String authHeader = credential.generateAuthorizationHeader(url, "GET", headers, false);
+
+ // Verify the signature matches a string-to-sign with account name appearing twice
+ String stringToSign = "GET\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:Mon, 31 Mar 2025 00:00:00 GMT\n"
+ + "x-ms-version:2025-01-05\n" + "/myaccount/myaccount/mycontainer/myblob";
+ String expectedSignature = credential.computeHmac256(stringToSign);
+
+ assertTrue(authHeader.startsWith("SharedKey myaccount:"),
+ "Authorization header should start with 'SharedKey myaccount:' but was: " + authHeader);
+ assertEquals("SharedKey myaccount:" + expectedSignature, authHeader);
+ }
}
diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java
new file mode 100644
index 000000000000..1981f5af130f
--- /dev/null
+++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/implementation/util/AutoRefreshingCacheTests.java
@@ -0,0 +1,483 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.common.implementation.util;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Deterministic, network-free tests for {@link AutoRefreshingCache} time-based behavior.
+ */
+@SuppressWarnings("unchecked")
+public class AutoRefreshingCacheTests {
+ private static final String FIRST_VALUE = "first-value";
+ private static final String SECOND_VALUE = "second-value";
+ private static final Duration VALUE_LIFETIME = Duration.ofMinutes(5);
+
+ @Test
+ public void expiredByTimeOnSecondRequestCreatesNewValue() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ OffsetDateTime expiration = now(clock).plus(VALUE_LIFETIME);
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, expiration))
+ .thenReturn(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createSync();
+ verify(provider, never()).createAsync();
+
+ clock.advance(VALUE_LIFETIME.plusSeconds(1));
+
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(2)).createSync();
+ verify(provider, never()).createAsync();
+ }
+
+ @Test
+ public void automaticBackgroundRefreshFiresWithoutHint() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)));
+ when(provider.createAsync())
+ .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createSync();
+ verify(provider, never()).createAsync();
+
+ clock.advance(VALUE_LIFETIME.minusSeconds(2));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createSync();
+ verify(provider, times(1)).createAsync();
+ }
+
+ @Test
+ public void failedBackgroundRefreshIsThrottled() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)));
+ when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom")));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ clock.advance(VALUE_LIFETIME.minusSeconds(2));
+
+ for (int i = 0; i < 3; i++) {
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ }
+
+ verify(provider, times(1)).createSync();
+ verify(provider, times(1)).createAsync();
+ }
+
+ @Test
+ public void throttledRefreshRetriesAfterBackoffElapses() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)));
+ when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom")))
+ .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ cache.forceRefreshValueInBackground();
+
+ verify(provider, times(1)).createAsync();
+
+ // One second before the backoff elapses the retry must still be suppressed.
+ clock.advance(Duration.ofSeconds(29));
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+
+ // Once it elapses the retry proceeds and the new value is adopted.
+ clock.advance(Duration.ofSeconds(1));
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(2)).createAsync();
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ }
+
+ @Test
+ public void forcedRefreshRespectsFailureBackoff() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)));
+ when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom")));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ clock.advance(VALUE_LIFETIME.minusSeconds(2));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+
+ cache.forceRefreshValueInBackground();
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+ }
+
+ @Test
+ public void expiredValueIsStillCreatedDuringBackoff() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)))
+ .thenReturn(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2))));
+ when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom")));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ clock.advance(VALUE_LIFETIME.minusSeconds(2));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+
+ clock.advance(Duration.ofSeconds(3));
+
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(2)).createSync();
+ verify(provider, times(1)).createAsync();
+ }
+
+ @Test
+ public void successfulCreationClearsFailureBackoff() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ // The replacement is deliberately short-lived so its own refresh window opens while the failure
+ // backoff armed by the first value would still have been active.
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)))
+ .thenReturn(value(SECOND_VALUE, now(clock).plus(Duration.ofSeconds(321))));
+ when(provider.createAsync()).thenReturn(Mono.error(new RuntimeException("boom")));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+
+ // T+298s: inside the first value's refresh window. The background refresh fails and arms the
+ // backoff until T+328s.
+ clock.advance(VALUE_LIFETIME.minusSeconds(2));
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(1)).createAsync();
+
+ // T+301s: the first value has expired, so the unthrottled foreground path mints a replacement.
+ clock.advance(Duration.ofSeconds(3));
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(2)).createSync();
+
+ // T+317s: inside the replacement's refresh window but still before T+328s, so this refresh can
+ // only happen because the successful creation cleared the backoff.
+ clock.advance(Duration.ofSeconds(16));
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ verify(provider, times(2)).createAsync();
+ }
+
+ @Test
+ public void forcedRefreshFromWithinOnNextStartsNewCreation() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createAsync()).thenReturn(Mono.just(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))))
+ .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))));
+
+ // Mirrors the production pipeline: a downstream subscriber inspects the response for the
+ // "session expiring" hint and forces a refresh from inside onNext, which runs before the
+ // creation Mono has reached its terminal signal.
+ TestExpiringValue delivered
+ = cache.getValidValueAsync().doOnNext(ignored -> cache.forceRefreshValueInBackground()).block();
+
+ assertEquals(FIRST_VALUE, delivered.getValue());
+ // The reentrant force must start a brand new creation rather than handing back the creation
+ // that is still mid-delivery.
+ verify(provider, times(2)).createAsync();
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ }
+
+ @Test
+ public void forcedRefreshFromWithinJoinerOnNextStartsNewCreation() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createAsync())
+ // The first creation is delayed so a joiner can attach.
+ .thenReturn(
+ Mono.just(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME))).delayElement(Duration.ofMillis(500)))
+ .thenReturn(Mono.just(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)))));
+
+ Mono owner = cache.getValidValueAsync();
+ Mono joiner
+ = cache.getValidValueAsync().doOnNext(ignored -> cache.forceRefreshValueInBackground());
+
+ // Run both owner and joiner pipelines. The joiner will be notified when the owner completes.
+ reactor.core.publisher.Mono.when(owner, joiner).block();
+
+ // The reentrant force from the joiner must start a brand new creation.
+ verify(provider, times(2)).createAsync();
+ assertEquals(SECOND_VALUE, cache.getValidValueSync().getValue());
+ }
+
+ @Test
+ public void noRefreshBeforeJitterWindowWithoutHint() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ when(provider.createSync()).thenReturn(value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME)));
+
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ clock.advance(Duration.ofSeconds(30));
+
+ for (int i = 0; i < 3; i++) {
+ assertEquals(FIRST_VALUE, cache.getValidValueSync().getValue());
+ }
+
+ verify(provider, times(1)).createSync();
+ verify(provider, never()).createAsync();
+ }
+
+ @Test
+ public void invalidateValueClearsMatchingValue() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ TestExpiringValue first = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME));
+ TestExpiringValue second = value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME.multipliedBy(2)));
+ when(provider.createSync()).thenReturn(first).thenReturn(second);
+
+ assertSame(first, cache.getValidValueSync());
+
+ assertTrue(cache.invalidateValue(first), "Invalidating the live value should report success.");
+
+ // The rejected value was the live one, so the next call must mint a replacement.
+ assertSame(second, cache.getValidValueSync());
+ verify(provider, times(2)).createSync();
+ }
+
+ @Test
+ public void invalidateValueIgnoresStaleTarget() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ TestExpiringValue live = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME));
+ when(provider.createSync()).thenReturn(live);
+
+ assertSame(live, cache.getValidValueSync());
+
+ // A late rejection naming a value that has already been replaced must not evict the live one.
+ assertFalse(cache.invalidateValue(value(SECOND_VALUE, now(clock).plus(VALUE_LIFETIME))),
+ "Invalidating a value that is not the cached one should report failure.");
+
+ assertSame(live, cache.getValidValueSync());
+ verify(provider, times(1)).createSync();
+ }
+
+ @Test
+ public void concurrentSyncCallersShareASingleCreation() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ TestExpiringValue created = value(FIRST_VALUE, now(clock).plus(VALUE_LIFETIME));
+ CountDownLatch creationEntered = new CountDownLatch(1);
+ CountDownLatch releaseCreation = new CountDownLatch(1);
+ when(provider.createSync()).thenAnswer(invocation -> {
+ creationEntered.countDown();
+ assertTrue(releaseCreation.await(10, TimeUnit.SECONDS));
+ return created;
+ });
+
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future owner = pool.submit(cache::getValidValueSync);
+ // Only start the second caller once the first genuinely owns the in-flight creation.
+ assertTrue(creationEntered.await(10, TimeUnit.SECONDS));
+ Future joiner = pool.submit(cache::getValidValueSync);
+
+ releaseCreation.countDown();
+
+ assertSame(created, owner.get(10, TimeUnit.SECONDS));
+ assertSame(created, joiner.get(10, TimeUnit.SECONDS));
+ } finally {
+ pool.shutdownNow();
+ }
+
+ // The joiner must have reused the owner's creation rather than minting a duplicate.
+ verify(provider, times(1)).createSync();
+ }
+
+ @Test
+ public void valueThatIsAlreadyExpiredOnArrivalIsReturnedNotRecreated() {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ // Clock skew can make a freshly minted value look expired the moment it arrives. It must be
+ // handed back once rather than sending the caller into another creation.
+ TestExpiringValue stillborn = value(FIRST_VALUE, now(clock).minusSeconds(1));
+ when(provider.createSync()).thenReturn(stillborn);
+
+ assertSame(stillborn, cache.getValidValueSync());
+ verify(provider, times(1)).createSync();
+ }
+
+ @Test
+ public void syncJoinerReturnsAnAlreadyExpiredValueWithoutRecreating() throws Exception {
+ MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z"));
+ AutoRefreshingCache.ValueProvider provider = mock(AutoRefreshingCache.ValueProvider.class);
+ AutoRefreshingCache cache
+ = new AutoRefreshingCache<>(provider, TestExpiringValue::getExpiration, clock);
+
+ TestExpiringValue stillborn = value(FIRST_VALUE, now(clock).minusSeconds(1));
+ CountDownLatch creationEntered = new CountDownLatch(1);
+ CountDownLatch releaseCreation = new CountDownLatch(1);
+ when(provider.createSync()).thenAnswer(invocation -> {
+ creationEntered.countDown();
+ assertTrue(releaseCreation.await(10, TimeUnit.SECONDS));
+ return stillborn;
+ });
+
+ AtomicReference joinerThread = new AtomicReference<>();
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ Future owner = pool.submit(cache::getValidValueSync);
+ assertTrue(creationEntered.await(10, TimeUnit.SECONDS));
+
+ Future joiner = pool.submit(() -> {
+ joinerThread.set(Thread.currentThread());
+ return cache.getValidValueSync();
+ });
+
+ // Wait until the joiner has actually parked on the in-flight creation's latch.
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ Thread running = joinerThread.get();
+ while (System.nanoTime() < deadline
+ && (running == null
+ || running.getState() == Thread.State.RUNNABLE
+ || running.getState() == Thread.State.NEW)) {
+ Thread.yield();
+ running = joinerThread.get();
+ }
+
+ releaseCreation.countDown();
+
+ assertSame(stillborn, owner.get(10, TimeUnit.SECONDS));
+ // The joiner must hand back what the owner published even though it is already expired,
+ // rather than looping and minting a second value.
+ assertSame(stillborn, joiner.get(10, TimeUnit.SECONDS));
+ } finally {
+ pool.shutdownNow();
+ }
+
+ verify(provider, times(1)).createSync();
+ }
+
+ private static OffsetDateTime now(Clock clock) {
+ return OffsetDateTime.now(clock);
+ }
+
+ private static TestExpiringValue value(String value, OffsetDateTime expiration) {
+ return new TestExpiringValue(value, expiration);
+ }
+
+ private static final class TestExpiringValue {
+ private final String value;
+ private final OffsetDateTime expiration;
+
+ private TestExpiringValue(String value, OffsetDateTime expiration) {
+ this.value = value;
+ this.expiration = expiration;
+ }
+
+ public OffsetDateTime getExpiration() {
+ return expiration;
+ }
+
+ private String getValue() {
+ return value;
+ }
+ }
+
+ private static final class MutableClock extends Clock {
+ private final ZoneId zone;
+ private Instant instant;
+
+ private MutableClock(Instant instant) {
+ this(instant, ZoneOffset.UTC);
+ }
+
+ private MutableClock(Instant instant, ZoneId zone) {
+ this.instant = instant;
+ this.zone = zone;
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return zone;
+ }
+
+ @Override
+ public Clock withZone(ZoneId newZone) {
+ return new MutableClock(instant, newZone);
+ }
+
+ @Override
+ public Instant instant() {
+ return instant;
+ }
+
+ private void advance(Duration duration) {
+ instant = instant.plus(duration);
+ }
+ }
+}
diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java
index 088b981fc19e..bd019e1a374a 100644
--- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java
+++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilder.java
@@ -28,6 +28,7 @@
import com.azure.storage.blob.BlobServiceClientBuilder;
import com.azure.storage.blob.BlobUrlParts;
import com.azure.storage.blob.models.BlobAudience;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.StorageSharedKeyCredential;
import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings;
import com.azure.storage.common.implementation.connectionstring.StorageConnectionString;
@@ -89,6 +90,7 @@ public class DataLakeServiceClientBuilder implements TokenCredentialTrait
+ * Sessions amortize authentication and authorization cost across many requests by signing them with a
+ * lightweight HMAC key instead of a full bearer token, and are only effective for accounts with a
+ * hierarchical namespace (HNS) enabled. Session mode is enabled by default whenever this builder is
+ * configured with a {@link com.azure.core.credential.TokenCredential}; requests that are not eligible for
+ * session authentication, or for which session negotiation fails (for example, because HNS is not enabled),
+ * transparently fall back to bearer token authentication.
+ *
+ * @param sessionOptions The session options for the HTTP pipeline.
+ * @return the updated DataLakeServiceClientBuilder object.
+ */
+ public DataLakeServiceClientBuilder sessionOptions(SessionOptions sessionOptions) {
+ this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions();
+ blobServiceClientBuilder.sessionOptions(this.sessionOptions);
+ return this;
+ }
}
diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java
new file mode 100644
index 000000000000..11880685b265
--- /dev/null
+++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeServiceClientBuilderTests.java
@@ -0,0 +1,90 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+package com.azure.storage.file.datalake;
+
+import com.azure.core.http.HttpPipeline;
+import com.azure.core.test.http.NoOpHttpClient;
+import com.azure.core.test.utils.MockTokenCredential;
+import com.azure.storage.blob.models.SessionMode;
+import com.azure.storage.blob.models.SessionOptions;
+import com.azure.storage.common.StorageSharedKeyCredential;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DataLakeServiceClientBuilderTests {
+
+ private static final String ENDPOINT = "https://account.blob.core.windows.net/";
+
+ @Test
+ public void defaultTokenCredentialClientsUseSessionPolicy() {
+ DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient();
+
+ assertTrue(hasPolicyOfType(client.blobServiceClient.getHttpPipeline(), "SessionTokenCredentialPolicy"));
+ }
+
+ @Test
+ public void disablingSessionsRemovesSessionPolicyButKeepsBearerPolicy() {
+ DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(new SessionOptions().setSessionMode(SessionMode.DISABLED))
+ .buildClient();
+
+ HttpPipeline pipeline = client.blobServiceClient.getHttpPipeline();
+ assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"));
+ assertTrue(hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy"));
+ }
+
+ @Test
+ public void fileSystemClientsReuseTheServiceSessionPipeline() {
+ DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .buildClient();
+
+ DataLakeFileSystemClient fileSystemClient = client.getFileSystemClient("filesystem");
+
+ assertTrue(hasPolicyOfType(fileSystemClient.getBlobContainerClient().getHttpPipeline(),
+ "SessionTokenCredentialPolicy"));
+ }
+
+ @Test
+ public void sharedKeyCredentialDoesNotUseBearerOrSessionPolicies() {
+ DataLakeServiceClient client = new DataLakeServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new StorageSharedKeyCredential("account", "accountKey"))
+ .httpClient(new NoOpHttpClient())
+ .buildClient();
+
+ HttpPipeline pipeline = client.blobServiceClient.getHttpPipeline();
+ assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"));
+ assertFalse(hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy"));
+ }
+
+ @Test
+ public void nullSessionOptionsBehaveLikeDefaultSessions() {
+ DataLakeServiceClient client = assertDoesNotThrow(() -> new DataLakeServiceClientBuilder().endpoint(ENDPOINT)
+ .credential(new MockTokenCredential())
+ .httpClient(new NoOpHttpClient())
+ .sessionOptions(null)
+ .buildClient());
+
+ assertTrue(hasPolicyOfType(client.blobServiceClient.getHttpPipeline(), "SessionTokenCredentialPolicy"));
+ }
+
+ private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) {
+ for (int i = 0; i < pipeline.getPolicyCount(); i++) {
+ if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java
index e24e61c41b6d..c2cc8d69bd2c 100644
--- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java
+++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/DataLakeTestBase.java
@@ -19,6 +19,7 @@
import com.azure.core.test.models.TestProxySanitizerType;
import com.azure.core.util.CoreUtils;
import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.StorageSharedKeyCredential;
import com.azure.storage.common.Utility;
import com.azure.storage.common.implementation.Constants;
@@ -135,10 +136,14 @@ public void beforeTest() {
prefix = StorageCommonTestUtils.getCrc32(testContextManager.getTestPlaybackRecordingName());
if (getTestMode() != TestMode.LIVE) {
- interceptorManager.addSanitizers(Arrays.asList(
- new TestProxySanitizer("sig=(.*)", "REDACTED", TestProxySanitizerType.URL),
- new TestProxySanitizer("x-ms-encryption-key", ".*", "REDACTED", TestProxySanitizerType.HEADER),
- new TestProxySanitizer("x-ms-rename-source", "sig=(.*)", "REDACTED", TestProxySanitizerType.HEADER)));
+ interceptorManager
+ .addSanitizers(Arrays.asList(new TestProxySanitizer("sig=(.*)", "REDACTED", TestProxySanitizerType.URL),
+ new TestProxySanitizer("x-ms-encryption-key", ".*", "REDACTED", TestProxySanitizerType.HEADER),
+ new TestProxySanitizer("x-ms-rename-source", "sig=(.*)", "REDACTED", TestProxySanitizerType.HEADER),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"),
+ new TestProxySanitizer("(?.*?)", "REDACTED",
+ TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret")));
// Remove `id` and `name` sanitizers from the list of common sanitizers.
interceptorManager.removeSanitizers("AZSDK3430", "AZSDK3493");
}
@@ -207,6 +212,43 @@ protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient() {
return getOAuthServiceClientBuilder().buildAsyncClient();
}
+ protected DataLakeServiceClient getOAuthServiceClient(SessionOptions sessionOptions) {
+ return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected DataLakeServiceClient getOAuthServiceClient(SessionOptions sessionOptions,
+ HttpPipelinePolicy... policies) {
+ return getOAuthServiceClientBuilder(sessionOptions, policies).buildClient();
+ }
+
+ protected DataLakeServiceClientBuilder getOAuthServiceClientBuilder(SessionOptions sessionOptions,
+ HttpPipelinePolicy... policies) {
+ DataLakeServiceClientBuilder builder
+ = new DataLakeServiceClientBuilder().endpoint(ENVIRONMENT.getDataLakeAccount().getDataLakeEndpoint())
+ .sessionOptions(sessionOptions);
+
+ instrument(builder);
+
+ if (policies != null) {
+ for (HttpPipelinePolicy policy : policies) {
+ if (policy != null) {
+ builder.addPolicy(policy);
+ }
+ }
+ }
+
+ return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager));
+ }
+
+ protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) {
+ return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null);
+ }
+
+ protected DataLakeServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions,
+ HttpPipelinePolicy... policies) {
+ return getOAuthServiceClientBuilder(sessionOptions, policies).buildAsyncClient();
+ }
+
protected DataLakeServiceClient getServiceClient(TestAccount account) {
return getServiceClient(account.getCredential(), account.getDataLakeEndpoint());
}
diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java
index c97ed9007c4f..5518189cc698 100644
--- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java
+++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemApiTests.java
@@ -3,13 +3,18 @@
package com.azure.storage.file.datalake;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
+import com.azure.core.test.utils.TestUtils;
import com.azure.core.util.Context;
import com.azure.core.util.CoreUtils;
import com.azure.identity.DefaultAzureCredentialBuilder;
import com.azure.storage.blob.BlobUrlParts;
import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.test.shared.TestHttpClientType;
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
@@ -39,17 +44,20 @@
import com.azure.storage.file.datalake.options.FileScheduleDeletionOptions;
import com.azure.storage.file.datalake.options.FileSystemEncryptionScopeOptions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
+import java.io.ByteArrayOutputStream;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -2518,4 +2526,49 @@ private void setupDirectoryForListing(DataLakeDirectoryClient client) {
//
// assertThrows(DataLakeStorageException.class, () -> dataLakeFileSystemClient.rename(generateFileSystemName()));
// }
+
+ // Session credentials are bound to the network context of the CreateSession call, so any TLS-terminating
+ // intermediary (including the test proxy) causes the service to reject the session-signed reads.
+ @Test
+ @LiveOnly
+ @ResourceLock("DataLakeSessionAuth")
+ public void readFileOverSessionAuth() {
+ int fileCount = 5;
+ List fileNames = new ArrayList<>();
+ for (int i = 0; i < fileCount; i++) {
+ String fileName = generatePathName();
+ dataLakeFileSystemClient.getFileClient(fileName).upload(DATA.getDefaultBinaryData());
+ fileNames.add(fileName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ HttpPipelinePolicy inspect = (context, next) -> {
+ HttpRequest req = context.getHttpRequest();
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && req.getHttpMethod() == HttpMethod.GET && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ return next.process();
+ };
+
+ DataLakeFileSystemClient sessionFileSystemClient = sessionEnabledFileSystemClient(inspect);
+
+ for (String fileName : fileNames) {
+ ByteArrayOutputStream outStream = new ByteArrayOutputStream();
+ sessionFileSystemClient.getFileClient(fileName).read(outStream);
+ TestUtils.assertArraysEqual(DATA.getDefaultBytes(), outStream.toByteArray());
+ }
+
+ assertTrue(downloadAuthSchemes.size() >= fileCount,
+ "Expected to observe at least one download request per file; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all file downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ private DataLakeFileSystemClient sessionEnabledFileSystemClient(HttpPipelinePolicy... policies) {
+ return getOAuthServiceClient(new SessionOptions(), policies)
+ .getFileSystemClient(dataLakeFileSystemClient.getFileSystemName());
+ }
}
diff --git a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java
index 504200c89c7c..0a6bc752139e 100644
--- a/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java
+++ b/sdk/storage/azure-storage-file-datalake/src/test/java/com/azure/storage/file/datalake/FileSystemAsyncApiTests.java
@@ -3,12 +3,18 @@
package com.azure.storage.file.datalake;
import com.azure.core.http.HttpHeaderName;
+import com.azure.core.http.HttpMethod;
+import com.azure.core.http.HttpRequest;
+import com.azure.core.http.policy.HttpPipelinePolicy;
import com.azure.core.http.rest.PagedResponse;
import com.azure.core.http.rest.Response;
+import com.azure.core.test.utils.TestUtils;
import com.azure.core.util.CoreUtils;
+import com.azure.core.util.FluxUtil;
import com.azure.identity.DefaultAzureCredentialBuilder;
import com.azure.storage.blob.BlobUrlParts;
import com.azure.storage.blob.models.BlobErrorCode;
+import com.azure.storage.blob.models.SessionOptions;
import com.azure.storage.common.test.shared.TestHttpClientType;
import com.azure.storage.common.test.shared.extensions.LiveOnly;
import com.azure.storage.common.test.shared.extensions.PlaybackOnly;
@@ -38,6 +44,7 @@
import com.azure.storage.file.datalake.options.FileSystemEncryptionScopeOptions;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
@@ -51,6 +58,7 @@
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
@@ -2605,4 +2613,53 @@ private Mono setupDirectoryForListing(DataLakeDire
.flatMap(foo2 -> foo2.createSubdirectory("bar"))
.then(baz.createSubdirectory("bar/foo")));
}
+
+ // Session credentials are bound to the network context of the CreateSession call, so any TLS-terminating
+ // intermediary (including the test proxy) causes the service to reject the session-signed reads.
+ @Test
+ @LiveOnly
+ @ResourceLock("DataLakeSessionAuth")
+ public void readFileOverSessionAuth() {
+ int fileCount = 5;
+ List fileNames = new ArrayList<>();
+ for (int i = 0; i < fileCount; i++) {
+ String fileName = generatePathName();
+ dataLakeFileSystemAsyncClient.getFileAsyncClient(fileName)
+ .upload(DATA.getDefaultBinaryData(), null)
+ .block();
+ fileNames.add(fileName);
+ }
+
+ List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>());
+ HttpPipelinePolicy inspect = (context, next) -> {
+ HttpRequest req = context.getHttpRequest();
+ String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION);
+ String path = req.getUrl().getPath();
+ String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path;
+ if (auth != null && req.getHttpMethod() == HttpMethod.GET && trimmed != null && trimmed.contains("/")) {
+ downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer");
+ }
+ return next.process();
+ };
+
+ DataLakeFileSystemAsyncClient sessionFileSystemAsyncClient = sessionEnabledFileSystemAsyncClient(inspect);
+
+ for (String fileName : fileNames) {
+ StepVerifier
+ .create(FluxUtil
+ .collectBytesInByteBufferStream(sessionFileSystemAsyncClient.getFileAsyncClient(fileName).read()))
+ .assertNext(bytes -> TestUtils.assertArraysEqual(DATA.getDefaultBytes(), bytes))
+ .verifyComplete();
+ }
+
+ assertTrue(downloadAuthSchemes.size() >= fileCount,
+ "Expected to observe at least one download request per file; saw " + downloadAuthSchemes);
+ assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals),
+ "Expected all file downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes);
+ }
+
+ private DataLakeFileSystemAsyncClient sessionEnabledFileSystemAsyncClient(HttpPipelinePolicy... policies) {
+ return getOAuthServiceAsyncClient(new SessionOptions(), policies)
+ .getFileSystemAsyncClient(dataLakeFileSystemAsyncClient.getFileSystemName());
+ }
}