From a8e418cf569bc7625b99d3c28aaa3e7aa2838bdc Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 30 Mar 2026 17:32:14 -0400 Subject: [PATCH 01/84] generate base files based on swagger docs --- .../blob/implementation/ContainersImpl.java | 143 ++++++++++++ .../models/AuthenticationType.java | 51 ++++ .../models/CreateSessionConfiguration.java | 119 ++++++++++ .../models/CreateSessionResponse.java | 221 ++++++++++++++++++ .../models/SessionCredentials.java | 149 ++++++++++++ .../azure-storage-blob/swagger/README.md | 2 +- 6 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index 7fd2af96e4df..b4b1a09d31d3 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java @@ -46,6 +46,8 @@ import com.azure.storage.blob.implementation.models.ContainersSetAccessPolicyHeaders; import com.azure.storage.blob.implementation.models.ContainersSetMetadataHeaders; import com.azure.storage.blob.implementation.models.ContainersSubmitBatchHeaders; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.FilterBlobsIncludeItem; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; @@ -938,6 +940,24 @@ Response getAccountInfoNoCustomHeadersSync(@HostParam("url") String url, @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Mono> createSession(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Response createSessionSync(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); } /** @@ -6707,4 +6727,127 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container throw ModelHelper.mapToBlobStorageException(internalException); } } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration) { + return FluxUtil + .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, context)) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Context context) { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service + .createSession(this.client.getUrl(), containerName, restype, comp, createSessionConfiguration, accept, + context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Context context) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createSessionWithResponse(String containerName, + CreateSessionConfiguration createSessionConfiguration, Context context) { + try { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, + createSessionConfiguration, accept, context); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public CreateSessionResponse createSession(String containerName, + CreateSessionConfiguration createSessionConfiguration) { + try { + return createSessionWithResponse(containerName, createSessionConfiguration, Context.NONE).getValue(); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java new file mode 100644 index 000000000000..76a92bba45e3 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Generated; +import com.azure.core.util.ExpandableStringEnum; +import java.util.Collection; + +/** + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ +public final class AuthenticationType extends ExpandableStringEnum { + /** + * Static value HMAC for AuthenticationType. + */ + @Generated + public static final AuthenticationType HMAC = fromString("HMAC"); + + /** + * Creates a new instance of AuthenticationType value. + * + * @deprecated Use the {@link #fromString(String)} factory method. + */ + @Generated + @Deprecated + public AuthenticationType() { + } + + /** + * Creates or finds a AuthenticationType from its string representation. + * + * @param name a name to look for. + * @return the corresponding AuthenticationType. + */ + @Generated + public static AuthenticationType fromString(String name) { + return fromString(name, AuthenticationType.class); + } + + /** + * Gets known AuthenticationType values. + * + * @return known AuthenticationType values. + */ + @Generated + public static Collection values() { + return values(AuthenticationType.class); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java new file mode 100644 index 000000000000..b52e86f169cd --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionConfiguration model. + */ +@Fluent +public final class CreateSessionConfiguration implements XmlSerializable { + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /** + * Creates an instance of CreateSessionConfiguration class. + */ + @Generated + public CreateSessionConfiguration() { + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionConfiguration object itself. + */ + @Generated + public CreateSessionConfiguration setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader, String rootElementName) + throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionConfiguration deserializedCreateSessionConfiguration = new CreateSessionConfiguration(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionConfiguration.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionConfiguration; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java new file mode 100644 index 000000000000..610080c98fd4 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import java.time.OffsetDateTime; +import java.util.Objects; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionResponse model. + */ +@Fluent +public final class CreateSessionResponse implements XmlSerializable { + /* + * A unique identifier for the created session. + */ + @Generated + private String id; + + /* + * The time when the session will expire. The format follows RFC 1123. + */ + @Generated + private DateTimeRfc1123 expiration; + + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /* + * The Credentials property. + */ + @Generated + private SessionCredentials credentials; + + /** + * Creates an instance of CreateSessionResponse class. + */ + @Generated + public CreateSessionResponse() { + } + + /** + * Get the id property: A unique identifier for the created session. + * + * @return the id value. + */ + @Generated + public String getId() { + return this.id; + } + + /** + * Set the id property: A unique identifier for the created session. + * + * @param id the id value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setId(String id) { + this.id = id; + return this; + } + + /** + * Get the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @return the expiration value. + */ + @Generated + public OffsetDateTime getExpiration() { + if (this.expiration == null) { + return null; + } + return this.expiration.getDateTime(); + } + + /** + * Set the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @param expiration the expiration value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setExpiration(OffsetDateTime expiration) { + if (expiration == null) { + this.expiration = null; + } else { + this.expiration = new DateTimeRfc1123(expiration); + } + return this; + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + /** + * Get the credentials property: The Credentials property. + * + * @return the credentials value. + */ + @Generated + public SessionCredentials getCredentials() { + return this.credentials; + } + + /** + * Set the credentials property: The Credentials property. + * + * @param credentials the credentials value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setCredentials(SessionCredentials credentials) { + this.credentials = credentials; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("Id", this.id); + xmlWriter.writeStringElement("Expiration", Objects.toString(this.expiration, null)); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + xmlWriter.writeXml(this.credentials, "Credentials"); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionResponse deserializedCreateSessionResponse = new CreateSessionResponse(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("Id".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.id = reader.getStringElement(); + } else if ("Expiration".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.expiration = reader.getNullableElement(DateTimeRfc1123::new); + } else if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else if ("Credentials".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.credentials = SessionCredentials.fromXml(reader, "Credentials"); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionResponse; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java new file mode 100644 index 000000000000..ed427a221aa7 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The SessionCredentials model. + */ +@Fluent +public final class SessionCredentials implements XmlSerializable { + /* + * An opaque token used to authorize subsequent requests in the session. Must be treated as a security credential. + */ + @Generated + private String sessionToken; + + /* + * Only returned when AuthenticationType is HMAC. A symmetric encryption key used to sign requests in the session + * using the Shared Key protocol. + */ + @Generated + private String sessionKey; + + /** + * Creates an instance of SessionCredentials class. + */ + @Generated + public SessionCredentials() { + } + + /** + * Get the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @return the sessionToken value. + */ + @Generated + public String getSessionToken() { + return this.sessionToken; + } + + /** + * Set the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @param sessionToken the sessionToken value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + + /** + * Get the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @return the sessionKey value. + */ + @Generated + public String getSessionKey() { + return this.sessionKey; + } + + /** + * Set the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @param sessionKey the sessionKey value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("SessionToken", this.sessionToken); + xmlWriter.writeStringElement("SessionKey", this.sessionKey); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + SessionCredentials deserializedSessionCredentials = new SessionCredentials(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("SessionToken".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionToken = reader.getStringElement(); + } else if ("SessionKey".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionKey = reader.getStringElement(); + } else { + reader.skipElement(); + } + } + + return deserializedSessionCredentials; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md index 292d2f7c231d..332afefd0e90 100644 --- a/sdk/storage/azure-storage-blob/swagger/README.md +++ b/sdk/storage/azure-storage-blob/swagger/README.md @@ -16,7 +16,7 @@ autorest ### Code generation settings ``` yaml use: '@autorest/java@4.1.63' -input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/15d7f54a5389d5906ffb4e56bb2f38fe5525c0d3/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-06-06/blob.json +input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/376c42b6ac08e61e67b9df67bb6b56cc33a1b349/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json java: true output-folder: ../ namespace: com.azure.storage.blob From 92ea65a682e423413826a84f8cd5199d21a04314 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 1 Apr 2026 18:03:26 -0400 Subject: [PATCH 02/84] create live tests for createSession - downgrade blobserviceversion to 2026_04_06 - change AZURE_LIVE_TEST_SERVICE_VERSION to V2026_04_06 in ci.system.properties in azure-storage-common - create both sync and async --- .../blob/BlobContainerAsyncClient.java | 45 ++++++++++++++++--- .../storage/blob/BlobContainerClient.java | 32 +++++++++++++ .../storage/blob/BlobServiceVersion.java | 9 +--- .../blob/implementation/ContainersImpl.java | 10 ++--- .../azure/storage/blob/ContainerApiTests.java | 18 ++++++++ .../storage/blob/ContainerAsyncApiTests.java | 20 +++++++++ .../azure-storage-common/ci.system.properties | 2 +- 7 files changed, 116 insertions(+), 20 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index b86fe4e76b2f..c1ad39c451b7 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -28,6 +28,9 @@ import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1691,11 +1694,39 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } - // private boolean validateNoTime(BlobRequestConditions modifiedRequestConditions) { - // if (modifiedRequestConditions == null) { - // return true; - // } - // return modifiedRequestConditions.getIfModifiedSince() == null - // && modifiedRequestConditions.getIfUnmodifiedSince() == null; - // } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSession() { + return createSessionWithResponse().flatMap(FluxUtil::toMono); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponse() { + try { + return withContext(this::createSessionWithResponse); + } catch (RuntimeException ex) { + return monoError(LOGGER, ex); + } + } + + Mono> createSessionWithResponse(Context context) { + context = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + return this.azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, context) + .map(response -> new SimpleResponse<>(response, response.getValue())); + } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 64de81617f9c..92c383728fa5 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -31,6 +31,9 @@ import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1509,4 +1512,33 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. + * @param context Additional context that is passed through the Http pipeline during the service call. + * @return A response containing the {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createSessionWithResponse(Duration timeout, Context context) { + Context finalContext = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + Callable> operation = () -> this.azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, finalContext); + return sendRequest(operation, timeout, BlobStorageException.class); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return The {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public CreateSessionResponse createSession() { + return createSessionWithResponse(null, Context.NONE).getValue(); + } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java index 75fb74a59a5d..efa24e69b84d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java @@ -157,12 +157,7 @@ public enum BlobServiceVersion implements ServiceVersion { /** * Service version {@code 2026-04-06}. */ - V2026_04_06("2026-04-06"), - - /** - * Service version {@code 2026-06-06}. - */ - V2026_06_06("2026-06-06"); + V2026_04_06("2026-04-06"); private final String version; @@ -184,6 +179,6 @@ public String getVersion() { * @return the latest {@link BlobServiceVersion} */ public static BlobServiceVersion getLatest() { - return V2026_06_06; + return V2026_04_06; } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index b4b1a09d31d3..1b3c8eab7880 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java @@ -948,7 +948,7 @@ Mono> createSession(@HostParam("url") String url @PathParam("containerName") String containerName, @QueryParam("restype") String restype, @QueryParam("comp") String comp, @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, - @HeaderParam("Accept") String accept, Context context); + @HeaderParam("x-ms-version") String version, @HeaderParam("Accept") String accept, Context context); @Post("/{containerName}") @ExpectedResponses({ 201 }) @@ -957,7 +957,7 @@ Response createSessionSync(@HostParam("url") String url, @PathParam("containerName") String containerName, @QueryParam("restype") String restype, @QueryParam("comp") String comp, @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, - @HeaderParam("Accept") String accept, Context context); + @HeaderParam("x-ms-version") String version, @HeaderParam("Accept") String accept, Context context); } /** @@ -6764,8 +6764,8 @@ public Mono> createSessionWithResponseAsync(Stri final String comp = "session"; final String accept = "application/xml"; return service - .createSession(this.client.getUrl(), containerName, restype, comp, createSessionConfiguration, accept, - context) + .createSession(this.client.getUrl(), containerName, restype, comp, createSessionConfiguration, + this.client.getVersion(), accept, context) .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); } @@ -6825,7 +6825,7 @@ public Response createSessionWithResponse(String containe final String comp = "session"; final String accept = "application/xml"; return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, - createSessionConfiguration, accept, context); + createSessionConfiguration, this.client.getVersion(), accept, context); } catch (BlobStorageExceptionInternal internalException) { throw ModelHelper.mapToBlobStorageException(internalException); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index f46116acdbb5..4604b89297a8 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 @@ -10,6 +10,8 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -2128,4 +2130,20 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } + + @Test + // @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06") + public void createSessionReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + Response response = oauthCc.createSessionWithResponse(null, null); + + assertResponseStatusCode(response, 201); + CreateSessionResponse session = response.getValue(); + assertNotNull(session.getId()); + assertNotNull(session.getExpiration()); + assertNotNull(session.getCredentials()); + assertNotNull(session.getCredentials().getSessionToken()); + assertNotNull(session.getCredentials().getSessionKey()); + assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index 04ebc06dc2b6..eca3a1be27a9 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 @@ -12,6 +12,8 @@ import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.*; import com.azure.storage.blob.options.BlobContainerCreateOptions; import com.azure.storage.blob.options.BlobParallelUploadOptions; @@ -2142,4 +2144,22 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } + + @Test + // @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06") + public void createSessionReturnsTokenAndKey() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + + StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> { + assertEquals(201, response.getStatusCode()); + CreateSessionResponse session = response.getValue(); + assertNotNull(session.getId()); + assertNotNull(session.getExpiration()); + assertNotNull(session.getCredentials()); + assertNotNull(session.getCredentials().getSessionToken()); + assertNotNull(session.getCredentials().getSessionKey()); + assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + }).verifyComplete(); + } } diff --git a/sdk/storage/azure-storage-common/ci.system.properties b/sdk/storage/azure-storage-common/ci.system.properties index 1d1c46cd13b4..907af8fd671d 100644 --- a/sdk/storage/azure-storage-common/ci.system.properties +++ b/sdk/storage/azure-storage-common/ci.system.properties @@ -1,2 +1,2 @@ -AZURE_LIVE_TEST_SERVICE_VERSION=V2026_06_06 +AZURE_LIVE_TEST_SERVICE_VERSION=V2026_04_06 AZURE_STORAGE_SAS_SERVICE_VERSION=2026-06-06 From d90ea8ead954714d4fa2cc54618a8a5b94343bfb Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 1 Apr 2026 18:09:56 -0400 Subject: [PATCH 03/84] add recordings --- sdk/storage/azure-storage-blob/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 8cad139f33ff..8fabf6253d18 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_47f4243e59" + "Tag": "java/storage/azure-storage-blob_85c6d2fa2f" } From 5c8171561eef838953c3c759778a4530b889ecd9 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 2 Apr 2026 18:34:07 -0400 Subject: [PATCH 04/84] create new files based on swagger update --- .../blob/implementation/ContainersImpl.java | 70 ++++++++++++++----- .../azure-storage-blob/swagger/README.md | 2 +- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index 1b3c8eab7880..8bc27e750abc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java @@ -946,18 +946,20 @@ Response getAccountInfoNoCustomHeadersSync(@HostParam("url") String url, @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) Mono> createSession(@HostParam("url") String url, @PathParam("containerName") String containerName, @QueryParam("restype") String restype, - @QueryParam("comp") String comp, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, - @HeaderParam("x-ms-version") String version, @HeaderParam("Accept") String accept, Context context); + @HeaderParam("Accept") String accept, Context context); @Post("/{containerName}") @ExpectedResponses({ 201 }) @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) Response createSessionSync(@HostParam("url") String url, @PathParam("containerName") String containerName, @QueryParam("restype") String restype, - @QueryParam("comp") String comp, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, - @HeaderParam("x-ms-version") String version, @HeaderParam("Accept") String accept, Context context); + @HeaderParam("Accept") String accept, Context context); } /** @@ -6733,6 +6735,11 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. @@ -6740,9 +6747,10 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container */ @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createSessionWithResponseAsync(String containerName, - CreateSessionConfiguration createSessionConfiguration) { + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { return FluxUtil - .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, context)) + .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, + requestId, context)) .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); } @@ -6751,6 +6759,11 @@ public Mono> createSessionWithResponseAsync(Stri * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @param context The context to associate with this operation. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. @@ -6759,13 +6772,13 @@ public Mono> createSessionWithResponseAsync(Stri */ @ServiceMethod(returns = ReturnType.SINGLE) public Mono> createSessionWithResponseAsync(String containerName, - CreateSessionConfiguration createSessionConfiguration, Context context) { + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { final String restype = "container"; final String comp = "session"; final String accept = "application/xml"; return service - .createSession(this.client.getUrl(), containerName, restype, comp, createSessionConfiguration, - this.client.getVersion(), accept, context) + .createSession(this.client.getUrl(), containerName, restype, comp, timeout, this.client.getVersion(), + requestId, createSessionConfiguration, accept, context) .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); } @@ -6774,6 +6787,11 @@ public Mono> createSessionWithResponseAsync(Stri * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. @@ -6781,8 +6799,8 @@ public Mono> createSessionWithResponseAsync(Stri */ @ServiceMethod(returns = ReturnType.SINGLE) public Mono createSessionAsync(String containerName, - CreateSessionConfiguration createSessionConfiguration) { - return createSessionWithResponseAsync(containerName, createSessionConfiguration) + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId) .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) .flatMap(res -> Mono.justOrEmpty(res.getValue())); } @@ -6792,6 +6810,11 @@ public Mono createSessionAsync(String containerName, * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @param context The context to associate with this operation. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. @@ -6800,8 +6823,8 @@ public Mono createSessionAsync(String containerName, */ @ServiceMethod(returns = ReturnType.SINGLE) public Mono createSessionAsync(String containerName, - CreateSessionConfiguration createSessionConfiguration, Context context) { - return createSessionWithResponseAsync(containerName, createSessionConfiguration, context) + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId, context) .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) .flatMap(res -> Mono.justOrEmpty(res.getValue())); } @@ -6811,6 +6834,11 @@ public Mono createSessionAsync(String containerName, * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @param context The context to associate with this operation. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. @@ -6819,13 +6847,13 @@ public Mono createSessionAsync(String containerName, */ @ServiceMethod(returns = ReturnType.SINGLE) public Response createSessionWithResponse(String containerName, - CreateSessionConfiguration createSessionConfiguration, Context context) { + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { try { final String restype = "container"; final String comp = "session"; final String accept = "application/xml"; - return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, - createSessionConfiguration, this.client.getVersion(), accept, context); + return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, timeout, + this.client.getVersion(), requestId, createSessionConfiguration, accept, context); } catch (BlobStorageExceptionInternal internalException) { throw ModelHelper.mapToBlobStorageException(internalException); } @@ -6836,6 +6864,11 @@ public Response createSessionWithResponse(String containe * * @param containerName The container name. * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. * @throws IllegalArgumentException thrown if parameters fail the validation. * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. @@ -6843,9 +6876,10 @@ public Response createSessionWithResponse(String containe */ @ServiceMethod(returns = ReturnType.SINGLE) public CreateSessionResponse createSession(String containerName, - CreateSessionConfiguration createSessionConfiguration) { + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { try { - return createSessionWithResponse(containerName, createSessionConfiguration, Context.NONE).getValue(); + return createSessionWithResponse(containerName, createSessionConfiguration, timeout, requestId, + Context.NONE).getValue(); } catch (BlobStorageExceptionInternal internalException) { throw ModelHelper.mapToBlobStorageException(internalException); } diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md index 332afefd0e90..e3f3f4ead37b 100644 --- a/sdk/storage/azure-storage-blob/swagger/README.md +++ b/sdk/storage/azure-storage-blob/swagger/README.md @@ -16,7 +16,7 @@ autorest ### Code generation settings ``` yaml use: '@autorest/java@4.1.63' -input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/376c42b6ac08e61e67b9df67bb6b56cc33a1b349/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json +input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/013866b01623e6f2cc6c313b44c9c6460de3e91e/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json java: true output-folder: ../ namespace: com.azure.storage.blob From dab6ac3023ec6c9fd5b34944f750bfebe0593070 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 2 Apr 2026 19:38:41 -0400 Subject: [PATCH 05/84] add two params to BlobContainerClient#createSessionWithResponse --- .../main/java/com/azure/storage/blob/BlobContainerClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 92c383728fa5..2331530ad3cd 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -1526,7 +1526,7 @@ public Response createSessionWithResponse(Duration timeou CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); Callable> operation = () -> this.azureBlobStorage.getContainers() - .createSessionWithResponse(containerName, config, finalContext); + .createSessionWithResponse(containerName, config, null, null, finalContext); return sendRequest(operation, timeout, BlobStorageException.class); } From 06bbcebe560755ea15f9365782270b158f59add7 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 3 Apr 2026 17:14:01 -0400 Subject: [PATCH 06/84] add sanitizers for SessionToken and SessionKey to BlobTestBase --- .../src/test/java/com/azure/storage/blob/BlobTestBase.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java index 99d925bff60c..5406fd69e941 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 @@ -196,7 +196,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 From dd12bfdb39200c34baa68ded08d3f7aa4836a3d9 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 3 Apr 2026 17:32:55 -0400 Subject: [PATCH 07/84] add recording for createSessionReturnsTokenAndKey --- sdk/storage/azure-storage-blob/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 8fabf6253d18..9b406d7acb7b 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_85c6d2fa2f" + "Tag": "java/storage/azure-storage-blob_63fe5a46f1" } From a20160901d4ce907e20491a682d2dda2cced643f Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 3 Apr 2026 18:45:28 -0400 Subject: [PATCH 08/84] create StorageSessionCredential with isExpired --- .../util/StorageSessionCredential.java | 213 ++++++++++++++++++ .../util/SessionTestHelper.java | 32 +++ .../util/StorageSessionCredentialTest.java | 74 ++++++ 3 files changed, 319 insertions(+) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java new file mode 100644 index 000000000000..a5fb88af89ee --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.Header; +import com.azure.storage.common.implementation.StorageImplUtils; + +import java.net.URL; +import java.text.Collator; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +import static com.azure.storage.common.Utility.urlDecode; + +/** + * Holds session credentials (token, key, expiration) and signs requests using the Shared Key protocol. + * The Authorization header format is: {@code Session :} + */ +public final class StorageSessionCredential { + + private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); + + private final String sessionToken; + private final String sessionKey; + private final OffsetDateTime expiration; + + /** + * Creates a StorageSessionCredential with the given session token, key, and expiration. + * + * @param sessionToken The opaque session token from Create Session response. + * @param sessionKey The Base64-encoded symmetric key for HMAC signing. + * @param expiration The time when this session expires. + */ + public StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration) { + this.sessionToken = sessionToken; + this.sessionKey = sessionKey; + this.expiration = expiration; + } + + /** + * Computes an HMAC-SHA256 signature for the given string-to-sign using the session key. + * + * @param stringToSign The string to sign. + * @return The Base64-encoded HMAC-SHA256 signature. + */ + public String computeHmac256(String stringToSign) { + return StorageImplUtils.computeHMac256(sessionKey, stringToSign); + } + + /** + * Generates the Session Authorization header value for a request. + * Format: {@code Session :} + * + * @param requestURL The request URL. + * @param httpMethod The HTTP method (GET, PUT, etc.). + * @param headers The request headers. + * @return The Authorization header value. + */ + public String generateAuthorizationHeader(URL requestURL, String httpMethod, HttpHeaders headers) { + String stringToSign = buildStringToSign(requestURL, httpMethod, headers); + String signature = computeHmac256(stringToSign); + return "Session " + sessionToken + ":" + signature; + } + + public String getSessionToken() { + return sessionToken; + } + + public String getSessionKey() { + return sessionKey; + } + + public OffsetDateTime getExpiration() { + return expiration; + } + + public Boolean isExpired() { + return OffsetDateTime.now().isAfter(expiration); + } + + // ---- String-to-sign logic (Shared Key protocol) ---- + // Ported from StorageSharedKeyCredential.buildStringToSign(). The signing format is identical; + // the only difference is that account name is extracted from the URL rather than a constructor parameter. + + private String buildStringToSign(URL requestURL, String httpMethod, HttpHeaders headers) { + String contentLength = headers.getValue(HttpHeaderName.CONTENT_LENGTH); + contentLength = "0".equals(contentLength) ? "" : contentLength; + + String dateHeader + = (headers.getValue(X_MS_DATE) != null) ? "" : getStandardHeaderValue(headers, HttpHeaderName.DATE); + + Collator collator = Collator.getInstance(Locale.ROOT); + return String.join("\n", httpMethod, getStandardHeaderValue(headers, HttpHeaderName.CONTENT_ENCODING), + getStandardHeaderValue(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength, + getStandardHeaderValue(headers, HttpHeaderName.CONTENT_MD5), + getStandardHeaderValue(headers, HttpHeaderName.CONTENT_TYPE), dateHeader, + getStandardHeaderValue(headers, HttpHeaderName.IF_MODIFIED_SINCE), + getStandardHeaderValue(headers, HttpHeaderName.IF_MATCH), + getStandardHeaderValue(headers, HttpHeaderName.IF_NONE_MATCH), + getStandardHeaderValue(headers, HttpHeaderName.IF_UNMODIFIED_SINCE), + getStandardHeaderValue(headers, HttpHeaderName.RANGE), getAdditionalXmsHeaders(headers, collator), + getCanonicalizedResource(requestURL, collator)); + } + + private static String getStandardHeaderValue(HttpHeaders headers, HttpHeaderName headerName) { + final Header header = headers.get(headerName); + return header == null ? "" : header.getValue(); + } + + private static String getAdditionalXmsHeaders(HttpHeaders headers, Collator collator) { + List
xmsHeaders = new ArrayList<>(); + + int stringBuilderSize = 0; + for (HttpHeader header : headers) { + String headerName = header.getName(); + if (!"x-ms-".regionMatches(true, 0, headerName, 0, 5)) { + continue; + } + + String headerValue = header.getValue(); + stringBuilderSize += headerName.length() + headerValue.length(); + + xmsHeaders.add(header); + } + + if (xmsHeaders.isEmpty()) { + return ""; + } + + final StringBuilder canonicalizedHeaders = new StringBuilder(stringBuilderSize + (2 * xmsHeaders.size()) - 1); + + xmsHeaders.sort((o1, o2) -> collator.compare(o1.getName(), o2.getName())); + + for (Header xmsHeader : xmsHeaders) { + if (canonicalizedHeaders.length() > 0) { + canonicalizedHeaders.append('\n'); + } + canonicalizedHeaders.append(xmsHeader.getName().toLowerCase(Locale.ROOT)) + .append(':') + .append(xmsHeader.getValue()); + } + + return canonicalizedHeaders.toString(); + } + + private static String getCanonicalizedResource(URL requestURL, Collator collator) { + // Extract account name from hostname: "myaccount.blob.core.windows.net" -> "myaccount" + String host = requestURL.getHost(); + String accountName = host.contains(".") ? host.substring(0, host.indexOf('.')) : host; + + String absolutePath = requestURL.getPath(); + if (CoreUtils.isNullOrEmpty(absolutePath)) { + absolutePath = "/"; + } + + String query = requestURL.getQuery(); + if (CoreUtils.isNullOrEmpty(query)) { + return "/" + accountName + absolutePath; + } + + int stringBuilderSize = 1 + accountName.length() + absolutePath.length() + query.length(); + + TreeMap> pieces = new TreeMap<>(collator); + + StorageImplUtils.parseQueryParameters(query).forEachRemaining(kvp -> { + String key = urlDecode(kvp.getKey()).toLowerCase(Locale.ROOT); + + pieces.compute(key, (k, values) -> { + if (values == null) { + values = new ArrayList<>(); + } + + for (String value : kvp.getValue().split(",")) { + values.add(urlDecode(value)); + } + + return values; + }); + }); + + stringBuilderSize += pieces.size(); + + StringBuilder canonicalizedResource + = new StringBuilder(stringBuilderSize).append('/').append(accountName).append(absolutePath); + + for (Map.Entry> queryParam : pieces.entrySet()) { + List queryParamValues = queryParam.getValue(); + queryParamValues.sort(collator); + canonicalizedResource.append('\n').append(queryParam.getKey()).append(':'); + + int size = queryParamValues.size(); + for (int i = 0; i < size; i++) { + String queryParamValue = queryParamValues.get(i); + if (i > 0) { + canonicalizedResource.append(','); + } + + canonicalizedResource.append(queryParamValue); + } + } + + return canonicalizedResource.toString(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java new file mode 100644 index 000000000000..7196ebce8f1f --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import java.time.OffsetDateTime; + +/** + * Shared test constants and factories for session-based auth tests. + */ +final class SessionTestHelper { + + // A valid Base64-encoded 32-byte key for testing + static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + static final String TEST_CONTAINER_NAME = "testcontainer"; + + static StorageSessionCredential createCredential(OffsetDateTime expiration) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration); + } + + static StorageSessionCredential createValidCredential() { + return createCredential(OffsetDateTime.now().plusHours(1)); + } + + static StorageSessionCredential createExpiredCredential() { + return createCredential(OffsetDateTime.now().minusMinutes(5)); + } + + private SessionTestHelper() { + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java new file mode 100644 index 000000000000..3b8dd46e4731 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.storage.common.implementation.StorageImplUtils; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class StorageSessionCredentialTest { + + @Test + public void signRequestWithSessionKey() { + // Given a known session key and a known string-to-sign + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + + 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/mycontainer/myblob"; + + // When computing HMAC + String signature = credential.computeHmac256(stringToSign); + + // Then it matches the expected HMAC from StorageImplUtils + String expected = StorageImplUtils.computeHMac256(SessionTestHelper.TEST_SESSION_KEY, stringToSign); + assertEquals(expected, signature); + } + + @Test + public void generateAuthorizationHeaderFormat() throws MalformedURLException { + // Given a session credential + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + + // And a request URL and headers + URL url = new URL("https://myaccount.blob.core.windows.net/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"); + + // When generating the authorization header + String authHeader = credential.generateAuthorizationHeader(url, "GET", headers); + + // Then it uses the "Session" scheme with the token and a signature + assertTrue(authHeader.startsWith("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":"), + "Authorization header should start with 'Session :' but was: " + authHeader); + + // And the signature portion is a valid Base64 HMAC + String signaturePart = authHeader.substring(authHeader.indexOf(':') + 1); + assertTrue(signaturePart.length() > 0, "Signature should not be empty"); + } + + @Test + public void isExpiredReturnsTrueWhenPastExpiration() { + StorageSessionCredential credential = SessionTestHelper.createExpiredCredential(); + + assertTrue(credential.isExpired(), "Credential should be expired when expiration is in the past"); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + + assertFalse(credential.isExpired(), "Credential should not be expired when expiration is in the future"); + } +} From ed36a921b3f2f8672ffa0f152451db5030159496 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 3 Apr 2026 20:23:52 -0400 Subject: [PATCH 09/84] create BlobSessionClient so that BlobSessionProvider takes it as a dep instead of ContainersImpl --- .../util/BlobSessionClient.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java new file mode 100644 index 000000000000..39e95a41eb34 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -0,0 +1,62 @@ +// 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.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.implementation.AzureBlobStorageImpl; +import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; +import com.azure.storage.blob.implementation.models.SessionCredentials; +import reactor.core.publisher.Mono; + +/** + * Package-private client for creating sessions via the CreateSession REST API. + * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}: + * takes an {@link HttpPipeline} (bearer-only, no SessionPolicy) and builds an + * {@link AzureBlobStorageImpl} internally. + */ +final class BlobSessionClient { + + private final AzureBlobStorageImpl azureBlobStorage; + private final String containerName; + + BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String containerName) { + this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) + .url(url) + .version(serviceVersion.getVersion()) + .buildClient(); + this.containerName = containerName; + } + + Mono createSession() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + return azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, null, null) + .map(this::toCredential); + } + + StorageSessionCredential createSessionSync() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Response response = azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, Context.NONE); + return toCredential(response); + } + + private StorageSessionCredential toCredential(Response response) { + CreateSessionResponse session = response.getValue(); + SessionCredentials creds = session.getCredentials(); + return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), + session.getExpiration()); + } +} From 183216a788cf985888cd380fc801459225806bbe Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 13:21:15 -0400 Subject: [PATCH 10/84] create BlobSEssionClient with tests --- .../util/BlobSessionClient.java | 5 +- .../util/BlobSessionClientTests.java | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index 39e95a41eb34..cabc057676cb 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -35,7 +35,7 @@ final class BlobSessionClient { this.containerName = containerName; } - Mono createSession() { + Mono createSessionAsync() { CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); @@ -56,7 +56,6 @@ StorageSessionCredential createSessionSync() { private StorageSessionCredential toCredential(Response response) { CreateSessionResponse session = response.getValue(); SessionCredentials creds = session.getCredentials(); - return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), - session.getExpiration()); + return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration()); } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java new file mode 100644 index 000000000000..18b451e16fda --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -0,0 +1,69 @@ +// 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.BlobServiceClientBuilder; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.common.test.shared.StorageCommonTestUtils; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class BlobSessionClientTests extends BlobTestBase { + + @Test + public void createSessionSyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(1, policyInvocationCount.get()); + } + + @Test + public void createSessionAsyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + }).verifyComplete(); + + assertEquals(1, policyInvocationCount.get()); + } + + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) { + HttpPipelinePolicy policy = (context, next) -> { + policyInvocationCount.incrementAndGet(); + return next.process(); + }; + + BlobServiceClientBuilder builder + = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()) + .credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)) + .addPolicy(policy); + + instrument(builder); + return builder.buildClient().getHttpPipeline(); + } +} From 14d51e579a9d5aeea206c860935dbaa636e88d20 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 13:49:09 -0400 Subject: [PATCH 11/84] add recorings for BlobSessionClient --- sdk/storage/azure-storage-blob/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 9b406d7acb7b..aeb6290c16cf 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_63fe5a46f1" + "Tag": "java/storage/azure-storage-blob_19ba853d10" } From cee223e8d71fcae3dbdedcc9ef7166f511cdbfc3 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 13:53:59 -0400 Subject: [PATCH 12/84] fix BlobContainerAsyncClient to match new swagger, add new recording --- sdk/storage/azure-storage-blob/assets.json | 2 +- .../java/com/azure/storage/blob/BlobContainerAsyncClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index aeb6290c16cf..a360b303a8f3 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_19ba853d10" + "Tag": "java/storage/azure-storage-blob_d64dbd3c88" } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index c1ad39c451b7..ea37d58f1e0d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -1725,7 +1725,7 @@ Mono> createSessionWithResponse(Context context) CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); return this.azureBlobStorage.getContainers() - .createSessionWithResponseAsync(containerName, config, context) + .createSessionWithResponseAsync(containerName, config, null, null, context) .map(response -> new SimpleResponse<>(response, response.getValue())); } From 53edfce2ec5429e4c0402cb5cc300d6347cf68c9 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 15:01:49 -0400 Subject: [PATCH 13/84] add SessionProvider and SessionProviderTest --- .../implementation/util/SessionProvider.java | 98 +++++++++++++++ .../util/SessionProviderTest.java | 118 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java new file mode 100644 index 000000000000..3ba9694b8975 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import reactor.core.publisher.Mono; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Manages session lifecycle: create, cache, refresh, and concurrency control. + * Holds an immutable {@link StorageSessionCredential} in an {@link AtomicReference} + * for lock-free reads and atomic swaps on refresh. + * + *

Concurrency strategy:

+ *
    + *
  • Async: a single in-flight {@code Mono} is shared via {@code .cache()} so all + * concurrent subscribers piggyback on one CreateSession call.
  • + *
  • Sync: {@code synchronized} with double-check locking guards the creation path.
  • + *
+ */ +final class SessionProvider { + + private final BlobSessionClient sessionClient; + private final AtomicReference cached = new AtomicReference<>(); + + /** + * Guards access to {@link #inflightCreation}. Only held briefly to read/write the + * reference — never held while waiting for a network call. + */ + private final Object creationLock = new Object(); + private volatile Mono inflightCreation; + + SessionProvider(BlobSessionClient sessionClient) { + this.sessionClient = sessionClient; + } + + /** + * Returns the cached session credential, or creates a new session if none is cached + * or the cached one is expired. Concurrent callers share a single in-flight Mono + * so only one CreateSession call is made. + */ + Mono getOrCreateSessionAsync() { + StorageSessionCredential current = getValidCachedSession(); + if (current != null) { + return Mono.just(current); + } + + synchronized (creationLock) { + // Double-check after acquiring lock + current = getValidCachedSession(); + if (current != null) { + return Mono.just(current); + } + + // Return existing in-flight Mono if another caller already started creation + if (inflightCreation != null) { + return inflightCreation; + } + + // Create and cache a shared Mono — all concurrent subscribers get the same result + inflightCreation = sessionClient.createSessionAsync() + .doOnNext(cached::set) + .doFinally(signal -> inflightCreation = null) + .cache(); + + return inflightCreation; + } + } + + /** + * Sync equivalent of {@link #getOrCreateSessionAsync()}. + * Uses double-check locking so only the first thread makes the network call. + */ + StorageSessionCredential getOrCreateSessionSync() { + StorageSessionCredential current = getValidCachedSession(); + if (current != null) { + return current; + } + + synchronized (this) { + // Double-check after acquiring lock + current = getValidCachedSession(); + if (current != null) { + return current; + } + + StorageSessionCredential newCred = sessionClient.createSessionSync(); + cached.set(newCred); + return newCred; + } + } + + private StorageSessionCredential getValidCachedSession() { + StorageSessionCredential current = cached.get(); + return current != null && !current.isExpired() ? current : null; + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java new file mode 100644 index 000000000000..f6b8f839bf4b --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SessionProviderTest { + + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token-should-not-appear"; + + private BlobSessionClient sessionClient; + private SessionProvider provider; + + @BeforeEach + public void beforeEach() { + sessionClient = mock(BlobSessionClient.class); + provider = new SessionProvider(sessionClient); + } + + private static StorageSessionCredential credentialWithToken(String token) { + return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, + OffsetDateTime.now().plusHours(1)); + } + + // ---- Async tests ---- + + @Test + public void providerCreatesSessionOnFirstCall() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + + StorageSessionCredential credential = provider.getOrCreateSessionAsync().block(); + + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + assertEquals(SessionTestHelper.TEST_SESSION_KEY, credential.getSessionKey()); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void providerReturnsCachedSessionOnSubsequentCalls() { + // First call returns FIRST_TOKEN, any leaked second call would return SECOND_TOKEN + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + + // Fire 5 parallel async calls — all should share one CreateSession + List results + = Flux.range(0, 5).flatMap(i -> provider.getOrCreateSessionAsync()).collectList().block(); + + assertNotNull(results); + assertEquals(5, results.size()); + // All results must have FIRST_TOKEN — if any has SECOND_TOKEN, dedup is broken + results.forEach(cred -> assertEquals(FIRST_TOKEN, cred.getSessionToken())); + verify(sessionClient, times(1)).createSessionAsync(); + } + + // ---- Sync tests ---- + + @Test + public void providerCreatesSessionSyncOnFirstCall() { + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + + StorageSessionCredential credential = provider.getOrCreateSessionSync(); + + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + } + + @Test + public void concurrentSyncCallsOnlyCreateOneSession() throws Exception { + // First call returns FIRST_TOKEN (with delay), second would return SECOND_TOKEN + when(sessionClient.createSessionSync()).thenAnswer(invocation -> { + Thread.sleep(100); + return credentialWithToken(FIRST_TOKEN); + }).thenReturn(credentialWithToken(SECOND_TOKEN)); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) provider::getOrCreateSessionSync) + .collect(Collectors.toList()); + + List> futures = executor.invokeAll(tasks); + + for (Future future : futures) { + StorageSessionCredential credential = future.get(); + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + } + + verify(sessionClient, times(1)).createSessionSync(); + } finally { + executor.shutdownNow(); + } + } +} From ba27517893263f64c7b9cdfde1db0ca575ffbb87 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 17:22:42 -0400 Subject: [PATCH 14/84] add accountName to BlobSessionClient --- .../blob/implementation/util/BlobSessionClient.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index cabc057676cb..65d17dae090c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -7,6 +7,7 @@ import com.azure.core.http.rest.Response; import com.azure.core.util.Context; import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.implementation.AzureBlobStorageImpl; import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; import com.azure.storage.blob.implementation.models.AuthenticationType; @@ -24,14 +25,21 @@ final class BlobSessionClient { private final AzureBlobStorageImpl azureBlobStorage; + private final String accountName; private final String containerName; BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String containerName) { + this(bearerPipeline, url, serviceVersion, BlobUrlParts.parse(url).getAccountName(), containerName); + } + + BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, String containerName) { this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) .url(url) .version(serviceVersion.getVersion()) .buildClient(); + this.accountName = accountName; this.containerName = containerName; } @@ -56,6 +64,7 @@ StorageSessionCredential createSessionSync() { private StorageSessionCredential toCredential(Response response) { CreateSessionResponse session = response.getValue(); SessionCredentials creds = session.getCredentials(); - return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration()); + return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), + accountName); } } From 7b1e62fcf78593b0f3fc5dac070b2434d6098f8e Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 6 Apr 2026 17:24:57 -0400 Subject: [PATCH 15/84] add accountName to StorageSessionCredential and SesionTestHelper --- .../util/StorageSessionCredential.java | 25 ++++++++++--------- .../util/SessionTestHelper.java | 7 +++++- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index a5fb88af89ee..56fd2bb155a4 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -10,6 +10,8 @@ import com.azure.core.util.Header; import com.azure.storage.common.implementation.StorageImplUtils; +import java.util.Objects; + import java.net.URL; import java.text.Collator; import java.time.OffsetDateTime; @@ -32,18 +34,22 @@ public final class StorageSessionCredential { private final String sessionToken; private final String sessionKey; private final OffsetDateTime expiration; + private final String accountName; /** - * Creates a StorageSessionCredential with the given session token, key, and expiration. + * Creates a StorageSessionCredential with the given session token, key, expiration, and storage account name. * * @param sessionToken The opaque session token from Create Session response. * @param sessionKey The Base64-encoded symmetric key for HMAC signing. * @param expiration The time when this session expires. + * @param accountName The storage account name associated with the request. */ - public StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration) { - this.sessionToken = sessionToken; - this.sessionKey = sessionKey; - this.expiration = expiration; + public StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, + String accountName) { + this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); + this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); + this.expiration = Objects.requireNonNull(expiration, "'expiration' cannot be null."); + this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); } /** @@ -88,8 +94,7 @@ public Boolean isExpired() { } // ---- String-to-sign logic (Shared Key protocol) ---- - // Ported from StorageSharedKeyCredential.buildStringToSign(). The signing format is identical; - // the only difference is that account name is extracted from the URL rather than a constructor parameter. + // Ported from StorageSharedKeyCredential.buildStringToSign(). The signing format is identical. private String buildStringToSign(URL requestURL, String httpMethod, HttpHeaders headers) { String contentLength = headers.getValue(HttpHeaderName.CONTENT_LENGTH); @@ -152,11 +157,7 @@ private static String getAdditionalXmsHeaders(HttpHeaders headers, Collator coll return canonicalizedHeaders.toString(); } - private static String getCanonicalizedResource(URL requestURL, Collator collator) { - // Extract account name from hostname: "myaccount.blob.core.windows.net" -> "myaccount" - String host = requestURL.getHost(); - String accountName = host.contains(".") ? host.substring(0, host.indexOf('.')) : host; - + private String getCanonicalizedResource(URL requestURL, Collator collator) { String absolutePath = requestURL.getPath(); if (CoreUtils.isNullOrEmpty(absolutePath)) { absolutePath = "/"; diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java index 7196ebce8f1f..592fc5f22241 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java @@ -13,10 +13,15 @@ final class SessionTestHelper { // A valid Base64-encoded 32-byte key for testing static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + static final String TEST_ACCOUNT_NAME = "myaccount"; static final String TEST_CONTAINER_NAME = "testcontainer"; static StorageSessionCredential createCredential(OffsetDateTime expiration) { - return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration); + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, TEST_ACCOUNT_NAME); + } + + static StorageSessionCredential createCredential(OffsetDateTime expiration, String accountName) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, accountName); } static StorageSessionCredential createValidCredential() { From a5033bd9cc6d942c6a6167901e6b1ac6c37a75c5 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 7 Apr 2026 07:52:15 -0400 Subject: [PATCH 16/84] wip --- .../implementation/util/SessionProvider.java | 15 ++-- .../util/SessionProviderTest.java | 2 +- .../util/StorageSessionCredentialTest.java | 73 +++++++++++++++++++ .../StorageSharedKeyCredentialTests.java | 34 +++++++++ 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java index 3ba9694b8975..ff908672c151 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java @@ -58,11 +58,14 @@ Mono getOrCreateSessionAsync() { return inflightCreation; } - // Create and cache a shared Mono — all concurrent subscribers get the same result - inflightCreation = sessionClient.createSessionAsync() - .doOnNext(cached::set) - .doFinally(signal -> inflightCreation = null) - .cache(); + // Create and cache a shared Mono — all concurrent subscribers get the same result. + // Clear inflightCreation inside the lock to avoid a race where another thread + // grabs a reference that is about to be nulled by an async doFinally callback. + inflightCreation = sessionClient.createSessionAsync().doOnNext(cached::set).doFinally(ignored -> { + synchronized (creationLock) { + inflightCreation = null; + } + }).cache(); return inflightCreation; } @@ -78,7 +81,7 @@ StorageSessionCredential getOrCreateSessionSync() { return current; } - synchronized (this) { + synchronized (creationLock) { // Double-check after acquiring lock current = getValidCachedSession(); if (current != null) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java index f6b8f839bf4b..720d11804614 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java @@ -40,7 +40,7 @@ public void beforeEach() { private static StorageSessionCredential credentialWithToken(String token) { return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, - OffsetDateTime.now().plusHours(1)); + OffsetDateTime.now().plusHours(1), SessionTestHelper.TEST_ACCOUNT_NAME); } // ---- Async tests ---- diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java index 3b8dd46e4731..ea6d101e4e42 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -5,12 +5,15 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.StorageImplUtils; import org.junit.jupiter.api.Test; import java.net.MalformedURLException; import java.net.URL; import java.time.OffsetDateTime; +import java.util.Map; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -31,6 +34,7 @@ public void signRequestWithSessionKey() { // Then it matches the expected HMAC from StorageImplUtils String expected = StorageImplUtils.computeHMac256(SessionTestHelper.TEST_SESSION_KEY, stringToSign); + assertEquals(expected, signature); } @@ -58,6 +62,48 @@ public void generateAuthorizationHeaderFormat() throws MalformedURLException { assertTrue(signaturePart.length() > 0, "Signature should not be empty"); } + @Test + public void generateAuthorizationHeaderUsesIpStyleRequestUrl() throws MalformedURLException { + StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, OffsetDateTime.now().plusHours(1), SessionTestHelper.TEST_ACCOUNT_NAME); + + 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); + + // This matches the expected string-to-sign format for an IP-style URL, wherein the first + // accoun + 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); + + assertEquals("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":" + expectedSignature, authHeader); + } + + @Test + public void generateAuthorizationHeaderUsesExplicitAccountNameForCustomDomainUrl() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createCredential(OffsetDateTime.now().plusHours(1), + SessionTestHelper.TEST_ACCOUNT_NAME); + + URL url = new URL("https://cdn.contoso.com/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); + + 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/mycontainer/myblob"; + String expectedSignature = credential.computeHmac256(stringToSign); + + assertEquals("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":" + expectedSignature, authHeader); + } + @Test public void isExpiredReturnsTrueWhenPastExpiration() { StorageSessionCredential credential = SessionTestHelper.createExpiredCredential(); @@ -71,4 +117,31 @@ public void isExpiredReturnsFalseWhenBeforeExpiration() { assertFalse(credential.isExpired(), "Credential should not be expired when expiration is in the future"); } + + @Test + public void sessionAndSharedKeyProduceSameSignatureForIpStyleUrl() throws MalformedURLException { + String accountName = SessionTestHelper.TEST_ACCOUNT_NAME; + String accountKey = SessionTestHelper.TEST_SESSION_KEY; + + StorageSessionCredential sessionCred + = new StorageSessionCredential("ignored-token", accountKey, OffsetDateTime.now().plusHours(1), accountName); + StorageSharedKeyCredential sharedKeyCred = 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"); + + // Extract just the signature portion from each — the prefix differs (Session vs SharedKey) + String sessionAuth = sessionCred.generateAuthorizationHeader(url, "GET", headers); + String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); + + Map headerMap = headers.stream().collect(Collectors.toMap(h -> h.getName(), h -> h.getValue())); + String sharedKeyAuth = sharedKeyCred.generateAuthorizationHeader(url, "GET", headerMap); + String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); + + assertEquals(sharedKeySignature, sessionSignature, + "Session and SharedKey should produce identical HMAC signatures for IP-style URLs"); + } } diff --git a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java index dfe6de66b555..ab8f7aa109d0 100644 --- a/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java +++ b/sdk/storage/azure-storage-common/src/test/java/com/azure/storage/common/StorageSharedKeyCredentialTests.java @@ -3,15 +3,21 @@ package com.azure.storage.common; import com.azure.core.credential.AzureNamedKeyCredential; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; import com.azure.core.util.CoreUtils; import com.azure.storage.common.implementation.StorageImplUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import java.net.MalformedURLException; +import java.net.URL; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; public class StorageSharedKeyCredentialTests { @Test @@ -74,4 +80,32 @@ public void cannotParseInvalidConnectionString(String connectionString) { assertThrows(IllegalArgumentException.class, () -> StorageSharedKeyCredential.fromConnectionString(connectionString)); } + + @Test + public void ipStyleUrlCanonicalizedResourceIncludesAccountNameTwice() throws MalformedURLException { + // For IP-style URLs (e.g., Azurite), the account name appears in the URL path. + // The canonicalized resource prepends / to the absolute path, + // so the account name correctly appears twice: ///container/blob + String accountName = "myaccount"; + String accountKey = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + + URL url = new URL("http://127.0.0.1:10000/myaccount/mycontainer/myblob"); + HttpHeaders headers + = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT") + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.CONTENT_LENGTH, "0"); + + String authHeader = credential.generateAuthorizationHeader(url, "GET", headers, false); + + // Verify the signature matches a string-to-sign with account name appearing twice + String stringToSign = "GET\n\n\n\n\n\n\n\n\n\n\n\n" + "x-ms-date:Mon, 31 Mar 2025 00:00:00 GMT\n" + + "x-ms-version:2025-01-05\n" + "/myaccount/myaccount/mycontainer/myblob"; + String expectedSignature = credential.computeHmac256(stringToSign); + + assertTrue(authHeader.startsWith("SharedKey myaccount:"), + "Authorization header should start with 'SharedKey myaccount:' but was: " + authHeader); + assertEquals("SharedKey myaccount:" + expectedSignature, authHeader); + } } From 651fc0fe306c682ccceac6f6e6cccd44ebf1afac Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 9 Apr 2026 18:59:57 -0400 Subject: [PATCH 17/84] change sessionprovider to SEssionTokenCredentialPolicy --- .../implementation/util/SessionProvider.java | 101 -------- .../util/SessionTokenCredentialPolicy.java | 182 +++++++++++++ .../util/SessionProviderTest.java | 118 --------- .../SessionTokenCredentialPolicyTest.java | 243 ++++++++++++++++++ 4 files changed, 425 insertions(+), 219 deletions(-) delete mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java delete mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java deleted file mode 100644 index ff908672c151..000000000000 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionProvider.java +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.storage.blob.implementation.util; - -import reactor.core.publisher.Mono; - -import java.util.concurrent.atomic.AtomicReference; - -/** - * Manages session lifecycle: create, cache, refresh, and concurrency control. - * Holds an immutable {@link StorageSessionCredential} in an {@link AtomicReference} - * for lock-free reads and atomic swaps on refresh. - * - *

Concurrency strategy:

- *
    - *
  • Async: a single in-flight {@code Mono} is shared via {@code .cache()} so all - * concurrent subscribers piggyback on one CreateSession call.
  • - *
  • Sync: {@code synchronized} with double-check locking guards the creation path.
  • - *
- */ -final class SessionProvider { - - private final BlobSessionClient sessionClient; - private final AtomicReference cached = new AtomicReference<>(); - - /** - * Guards access to {@link #inflightCreation}. Only held briefly to read/write the - * reference — never held while waiting for a network call. - */ - private final Object creationLock = new Object(); - private volatile Mono inflightCreation; - - SessionProvider(BlobSessionClient sessionClient) { - this.sessionClient = sessionClient; - } - - /** - * Returns the cached session credential, or creates a new session if none is cached - * or the cached one is expired. Concurrent callers share a single in-flight Mono - * so only one CreateSession call is made. - */ - Mono getOrCreateSessionAsync() { - StorageSessionCredential current = getValidCachedSession(); - if (current != null) { - return Mono.just(current); - } - - synchronized (creationLock) { - // Double-check after acquiring lock - current = getValidCachedSession(); - if (current != null) { - return Mono.just(current); - } - - // Return existing in-flight Mono if another caller already started creation - if (inflightCreation != null) { - return inflightCreation; - } - - // Create and cache a shared Mono — all concurrent subscribers get the same result. - // Clear inflightCreation inside the lock to avoid a race where another thread - // grabs a reference that is about to be nulled by an async doFinally callback. - inflightCreation = sessionClient.createSessionAsync().doOnNext(cached::set).doFinally(ignored -> { - synchronized (creationLock) { - inflightCreation = null; - } - }).cache(); - - return inflightCreation; - } - } - - /** - * Sync equivalent of {@link #getOrCreateSessionAsync()}. - * Uses double-check locking so only the first thread makes the network call. - */ - StorageSessionCredential getOrCreateSessionSync() { - StorageSessionCredential current = getValidCachedSession(); - if (current != null) { - return current; - } - - synchronized (creationLock) { - // Double-check after acquiring lock - current = getValidCachedSession(); - if (current != null) { - return current; - } - - StorageSessionCredential newCred = sessionClient.createSessionSync(); - cached.set(newCred); - return newCred; - } - } - - private StorageSessionCredential getValidCachedSession() { - StorageSessionCredential current = cached.get(); - return current != null && !current.isExpired() ? current : null; - } -} 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..e9493b9d842f --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -0,0 +1,182 @@ +// 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.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.logging.ClientLogger; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. + */ +final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { + private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); + private static final Duration DEFAULT_REFRESH_OFFSET = Duration.ofMinutes(5); + private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; + + private final BlobSessionClient sessionClient; + private final Duration refreshOffset; + private final AtomicReference cached = new AtomicReference<>(); + private final Object creationLock = new Object(); + private volatile Mono inflightCreation; + + SessionTokenCredentialPolicy(BlobSessionClient sessionClient) { + this(sessionClient, DEFAULT_REFRESH_OFFSET); + } + + SessionTokenCredentialPolicy(BlobSessionClient sessionClient, Duration refreshOffset) { + this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + this.refreshOffset = Objects.requireNonNull(refreshOffset, "'refreshOffset' cannot be null."); + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + HttpPipelineNextPolicy retryNext = next.clone(); + return getValidSessionAsync().flatMap(session -> { + signRequest(context, session); + return next.process().flatMap(response -> { + if (shouldRefreshSession(context, response)) { + return Mono.just(response); + } + + response.close(); + invalidateSession(session); + context.setData(RETRY_CONTEXT_KEY, true); + return getValidSessionAsync().flatMap(refreshed -> { + signRequest(context, refreshed); + return retryNext.process(); + }); + }); + }); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + HttpPipelineNextSyncPolicy retryNext = next.clone(); + StorageSessionCredential session = getValidSessionSync(); + signRequest(context, session); + + HttpResponse response = next.processSync(); + if (shouldRefreshSession(context, response)) { + return response; + } + + response.close(); + invalidateSession(session); + context.setData(RETRY_CONTEXT_KEY, true); + + StorageSessionCredential refreshed = getValidSessionSync(); + signRequest(context, refreshed); + return retryNext.processSync(); + } + + Mono getValidSessionAsync() { + StorageSessionCredential current = cached.get(); + if (current != null && !current.isExpired()) { + if (shouldRefresh(current)) { + refreshSessionInBackground(); + } + return Mono.just(current); + } + + return startSessionCreationAsync(); + } + + StorageSessionCredential getValidSessionSync() { + StorageSessionCredential current = cached.get(); + if (current != null && !current.isExpired()) { + if (shouldRefresh(current)) { + refreshSessionInBackground(); + } + return current; + } + + synchronized (creationLock) { + current = cached.get(); + if (current != null && !current.isExpired()) { + if (shouldRefresh(current)) { + refreshSessionInBackground(); + } + return current; + } + + Mono inFlight = inflightCreation; + if (inFlight != null) { + StorageSessionCredential refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + + StorageSessionCredential created = sessionClient.createSessionSync(); + cached.set(created); + return created; + } + } + + void invalidateSession(StorageSessionCredential credential) { + synchronized (creationLock) { + cached.compareAndSet(credential, null); + inflightCreation = null; + } + } + + private void signRequest(HttpPipelineCallContext context, StorageSessionCredential credential) { + context.getHttpRequest() + .setHeader(HttpHeaderName.AUTHORIZATION, + credential.generateAuthorizationHeader(context.getHttpRequest().getUrl(), + context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); + } + + private boolean shouldRefresh(StorageSessionCredential credential) { + return !refreshOffset.isNegative() + && !refreshOffset.isZero() + && !OffsetDateTime.now().plus(refreshOffset).isBefore(credential.getExpiration()); + } + + private void refreshSessionInBackground() { + startSessionCreationAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + private Mono startSessionCreationAsync() { + synchronized (creationLock) { + StorageSessionCredential current = cached.get(); + if (current != null && !current.isExpired() && !shouldRefresh(current)) { + return Mono.just(current); + } + + if (inflightCreation != null) { + return inflightCreation; + } + + inflightCreation = sessionClient.createSessionAsync().doOnNext(cached::set).doFinally(ignored -> { + synchronized (creationLock) { + inflightCreation = null; + } + }).cache(); + + return inflightCreation; + } + } + + private boolean shouldRefreshSession(HttpPipelineCallContext context, HttpResponse response) { + if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { + return true; + } + + int statusCode = response.getStatusCode(); + return statusCode != 401 && statusCode != 403; + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java deleted file mode 100644 index 720d11804614..000000000000 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTest.java +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.storage.blob.implementation.util; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import java.time.OffsetDateTime; -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; -import java.util.stream.Collectors; -import java.util.stream.IntStream; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -public class SessionProviderTest { - - private static final String FIRST_TOKEN = "first-session-token"; - private static final String SECOND_TOKEN = "second-session-token-should-not-appear"; - - private BlobSessionClient sessionClient; - private SessionProvider provider; - - @BeforeEach - public void beforeEach() { - sessionClient = mock(BlobSessionClient.class); - provider = new SessionProvider(sessionClient); - } - - private static StorageSessionCredential credentialWithToken(String token) { - return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, - OffsetDateTime.now().plusHours(1), SessionTestHelper.TEST_ACCOUNT_NAME); - } - - // ---- Async tests ---- - - @Test - public void providerCreatesSessionOnFirstCall() { - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); - - StorageSessionCredential credential = provider.getOrCreateSessionAsync().block(); - - assertNotNull(credential); - assertEquals(FIRST_TOKEN, credential.getSessionToken()); - assertEquals(SessionTestHelper.TEST_SESSION_KEY, credential.getSessionKey()); - verify(sessionClient, times(1)).createSessionAsync(); - } - - @Test - public void providerReturnsCachedSessionOnSubsequentCalls() { - // First call returns FIRST_TOKEN, any leaked second call would return SECOND_TOKEN - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) - .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); - - // Fire 5 parallel async calls — all should share one CreateSession - List results - = Flux.range(0, 5).flatMap(i -> provider.getOrCreateSessionAsync()).collectList().block(); - - assertNotNull(results); - assertEquals(5, results.size()); - // All results must have FIRST_TOKEN — if any has SECOND_TOKEN, dedup is broken - results.forEach(cred -> assertEquals(FIRST_TOKEN, cred.getSessionToken())); - verify(sessionClient, times(1)).createSessionAsync(); - } - - // ---- Sync tests ---- - - @Test - public void providerCreatesSessionSyncOnFirstCall() { - when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); - - StorageSessionCredential credential = provider.getOrCreateSessionSync(); - - assertNotNull(credential); - assertEquals(FIRST_TOKEN, credential.getSessionToken()); - verify(sessionClient, times(1)).createSessionSync(); - } - - @Test - public void concurrentSyncCallsOnlyCreateOneSession() throws Exception { - // First call returns FIRST_TOKEN (with delay), second would return SECOND_TOKEN - when(sessionClient.createSessionSync()).thenAnswer(invocation -> { - Thread.sleep(100); - return credentialWithToken(FIRST_TOKEN); - }).thenReturn(credentialWithToken(SECOND_TOKEN)); - - int threadCount = 5; - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - try { - List> tasks = IntStream.range(0, threadCount) - .mapToObj(i -> (Callable) provider::getOrCreateSessionSync) - .collect(Collectors.toList()); - - List> futures = executor.invokeAll(tasks); - - for (Future future : futures) { - StorageSessionCredential credential = future.get(); - assertNotNull(credential); - assertEquals(FIRST_TOKEN, credential.getSessionToken()); - } - - verify(sessionClient, times(1)).createSessionSync(); - } finally { - executor.shutdownNow(); - } - } -} 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..3e921010a4db --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SessionTokenCredentialPolicyTest { + + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token"; + + private BlobSessionClient sessionClient; + private SessionTokenCredentialPolicy policy; + + @BeforeEach + public void beforeEach() { + sessionClient = mock(BlobSessionClient.class); + policy = new SessionTokenCredentialPolicy(sessionClient); + } + + @Test + public void policyCreatesSessionOnFirstAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + + StorageSessionCredential credential = policy.getValidSessionAsync().block(); + + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + + List results + = Flux.range(0, 5).flatMap(ignored -> policy.getValidSessionAsync()).collectList().block(); + + assertNotNull(results); + assertEquals(5, results.size()); + results.forEach(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { + policy = new SessionTokenCredentialPolicy(sessionClient, Duration.ofMinutes(5)); + StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusMinutes(1)); + StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); + + when(sessionClient.createSessionSync()).thenReturn(nearExpiry); + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed)); + + StorageSessionCredential initial = policy.getValidSessionSync(); + StorageSessionCredential duringRefresh = policy.getValidSessionSync(); + StorageSessionCredential afterRefresh = policy.getValidSessionSync(); + + assertEquals(FIRST_TOKEN, initial.getSessionToken()); + assertEquals(FIRST_TOKEN, duringRefresh.getSessionToken()); + assertEquals(SECOND_TOKEN, afterRefresh.getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { + when(sessionClient.createSessionSync()).thenAnswer(invocation -> { + Thread.sleep(100); + return credentialWithToken(FIRST_TOKEN); + }).thenReturn(credentialWithToken(SECOND_TOKEN)); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) policy::getValidSessionSync) + .collect(Collectors.toList()); + + List> futures = executor.invokeAll(tasks); + for (Future future : futures) { + assertEquals(FIRST_TOKEN, future.get().getSessionToken()); + } + + verify(sessionClient, times(1)).createSessionSync(); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void policySignsRequestWithSessionCredential() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = policy.process(context, next).block(); + + assertEquals(response, actualResponse); + assertTrue(context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + verify(next, times(1)).process(); + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceAsync() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(403); + when(retriedResponse.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = policy.process(context, next).block(); + + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).process(); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceSync() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) + .thenReturn(credentialWithToken(SECOND_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(initialResponse); + when(retryNext.processSync()).thenReturn(retriedResponse); + when(initialResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = policy.processSync(context, next); + + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + } + + @Test + public void policyOnlyRetriesOncePerRequest() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(403); + when(retriedResponse.getStatusCode()).thenReturn(403); + + HttpResponse actualResponse = policy.process(context, next).block(); + + assertEquals(retriedResponse, actualResponse); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + + private static StorageSessionCredential credentialWithToken(String token) { + return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); + } + + private static StorageSessionCredential credentialWithToken(String token, OffsetDateTime expiration) { + return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration, + SessionTestHelper.TEST_ACCOUNT_NAME); + } + + private static HttpPipelineCallContext createContext(String url) { + HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); + HttpRequest request = new HttpRequest(HttpMethod.GET, url); + 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; + } +} From 51735271d9007a6084b8bfcc47b9d32f4ff67777 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 14 Apr 2026 12:29:13 -0400 Subject: [PATCH 18/84] wip --- .../util/SessionTokenCredentialPolicy.java | 157 ++++++++++++------ .../SessionTokenCredentialPolicyTest.java | 19 ++- 2 files changed, 123 insertions(+), 53 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index e9493b9d842f..687133f4d15e 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -15,29 +15,31 @@ import java.time.Duration; import java.time.OffsetDateTime; import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.ThreadLocalRandom; /** * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. */ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); - private static final Duration DEFAULT_REFRESH_OFFSET = Duration.ofMinutes(5); private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); + private static final String SESSION_SCHEME = "Session"; + private static final String SESSION_EXPIRING = "session_expiring"; + private static final String SESSION_EXPIRED = "session_expired"; + private static final String SESSION_TOKEN_INVALID = "session_token_invalid"; private final BlobSessionClient sessionClient; - private final Duration refreshOffset; - private final AtomicReference cached = new AtomicReference<>(); private final Object creationLock = new Object(); + private volatile StorageSessionCredential credential; + private volatile OffsetDateTime nextRefreshTime; + private volatile boolean refreshing; private volatile Mono inflightCreation; SessionTokenCredentialPolicy(BlobSessionClient sessionClient) { - this(sessionClient, DEFAULT_REFRESH_OFFSET); - } - - SessionTokenCredentialPolicy(BlobSessionClient sessionClient, Duration refreshOffset) { this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); - this.refreshOffset = Objects.requireNonNull(refreshOffset, "'refreshOffset' cannot be null."); } @Override @@ -46,7 +48,9 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN return getValidSessionAsync().flatMap(session -> { signRequest(context, session); return next.process().flatMap(response -> { - if (shouldRefreshSession(context, response)) { + handleSessionExpiringHeader(response); + + if (shouldReturnResponse(context, response)) { return Mono.just(response); } @@ -68,7 +72,8 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex signRequest(context, session); HttpResponse response = next.processSync(); - if (shouldRefreshSession(context, response)) { + handleSessionExpiringHeader(response); + if (shouldReturnResponse(context, response)) { return response; } @@ -82,9 +87,10 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex } Mono getValidSessionAsync() { - StorageSessionCredential current = cached.get(); - if (current != null && !current.isExpired()) { - if (shouldRefresh(current)) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { refreshSessionInBackground(); } return Mono.just(current); @@ -94,66 +100,75 @@ Mono getValidSessionAsync() { } StorageSessionCredential getValidSessionSync() { - StorageSessionCredential current = cached.get(); - if (current != null && !current.isExpired()) { - if (shouldRefresh(current)) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { refreshSessionInBackground(); } return current; } + // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. + Mono inFlight = inflightCreation; + if (inFlight != null) { + StorageSessionCredential refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + synchronized (creationLock) { - current = cached.get(); - if (current != null && !current.isExpired()) { - if (shouldRefresh(current)) { + current = credential; + now = OffsetDateTime.now(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { refreshSessionInBackground(); } return current; } - Mono inFlight = inflightCreation; - if (inFlight != null) { - StorageSessionCredential refreshed = inFlight.block(); - if (refreshed != null) { - return refreshed; - } - } - StorageSessionCredential created = sessionClient.createSessionSync(); - cached.set(created); + setActiveCredential(created); return created; } } - void invalidateSession(StorageSessionCredential credential) { + void invalidateSession(StorageSessionCredential target) { synchronized (creationLock) { - cached.compareAndSet(credential, null); + if (credential == target) { + credential = null; + nextRefreshTime = null; + refreshing = false; + } inflightCreation = null; } } - private void signRequest(HttpPipelineCallContext context, StorageSessionCredential credential) { + private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { context.getHttpRequest() - .setHeader(HttpHeaderName.AUTHORIZATION, - credential.generateAuthorizationHeader(context.getHttpRequest().getUrl(), - context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); - } - - private boolean shouldRefresh(StorageSessionCredential credential) { - return !refreshOffset.isNegative() - && !refreshOffset.isZero() - && !OffsetDateTime.now().plus(refreshOffset).isBefore(credential.getExpiration()); + .setHeader(HttpHeaderName.AUTHORIZATION, cred.generateAuthorizationHeader(context.getHttpRequest().getUrl(), + context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); } private void refreshSessionInBackground() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { + return; + } + refreshing = true; + } + startSessionCreationAsync().subscribe(ignored -> { }, error -> LOGGER.warning("Background session refresh failed.", error)); } private Mono startSessionCreationAsync() { synchronized (creationLock) { - StorageSessionCredential current = cached.get(); - if (current != null && !current.isExpired() && !shouldRefresh(current)) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now) && !isRefreshDue(now)) { return Mono.just(current); } @@ -161,7 +176,13 @@ private Mono startSessionCreationAsync() { return inflightCreation; } - inflightCreation = sessionClient.createSessionAsync().doOnNext(cached::set).doFinally(ignored -> { + refreshing = true; + + inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { + synchronized (creationLock) { + setActiveCredential(cred); + } + }).doFinally(ignored -> { synchronized (creationLock) { inflightCreation = null; } @@ -171,12 +192,54 @@ private Mono startSessionCreationAsync() { } } - private boolean shouldRefreshSession(HttpPipelineCallContext context, HttpResponse response) { + private void setActiveCredential(StorageSessionCredential newCredential) { + credential = newCredential; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); + refreshing = false; + } + + private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { + return cred != null && !now.isAfter(cred.getExpiration()); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { + long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); + if (availableMillis <= 0) { + return now; + } + + double refreshPoint + = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); + return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); + } + + private void handleSessionExpiringHeader(HttpResponse response) { + String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); + if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { + refreshSessionInBackground(); + } + } + + private static boolean isSessionAuthFailure(HttpResponse response) { + if (response.getStatusCode() != 401) { + return false; + } + String wwwAuth = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE); + return wwwAuth != null + && wwwAuth.startsWith(SESSION_SCHEME) + && (wwwAuth.contains(SESSION_EXPIRED) || wwwAuth.contains(SESSION_TOKEN_INVALID)); + } + + private boolean shouldReturnResponse(HttpPipelineCallContext context, HttpResponse response) { if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { return true; } - int statusCode = response.getStatusCode(); - return statusCode != 401 && statusCode != 403; + return !isSessionAuthFailure(response); } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 3e921010a4db..3022b9d80e85 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -3,6 +3,7 @@ package com.azure.storage.blob.implementation.util; +import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipelineCallContext; import com.azure.core.http.HttpPipelineNextPolicy; @@ -14,7 +15,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import java.time.Duration; import java.time.OffsetDateTime; import java.util.List; import java.util.Map; @@ -78,8 +78,7 @@ public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { @Test public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { - policy = new SessionTokenCredentialPolicy(sessionClient, Duration.ofMinutes(5)); - StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusMinutes(1)); + StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2)); StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); when(sessionClient.createSessionSync()).thenReturn(nearExpiry); @@ -153,7 +152,9 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(initialResponse)); when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); - when(initialResponse.getStatusCode()).thenReturn(403); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); when(retriedResponse.getStatusCode()).thenReturn(200); HttpResponse actualResponse = policy.process(context, next).block(); @@ -181,6 +182,8 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { when(next.processSync()).thenReturn(initialResponse); when(retryNext.processSync()).thenReturn(retriedResponse); when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_token_invalid"); when(retriedResponse.getStatusCode()).thenReturn(200); HttpResponse actualResponse = policy.processSync(context, next); @@ -206,8 +209,12 @@ public void policyOnlyRetriesOncePerRequest() { when(next.clone()).thenReturn(retryNext); when(next.process()).thenReturn(Mono.just(initialResponse)); when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); - when(initialResponse.getStatusCode()).thenReturn(403); - when(retriedResponse.getStatusCode()).thenReturn(403); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); HttpResponse actualResponse = policy.process(context, next).block(); From 2ae4c4dcd34a7a21d050e0c447005971ba1c92c4 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 14 Apr 2026 15:46:48 -0400 Subject: [PATCH 19/84] move session tests from containerapi to blobsessionclienttests --- .../storage/blob/BlobContainerClient.java | 30 ----------------- .../azure/storage/blob/ContainerApiTests.java | 18 ----------- .../storage/blob/ContainerAsyncApiTests.java | 18 ----------- .../util/BlobSessionClientTests.java | 32 +++++++++++++++++++ 4 files changed, 32 insertions(+), 66 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 2331530ad3cd..341bda19360d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -1511,34 +1511,4 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV return new BlobSasImplUtil(blobServiceSasSignatureValues, getBlobContainerName()) .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } - - /** - * Creates a session scoped to this container. The session provides temporary credentials (a session token and - * session key) that can be used to sign subsequent requests using the Shared Key protocol. - * - * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. - * @param context Additional context that is passed through the Http pipeline during the service call. - * @return A response containing the {@link CreateSessionResponse} with session credentials. - */ - @ServiceMethod(returns = ReturnType.SINGLE) - public Response createSessionWithResponse(Duration timeout, Context context) { - Context finalContext = context == null ? Context.NONE : context; - CreateSessionConfiguration config - = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); - Callable> operation = () -> this.azureBlobStorage.getContainers() - .createSessionWithResponse(containerName, config, null, null, finalContext); - return sendRequest(operation, timeout, BlobStorageException.class); - } - - /** - * Creates a session scoped to this container. The session provides temporary credentials (a session token and - * session key) that can be used to sign subsequent requests using the Shared Key protocol. - * - * @return The {@link CreateSessionResponse} with session credentials. - */ - @ServiceMethod(returns = ReturnType.SINGLE) - public CreateSessionResponse createSession() { - return createSessionWithResponse(null, Context.NONE).getValue(); - } - } 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 4604b89297a8..f46116acdbb5 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 @@ -10,8 +10,6 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; -import com.azure.storage.blob.implementation.models.AuthenticationType; -import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -2130,20 +2128,4 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } - - @Test - // @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06") - public void createSessionReturnsTokenAndKey() { - BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); - Response response = oauthCc.createSessionWithResponse(null, null); - - assertResponseStatusCode(response, 201); - CreateSessionResponse session = response.getValue(); - assertNotNull(session.getId()); - assertNotNull(session.getExpiration()); - assertNotNull(session.getCredentials()); - assertNotNull(session.getCredentials().getSessionToken()); - assertNotNull(session.getCredentials().getSessionKey()); - assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); - } } 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 eca3a1be27a9..e7beb220f7f1 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 @@ -2144,22 +2144,4 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } - - @Test - // @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2026-06-06") - public void createSessionReturnsTokenAndKey() { - BlobContainerAsyncClient oauthCcAsync - = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); - - StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> { - assertEquals(201, response.getStatusCode()); - CreateSessionResponse session = response.getValue(); - assertNotNull(session.getId()); - assertNotNull(session.getExpiration()); - assertNotNull(session.getCredentials()); - assertNotNull(session.getCredentials().getSessionToken()); - assertNotNull(session.getCredentials().getSessionKey()); - assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); - }).verifyComplete(); - } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 18b451e16fda..281298230cee 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -5,6 +5,7 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.BlobServiceClientBuilder; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobTestBase; @@ -19,6 +20,36 @@ public class BlobSessionClientTests extends BlobTestBase { + @Test + public void createSessionReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), + oauthCc.getBlobContainerUrl(), BlobServiceVersion.getLatest(), cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + } + + @Test + public void createSessionAsyncReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), + oauthCc.getBlobContainerUrl(), BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + }).verifyComplete(); + } + @Test public void createSessionSyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); @@ -47,6 +78,7 @@ public void createSessionAsyncUsesProvidedHttpPipeline() { assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); assertNotNull(credential.getExpiration()); + // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); }).verifyComplete(); assertEquals(1, policyInvocationCount.get()); From dc0609e3859993c35045b1bccfa9616632dcf3b2 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 14 Apr 2026 17:02:22 -0400 Subject: [PATCH 20/84] fix blobsessiontests and add place holder for end-to-end tests in containerapitests --- .../blob/implementation/util/BlobSessionClient.java | 5 +++-- .../com/azure/storage/blob/ContainerApiTests.java | 2 ++ .../azure/storage/blob/ContainerAsyncApiTests.java | 4 ++++ .../implementation/util/BlobSessionClientTests.java | 11 +++++++---- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index 65d17dae090c..a4bf11528cc4 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -28,9 +28,10 @@ final class BlobSessionClient { private final String accountName; private final String containerName; - BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + BlobSessionClient(HttpPipeline bearerPipeline, String serviceEndpoint, BlobServiceVersion serviceVersion, String containerName) { - this(bearerPipeline, url, serviceVersion, BlobUrlParts.parse(url).getAccountName(), containerName); + this(bearerPipeline, serviceEndpoint, serviceVersion, BlobUrlParts.parse(serviceEndpoint).getAccountName(), + containerName); } BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index f46116acdbb5..f61aa01179aa 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 @@ -2128,4 +2128,6 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } + + // Need to create a container client test here to test that sessions have been enabled and used } 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 e7beb220f7f1..48fb8e9826e0 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 @@ -2144,4 +2144,8 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } + + + // Need to create a container client test here to test that sessions have been enabled and used + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 281298230cee..77873071d406 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -23,8 +23,9 @@ public class BlobSessionClientTests extends BlobTestBase { @Test public void createSessionReturnsTokenAndKey() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); - BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), - oauthCc.getBlobContainerUrl(), BlobServiceVersion.getLatest(), cc.getBlobContainerName()); + BlobSessionClient sessionClient + = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), cc.getBlobContainerName()); StorageSessionCredential credential = sessionClient.createSessionSync(); @@ -32,14 +33,16 @@ public void createSessionReturnsTokenAndKey() { assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); assertNotNull(credential.getExpiration()); + // assertNotNull(credential.) assertEquals(false, credential.isExpired()); } @Test public void createSessionAsyncReturnsTokenAndKey() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); - BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), - oauthCc.getBlobContainerUrl(), BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient + = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { assertNotNull(credential); From d2f1e0d8b0f596e66525dc892a1b6baa873e7c41 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 15 Apr 2026 13:07:34 -0400 Subject: [PATCH 21/84] add recordings for blobsessionclient --- sdk/storage/azure-storage-blob/assets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index a360b303a8f3..15c3910a9d57 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_d64dbd3c88" + "Tag": "java/storage/azure-storage-blob_185d12486c" } From 70b055258616000caacc8c10ad96e82d4af34161 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 15 Apr 2026 13:09:07 -0400 Subject: [PATCH 22/84] linting --- .../test/java/com/azure/storage/blob/ContainerAsyncApiTests.java | 1 - 1 file changed, 1 deletion(-) 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 48fb8e9826e0..0e685b943447 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 @@ -2145,7 +2145,6 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } - // Need to create a container client test here to test that sessions have been enabled and used } From cf7f3d3c7b21660ea36a1f058d18732ff7d4b4e0 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 15 Apr 2026 13:10:01 -0400 Subject: [PATCH 23/84] refactor cache into separate class so it follows BearerTokenAuthenticationPolicy + AccessTokenCache pattern --- .../util/SessionTokenCredentialPolicy.java | 210 +++++------------- .../util/StorageSessionCredentialCache.java | 158 +++++++++++++ .../SessionTokenCredentialPolicyTest.java | 51 ++++- 3 files changed, 265 insertions(+), 154 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 687133f4d15e..51c2ea201d64 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -9,37 +9,31 @@ import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; -import com.azure.core.util.logging.ClientLogger; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.time.OffsetDateTime; import java.util.Objects; -import java.util.concurrent.ThreadLocalRandom; /** * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. */ 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 Duration SAFETY_BUFFER = Duration.ofSeconds(5); - private static final double JITTER_WINDOW_START_RATIO = 0.8d; private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); private static final String SESSION_SCHEME = "Session"; private static final String SESSION_EXPIRING = "session_expiring"; private static final String SESSION_EXPIRED = "session_expired"; private static final String SESSION_TOKEN_INVALID = "session_token_invalid"; - private final BlobSessionClient sessionClient; - private final Object creationLock = new Object(); - private volatile StorageSessionCredential credential; - private volatile OffsetDateTime nextRefreshTime; - private volatile boolean refreshing; - private volatile Mono inflightCreation; + private final StorageSessionCredentialCache sessionCredentialCache; SessionTokenCredentialPolicy(BlobSessionClient sessionClient) { - this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + this(new StorageSessionCredentialCache( + Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."))); + } + + SessionTokenCredentialPolicy(StorageSessionCredentialCache sessionCredentialCache) { + this.sessionCredentialCache + = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); } @Override @@ -50,17 +44,20 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN return next.process().flatMap(response -> { handleSessionExpiringHeader(response); - if (shouldReturnResponse(context, response)) { - return Mono.just(response); + if (isSessionAuthResponse(response)) { + invalidateSession(session); + } + + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + return getValidSessionAsync().flatMap(refreshed -> { + signRequest(context, refreshed); + return retryNext.process(); + }); } - response.close(); - invalidateSession(session); - context.setData(RETRY_CONTEXT_KEY, true); - return getValidSessionAsync().flatMap(refreshed -> { - signRequest(context, refreshed); - return retryNext.process(); - }); + return Mono.just(response); }); }); } @@ -73,76 +70,33 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex HttpResponse response = next.processSync(); handleSessionExpiringHeader(response); - if (shouldReturnResponse(context, response)) { - return response; + + if (isSessionAuthResponse(response)) { + invalidateSession(session); } - response.close(); - invalidateSession(session); - context.setData(RETRY_CONTEXT_KEY, true); + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + + StorageSessionCredential refreshed = getValidSessionSync(); + signRequest(context, refreshed); + return retryNext.processSync(); + } - StorageSessionCredential refreshed = getValidSessionSync(); - signRequest(context, refreshed); - return retryNext.processSync(); + return response; } Mono getValidSessionAsync() { - OffsetDateTime now = OffsetDateTime.now(); - StorageSessionCredential current = credential; - if (isUsable(current, now)) { - if (isRefreshDue(now)) { - refreshSessionInBackground(); - } - return Mono.just(current); - } - - return startSessionCreationAsync(); + return sessionCredentialCache.getValidSessionAsync(); } StorageSessionCredential getValidSessionSync() { - OffsetDateTime now = OffsetDateTime.now(); - StorageSessionCredential current = credential; - if (isUsable(current, now)) { - if (isRefreshDue(now)) { - refreshSessionInBackground(); - } - return current; - } - - // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. - Mono inFlight = inflightCreation; - if (inFlight != null) { - StorageSessionCredential refreshed = inFlight.block(); - if (refreshed != null) { - return refreshed; - } - } - - synchronized (creationLock) { - current = credential; - now = OffsetDateTime.now(); - if (isUsable(current, now)) { - if (isRefreshDue(now)) { - refreshSessionInBackground(); - } - return current; - } - - StorageSessionCredential created = sessionClient.createSessionSync(); - setActiveCredential(created); - return created; - } + return sessionCredentialCache.getValidSessionSync(); } void invalidateSession(StorageSessionCredential target) { - synchronized (creationLock) { - if (credential == target) { - credential = null; - nextRefreshTime = null; - refreshing = false; - } - inflightCreation = null; - } + sessionCredentialCache.invalidateSession(target); } private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { @@ -151,81 +105,18 @@ private void signRequest(HttpPipelineCallContext context, StorageSessionCredenti context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); } - private void refreshSessionInBackground() { - synchronized (creationLock) { - OffsetDateTime now = OffsetDateTime.now(); - if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { - return; - } - refreshing = true; - } - - startSessionCreationAsync().subscribe(ignored -> { - }, error -> LOGGER.warning("Background session refresh failed.", error)); - } - - private Mono startSessionCreationAsync() { - synchronized (creationLock) { - OffsetDateTime now = OffsetDateTime.now(); - StorageSessionCredential current = credential; - if (isUsable(current, now) && !isRefreshDue(now)) { - return Mono.just(current); - } - - if (inflightCreation != null) { - return inflightCreation; - } - - refreshing = true; - - inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { - synchronized (creationLock) { - setActiveCredential(cred); - } - }).doFinally(ignored -> { - synchronized (creationLock) { - inflightCreation = null; - } - }).cache(); - - return inflightCreation; - } - } - - private void setActiveCredential(StorageSessionCredential newCredential) { - credential = newCredential; - nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); - refreshing = false; - } - - private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { - return cred != null && !now.isAfter(cred.getExpiration()); - } - - private boolean isRefreshDue(OffsetDateTime now) { - OffsetDateTime refresh = nextRefreshTime; - return refresh != null && !now.isBefore(refresh); - } - - private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { - long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); - if (availableMillis <= 0) { - return now; - } - - double refreshPoint - = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); - return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); - } - private void handleSessionExpiringHeader(HttpResponse response) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { - refreshSessionInBackground(); + sessionCredentialCache.refreshSessionInBackground(); } } - private static boolean isSessionAuthFailure(HttpResponse response) { + /** + * Returns true for any 401 that the session service issued (expired or invalid token). + * Used to decide whether to invalidate the cached session. + */ + private static boolean isSessionAuthResponse(HttpResponse response) { if (response.getStatusCode() != 401) { return false; } @@ -235,11 +126,24 @@ private static boolean isSessionAuthFailure(HttpResponse response) { && (wwwAuth.contains(SESSION_EXPIRED) || wwwAuth.contains(SESSION_TOKEN_INVALID)); } - private boolean shouldReturnResponse(HttpPipelineCallContext context, HttpResponse response) { + /** + * Returns true only for 401 session_expired — the only error that warrants an automatic retry + * with a refreshed session. session_token_invalid is not retryable because the token itself is + * bad (not just expired), so a new session is needed but the current request should fail. + */ + private static boolean isRetryableSessionFailure(HttpResponse response) { + if (response.getStatusCode() != 401) { + return false; + } + String wwwAuth = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE); + return wwwAuth != null && wwwAuth.startsWith(SESSION_SCHEME) && wwwAuth.contains(SESSION_EXPIRED); + } + + private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpResponse response) { if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { - return true; + return false; } - return !isSessionAuthFailure(response); + return isRetryableSessionFailure(response); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java new file mode 100644 index 000000000000..17f316ac69ec --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Cache for container-scoped storage session credentials. + */ +final class StorageSessionCredentialCache { + private static final ClientLogger LOGGER = new ClientLogger(StorageSessionCredentialCache.class); + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + + private final BlobSessionClient sessionClient; + private final Object creationLock = new Object(); + private volatile StorageSessionCredential credential; + private volatile OffsetDateTime nextRefreshTime; + private volatile boolean refreshing; + private volatile Mono inflightCreation; + + StorageSessionCredentialCache(BlobSessionClient sessionClient) { + this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + } + + Mono getValidSessionAsync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return Mono.just(current); + } + + return startSessionCreationAsync(); + } + + StorageSessionCredential getValidSessionSync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. + Mono inFlight = inflightCreation; + if (inFlight != null) { + StorageSessionCredential refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + + synchronized (creationLock) { + current = credential; + now = OffsetDateTime.now(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + StorageSessionCredential created = sessionClient.createSessionSync(); + setActiveCredential(created); + return created; + } + } + + void invalidateSession(StorageSessionCredential target) { + synchronized (creationLock) { + if (credential == target) { + credential = null; + nextRefreshTime = null; + refreshing = false; + } + inflightCreation = null; + } + } + + void refreshSessionInBackground() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { + return; + } + refreshing = true; + } + + startSessionCreationAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + private Mono startSessionCreationAsync() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now) && !isRefreshDue(now)) { + return Mono.just(current); + } + + if (inflightCreation != null) { + return inflightCreation; + } + + refreshing = true; + + inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { + synchronized (creationLock) { + setActiveCredential(cred); + } + }).doFinally(ignored -> { + synchronized (creationLock) { + inflightCreation = null; + } + }).cache(); + + return inflightCreation; + } + } + + private void setActiveCredential(StorageSessionCredential newCredential) { + credential = newCredential; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); + refreshing = false; + } + + private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { + return cred != null && !now.isAfter(cred.getExpiration()); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { + long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); + if (availableMillis <= 0) { + return now; + } + + double refreshPoint + = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); + return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 3022b9d80e85..699ed3c1b166 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -183,7 +183,7 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { when(retryNext.processSync()).thenReturn(retriedResponse); when(initialResponse.getStatusCode()).thenReturn(401); when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) - .thenReturn("Session error=session_token_invalid"); + .thenReturn("Session error=session_expired"); when(retriedResponse.getStatusCode()).thenReturn(200); HttpResponse actualResponse = policy.processSync(context, next); @@ -223,6 +223,55 @@ public void policyOnlyRetriesOncePerRequest() { verify(sessionClient, times(2)).createSessionAsync(); } + @Test + public void policyReturns403WithoutRetry() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse forbiddenResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(forbiddenResponse)); + when(forbiddenResponse.getStatusCode()).thenReturn(403); + + HttpResponse actualResponse = policy.process(context, next).block(); + + assertEquals(forbiddenResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(forbiddenResponse, times(0)).close(); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse invalidResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(invalidResponse)); + when(invalidResponse.getStatusCode()).thenReturn(401); + when(invalidResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_token_invalid"); + + HttpResponse actualResponse = policy.process(context, next).block(); + + // Returns the 401 as-is — no retry + assertEquals(invalidResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(invalidResponse, times(0)).close(); + + // But the session was invalidated so the next request gets a fresh session + StorageSessionCredential nextSession = policy.getValidSessionAsync().block(); + assertEquals(SECOND_TOKEN, nextSession.getSessionToken()); + } + private static StorageSessionCredential credentialWithToken(String token) { return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); } From 7954161748bc7360d224a172e1cd8f668c870f8a Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 15 Apr 2026 13:28:19 -0400 Subject: [PATCH 24/84] add 503 fallback --- .../util/SessionTokenCredentialPolicy.java | 44 ++++++++++- .../SessionTokenCredentialPolicyTest.java | 76 +++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 51c2ea201d64..eeed9a42dd62 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -23,6 +23,7 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String SESSION_EXPIRING = "session_expiring"; private static final String SESSION_EXPIRED = "session_expired"; private static final String SESSION_TOKEN_INVALID = "session_token_invalid"; + private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; private final StorageSessionCredentialCache sessionCredentialCache; @@ -44,7 +45,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN return next.process().flatMap(response -> { handleSessionExpiringHeader(response); - if (isSessionAuthResponse(response)) { + if (isSessionCredentialRejected(response)) { invalidateSession(session); } @@ -57,6 +58,13 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN }); } + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return retryNext.process(); + } + return Mono.just(response); }); }); @@ -71,7 +79,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex HttpResponse response = next.processSync(); handleSessionExpiringHeader(response); - if (isSessionAuthResponse(response)) { + if (isSessionCredentialRejected(response)) { invalidateSession(session); } @@ -84,6 +92,13 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex return retryNext.processSync(); } + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return retryNext.processSync(); + } + return response; } @@ -113,10 +128,10 @@ private void handleSessionExpiringHeader(HttpResponse response) { } /** - * Returns true for any 401 that the session service issued (expired or invalid token). + * Returns true for any 401 where the session service rejected the credential (expired or invalid token). * Used to decide whether to invalidate the cached session. */ - private static boolean isSessionAuthResponse(HttpResponse response) { + private static boolean isSessionCredentialRejected(HttpResponse response) { if (response.getStatusCode() != 401) { return false; } @@ -146,4 +161,25 @@ private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpR return isRetryableSessionFailure(response); } + + /** + * Returns true for 503 with SessionOperationsTemporarilyUnavailable error code. + * The session infrastructure is temporarily down, so we strip session auth and let + * the downstream BearerTokenPolicy handle the request with a bearer token. + */ + private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) { + if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { + return false; + } + + return isSessionUnavailable(response); + } + + private static boolean isSessionUnavailable(HttpResponse response) { + if (response.getStatusCode() != 503) { + return false; + } + String errorCode = response.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code")); + return SESSION_OPS_UNAVAILABLE.equals(errorCode); + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 699ed3c1b166..cceca1ff8562 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -272,6 +272,82 @@ public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() assertEquals(SECOND_TOKEN, nextSession.getSessionToken()); } + @Test + public void policyFallsToBearerOn503SessionUnavailableAsync() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(unavailableResponse)); + when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = policy.process(context, next).block(); + + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + verify(retryNext, times(1)).process(); + // Authorization header should have been stripped so bearer policy can add its own + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + + @Test + public void policyFallsToBearerOn503SessionUnavailableSync() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(unavailableResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = policy.processSync(context, next); + + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + verify(retryNext, times(1)).processSync(); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + + @Test + public void policyReturns503ServerBusyWithoutBearerFallback() { + HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse busyResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(busyResponse)); + when(busyResponse.getStatusCode()).thenReturn(503); + when(busyResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))).thenReturn("ServerBusy"); + + HttpResponse actualResponse = policy.process(context, next).block(); + + // ServerBusy 503 is not session-specific — return as-is for retry policy to handle + assertEquals(busyResponse, actualResponse); + verify(retryNext, times(0)).process(); + verify(busyResponse, times(0)).close(); + } + private static StorageSessionCredential credentialWithToken(String token) { return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); } From 5427d796399cb6797f2e4866307a99e1162b65e0 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 15 Apr 2026 14:52:10 -0400 Subject: [PATCH 25/84] add tests for udsas, but disabled for now --- .../util/BlobSessionClientTests.java | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 77873071d406..2a6f18e03ad4 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -6,10 +6,14 @@ 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.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; @@ -33,8 +37,6 @@ public void createSessionReturnsTokenAndKey() { assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); assertNotNull(credential.getExpiration()); - // assertNotNull(credential.) - assertEquals(false, credential.isExpired()); } @Test @@ -49,7 +51,6 @@ public void createSessionAsyncReturnsTokenAndKey() { assertNotNull(credential.getSessionToken()); assertNotNull(credential.getSessionKey()); assertNotNull(credential.getExpiration()); - assertEquals(false, credential.isExpired()); }).verifyComplete(); } @@ -87,6 +88,66 @@ public void createSessionAsyncUsesProvidedHttpPipeline() { assertEquals(1, policyInvocationCount.get()); } + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()); + + BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); + + BlobSessionClient sessionClient + = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), sasCc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionAsyncWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClient sasCc + = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) + .buildClient(); + + BlobSessionClient sessionClient + = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + }).verifyComplete(); + } + + private String generateUserDelegationContainerSas(BlobContainerClient containerClient) { + BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true) + .setWritePermission(true) + .setCreatePermission(true) + .setListPermission(true); + BlobServiceSasSignatureValues sasValues + = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions); + + return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient() + .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1))); + } + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) { HttpPipelinePolicy policy = (context, next) -> { policyInvocationCount.incrementAndGet(); From cce1e4e10d9ff971b1d1fc5149cf5b3bb9d01736 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 16 Apr 2026 12:16:47 -0400 Subject: [PATCH 26/84] refactor createContext to use hardcoded endpoint --- .../SessionTokenCredentialPolicyTest.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index cceca1ff8562..4905f14af173 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -10,6 +10,7 @@ import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpRequest; import com.azure.core.http.HttpResponse; +import com.azure.storage.blob.models.SessionMode; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -29,6 +30,7 @@ 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.anyString; import static org.mockito.Mockito.doAnswer; @@ -41,6 +43,7 @@ public class SessionTokenCredentialPolicyTest { private static final String FIRST_TOKEN = "first-session-token"; private static final String SECOND_TOKEN = "second-session-token"; + HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; private BlobSessionClient sessionClient; private SessionTokenCredentialPolicy policy; @@ -48,7 +51,7 @@ public class SessionTokenCredentialPolicyTest { @BeforeEach public void beforeEach() { sessionClient = mock(BlobSessionClient.class); - policy = new SessionTokenCredentialPolicy(sessionClient); + policy = createPolicy(SessionMode.ALWAYS); } @Test @@ -122,7 +125,7 @@ public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { @Test public void policySignsRequestWithSessionCredential() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpResponse response = mock(HttpResponse.class); @@ -141,7 +144,7 @@ public void policySignsRequestWithSessionCredential() { @Test public void policyInvalidatesSessionAndRetriesOnceAsync() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse initialResponse = mock(HttpResponse.class); @@ -170,7 +173,7 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { @Test public void policyInvalidatesSessionAndRetriesOnceSync() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); HttpResponse initialResponse = mock(HttpResponse.class); @@ -198,7 +201,7 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { @Test public void policyOnlyRetriesOncePerRequest() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse initialResponse = mock(HttpResponse.class); @@ -225,7 +228,7 @@ public void policyOnlyRetriesOncePerRequest() { @Test public void policyReturns403WithoutRetry() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse forbiddenResponse = mock(HttpResponse.class); @@ -246,7 +249,7 @@ public void policyReturns403WithoutRetry() { @Test public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse invalidResponse = mock(HttpResponse.class); @@ -274,7 +277,7 @@ public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() @Test public void policyFallsToBearerOn503SessionUnavailableAsync() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse unavailableResponse = mock(HttpResponse.class); @@ -302,7 +305,7 @@ public void policyFallsToBearerOn503SessionUnavailableAsync() { @Test public void policyFallsToBearerOn503SessionUnavailableSync() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); HttpResponse unavailableResponse = mock(HttpResponse.class); @@ -329,7 +332,7 @@ public void policyFallsToBearerOn503SessionUnavailableSync() { @Test public void policyReturns503ServerBusyWithoutBearerFallback() { - HttpPipelineCallContext context = createContext("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); HttpResponse busyResponse = mock(HttpResponse.class); @@ -357,9 +360,10 @@ private static StorageSessionCredential credentialWithToken(String token, Offset SessionTestHelper.TEST_ACCOUNT_NAME); } - private static HttpPipelineCallContext createContext(String url) { + private static HttpPipelineCallContext createContext() { HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); - HttpRequest request = new HttpRequest(HttpMethod.GET, url); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); Map data = new ConcurrentHashMap<>(); when(context.getHttpRequest()).thenReturn(request); From ce166ec4eee68f9b6fdb650f8ceb405334d58de8 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 16 Apr 2026 12:40:12 -0400 Subject: [PATCH 27/84] add SessionMode and tests for SessionMode --- .../util/SessionTokenCredentialPolicy.java | 44 +++++- .../storage/blob/models/SessionMode.java | 39 ++++++ .../SessionTokenCredentialPolicyTest.java | 126 ++++++++++++++++++ 3 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index eeed9a42dd62..44d5ee8f8dbc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -9,9 +9,11 @@ import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.storage.blob.models.SessionMode; import reactor.core.publisher.Mono; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; /** * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. @@ -26,19 +28,27 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; private final StorageSessionCredentialCache sessionCredentialCache; + private final SessionMode mode; + private final AtomicBoolean autoActivated = new AtomicBoolean(false); SessionTokenCredentialPolicy(BlobSessionClient sessionClient) { - this(new StorageSessionCredentialCache( - Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."))); + this( + new StorageSessionCredentialCache(Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null.")), + SessionMode.AUTO); } - SessionTokenCredentialPolicy(StorageSessionCredentialCache sessionCredentialCache) { + SessionTokenCredentialPolicy(StorageSessionCredentialCache sessionCredentialCache, SessionMode mode) { this.sessionCredentialCache = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); + this.mode = Objects.requireNonNull(mode, "'mode' cannot be null."); } @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (!shouldUseSession()) { + return next.process(); + } + HttpPipelineNextPolicy retryNext = next.clone(); return getValidSessionAsync().flatMap(session -> { signRequest(context, session); @@ -72,6 +82,10 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN @Override public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (!shouldUseSession()) { + return next.processSync(); + } + HttpPipelineNextSyncPolicy retryNext = next.clone(); StorageSessionCredential session = getValidSessionSync(); signRequest(context, session); @@ -106,6 +120,30 @@ Mono getValidSessionAsync() { return sessionCredentialCache.getValidSessionAsync(); } + /** + * Determines whether this request should use session auth based on the configured mode. + *
    + *
  • {@link SessionMode#NONE}: always returns false — passthrough to bearer.
  • + *
  • {@link SessionMode#ALWAYS}: always returns true — session from first request.
  • + *
  • {@link SessionMode#AUTO}: returns false for the first request (bearer), true thereafter.
  • + *
+ */ + private boolean shouldUseSession() { + switch (mode) { + case NONE: + return false; + + case ALWAYS: + return true; + + case AUTO: + return autoActivated.getAndSet(true); + + default: + return true; + } + } + StorageSessionCredential getValidSessionSync() { return sessionCredentialCache.getValidSessionSync(); } 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..e648e6dbd48d --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Defines the session management strategy used by the SDK when sending requests to a container. + *

+ * A session is a temporary security context scoped to a container that amortizes authentication + * and authorization cost across many requests by signing them with a lightweight HMAC key instead + * of a full bearer token. + * {@link #NONE} + * {@link #ALWAYS} + * {@link #AUTO} + */ +public enum SessionMode { + + /** + * The SDK never implicitly creates sessions. Use this mode when calling Create Session + * explicitly or when sending a very small number of requests where the overhead of an + * extra round-trip is not justified. + */ + NONE, + + /** + * The SDK creates a session on the first request and keeps an active session until it + * receives no requests for 5 minutes. + */ + ALWAYS, + + /** + * The SDK creates a session on the second request and keeps an active session until it + * receives no requests for 5 minutes. This avoids the overhead of session creation for + * one-shot operations while still benefiting from sessions for repeated access. + *

+ * This is the default mode. + */ + AUTO +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 4905f14af173..ed09075a1766 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -351,6 +351,132 @@ public void policyReturns503ServerBusyWithoutBearerFallback() { verify(busyResponse, times(0)).close(); } + @Test + public void noneModeAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = nonePolicy.process(context, next).block(); + + assertEquals(response, actualResponse); + verify(next, times(1)).process(); + verify(sessionClient, times(0)).createSessionAsync(); + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + } + + @Test + public void noneModeSyncAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.processSync()).thenReturn(response); + when(response.getStatusCode()).thenReturn(200); + + HttpResponse actualResponse = nonePolicy.processSync(context, next); + + assertEquals(response, actualResponse); + verify(next, times(1)).processSync(); + verify(sessionClient, times(0)).createSessionSync(); + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + } + + @Test + public void alwaysModeSignsFirstRequest() { + // The default `policy` in setUp is ALWAYS — verify it signs the very first request + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void autoModePassesThroughFirstRequestThenSignsSecond() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse firstResponse = mock(HttpResponse.class); + HttpResponse secondResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(firstResponse.getStatusCode()).thenReturn(200); + when(secondResponse.getStatusCode()).thenReturn(200); + + // First request — should pass through without session + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); + when(next1.process()).thenReturn(Mono.just(firstResponse)); + + HttpResponse actual1 = autoPolicy.process(context1, next1).block(); + + assertEquals(firstResponse, actual1); + verify(sessionClient, times(0)).createSessionAsync(); + assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + + // Second request — should use session + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextPolicy next2 = mock(HttpPipelineNextPolicy.class); + when(next2.clone()).thenReturn(next2); + when(next2.process()).thenReturn(Mono.just(secondResponse)); + + HttpResponse actual2 = autoPolicy.process(context2, next2).block(); + + assertEquals(secondResponse, actual2); + verify(sessionClient, times(1)).createSessionAsync(); + assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + } + + @Test + public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse firstResponse = mock(HttpResponse.class); + HttpResponse secondResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(firstResponse.getStatusCode()).thenReturn(200); + when(secondResponse.getStatusCode()).thenReturn(200); + + // First request — pass through + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); + when(next1.processSync()).thenReturn(firstResponse); + + HttpResponse actual1 = autoPolicy.processSync(context1, next1); + + assertEquals(firstResponse, actual1); + verify(sessionClient, times(0)).createSessionSync(); + assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + + // Second request — session signed + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextSyncPolicy next2 = mock(HttpPipelineNextSyncPolicy.class); + when(next2.clone()).thenReturn(next2); + when(next2.processSync()).thenReturn(secondResponse); + + HttpResponse actual2 = autoPolicy.processSync(context2, next2); + + assertEquals(secondResponse, actual2); + verify(sessionClient, times(1)).createSessionSync(); + assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + } + + private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { + return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), mode); + } + private static StorageSessionCredential credentialWithToken(String token) { return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); } From 7a5ee36e5e29d07349eb5bb9a32589c9b493ef5a Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 17 Apr 2026 12:38:49 -0400 Subject: [PATCH 28/84] add sessionOptions to buildPipeline, add null to builders not using sessions --- .../azure/storage/blob/BlobClientBuilder.java | 2 +- .../blob/BlobContainerClientBuilder.java | 51 +++++++++-- .../blob/BlobServiceClientBuilder.java | 4 +- .../implementation/util/BuilderHelper.java | 78 ++++++++++++++++- .../implementation/util/SessionOptions.java | 86 +++++++++++++++++++ .../util/SessionTokenCredentialPolicy.java | 15 +++- .../SpecializedBlobClientBuilder.java | 2 +- .../storage/blob/BuilderHelperTests.java | 6 +- 8 files changed, 225 insertions(+), 19 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 1d0ac36d4ce8..627ed7d7676a 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -200,7 +200,7 @@ private HttpPipeline constructPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + perRetryPolicies, configuration, audience, LOGGER, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 5ca6281bb1fb..1ac6eb653e27 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -32,6 +32,8 @@ import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; +import com.azure.storage.blob.implementation.util.SessionOptions; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; import com.azure.storage.common.implementation.connectionstring.StorageConnectionString; @@ -91,6 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. When session mode is set to a value + * other than {@link SessionMode#NONE}, {@link #containerName(String) containerName} must also be set. + * + * @param sessionMode The session mode to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * when identity-based authentication (bearer token) is configured. + * @return the updated BlobContainerClientBuilder object. + */ + public BlobContainerClientBuilder sessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode; + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 5fb46965824f..6291978b4ccd 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -151,11 +151,11 @@ private HttpPipeline constructPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + perRetryPolicies, configuration, audience, LOGGER, null); } /** - * Creates a {@link BlobServiceAsyncClient} based on options set in the builder. Every time + * Creates a {@link BlobServiceAsyncClient}based on options set in the builder. Every time * {@code buildAsyncClient()} is called, a new instance of {@link BlobServiceAsyncClient} is created. * * @return a {@link BlobServiceAsyncClient} created from the configurations in this builder. diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 0866d310981c..49be3460ab93 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -30,6 +30,7 @@ import com.azure.core.util.tracing.TracerProvider; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; @@ -64,7 +65,12 @@ public final class BuilderHelper { } /** - * Constructs a {@link HttpPipeline} from values passed from a builder. + * Constructs a {@link HttpPipeline} from values passed from a builder, with optional session-based + * authentication support. + *

+ * When {@code sessionOptions} is non-null and the resolved session mode is not {@link SessionMode#NONE}, + * and a {@code tokenCredential} is present, a {@link SessionTokenCredentialPolicy} is added after the + * bearer token policy. The session policy uses a separate bearer-only pipeline for CreateSession calls. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. * @param tokenCredential {@link TokenCredential} if present. @@ -81,6 +87,8 @@ public final class BuilderHelper { * @param configuration Configuration store contain environment settings. * @param logger {@link ClientLogger} used to log any exception. * @param audience {@link BlobAudience} used to determine the audience of the blob. + * @param sessionOptions {@link SessionOptions} containing session mode, container name, and service version. + * Pass {@code null} to disable session support. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -88,7 +96,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger) { + ClientLogger logger, SessionOptions sessionOptions) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -127,6 +135,9 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); } + addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, logOptions, clientOptions, + httpClient, logger); + if (azureSasCredential != null) { policies.add(new AzureSasCredentialPolicy(azureSasCredential, false)); } else if (sasToken != null) { @@ -150,6 +161,69 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare .build(); } + + private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, + TokenCredential tokenCredential, String endpoint, HttpLogOptions logOptions, ClientOptions clientOptions, + HttpClient httpClient, ClientLogger logger) { + + if (sessionOptions == null || tokenCredential == null) { + return; + } + + SessionMode effectiveMode = resolveSessionMode(sessionOptions.getSessionMode(), tokenCredential); + if (effectiveMode == SessionMode.NONE) { + return; + } + + validateSessionOptions(sessionOptions, effectiveMode, logger); + + // Build a bearer-only pipeline from the policies accumulated so far (before the session policy). + // This pipeline is used by BlobSessionClient for CreateSession calls and intentionally has no + // session policy to avoid circular dependency. + HttpPipeline bearerPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) + .build(); + + SessionTokenCredentialPolicy sessionPolicy + = createSessionPolicy(bearerPipeline, endpoint, sessionOptions, effectiveMode); + + // TODO (GA): Move SessionTokenCredentialPolicy before BearerTokenPolicy and modify + // StorageBearerTokenChallengeAuthorizationPolicy to skip when Authorization is already set. + // This avoids the unnecessary bearer token cache lookup on every session-signed request. + // Currently session policy sits after bearer and overwrites the header, which is correct + // but wastes a token cache hit per request. See policy ordering analysis in session auth design. + policies.add(sessionPolicy); + } + + private static void validateSessionOptions(SessionOptions sessionOptions, SessionMode effectiveMode, + ClientLogger logger) { + if (CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + throw logger.logExceptionAsError(new IllegalArgumentException( + "containerName must be set in SessionOptions when using SessionMode." + effectiveMode)); + } + if (sessionOptions.getServiceVersion() == null) { + throw logger.logExceptionAsError(new IllegalArgumentException( + "serviceVersion must be set in SessionOptions when using SessionMode." + effectiveMode)); + } + } + + private static SessionTokenCredentialPolicy createSessionPolicy(HttpPipeline bearerPipeline, String endpoint, + SessionOptions sessionOptions, SessionMode effectiveMode) { + BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, + sessionOptions.getServiceVersion(), sessionOptions.getContainerName()); + return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); + } + + private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCredential tokenCredential) { + if (sessionMode != null) { + return sessionMode; + } + // Default to AUTO when identity-based auth is configured, NONE otherwise. + return tokenCredential != null ? SessionMode.AUTO : SessionMode.NONE; + } + /** * Gets the default http log option for Storage Blob. * diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java new file mode 100644 index 000000000000..bad07a2b0a3e --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionMode; + +/** + * Internal options bag that groups session-related parameters for + * {@link BuilderHelper#buildPipeline}. + *

+ * RESERVED FOR INTERNAL USE. + */ +public final class SessionOptions { + + private SessionMode sessionMode; + private String containerName; + private BlobServiceVersion serviceVersion; + + /** + * Creates a new {@link SessionOptions} instance with default values. + */ + public SessionOptions() { + } + + /** + * Gets the session mode. + * + * @return the {@link SessionMode}, or {@code null} if not set. + */ + public SessionMode getSessionMode() { + return sessionMode; + } + + /** + * Sets the session mode. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode; + return this; + } + + /** + * Gets the container name used for session creation. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name used for session creation. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the service version used for session creation. + * + * @return the {@link BlobServiceVersion}, or {@code null} if not set. + */ + public BlobServiceVersion getServiceVersion() { + return serviceVersion; + } + + /** + * Sets the service version used for session creation. + * + * @param serviceVersion the {@link BlobServiceVersion}. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setServiceVersion(BlobServiceVersion serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 44d5ee8f8dbc..b8b0acc1b35a 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -50,6 +50,8 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN } HttpPipelineNextPolicy retryNext = next.clone(); + // Save the bearer token set by the upstream BearerTokenPolicy so we can restore it on fallback. + String bearerAuth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); return getValidSessionAsync().flatMap(session -> { signRequest(context, session); return next.process().flatMap(response -> { @@ -71,7 +73,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + restoreBearerAuth(context, bearerAuth); return retryNext.process(); } @@ -87,6 +89,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex } HttpPipelineNextSyncPolicy retryNext = next.clone(); + String bearerAuth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); StorageSessionCredential session = getValidSessionSync(); signRequest(context, session); @@ -109,7 +112,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + restoreBearerAuth(context, bearerAuth); return retryNext.processSync(); } @@ -158,6 +161,14 @@ private void signRequest(HttpPipelineCallContext context, StorageSessionCredenti context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); } + private static void restoreBearerAuth(HttpPipelineCallContext context, String bearerAuth) { + if (bearerAuth != null) { + context.getHttpRequest().setHeader(HttpHeaderName.AUTHORIZATION, bearerAuth); + } else { + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + } + } + private void handleSessionExpiringHeader(HttpResponse response) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { 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..b1f910862c1e 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); } private BlobServiceVersion getServiceVersion() { 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..537ea1521d5c 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 @@ -75,7 +75,7 @@ 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)); + new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -176,7 +176,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); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -305,7 +305,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); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) From a4c9e431104fc427df5bedcfe7afa126e69fa8d1 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 17 Apr 2026 20:30:29 -0400 Subject: [PATCH 29/84] move SessionTokenCredentialPolicy ahead of StorageBearerTokenChallengeAuthorizationPolicy --- .../implementation/util/BuilderHelper.java | 37 +++++++++---------- .../util/SessionTokenCredentialPolicy.java | 16 ++------ ...arerTokenChallengeAuthorizationPolicy.java | 16 +++++++- 3 files changed, 35 insertions(+), 34 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 49be3460ab93..aee3317eae14 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -127,6 +127,9 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } + addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, clientOptions, httpClient, + audience, logger); + if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null @@ -135,9 +138,6 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); } - addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, logOptions, clientOptions, - httpClient, logger); - if (azureSasCredential != null) { policies.add(new AzureSasCredentialPolicy(azureSasCredential, false)); } else if (sasToken != null) { @@ -161,10 +161,9 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare .build(); } - private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, - TokenCredential tokenCredential, String endpoint, HttpLogOptions logOptions, ClientOptions clientOptions, - HttpClient httpClient, ClientLogger logger) { + TokenCredential tokenCredential, String endpoint, ClientOptions clientOptions, HttpClient httpClient, + BlobAudience audience, ClientLogger logger) { if (sessionOptions == null || tokenCredential == null) { return; @@ -177,23 +176,23 @@ private static void addSessionPolicyIfEnabled(List policies, validateSessionOptions(sessionOptions, effectiveMode, logger); - // Build a bearer-only pipeline from the policies accumulated so far (before the session policy). - // This pipeline is used by BlobSessionClient for CreateSession calls and intentionally has no - // session policy to avoid circular dependency. - HttpPipeline bearerPipeline = new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) - .httpClient(httpClient) - .clientOptions(clientOptions) - .tracer(createTracer(clientOptions)) - .build(); + List bearerPolicies = new ArrayList<>(policies); + httpsValidation(tokenCredential, "bearer token", endpoint, logger); + String scope = audience != null + ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) + : Constants.STORAGE_SCOPE; + bearerPolicies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + + HttpPipeline bearerPipeline + = new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) + .build(); SessionTokenCredentialPolicy sessionPolicy = createSessionPolicy(bearerPipeline, endpoint, sessionOptions, effectiveMode); - // TODO (GA): Move SessionTokenCredentialPolicy before BearerTokenPolicy and modify - // StorageBearerTokenChallengeAuthorizationPolicy to skip when Authorization is already set. - // This avoids the unnecessary bearer token cache lookup on every session-signed request. - // Currently session policy sits after bearer and overwrites the header, which is correct - // but wastes a token cache hit per request. See policy ordering analysis in session auth design. policies.add(sessionPolicy); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index b8b0acc1b35a..a0674d6e9814 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -50,8 +50,6 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN } HttpPipelineNextPolicy retryNext = next.clone(); - // Save the bearer token set by the upstream BearerTokenPolicy so we can restore it on fallback. - String bearerAuth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); return getValidSessionAsync().flatMap(session -> { signRequest(context, session); return next.process().flatMap(response -> { @@ -73,7 +71,8 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - restoreBearerAuth(context, bearerAuth); + // Remove session auth so the downstream BearerTokenPolicy adds a bearer token. + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); return retryNext.process(); } @@ -89,7 +88,6 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex } HttpPipelineNextSyncPolicy retryNext = next.clone(); - String bearerAuth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); StorageSessionCredential session = getValidSessionSync(); signRequest(context, session); @@ -112,7 +110,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - restoreBearerAuth(context, bearerAuth); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); return retryNext.processSync(); } @@ -161,14 +159,6 @@ private void signRequest(HttpPipelineCallContext context, StorageSessionCredenti context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); } - private static void restoreBearerAuth(HttpPipelineCallContext context, String bearerAuth) { - if (bearerAuth != null) { - context.getHttpRequest().setHeader(HttpHeaderName.AUTHORIZATION, bearerAuth); - } else { - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - } - } - private void handleSessionExpiringHeader(HttpResponse response) { String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { diff --git a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java index 8459755589d9..6ba4639b9b01 100644 --- a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java @@ -46,13 +46,20 @@ public StorageBearerTokenChallengeAuthorizationPolicy(TokenCredential credential @Override public Mono authorizeRequest(HttpPipelineCallContext context) { - // Delegate to superclass to maintain previous behavior + // If another policy (e.g., SessionTokenCredentialPolicy) has already set the Authorization header, + // skip bearer token authorization to avoid overwriting it. + if (hasSessionHeader(context)) { + return Mono.empty(); + } return super.authorizeRequest(context); } + @Override public void authorizeRequestSync(HttpPipelineCallContext context) { - // Delegate to superclass to maintain previous behavior + if (hasSessionHeader(context)) { + return; + } super.authorizeRequestSync(context); } @@ -154,4 +161,9 @@ static boolean isBearerChallenge(String authenticateHeader) { return (!CoreUtils.isNullOrEmpty(authenticateHeader) && authenticateHeader.regionMatches(true, 0, BEARER_TOKEN_PREFIX, 0, BEARER_TOKEN_PREFIX.length())); } + + + private boolean hasSessionHeader(HttpPipelineCallContext context) { + return context.getHttpRequest().getHeaders().get(HttpHeaderName.AUTHORIZATION) != null; + } } From 96e2dd7f83f7b9e1a45311a06438bab9eb1392f2 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 17 Apr 2026 21:03:37 -0400 Subject: [PATCH 30/84] fix linting issues --- .../policy/StorageBearerTokenChallengeAuthorizationPolicy.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java index 6ba4639b9b01..73e3860b98d1 100644 --- a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java @@ -54,7 +54,6 @@ public Mono authorizeRequest(HttpPipelineCallContext context) { return super.authorizeRequest(context); } - @Override public void authorizeRequestSync(HttpPipelineCallContext context) { if (hasSessionHeader(context)) { @@ -162,7 +161,6 @@ static boolean isBearerChallenge(String authenticateHeader) { && authenticateHeader.regionMatches(true, 0, BEARER_TOKEN_PREFIX, 0, BEARER_TOKEN_PREFIX.length())); } - private boolean hasSessionHeader(HttpPipelineCallContext context) { return context.getHttpRequest().getHeaders().get(HttpHeaderName.AUTHORIZATION) != null; } From 1179b0db09bb04192fcdb726a3adad240d0efc82 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 11:42:40 -0400 Subject: [PATCH 31/84] add session to BlobServiceClients and BlobServiceClientBuildeer --- .../storage/blob/BlobServiceAsyncClient.java | 12 +- .../azure/storage/blob/BlobServiceClient.java | 11 +- .../blob/BlobServiceClientBuilder.java | 22 +++- .../implementation/util/BuilderHelper.java | 58 +++++++++- .../storage/blob/BuilderHelperTests.java | 108 ++++++++++++++++++ 5 files changed, 203 insertions(+), 8 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index 3254b5da630d..4fb9d757d27c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -22,7 +22,9 @@ import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.models.ServicesGetAccountInfoHeaders; +import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; +import com.azure.storage.blob.implementation.util.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobCorsRule; @@ -33,6 +35,7 @@ import com.azure.storage.blob.models.KeyInfo; import com.azure.storage.blob.models.ListBlobContainersOptions; import com.azure.storage.blob.models.PublicAccessType; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.TaggedBlobItem; import com.azure.storage.blob.models.UserDelegationKey; @@ -95,6 +98,7 @@ public final class BlobServiceAsyncClient { private final BlobContainerEncryptionScope blobContainerEncryptionScope; // only used to pass down to container // clients private final boolean anonymousAccess; + private final SessionMode sessionMode; /** * Package-private constructor for use by {@link BlobServiceClientBuilder}. @@ -111,7 +115,8 @@ public final class BlobServiceAsyncClient { */ BlobServiceAsyncClient(HttpPipeline pipeline, String url, BlobServiceVersion serviceVersion, String accountName, CpkInfo customerProvidedKey, EncryptionScope encryptionScope, - BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess) { + BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess, + SessionOptions sessionOptions) { /* Check to make sure the uri is valid. We don't want the error to occur later in the generated layer when the sas token has already been applied. */ try { @@ -130,6 +135,7 @@ public final class BlobServiceAsyncClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; + this.sessionMode = sessionOptions.getSessionMode(); } /** @@ -154,7 +160,9 @@ public BlobContainerAsyncClient getBlobContainerAsyncClient(String containerName containerName = BlobContainerAsyncClient.ROOT_CONTAINER_NAME; } - return new BlobContainerAsyncClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), + HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), sessionMode, + getAccountUrl(), getServiceVersion(), containerName); + return new BlobContainerAsyncClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 3ad51c9a9b5f..4c8a0dd33c13 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -27,7 +27,9 @@ import com.azure.storage.blob.implementation.models.ServicesGetPropertiesHeaders; import com.azure.storage.blob.implementation.models.ServicesGetStatisticsHeaders; import com.azure.storage.blob.implementation.models.ServicesGetUserDelegationKeyHeaders; +import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; +import com.azure.storage.blob.implementation.util.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobContainerListDetails; @@ -91,6 +93,7 @@ public final class BlobServiceClient { private final BlobContainerEncryptionScope blobContainerEncryptionScope; // only used to pass down to container // clients private final boolean anonymousAccess; + private final SessionOptions sessionOptions; /** * Package-private constructor for use by {@link BlobServiceClientBuilder}. @@ -107,7 +110,8 @@ public final class BlobServiceClient { */ BlobServiceClient(HttpPipeline pipeline, String url, BlobServiceVersion serviceVersion, String accountName, CpkInfo customerProvidedKey, EncryptionScope encryptionScope, - BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess) { + BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess, + SessionOptions sessionOptions) { /* Check to make sure the uri is valid. We don't want the error to occur later in the generated layer when the sas token has already been applied. */ try { @@ -126,6 +130,7 @@ public final class BlobServiceClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; + this.sessionOptions = sessionOptions; } /** @@ -147,7 +152,9 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } - return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), + HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), + sessionOptions.getSessionMode(), getAccountUrl(), getServiceVersion(), containerName); + return new BlobContainerClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 6291978b4ccd..1961b24d59e3 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,6 +30,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.implementation.util.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; @@ -93,6 +94,7 @@ public final class BlobServiceClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. This setting is passed to container + * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}. + * + * @param sessionOptions The session options for the HTTP pipeline. + * @return the updated BlobServiceClientBuilder object. + */ + public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = sessionOptions; + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index aee3317eae14..9800e32265bd 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -28,6 +28,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; +import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.SessionMode; @@ -216,11 +217,64 @@ private static SessionTokenCredentialPolicy createSessionPolicy(HttpPipeline bea } private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCredential tokenCredential) { + return resolveSessionMode(sessionMode, tokenCredential != null); + } + + /** + * Wraps an existing pipeline with a per-container {@link SessionTokenCredentialPolicy}. + * Used by {@link com.azure.storage.blob.BlobServiceClient#getBlobContainerClient(String)} to give each + * container its own session credential cache while sharing all other policies. + * + * @param basePipeline The service-level pipeline (used as-is for CreateSession calls). + * @param sessionMode The session mode. If {@code null}, defaults to AUTO when bearer auth is detected. + * @param endpoint The storage account endpoint. + * @param serviceVersion The blob service version. + * @param containerName The container name for session scoping. + * @return A new pipeline with session support, or {@code basePipeline} unchanged if sessions are not applicable. + */ + public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, SessionMode sessionMode, + String endpoint, BlobServiceVersion serviceVersion, String containerName) { + + // Detect whether the pipeline has bearer auth by scanning for the policy. + boolean hasBearerAuth = false; + int bearerIndex = -1; + for (int i = 0; i < basePipeline.getPolicyCount(); i++) { + if (basePipeline.getPolicy(i) instanceof StorageBearerTokenChallengeAuthorizationPolicy) { + hasBearerAuth = true; + bearerIndex = i; + break; + } + } + + SessionMode effectiveMode = resolveSessionMode(sessionMode, hasBearerAuth); + if (effectiveMode == SessionMode.NONE || !hasBearerAuth) { + return basePipeline; + } + + // The base pipeline (with bearer) serves as the bearer-only pipeline for CreateSession calls. + BlobSessionClient sessionClient = new BlobSessionClient(basePipeline, endpoint, serviceVersion, containerName); + SessionTokenCredentialPolicy sessionPolicy + = new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); + + // Build a new pipeline with session policy inserted before the bearer policy. + List policies = new ArrayList<>(); + for (int i = 0; i < basePipeline.getPolicyCount(); i++) { + if (i == bearerIndex) { + policies.add(sessionPolicy); + } + policies.add(basePipeline.getPolicy(i)); + } + + return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(basePipeline.getHttpClient()) + .build(); + } + + private static SessionMode resolveSessionMode(SessionMode sessionMode, boolean hasBearerAuth) { if (sessionMode != null) { return sessionMode; } - // Default to AUTO when identity-based auth is configured, NONE otherwise. - return tokenCredential != null ? SessionMode.AUTO : SessionMode.NONE; + return hasBearerAuth ? SessionMode.AUTO : SessionMode.NONE; } /** 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 537ea1521d5c..b9047d240a66 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -22,6 +22,7 @@ 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.SessionMode; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -50,6 +51,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -680,4 +682,110 @@ public Mono send(HttpRequest request) { return Mono.just(new MockHttpResponse(request, 200)); } } + + // region wrapWithSessionPolicy tests + @Test + public void wrapWithSessionPolicyNoBearerAuthReturnsSamePipeline() { + HttpPipeline sharedKeyPipeline = buildSharedKeyPipeline(); + + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(sharedKeyPipeline, SessionMode.ALWAYS, ENDPOINT, + BlobServiceVersion.getLatest(), "mycontainer"); + + assertSame(sharedKeyPipeline, result, "Pipeline without bearer auth should be returned unchanged"); + } + + @Test + public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { + HttpPipeline bearerPipeline = buildBearerPipeline(); + + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.NONE, ENDPOINT, + BlobServiceVersion.getLatest(), "mycontainer"); + + assertSame(bearerPipeline, result, "SessionMode.NONE should return the pipeline unchanged"); + } + + @Test + public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { + HttpPipeline bearerPipeline = buildBearerPipeline(); + + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, null, ENDPOINT, + BlobServiceVersion.getLatest(), "mycontainer"); + + assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), + "Null sessionMode with bearer should resolve to AUTO and add SessionTokenCredentialPolicy"); + } + + @Test + public void wrapWithSessionPolicyAlwaysWithBearerAddsSesionPolicy() { + HttpPipeline bearerPipeline = buildBearerPipeline(); + + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.ALWAYS, ENDPOINT, + BlobServiceVersion.getLatest(), "mycontainer"); + + assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), + "SessionMode.ALWAYS with bearer should add SessionTokenCredentialPolicy"); + assertEquals(bearerPipeline.getPolicyCount() + 1, result.getPolicyCount(), + "Wrapped pipeline should have exactly one additional policy"); + } + + @Test + public void wrapWithSessionPolicyInsertsSessionPolicyBeforeBearer() { + HttpPipeline bearerPipeline = buildBearerPipeline(); + + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.ALWAYS, ENDPOINT, + BlobServiceVersion.getLatest(), "mycontainer"); + + int sessionIndex = indexOfPolicy(result, "SessionTokenCredentialPolicy"); + int bearerIndex = indexOfPolicy(result, "StorageBearerTokenChallengeAuthorizationPolicy"); + + assertTrue(sessionIndex >= 0, "SessionTokenCredentialPolicy should be present"); + assertTrue(bearerIndex >= 0, "StorageBearerTokenChallengeAuthorizationPolicy should be present"); + assertTrue(sessionIndex < bearerIndex, "SessionTokenCredentialPolicy (index " + sessionIndex + + ") should appear before " + "StorageBearerTokenChallengeAuthorizationPolicy (index " + bearerIndex + ")"); + } + + /** + * Helper to build a pipeline with bearer token auth for session wrapping tests. + */ + private static HttpPipeline buildBearerPipeline() { + return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), null); + } + + /** + * Helper to build a pipeline without bearer token auth (shared key only) for session wrapping tests. + */ + 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); + } + + /** + * Checks whether the pipeline contains a policy whose simple class name matches the given name. + */ + private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first policy whose simple class name matches, or -1 if not found. + */ + private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return i; + } + } + return -1; + } + + // endregion } From e10c762b6a9ceabcbf7ca7e9c40fc30df92415ab Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 11:52:27 -0400 Subject: [PATCH 32/84] change expiration so that it defaults to 5 minutes, if there is no expiration --- .../blob/implementation/util/StorageSessionCredential.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index 56fd2bb155a4..8914eac27052 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -48,7 +48,7 @@ public StorageSessionCredential(String sessionToken, String sessionKey, OffsetDa String accountName) { this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); - this.expiration = Objects.requireNonNull(expiration, "'expiration' cannot be null."); + this.expiration = expiration != null ? expiration : OffsetDateTime.now().plusMinutes(5L); this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); } From f1257f49975ffea0e13004be330bd48abb6d39c1 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 12:51:49 -0400 Subject: [PATCH 33/84] move SessionOptions so that it is public --- .../blob/BlobContainerClientBuilder.java | 33 +++---- .../storage/blob/BlobServiceAsyncClient.java | 2 +- .../azure/storage/blob/BlobServiceClient.java | 2 +- .../blob/BlobServiceClientBuilder.java | 2 +- .../implementation/util/BuilderHelper.java | 1 + .../storage/blob/models/SessionOptions.java | 88 +++++++++++++++++++ .../storage/blob/BuilderHelperTests.java | 49 +++++++++++ 7 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 1ac6eb653e27..ec881a4358d2 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -32,7 +32,7 @@ import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; -import com.azure.storage.blob.implementation.util.SessionOptions; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.SessionMode; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; @@ -93,7 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait * Sessions amortize authentication and authorization cost across many requests by signing them - * with a lightweight HMAC key instead of a full bearer token. When session mode is set to a value - * other than {@link SessionMode#NONE}, {@link #containerName(String) containerName} must also be set. + * with a lightweight HMAC key instead of a full bearer token. When the session mode within the options + * is set to a value other than {@link SessionMode#NONE}, + * {@link #containerName(String) containerName} must also be set. * - * @param sessionMode The session mode to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO} * when identity-based authentication (bearer token) is configured. * @return the updated BlobContainerClientBuilder object. */ - public BlobContainerClientBuilder sessionMode(SessionMode sessionMode) { - this.sessionMode = sessionMode; + public BlobContainerClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = sessionOptions; return this; } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index 4fb9d757d27c..beb2c2c041c7 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -24,7 +24,7 @@ import com.azure.storage.blob.implementation.models.ServicesGetAccountInfoHeaders; import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; -import com.azure.storage.blob.implementation.util.SessionOptions; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobCorsRule; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 4c8a0dd33c13..3b171114e360 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -29,7 +29,7 @@ import com.azure.storage.blob.implementation.models.ServicesGetUserDelegationKeyHeaders; import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; -import com.azure.storage.blob.implementation.util.SessionOptions; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobContainerListDetails; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 1961b24d59e3..a6070c89af7c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,7 +30,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; -import com.azure.storage.blob.implementation.util.SessionOptions; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 9800e32265bd..18ddb7947f8e 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -32,6 +32,7 @@ import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; 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..dedd932bad9c --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import com.azure.storage.blob.BlobServiceVersion; + +/** + * Options bag that groups session-related parameters for configuring session-based authentication + * on blob storage builders. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. + * + * @see SessionMode + */ +public final class SessionOptions { + + private SessionMode sessionMode; + private String containerName; + private BlobServiceVersion serviceVersion; + + /** + * Creates a new {@link SessionOptions} instance with default values. + */ + public SessionOptions() { + } + + /** + * Gets the session mode. + * + * @return the {@link SessionMode}, or {@code null} if not set. + */ + public SessionMode getSessionMode() { + return sessionMode; + } + + /** + * Sets the session mode. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode; + return this; + } + + /** + * Gets the container name used for session creation. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name used for session creation. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the service version used for session creation. + * + * @return the {@link BlobServiceVersion}, or {@code null} if not set. + */ + public BlobServiceVersion getServiceVersion() { + return serviceVersion; + } + + /** + * Sets the service version used for session creation. + * + * @param serviceVersion the {@link BlobServiceVersion}. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setServiceVersion(BlobServiceVersion serviceVersion) { + this.serviceVersion = serviceVersion; + return this; + } +} 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 b9047d240a66..477fc8ac338c 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -22,6 +22,7 @@ import com.azure.core.util.Header; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; @@ -788,4 +789,52 @@ private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) } // endregion + + // region BlobContainerClientBuilder sessionOptions tests + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.ALWAYS); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndNoContainerNameThrows() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.ALWAYS); + + assertThrows(IllegalArgumentException.class, + () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsNoneAndNoContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithNoSessionOptionsSucceeds() { + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient()); + } + + // endregion } From 54e05a11520f50aeca681795cb2848dde2c5fc67 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 13:11:21 -0400 Subject: [PATCH 34/84] remove old SessionOptions --- .../implementation/util/SessionOptions.java | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java deleted file mode 100644 index bad07a2b0a3e..000000000000 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionOptions.java +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.storage.blob.implementation.util; - -import com.azure.storage.blob.BlobServiceVersion; -import com.azure.storage.blob.models.SessionMode; - -/** - * Internal options bag that groups session-related parameters for - * {@link BuilderHelper#buildPipeline}. - *

- * RESERVED FOR INTERNAL USE. - */ -public final class SessionOptions { - - private SessionMode sessionMode; - private String containerName; - private BlobServiceVersion serviceVersion; - - /** - * Creates a new {@link SessionOptions} instance with default values. - */ - public SessionOptions() { - } - - /** - * Gets the session mode. - * - * @return the {@link SessionMode}, or {@code null} if not set. - */ - public SessionMode getSessionMode() { - return sessionMode; - } - - /** - * Sets the session mode. - * - * @param sessionMode the {@link SessionMode} to set. - * @return the updated {@link SessionOptions} object. - */ - public SessionOptions setSessionMode(SessionMode sessionMode) { - this.sessionMode = sessionMode; - return this; - } - - /** - * Gets the container name used for session creation. - * - * @return the container name, or {@code null} if not set. - */ - public String getContainerName() { - return containerName; - } - - /** - * Sets the container name used for session creation. - * - * @param containerName the container name. - * @return the updated {@link SessionOptions} object. - */ - public SessionOptions setContainerName(String containerName) { - this.containerName = containerName; - return this; - } - - /** - * Gets the service version used for session creation. - * - * @return the {@link BlobServiceVersion}, or {@code null} if not set. - */ - public BlobServiceVersion getServiceVersion() { - return serviceVersion; - } - - /** - * Sets the service version used for session creation. - * - * @param serviceVersion the {@link BlobServiceVersion}. - * @return the updated {@link SessionOptions} object. - */ - public SessionOptions setServiceVersion(BlobServiceVersion serviceVersion) { - this.serviceVersion = serviceVersion; - return this; - } -} From 4d811463b7c2bc1e477f262607b7c49eafed426d Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 13:12:19 -0400 Subject: [PATCH 35/84] remove unnecessary references to containerName and serviceVersion --- .../azure/storage/blob/BlobClientBuilder.java | 2 +- .../blob/BlobContainerClientBuilder.java | 4 +- .../blob/BlobServiceClientBuilder.java | 2 +- .../implementation/util/BuilderHelper.java | 36 +++++++------- .../storage/blob/models/SessionOptions.java | 47 +------------------ .../SpecializedBlobClientBuilder.java | 2 +- .../storage/blob/BuilderHelperTests.java | 17 +++---- 7 files changed, 33 insertions(+), 77 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 627ed7d7676a..3c68c2145f82 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -200,7 +200,7 @@ private HttpPipeline constructPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, null); + perRetryPolicies, configuration, audience, LOGGER, null, null, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index ec881a4358d2..6afed881a3fb 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -191,11 +191,9 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion if (httpPipeline != null) { return httpPipeline; } - SessionOptions effectiveOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); - effectiveOptions.setContainerName(containerName).setServiceVersion(serviceVersion); return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, effectiveOptions); + perRetryPolicies, configuration, audience, LOGGER, sessionOptions, containerName, serviceVersion); } private void validateSessionMode() { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index a6070c89af7c..877eca9e0071 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -153,7 +153,7 @@ private HttpPipeline constructPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, null); + perRetryPolicies, configuration, audience, LOGGER, null, null, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 18ddb7947f8e..ecd21022bbf0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -89,8 +89,10 @@ public final class BuilderHelper { * @param configuration Configuration store contain environment settings. * @param logger {@link ClientLogger} used to log any exception. * @param audience {@link BlobAudience} used to determine the audience of the blob. - * @param sessionOptions {@link SessionOptions} containing session mode, container name, and service version. + * @param sessionOptions {@link SessionOptions} containing the session mode. * Pass {@code null} to disable session support. + * @param containerName The container name for session scoping. Required when session is active. + * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -98,7 +100,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger, SessionOptions sessionOptions) { + ClientLogger logger, SessionOptions sessionOptions, String containerName, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -130,7 +132,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare } addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, clientOptions, httpClient, - audience, logger); + audience, logger, containerName, serviceVersion); if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); @@ -165,7 +167,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, TokenCredential tokenCredential, String endpoint, ClientOptions clientOptions, HttpClient httpClient, - BlobAudience audience, ClientLogger logger) { + BlobAudience audience, ClientLogger logger, String containerName, BlobServiceVersion serviceVersion) { if (sessionOptions == null || tokenCredential == null) { return; @@ -176,7 +178,7 @@ private static void addSessionPolicyIfEnabled(List policies, return; } - validateSessionOptions(sessionOptions, effectiveMode, logger); + validateSessionOptions(containerName, serviceVersion, effectiveMode, logger); List bearerPolicies = new ArrayList<>(policies); httpsValidation(tokenCredential, "bearer token", endpoint, logger); @@ -193,27 +195,27 @@ private static void addSessionPolicyIfEnabled(List policies, .build(); SessionTokenCredentialPolicy sessionPolicy - = createSessionPolicy(bearerPipeline, endpoint, sessionOptions, effectiveMode); + = createSessionPolicy(bearerPipeline, endpoint, containerName, serviceVersion, effectiveMode); policies.add(sessionPolicy); } - private static void validateSessionOptions(SessionOptions sessionOptions, SessionMode effectiveMode, - ClientLogger logger) { - if (CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { - throw logger.logExceptionAsError(new IllegalArgumentException( - "containerName must be set in SessionOptions when using SessionMode." + effectiveMode)); + private static void validateSessionOptions(String containerName, BlobServiceVersion serviceVersion, + SessionMode effectiveMode, ClientLogger logger) { + if (CoreUtils.isNullOrEmpty(containerName)) { + throw logger.logExceptionAsError( + new IllegalArgumentException("containerName must be set when using SessionMode." + effectiveMode)); } - if (sessionOptions.getServiceVersion() == null) { - throw logger.logExceptionAsError(new IllegalArgumentException( - "serviceVersion must be set in SessionOptions when using SessionMode." + effectiveMode)); + if (serviceVersion == null) { + throw logger.logExceptionAsError( + new IllegalArgumentException("serviceVersion must be set when using SessionMode." + effectiveMode)); } } private static SessionTokenCredentialPolicy createSessionPolicy(HttpPipeline bearerPipeline, String endpoint, - SessionOptions sessionOptions, SessionMode effectiveMode) { - BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, - sessionOptions.getServiceVersion(), sessionOptions.getContainerName()); + String containerName, BlobServiceVersion serviceVersion, SessionMode effectiveMode) { + BlobSessionClient sessionClient + = new BlobSessionClient(bearerPipeline, endpoint, serviceVersion, containerName); return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java index dedd932bad9c..0a67810b1df7 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -3,11 +3,8 @@ package com.azure.storage.blob.models; -import com.azure.storage.blob.BlobServiceVersion; - /** - * Options bag that groups session-related parameters for configuring session-based authentication - * on blob storage builders. + * Options bag that configures session-based authentication on blob storage builders. *

* Sessions amortize authentication and authorization cost across many requests by signing them * with a lightweight HMAC key instead of a full bearer token. @@ -17,8 +14,6 @@ public final class SessionOptions { private SessionMode sessionMode; - private String containerName; - private BlobServiceVersion serviceVersion; /** * Creates a new {@link SessionOptions} instance with default values. @@ -45,44 +40,4 @@ public SessionOptions setSessionMode(SessionMode sessionMode) { this.sessionMode = sessionMode; return this; } - - /** - * Gets the container name used for session creation. - * - * @return the container name, or {@code null} if not set. - */ - public String getContainerName() { - return containerName; - } - - /** - * Sets the container name used for session creation. - * - * @param containerName the container name. - * @return the updated {@link SessionOptions} object. - */ - public SessionOptions setContainerName(String containerName) { - this.containerName = containerName; - return this; - } - - /** - * Gets the service version used for session creation. - * - * @return the {@link BlobServiceVersion}, or {@code null} if not set. - */ - public BlobServiceVersion getServiceVersion() { - return serviceVersion; - } - - /** - * Sets the service version used for session creation. - * - * @param serviceVersion the {@link BlobServiceVersion}. - * @return the updated {@link SessionOptions} object. - */ - public SessionOptions setServiceVersion(BlobServiceVersion serviceVersion) { - this.serviceVersion = serviceVersion; - 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 b1f910862c1e..2b11d9bdc9d6 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, null); + perRetryPolicies, configuration, audience, LOGGER, null, null, null); } private BlobServiceVersion getServiceVersion() { 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 477fc8ac338c..f6607750060b 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 @@ -75,10 +75,10 @@ private static HttpRequest request(String url) { */ @Test public void freshDateAppliedOnRetry() { - HttpPipeline pipeline - = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null, - BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new FreshDateTestClient(), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null); + 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, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -179,7 +179,8 @@ 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), null); + new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null, + null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -308,7 +309,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), null); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -752,7 +753,7 @@ private static HttpPipeline buildBearerPipeline() { return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, - new ClientLogger(BuilderHelperTests.class), null); + new ClientLogger(BuilderHelperTests.class), null, null, null); } /** @@ -761,7 +762,7 @@ private static HttpPipeline buildBearerPipeline() { 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); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null, null); } /** From 7e8eb5531dbe471ffbe6017c5f6bdf5b34fd68b3 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 13:52:16 -0400 Subject: [PATCH 36/84] add BlobContainerSessionInfo, add other Copilot recommendations --- .../blob/BlobContainerAsyncClient.java | 24 ++++-- .../storage/blob/BlobContainerClient.java | 3 - .../implementation/util/BuilderHelper.java | 2 +- .../util/StorageSessionCredential.java | 4 +- .../blob/models/BlobContainerSessionInfo.java | 73 +++++++++++++++++++ 5 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index ea37d58f1e0d..4568e3b55cd8 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -30,13 +30,13 @@ import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; import com.azure.storage.blob.implementation.models.AuthenticationType; import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; -import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; import com.azure.storage.blob.models.BlobContainerAccessPolicies; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerProperties; +import com.azure.storage.blob.models.BlobContainerSessionInfo; import com.azure.storage.blob.models.BlobItem; import com.azure.storage.blob.models.BlobRequestConditions; import com.azure.storage.blob.models.BlobSignedIdentifier; @@ -1698,10 +1698,10 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV * Creates a session scoped to this container. The session provides temporary credentials (a session token and * session key) that can be used to sign subsequent requests using the Shared Key protocol. * - * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. + * @return A {@link Mono} containing the {@link BlobContainerSessionInfo} with session credentials. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono createSession() { + public Mono createSession() { return createSessionWithResponse().flatMap(FluxUtil::toMono); } @@ -1709,10 +1709,10 @@ public Mono createSession() { * Creates a session scoped to this container. The session provides temporary credentials (a session token and * session key) that can be used to sign subsequent requests using the Shared Key protocol. * - * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. + * @return A {@link Mono} containing a {@link Response} with the {@link BlobContainerSessionInfo}. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono> createSessionWithResponse() { + public Mono> createSessionWithResponse() { try { return withContext(this::createSessionWithResponse); } catch (RuntimeException ex) { @@ -1720,13 +1720,23 @@ public Mono> createSessionWithResponse() { } } - Mono> createSessionWithResponse(Context context) { + Mono> createSessionWithResponse(Context context) { context = context == null ? Context.NONE : context; CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); return this.azureBlobStorage.getContainers() .createSessionWithResponseAsync(containerName, config, null, null, context) - .map(response -> new SimpleResponse<>(response, response.getValue())); + .map(response -> { + BlobContainerSessionInfo info + = new BlobContainerSessionInfo(response.getValue().getId(), response.getValue().getExpiration(), + response.getValue().getCredentials() != null + ? response.getValue().getCredentials().getSessionToken() + : null, + response.getValue().getCredentials() != null + ? response.getValue().getCredentials().getSessionKey() + : null); + return new SimpleResponse<>(response, info); + }); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 341bda19360d..9ce23d04ae6b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -31,9 +31,6 @@ import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; -import com.azure.storage.blob.implementation.models.AuthenticationType; -import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; -import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index ecd21022bbf0..a2a1a6cd2505 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -71,7 +71,7 @@ public final class BuilderHelper { * authentication support. *

* When {@code sessionOptions} is non-null and the resolved session mode is not {@link SessionMode#NONE}, - * and a {@code tokenCredential} is present, a {@link SessionTokenCredentialPolicy} is added after the + * and a {@code tokenCredential} is present, a {@link SessionTokenCredentialPolicy} is added before the * bearer token policy. The session policy uses a separate bearer-only pipeline for CreateSession calls. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index 8914eac27052..f56f25f02942 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -27,7 +27,7 @@ * Holds session credentials (token, key, expiration) and signs requests using the Shared Key protocol. * The Authorization header format is: {@code Session :} */ -public final class StorageSessionCredential { +final class StorageSessionCredential { private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); @@ -89,7 +89,7 @@ public OffsetDateTime getExpiration() { return expiration; } - public Boolean isExpired() { + public boolean isExpired() { return OffsetDateTime.now().isAfter(expiration); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java new file mode 100644 index 000000000000..c19583ea847c --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import java.time.OffsetDateTime; + +/** + * Contains the results of a Create Session operation on a blob container. + *

+ * A session provides temporary credentials (a session token and session key) scoped to a container + * that can be used to sign subsequent requests using the HMAC Shared Key protocol, amortizing + * authentication and authorization cost across many requests. + */ +public final class BlobContainerSessionInfo { + + private final String sessionId; + private final OffsetDateTime expiration; + private final String sessionToken; + private final String sessionKey; + + /** + * Creates a new {@link BlobContainerSessionInfo}. + * + * @param sessionId A unique identifier for the created session. + * @param expiration The time when the session will expire. + * @param sessionToken An opaque token used to authorize subsequent requests in the session. + * @param sessionKey A symmetric key used to sign requests in the session using the Shared Key protocol. + */ + public BlobContainerSessionInfo(String sessionId, OffsetDateTime expiration, String sessionToken, + String sessionKey) { + this.sessionId = sessionId; + this.expiration = expiration; + this.sessionToken = sessionToken; + this.sessionKey = sessionKey; + } + + /** + * Gets the unique identifier for the session. + * + * @return the session ID. + */ + public String getSessionId() { + return sessionId; + } + + /** + * Gets the time when the session will expire. + * + * @return the expiration time. + */ + public OffsetDateTime getExpiration() { + return expiration; + } + + /** + * Gets the opaque token used to authorize subsequent requests in the session. + * + * @return the session token. + */ + public String getSessionToken() { + return sessionToken; + } + + /** + * Gets the symmetric key used to sign requests in the session using the Shared Key protocol. + * + * @return the session key. + */ + public String getSessionKey() { + return sessionKey; + } +} From ae109833164b3bbe47b674207dfb058129fdcab7 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 16:01:19 -0400 Subject: [PATCH 37/84] delete BlobContainerSessionInfo, restore return CreateSessionResponse --- .../blob/BlobContainerAsyncClient.java | 24 ++---- .../storage/blob/BlobContainerClient.java | 37 ++++++++++ .../storage/blob/BlobServiceAsyncClient.java | 2 +- .../azure/storage/blob/BlobServiceClient.java | 4 +- .../blob/models/BlobContainerSessionInfo.java | 73 ------------------- .../util/BlobSessionClientTests.java | 4 +- 6 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index 4568e3b55cd8..ea37d58f1e0d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -30,13 +30,13 @@ import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; import com.azure.storage.blob.implementation.models.AuthenticationType; import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; import com.azure.storage.blob.models.BlobContainerAccessPolicies; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerProperties; -import com.azure.storage.blob.models.BlobContainerSessionInfo; import com.azure.storage.blob.models.BlobItem; import com.azure.storage.blob.models.BlobRequestConditions; import com.azure.storage.blob.models.BlobSignedIdentifier; @@ -1698,10 +1698,10 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV * Creates a session scoped to this container. The session provides temporary credentials (a session token and * session key) that can be used to sign subsequent requests using the Shared Key protocol. * - * @return A {@link Mono} containing the {@link BlobContainerSessionInfo} with session credentials. + * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono createSession() { + public Mono createSession() { return createSessionWithResponse().flatMap(FluxUtil::toMono); } @@ -1709,10 +1709,10 @@ public Mono createSession() { * Creates a session scoped to this container. The session provides temporary credentials (a session token and * session key) that can be used to sign subsequent requests using the Shared Key protocol. * - * @return A {@link Mono} containing a {@link Response} with the {@link BlobContainerSessionInfo}. + * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono> createSessionWithResponse() { + public Mono> createSessionWithResponse() { try { return withContext(this::createSessionWithResponse); } catch (RuntimeException ex) { @@ -1720,23 +1720,13 @@ public Mono> createSessionWithResponse() { } } - Mono> createSessionWithResponse(Context context) { + Mono> createSessionWithResponse(Context context) { context = context == null ? Context.NONE : context; CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); return this.azureBlobStorage.getContainers() .createSessionWithResponseAsync(containerName, config, null, null, context) - .map(response -> { - BlobContainerSessionInfo info - = new BlobContainerSessionInfo(response.getValue().getId(), response.getValue().getExpiration(), - response.getValue().getCredentials() != null - ? response.getValue().getCredentials().getSessionToken() - : null, - response.getValue().getCredentials() != null - ? response.getValue().getCredentials().getSessionKey() - : null); - return new SimpleResponse<>(response, info); - }); + .map(response -> new SimpleResponse<>(response, response.getValue())); } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 9ce23d04ae6b..43ba34929663 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -31,6 +31,9 @@ import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1508,4 +1511,38 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV return new BlobSasImplUtil(blobServiceSasSignatureValues, getBlobContainerName()) .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return The {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public CreateSessionResponse createSession() { + return createSessionWithResponse(null, Context.NONE).getValue(); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. + * @param context Additional context that is passed through the Http pipeline during the service call. + * @return A {@link Response} containing the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createSessionWithResponse(Duration timeout, Context context) { + Context finalContext = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Callable> operation = () -> { + Response response = this.azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, finalContext); + return new SimpleResponse<>(response, response.getValue()); + }; + + return sendRequest(operation, timeout, BlobStorageException.class); + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index beb2c2c041c7..a2c19e8a28af 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -135,7 +135,7 @@ public final class BlobServiceAsyncClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; - this.sessionMode = sessionOptions.getSessionMode(); + this.sessionMode = sessionOptions != null ? sessionOptions.getSessionMode() : SessionMode.NONE; } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 3b171114e360..4d84674fd6cb 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -29,6 +29,7 @@ import com.azure.storage.blob.implementation.models.ServicesGetUserDelegationKeyHeaders; import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; @@ -153,7 +154,8 @@ public BlobContainerClient getBlobContainerClient(String containerName) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), - sessionOptions.getSessionMode(), getAccountUrl(), getServiceVersion(), containerName); + sessionOptions != null ? sessionOptions.getSessionMode() : SessionMode.NONE, getAccountUrl(), + getServiceVersion(), containerName); return new BlobContainerClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java deleted file mode 100644 index c19583ea847c..000000000000 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/BlobContainerSessionInfo.java +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.storage.blob.models; - -import java.time.OffsetDateTime; - -/** - * Contains the results of a Create Session operation on a blob container. - *

- * A session provides temporary credentials (a session token and session key) scoped to a container - * that can be used to sign subsequent requests using the HMAC Shared Key protocol, amortizing - * authentication and authorization cost across many requests. - */ -public final class BlobContainerSessionInfo { - - private final String sessionId; - private final OffsetDateTime expiration; - private final String sessionToken; - private final String sessionKey; - - /** - * Creates a new {@link BlobContainerSessionInfo}. - * - * @param sessionId A unique identifier for the created session. - * @param expiration The time when the session will expire. - * @param sessionToken An opaque token used to authorize subsequent requests in the session. - * @param sessionKey A symmetric key used to sign requests in the session using the Shared Key protocol. - */ - public BlobContainerSessionInfo(String sessionId, OffsetDateTime expiration, String sessionToken, - String sessionKey) { - this.sessionId = sessionId; - this.expiration = expiration; - this.sessionToken = sessionToken; - this.sessionKey = sessionKey; - } - - /** - * Gets the unique identifier for the session. - * - * @return the session ID. - */ - public String getSessionId() { - return sessionId; - } - - /** - * Gets the time when the session will expire. - * - * @return the expiration time. - */ - public OffsetDateTime getExpiration() { - return expiration; - } - - /** - * Gets the opaque token used to authorize subsequent requests in the session. - * - * @return the session token. - */ - public String getSessionToken() { - return sessionToken; - } - - /** - * Gets the symmetric key used to sign requests in the session using the Shared Key protocol. - * - * @return the session key. - */ - public String getSessionKey() { - return sessionKey; - } -} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 2a6f18e03ad4..0489b6ad7a15 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -5,6 +5,7 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.storage.blob.BlobContainerAsyncClient; import com.azure.storage.blob.BlobContainerClient; import com.azure.storage.blob.BlobContainerClientBuilder; import com.azure.storage.blob.BlobServiceClientBuilder; @@ -41,7 +42,8 @@ public void createSessionReturnsTokenAndKey() { @Test public void createSessionAsyncReturnsTokenAndKey() { - BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + BlobContainerAsyncClient oauthCc + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); From 384909d5108b1d085db009d15147e6f6f9c6ad48 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 16:02:11 -0400 Subject: [PATCH 38/84] create createSession end-to-end test with recordings --- sdk/storage/azure-storage-blob/assets.json | 2 +- .../azure/storage/blob/ContainerApiTests.java | 30 +++++++++++++++++ .../storage/blob/ContainerAsyncApiTests.java | 32 +++++++++++++++++-- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 15c3910a9d57..f531497084e0 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_185d12486c" + "Tag": "java/storage/azure-storage-blob_4f95fc8478" } 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 f61aa01179aa..5c4d1c8c914f 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 @@ -10,6 +10,7 @@ import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -2130,4 +2131,33 @@ public void getBlobContainerUrlEncodesContainerName() { // } // Need to create a container client test here to test that sessions have been enabled and used + + @Test + public void createSession() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + CreateSessionResponse response = oauthCc.createSession(); + + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + } + + @Test + public void createSessionWithResponse() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + Response response = oauthCc.createSessionWithResponse(null, Context.NONE); + + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(OffsetDateTime.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + } } 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 0e685b943447..c003d371662c 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 @@ -12,7 +12,6 @@ import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; -import com.azure.storage.blob.implementation.models.AuthenticationType; import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.*; import com.azure.storage.blob.options.BlobContainerCreateOptions; @@ -2145,6 +2144,35 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } - // Need to create a container client test here to test that sessions have been enabled and used + @Test + public void createSession() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSession()).assertNext(response -> { + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + public void createSessionWithResponse() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> { + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(OffsetDateTime.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + }).verifyComplete(); + } } From 7f6247a7342c42e9c07d28ba9811239f16f408c3 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 16:42:04 -0400 Subject: [PATCH 39/84] only allow get requests for getblob --- .../util/SessionTokenCredentialPolicy.java | 46 ++++- .../azure/storage/blob/ContainerApiTests.java | 41 ++++ .../storage/blob/ContainerAsyncApiTests.java | 37 ++++ .../SessionTokenCredentialPolicyTest.java | 184 +++++++++++++++++- .../implementation/util/TransformUtils.java | 3 - 5 files changed, 302 insertions(+), 9 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index a0674d6e9814..23bbd99b57dd 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -4,14 +4,18 @@ package com.azure.storage.blob.implementation.util; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpPipelineCallContext; import com.azure.core.http.HttpPipelineNextPolicy; import com.azure.core.http.HttpPipelineNextSyncPolicy; import com.azure.core.http.HttpResponse; import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.CoreUtils; +import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.SessionMode; import reactor.core.publisher.Mono; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @@ -45,7 +49,7 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (!shouldUseSession()) { + if (!isGetBlobRequest(context) || !shouldUseSession()) { return next.process(); } @@ -83,7 +87,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN @Override public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { - if (!shouldUseSession()) { + if (!isGetBlobRequest(context) || !shouldUseSession()) { return next.processSync(); } @@ -123,10 +127,13 @@ Mono getValidSessionAsync() { /** * Determines whether this request should use session auth based on the configured mode. + *

+ * This method is only called for GetBlob requests — the caller checks {@link #isGetBlobRequest} first, + * so the AUTO counter only advances on eligible operations. *

    *
  • {@link SessionMode#NONE}: always returns false — passthrough to bearer.
  • - *
  • {@link SessionMode#ALWAYS}: always returns true — session from first request.
  • - *
  • {@link SessionMode#AUTO}: returns false for the first request (bearer), true thereafter.
  • + *
  • {@link SessionMode#ALWAYS}: always returns true — session from first GetBlob request.
  • + *
  • {@link SessionMode#AUTO}: returns false for the first GetBlob request (bearer), true thereafter.
  • *
*/ private boolean shouldUseSession() { @@ -145,6 +152,37 @@ private boolean shouldUseSession() { } } + /** + * Returns {@code true} if the request is a GetBlob (download) operation. + *

+ * A GetBlob request is identified as an HTTP GET to a URL with both a container name and blob name, + * and no {@code comp} or {@code restype} query parameters (those indicate metadata, properties, + * list, or other sub-operations that should use bearer auth). + *

+ * GET requests with {@code snapshot} or {@code versionid} query parameters are still considered + * GetBlob operations and are eligible for session auth. + */ + private static boolean isGetBlobRequest(HttpPipelineCallContext context) { + if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return false; + } + + BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + + // Must target a specific blob (container + blob name present). + if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { + return false; + } + + // comp= or restype= indicate sub-operations (properties, metadata, list, tags, etc.) + Map queryParams = parts.getUnparsedParameters(); + if (queryParams.containsKey("comp") || queryParams.containsKey("restype")) { + return false; + } + + return true; + } + StorageSessionCredential getValidSessionSync() { return sessionCredentialCache.getValidSessionSync(); } 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 5c4d1c8c914f..a4d269b8f769 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -4,6 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedIterable; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; @@ -34,6 +35,8 @@ import com.azure.storage.blob.models.ObjectReplicationStatus; import com.azure.storage.blob.models.PublicAccessType; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.TaggedBlobItem; import com.azure.storage.blob.options.BlobContainerCreateOptions; @@ -64,6 +67,7 @@ import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Base64; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -2160,4 +2164,41 @@ public void createSessionWithResponse() { assertNotNull(sessionResponse.getCredentials().getSessionToken()); assertNotNull(sessionResponse.getCredentials().getSessionKey()); } + + @Test + public void sessionAuthUsedForGetBlobOnly() { + // Upload a blob using shared key auth + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), 7); + + // Build a session-enabled container client with token credential + List capturedAuthHeaders = new ArrayList<>(); + HttpPipelinePolicy capturePolicy = (context, next) -> { + String auth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + if (auth != null) { + capturedAuthHeaders.add(auth); + } + return next.process(); + }; + + BlobContainerClient sessionCc + = getContainerClientBuilderWithTokenCredential(cc.getBlobContainerUrl(), capturePolicy) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.ALWAYS)) + .buildClient(); + + // Download the blob — should use session auth + capturedAuthHeaders.clear(); + sessionCc.getBlobClient(blobName).downloadContent(); + assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for download"); + assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Session "), + "GetBlob should use Session auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); + + // List blobs — should use bearer auth, not session + capturedAuthHeaders.clear(); + sessionCc.listBlobs().forEach(blob -> { + }); + assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for listBlobs"); + assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Bearer "), + "ListBlobs should use Bearer auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); + } } 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 c003d371662c..7a395ea0481a 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,6 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +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; @@ -2175,4 +2176,40 @@ public void createSessionWithResponse() { }).verifyComplete(); } + @Test + public void sessionAuthUsedForGetBlobOnly() { + // Upload a blob using shared key auth + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient().upload(DATA.getDefaultFlux(), 7).block(); + + // Build a session-enabled container client with token credential + List capturedAuthHeaders = Collections.synchronizedList(new ArrayList<>()); + HttpPipelinePolicy capturePolicy = (context, next) -> { + String auth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + if (auth != null) { + capturedAuthHeaders.add(auth); + } + return next.process(); + }; + + BlobContainerAsyncClient sessionCcAsync + = getContainerClientBuilderWithTokenCredential(ccAsync.getBlobContainerUrl(), capturePolicy) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.ALWAYS)) + .buildAsyncClient(); + + // Download the blob — should use session auth + capturedAuthHeaders.clear(); + sessionCcAsync.getBlobAsyncClient(blobName).downloadContent().block(); + assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for download"); + assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Session "), + "GetBlob should use Session auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); + + // List blobs — should use bearer auth, not session + capturedAuthHeaders.clear(); + sessionCcAsync.listBlobs().collectList().block(); + assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for listBlobs"); + assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Bearer "), + "ListBlobs should use Bearer auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index ed09075a1766..c4a068698d07 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -487,9 +487,15 @@ private static StorageSessionCredential credentialWithToken(String token, Offset } private static HttpPipelineCallContext createContext() { + return createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + } + + private static HttpPipelineCallContext createContextForUrl(String url) { + return createContextForRequest(new HttpRequest(HttpMethod.GET, url)); + } + + private static HttpPipelineCallContext createContextForRequest(HttpRequest request) { HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); - HttpRequest request - = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); Map data = new ConcurrentHashMap<>(); when(context.getHttpRequest()).thenReturn(request); @@ -502,4 +508,178 @@ private static HttpPipelineCallContext createContext() { return context; } + + // region GetBlob-only filtering tests + + @Test + public void getBlobRequestUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob request should be signed with session auth"); + } + + @Test + public void putBlobRequestSkipsSessionAuth() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block(); + + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), + "PUT request should not be signed with session auth"); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void listBlobsRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container&comp=list"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block(); + + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), + "ListBlobs request (restype=container&comp=list) should not be signed with session auth"); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobPropertiesRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob?comp=metadata"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block(); + + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), + "GetBlobProperties (comp=metadata) should not be signed with session auth"); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobWithSnapshotUsesSessionAuth() { + HttpPipelineCallContext context = createContextForUrl( + "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2021-01-01T00:00:00Z"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob with snapshot should still use session auth"); + } + + @Test + public void containerLevelGetRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block(); + + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), + "Container-level GET (restype=container) should not use session auth"); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void autoModeCounterOnlyAdvancesOnGetBlobRequests() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(200); + + // First request: PUT (non-GetBlob) — should not advance AUTO counter + HttpPipelineCallContext putContext = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); + when(putNext.process()).thenReturn(Mono.just(response)); + autoPolicy.process(putContext, putNext).block(); + + // Second request: GET blob — should be first GetBlob, so AUTO returns false (bearer) + HttpPipelineCallContext getContext1 + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy getNext1 = mock(HttpPipelineNextPolicy.class); + when(getNext1.process()).thenReturn(Mono.just(response)); + autoPolicy.process(getContext1, getNext1).block(); + + assertNull(getContext1.getHttpRequest().getHeaders().getValue(authHeaderName), + "First GetBlob in AUTO mode should use bearer (counter not advanced by PUT)"); + + // Third request: GET blob — should now use session (counter was advanced by previous GetBlob) + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + HttpPipelineCallContext getContext2 + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy getNext2 = mock(HttpPipelineNextPolicy.class); + when(getNext2.clone()).thenReturn(getNext2); + when(getNext2.process()).thenReturn(Mono.just(response)); + autoPolicy.process(getContext2, getNext2).block(); + + assertTrue(getContext2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "Second GetBlob in AUTO mode should use session auth"); + } + + @Test + public void alwaysModeNonGetBlobSkipsSession() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.DELETE, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block(); + + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), + "ALWAYS mode should still skip session auth for non-GetBlob (DELETE) requests"); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void ipStyleEndpointGetBlobUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://127.0.0.1:10000/devstoreaccount1/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob on IP-style endpoint should use session auth"); + } + + // endregion } diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java index fe07d636f95c..55e8468c6440 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java @@ -104,9 +104,6 @@ public static BlobServiceVersion toBlobServiceVersion(DataLakeServiceVersion ver case V2026_04_06: return BlobServiceVersion.V2026_04_06; - case V2026_06_06: - return BlobServiceVersion.V2026_06_06; - default: return null; } From 1a9a61170fa412236d16d224990b7d5fa75d837a Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 17:47:33 -0400 Subject: [PATCH 40/84] wrap tests in try-with-resources --- .../SessionTokenCredentialPolicyTest.java | 226 +++++++++--------- 1 file changed, 114 insertions(+), 112 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index c4a068698d07..c2c9203c3247 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -19,6 +19,7 @@ import java.time.OffsetDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; @@ -134,12 +135,13 @@ public void policySignsRequestWithSessionCredential() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = policy.process(context, next).block(); - - assertEquals(response, actualResponse); - assertTrue(context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN), - "Expected request to be signed with a session credential."); - verify(next, times(1)).process(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(response, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + verify(next, times(1)).process(); + } } @Test @@ -160,15 +162,15 @@ public void policyInvalidatesSessionAndRetriesOnceAsync() { .thenReturn("Session error=session_expired"); when(retriedResponse.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = policy.process(context, next).block(); - - assertEquals(retriedResponse, actualResponse); - assertTrue( - context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); - verify(initialResponse, times(1)).close(); - verify(next, times(1)).process(); - verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createSessionAsync(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).process(); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } } @Test @@ -189,14 +191,14 @@ public void policyInvalidatesSessionAndRetriesOnceSync() { .thenReturn("Session error=session_expired"); when(retriedResponse.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = policy.processSync(context, next); - - assertEquals(retriedResponse, actualResponse); - assertTrue( - context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); - verify(initialResponse, times(1)).close(); - verify(next, times(1)).processSync(); - verify(retryNext, times(1)).processSync(); + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + } } @Test @@ -219,11 +221,11 @@ public void policyOnlyRetriesOncePerRequest() { when(retriedResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) .thenReturn("Session error=session_expired"); - HttpResponse actualResponse = policy.process(context, next).block(); - - assertEquals(retriedResponse, actualResponse); - verify(retryNext, times(1)).process(); - verify(sessionClient, times(2)).createSessionAsync(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } } @Test @@ -238,13 +240,13 @@ public void policyReturns403WithoutRetry() { when(next.process()).thenReturn(Mono.just(forbiddenResponse)); when(forbiddenResponse.getStatusCode()).thenReturn(403); - HttpResponse actualResponse = policy.process(context, next).block(); - - assertEquals(forbiddenResponse, actualResponse); - verify(next, times(1)).process(); - verify(retryNext, times(0)).process(); - verify(forbiddenResponse, times(0)).close(); - verify(sessionClient, times(1)).createSessionAsync(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(forbiddenResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(forbiddenResponse, times(0)).close(); + verify(sessionClient, times(1)).createSessionAsync(); + } } @Test @@ -262,17 +264,17 @@ public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() when(invalidResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) .thenReturn("Session error=session_token_invalid"); - HttpResponse actualResponse = policy.process(context, next).block(); - - // Returns the 401 as-is — no retry - assertEquals(invalidResponse, actualResponse); - verify(next, times(1)).process(); - verify(retryNext, times(0)).process(); - verify(invalidResponse, times(0)).close(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + // Returns the 401 as-is — no retry + assertEquals(invalidResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(invalidResponse, times(0)).close(); - // But the session was invalidated so the next request gets a fresh session - StorageSessionCredential nextSession = policy.getValidSessionAsync().block(); - assertEquals(SECOND_TOKEN, nextSession.getSessionToken()); + // But the session was invalidated so the next request gets a fresh session + StorageSessionCredential nextSession = policy.getValidSessionAsync().block(); + assertEquals(SECOND_TOKEN, nextSession.getSessionToken()); + } } @Test @@ -292,15 +294,15 @@ public void policyFallsToBearerOn503SessionUnavailableAsync() { .thenReturn("SessionOperationsTemporarilyUnavailable"); when(bearerResponse.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = policy.process(context, next).block(); - - assertEquals(bearerResponse, actualResponse); - verify(unavailableResponse, times(1)).close(); - verify(retryNext, times(1)).process(); - // Authorization header should have been stripped so bearer policy can add its own - String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); - assertTrue(authHeader == null || !authHeader.startsWith("Session"), - "Session auth should have been stripped but was: " + authHeader); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + verify(retryNext, times(1)).process(); + // Authorization header should have been stripped so bearer policy can add its own + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } } @Test @@ -320,14 +322,14 @@ public void policyFallsToBearerOn503SessionUnavailableSync() { .thenReturn("SessionOperationsTemporarilyUnavailable"); when(bearerResponse.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = policy.processSync(context, next); - - assertEquals(bearerResponse, actualResponse); - verify(unavailableResponse, times(1)).close(); - verify(retryNext, times(1)).processSync(); - String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); - assertTrue(authHeader == null || !authHeader.startsWith("Session"), - "Session auth should have been stripped but was: " + authHeader); + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + verify(retryNext, times(1)).processSync(); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } } @Test @@ -343,12 +345,12 @@ public void policyReturns503ServerBusyWithoutBearerFallback() { when(busyResponse.getStatusCode()).thenReturn(503); when(busyResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))).thenReturn("ServerBusy"); - HttpResponse actualResponse = policy.process(context, next).block(); - - // ServerBusy 503 is not session-specific — return as-is for retry policy to handle - assertEquals(busyResponse, actualResponse); - verify(retryNext, times(0)).process(); - verify(busyResponse, times(0)).close(); + try (HttpResponse actualResponse = policy.process(context, next).block()) { + // ServerBusy 503 is not session-specific — return as-is for retry policy to handle + assertEquals(busyResponse, actualResponse); + verify(retryNext, times(0)).process(); + verify(busyResponse, times(0)).close(); + } } @Test @@ -361,12 +363,12 @@ public void noneModeAlwaysPassesThrough() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = nonePolicy.process(context, next).block(); - - assertEquals(response, actualResponse); - verify(next, times(1)).process(); - verify(sessionClient, times(0)).createSessionAsync(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) { + assertEquals(response, actualResponse); + verify(next, times(1)).process(); + verify(sessionClient, times(0)).createSessionAsync(); + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + } } @Test @@ -379,12 +381,12 @@ public void noneModeSyncAlwaysPassesThrough() { when(next.processSync()).thenReturn(response); when(response.getStatusCode()).thenReturn(200); - HttpResponse actualResponse = nonePolicy.processSync(context, next); - - assertEquals(response, actualResponse); - verify(next, times(1)).processSync(); - verify(sessionClient, times(0)).createSessionSync(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) { + assertEquals(response, actualResponse); + verify(next, times(1)).processSync(); + verify(sessionClient, times(0)).createSessionSync(); + assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); + } } @Test @@ -399,7 +401,7 @@ public void alwaysModeSignsFirstRequest() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); verify(sessionClient, times(1)).createSessionAsync(); @@ -420,11 +422,11 @@ public void autoModePassesThroughFirstRequestThenSignsSecond() { HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); when(next1.process()).thenReturn(Mono.just(firstResponse)); - HttpResponse actual1 = autoPolicy.process(context1, next1).block(); - - assertEquals(firstResponse, actual1); - verify(sessionClient, times(0)).createSessionAsync(); - assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { + assertEquals(firstResponse, actual1); + verify(sessionClient, times(0)).createSessionAsync(); + assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + } // Second request — should use session HttpPipelineCallContext context2 = createContext(); @@ -432,11 +434,11 @@ public void autoModePassesThroughFirstRequestThenSignsSecond() { when(next2.clone()).thenReturn(next2); when(next2.process()).thenReturn(Mono.just(secondResponse)); - HttpResponse actual2 = autoPolicy.process(context2, next2).block(); - - assertEquals(secondResponse, actual2); - verify(sessionClient, times(1)).createSessionAsync(); - assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) { + assertEquals(secondResponse, actual2); + verify(sessionClient, times(1)).createSessionAsync(); + assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + } } @Test @@ -454,11 +456,11 @@ public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); when(next1.processSync()).thenReturn(firstResponse); - HttpResponse actual1 = autoPolicy.processSync(context1, next1); - - assertEquals(firstResponse, actual1); - verify(sessionClient, times(0)).createSessionSync(); - assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { + assertEquals(firstResponse, actual1); + verify(sessionClient, times(0)).createSessionSync(); + assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + } // Second request — session signed HttpPipelineCallContext context2 = createContext(); @@ -466,11 +468,11 @@ public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { when(next2.clone()).thenReturn(next2); when(next2.processSync()).thenReturn(secondResponse); - HttpResponse actual2 = autoPolicy.processSync(context2, next2); - - assertEquals(secondResponse, actual2); - verify(sessionClient, times(1)).createSessionSync(); - assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) { + assertEquals(secondResponse, actual2); + verify(sessionClient, times(1)).createSessionSync(); + assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + } } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { @@ -523,7 +525,7 @@ public void getBlobRequestUsesSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), "GetBlob request should be signed with session auth"); @@ -538,7 +540,7 @@ public void putBlobRequestSkipsSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), "PUT request should not be signed with session auth"); @@ -554,7 +556,7 @@ public void listBlobsRequestSkipsSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), "ListBlobs request (restype=container&comp=list) should not be signed with session auth"); @@ -570,7 +572,7 @@ public void getBlobPropertiesRequestSkipsSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), "GetBlobProperties (comp=metadata) should not be signed with session auth"); @@ -589,7 +591,7 @@ public void getBlobWithSnapshotUsesSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), "GetBlob with snapshot should still use session auth"); @@ -604,7 +606,7 @@ public void containerLevelGetRequestSkipsSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); - policy.process(context, next).block(); + policy.process(context, next).block().close(); assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), "Container-level GET (restype=container) should not use session auth"); @@ -622,14 +624,14 @@ public void autoModeCounterOnlyAdvancesOnGetBlobRequests() { new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); when(putNext.process()).thenReturn(Mono.just(response)); - autoPolicy.process(putContext, putNext).block(); + autoPolicy.process(putContext, putNext).block().close(); // Second request: GET blob — should be first GetBlob, so AUTO returns false (bearer) HttpPipelineCallContext getContext1 = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); HttpPipelineNextPolicy getNext1 = mock(HttpPipelineNextPolicy.class); when(getNext1.process()).thenReturn(Mono.just(response)); - autoPolicy.process(getContext1, getNext1).block(); + Objects.requireNonNull(autoPolicy.process(getContext1, getNext1).block()).close(); assertNull(getContext1.getHttpRequest().getHeaders().getValue(authHeaderName), "First GetBlob in AUTO mode should use bearer (counter not advanced by PUT)"); @@ -641,7 +643,7 @@ public void autoModeCounterOnlyAdvancesOnGetBlobRequests() { HttpPipelineNextPolicy getNext2 = mock(HttpPipelineNextPolicy.class); when(getNext2.clone()).thenReturn(getNext2); when(getNext2.process()).thenReturn(Mono.just(response)); - autoPolicy.process(getContext2, getNext2).block(); + Objects.requireNonNull(autoPolicy.process(getContext2, getNext2).block()).close(); assertTrue(getContext2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), "Second GetBlob in AUTO mode should use session auth"); @@ -656,7 +658,7 @@ public void alwaysModeNonGetBlobSkipsSession() { when(next.process()).thenReturn(Mono.just(response)); - policy.process(context, next).block(); + Objects.requireNonNull(policy.process(context, next).block()).close(); assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), "ALWAYS mode should still skip session auth for non-GetBlob (DELETE) requests"); @@ -675,7 +677,7 @@ public void ipStyleEndpointGetBlobUsesSessionAuth() { when(next.process()).thenReturn(Mono.just(response)); when(response.getStatusCode()).thenReturn(200); - policy.process(context, next).block(); + Objects.requireNonNull(policy.process(context, next).block()).close(); assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), "GetBlob on IP-style endpoint should use session auth"); From 7436cf14e1bcab0242b47a368c13991e26438667 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 18:12:25 -0400 Subject: [PATCH 41/84] make createSession package private --- .../java/com/azure/storage/blob/BlobContainerAsyncClient.java | 4 ++-- .../main/java/com/azure/storage/blob/BlobContainerClient.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index ea37d58f1e0d..d79a68c24a96 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -1701,7 +1701,7 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono createSession() { + Mono createSession() { return createSessionWithResponse().flatMap(FluxUtil::toMono); } @@ -1712,7 +1712,7 @@ public Mono createSession() { * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Mono> createSessionWithResponse() { + Mono> createSessionWithResponse() { try { return withContext(this::createSessionWithResponse); } catch (RuntimeException ex) { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 43ba34929663..426e38c46b76 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -1519,7 +1519,7 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV * @return The {@link CreateSessionResponse} with session credentials. */ @ServiceMethod(returns = ReturnType.SINGLE) - public CreateSessionResponse createSession() { + CreateSessionResponse createSession() { return createSessionWithResponse(null, Context.NONE).getValue(); } @@ -1532,7 +1532,7 @@ public CreateSessionResponse createSession() { * @return A {@link Response} containing the {@link CreateSessionResponse}. */ @ServiceMethod(returns = ReturnType.SINGLE) - public Response createSessionWithResponse(Duration timeout, Context context) { + Response createSessionWithResponse(Duration timeout, Context context) { Context finalContext = context == null ? Context.NONE : context; CreateSessionConfiguration config = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); From 7d347c97198be696ae60545d975f64bc8df57329 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 20 Apr 2026 18:27:13 -0400 Subject: [PATCH 42/84] fixes based on copilot suggestions --- .../blob/implementation/util/StorageSessionCredentialCache.java | 1 + .../test/java/com/azure/storage/blob/BuilderHelperTests.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java index 17f316ac69ec..594f976207df 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java @@ -123,6 +123,7 @@ private Mono startSessionCreationAsync() { }).doFinally(ignored -> { synchronized (creationLock) { inflightCreation = null; + refreshing = false; } }).cache(); 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 f6607750060b..704cf704e64c 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 @@ -718,7 +718,7 @@ public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { } @Test - public void wrapWithSessionPolicyAlwaysWithBearerAddsSesionPolicy() { + public void wrapWithSessionPolicyAlwaysWithBearerAddsSessionPolicy() { HttpPipeline bearerPipeline = buildBearerPipeline(); HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.ALWAYS, ENDPOINT, From d7aef3ccf271ad7bba5d77d85da448177c9fb0f0 Mon Sep 17 00:00:00 2001 From: browndav-msft Date: Mon, 20 Apr 2026 18:31:53 -0400 Subject: [PATCH 43/84] Update sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../blob/implementation/util/BlobSessionClient.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index a4bf11528cc4..28bfde61ad6d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -64,7 +64,14 @@ StorageSessionCredential createSessionSync() { private StorageSessionCredential toCredential(Response response) { CreateSessionResponse session = response.getValue(); + if (session == null) { + throw new IllegalStateException("CreateSession response did not contain a session payload."); + } + SessionCredentials creds = session.getCredentials(); + if (creds == null) { + throw new IllegalStateException("CreateSession response did not contain HMAC session credentials."); + } return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), accountName); } From e9b5228ae481e587352506a8beffd34c44315eea Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 11:16:29 -0400 Subject: [PATCH 44/84] add containerName to SessionOptions --- .../blob/BlobContainerClientBuilder.java | 7 ++++- .../storage/blob/BlobServiceAsyncClient.java | 8 ++++-- .../azure/storage/blob/BlobServiceClient.java | 8 +++--- .../implementation/util/BuilderHelper.java | 27 +++++++++++-------- .../storage/blob/models/SessionOptions.java | 22 +++++++++++++++ .../storage/blob/BuilderHelperTests.java | 25 ++++++++++------- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 6afed881a3fb..0d3152cabe89 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -191,9 +191,14 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion if (httpPipeline != null) { return httpPipeline; } + // Set containerName on sessionOptions so BuilderHelper can read it from one place. + SessionOptions effectiveSessionOptions = sessionOptions; + if (effectiveSessionOptions != null && containerName != null) { + effectiveSessionOptions.setContainerName(containerName); + } return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, sessionOptions, containerName, serviceVersion); + perRetryPolicies, configuration, audience, LOGGER, effectiveSessionOptions, accountName, serviceVersion); } private void validateSessionMode() { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index a2c19e8a28af..1bd49ac74c2e 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -112,6 +112,7 @@ public final class BlobServiceAsyncClient { * @param encryptionScope Encryption scope used during encryption of the blob's data on the server, pass * {@code null} to allow the service to use its own encryption. * @param anonymousAccess Whether the client was built with anonymousAccess + * @param sessionOptions Session options for session-based authentication. */ BlobServiceAsyncClient(HttpPipeline pipeline, String url, BlobServiceVersion serviceVersion, String accountName, CpkInfo customerProvidedKey, EncryptionScope encryptionScope, @@ -160,8 +161,11 @@ public BlobContainerAsyncClient getBlobContainerAsyncClient(String containerName containerName = BlobContainerAsyncClient.ROOT_CONTAINER_NAME; } - HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), sessionMode, - getAccountUrl(), getServiceVersion(), containerName); + SessionOptions containerSessionOptions = sessionMode != null && sessionMode != SessionMode.NONE + ? new SessionOptions().setSessionMode(sessionMode).setContainerName(containerName) + : null; + HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, + getAccountUrl(), getServiceVersion(), getAccountName()); return new BlobContainerAsyncClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 4d84674fd6cb..adf45a102d3c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -153,9 +153,11 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } - HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), - sessionOptions != null ? sessionOptions.getSessionMode() : SessionMode.NONE, getAccountUrl(), - getServiceVersion(), containerName); + SessionOptions containerSessionOptions = sessionOptions != null + ? new SessionOptions().setSessionMode(sessionOptions.getSessionMode()).setContainerName(containerName) + : null; + HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, + getAccountUrl(), getServiceVersion(), getAccountName()); return new BlobContainerClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index a2a1a6cd2505..1b65e06353d9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -100,7 +100,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger, SessionOptions sessionOptions, String containerName, BlobServiceVersion serviceVersion) { + ClientLogger logger, SessionOptions sessionOptions, String accountName, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -132,7 +132,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare } addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, clientOptions, httpClient, - audience, logger, containerName, serviceVersion); + audience, logger, accountName, serviceVersion); if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); @@ -167,7 +167,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, TokenCredential tokenCredential, String endpoint, ClientOptions clientOptions, HttpClient httpClient, - BlobAudience audience, ClientLogger logger, String containerName, BlobServiceVersion serviceVersion) { + BlobAudience audience, ClientLogger logger, String accountName, BlobServiceVersion serviceVersion) { if (sessionOptions == null || tokenCredential == null) { return; @@ -178,6 +178,7 @@ private static void addSessionPolicyIfEnabled(List policies, return; } + String containerName = sessionOptions.getContainerName(); validateSessionOptions(containerName, serviceVersion, effectiveMode, logger); List bearerPolicies = new ArrayList<>(policies); @@ -195,7 +196,7 @@ private static void addSessionPolicyIfEnabled(List policies, .build(); SessionTokenCredentialPolicy sessionPolicy - = createSessionPolicy(bearerPipeline, endpoint, containerName, serviceVersion, effectiveMode); + = createSessionPolicy(bearerPipeline, endpoint, accountName, containerName, serviceVersion, effectiveMode); policies.add(sessionPolicy); } @@ -213,9 +214,9 @@ private static void validateSessionOptions(String containerName, BlobServiceVers } private static SessionTokenCredentialPolicy createSessionPolicy(HttpPipeline bearerPipeline, String endpoint, - String containerName, BlobServiceVersion serviceVersion, SessionMode effectiveMode) { + String accountName, String containerName, BlobServiceVersion serviceVersion, SessionMode effectiveMode) { BlobSessionClient sessionClient - = new BlobSessionClient(bearerPipeline, endpoint, serviceVersion, containerName); + = new BlobSessionClient(bearerPipeline, endpoint, serviceVersion, accountName, containerName); return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); } @@ -229,14 +230,17 @@ private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCred * container its own session credential cache while sharing all other policies. * * @param basePipeline The service-level pipeline (used as-is for CreateSession calls). - * @param sessionMode The session mode. If {@code null}, defaults to AUTO when bearer auth is detected. + * @param sessionOptions The session options containing mode and container name. * @param endpoint The storage account endpoint. * @param serviceVersion The blob service version. - * @param containerName The container name for session scoping. + * @param accountName The storage account name. * @return A new pipeline with session support, or {@code basePipeline} unchanged if sessions are not applicable. */ - public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, SessionMode sessionMode, - String endpoint, BlobServiceVersion serviceVersion, String containerName) { + public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, SessionOptions sessionOptions, + String endpoint, BlobServiceVersion serviceVersion, String accountName) { + + SessionMode sessionMode = sessionOptions != null ? sessionOptions.getSessionMode() : null; + String containerName = sessionOptions != null ? sessionOptions.getContainerName() : null; // Detect whether the pipeline has bearer auth by scanning for the policy. boolean hasBearerAuth = false; @@ -255,7 +259,8 @@ public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, Sess } // The base pipeline (with bearer) serves as the bearer-only pipeline for CreateSession calls. - BlobSessionClient sessionClient = new BlobSessionClient(basePipeline, endpoint, serviceVersion, containerName); + BlobSessionClient sessionClient + = new BlobSessionClient(basePipeline, endpoint, serviceVersion, accountName, containerName); SessionTokenCredentialPolicy sessionPolicy = new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java index 0a67810b1df7..5a4071cff829 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -14,6 +14,7 @@ public final class SessionOptions { private SessionMode sessionMode; + private String containerName; /** * Creates a new {@link SessionOptions} instance with default values. @@ -40,4 +41,25 @@ public SessionOptions setSessionMode(SessionMode sessionMode) { this.sessionMode = sessionMode; return this; } + + /** + * Gets the container name that the session is scoped to. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name that the session is scoped to. This is required when the session mode + * is not {@link SessionMode#NONE}. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } } 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 704cf704e64c..46884d368cf5 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 @@ -690,8 +690,9 @@ public Mono send(HttpRequest request) { public void wrapWithSessionPolicyNoBearerAuthReturnsSamePipeline() { HttpPipeline sharedKeyPipeline = buildSharedKeyPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(sharedKeyPipeline, SessionMode.ALWAYS, ENDPOINT, - BlobServiceVersion.getLatest(), "mycontainer"); + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(sharedKeyPipeline, + new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, + BlobServiceVersion.getLatest(), "myaccount"); assertSame(sharedKeyPipeline, result, "Pipeline without bearer auth should be returned unchanged"); } @@ -700,8 +701,9 @@ public void wrapWithSessionPolicyNoBearerAuthReturnsSamePipeline() { public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { HttpPipeline bearerPipeline = buildBearerPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.NONE, ENDPOINT, - BlobServiceVersion.getLatest(), "mycontainer"); + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, + new SessionOptions().setSessionMode(SessionMode.NONE).setContainerName("mycontainer"), ENDPOINT, + BlobServiceVersion.getLatest(), "myaccount"); assertSame(bearerPipeline, result, "SessionMode.NONE should return the pipeline unchanged"); } @@ -710,8 +712,9 @@ public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { HttpPipeline bearerPipeline = buildBearerPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, null, ENDPOINT, - BlobServiceVersion.getLatest(), "mycontainer"); + HttpPipeline result + = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, new SessionOptions().setContainerName("mycontainer"), + ENDPOINT, BlobServiceVersion.getLatest(), "myaccount"); assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), "Null sessionMode with bearer should resolve to AUTO and add SessionTokenCredentialPolicy"); @@ -721,8 +724,9 @@ public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { public void wrapWithSessionPolicyAlwaysWithBearerAddsSessionPolicy() { HttpPipeline bearerPipeline = buildBearerPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.ALWAYS, ENDPOINT, - BlobServiceVersion.getLatest(), "mycontainer"); + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, + new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, + BlobServiceVersion.getLatest(), "myaccount"); assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), "SessionMode.ALWAYS with bearer should add SessionTokenCredentialPolicy"); @@ -734,8 +738,9 @@ public void wrapWithSessionPolicyAlwaysWithBearerAddsSessionPolicy() { public void wrapWithSessionPolicyInsertsSessionPolicyBeforeBearer() { HttpPipeline bearerPipeline = buildBearerPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, SessionMode.ALWAYS, ENDPOINT, - BlobServiceVersion.getLatest(), "mycontainer"); + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, + new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, + BlobServiceVersion.getLatest(), "myaccount"); int sessionIndex = indexOfPolicy(result, "SessionTokenCredentialPolicy"); int bearerIndex = indexOfPolicy(result, "StorageBearerTokenChallengeAuthorizationPolicy"); From 2f11835ac68005a73f86f0d4043a6bd25a03c94d Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 15:46:39 -0400 Subject: [PATCH 45/84] move accountName to SessionOptions --- .../azure/storage/blob/BlobClientBuilder.java | 2 +- .../blob/BlobContainerClientBuilder.java | 12 ++++-- .../storage/blob/BlobServiceAsyncClient.java | 13 +++--- .../azure/storage/blob/BlobServiceClient.java | 7 +++- .../blob/BlobServiceClientBuilder.java | 4 +- .../implementation/util/BuilderHelper.java | 16 ++++---- .../storage/blob/models/SessionOptions.java | 23 +++++++++++ .../SpecializedBlobClientBuilder.java | 2 +- .../storage/blob/BuilderHelperTests.java | 41 +++++++++++-------- 9 files changed, 80 insertions(+), 40 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 3c68c2145f82..2598621b09fa 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -200,7 +200,7 @@ private HttpPipeline constructPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, null, null, null); + perRetryPolicies, configuration, audience, LOGGER, null, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 0d3152cabe89..39456bc02a25 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -191,14 +191,18 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion if (httpPipeline != null) { return httpPipeline; } - // Set containerName on sessionOptions so BuilderHelper can read it from one place. SessionOptions effectiveSessionOptions = sessionOptions; - if (effectiveSessionOptions != null && containerName != null) { - effectiveSessionOptions.setContainerName(containerName); + if (effectiveSessionOptions != null) { + if (containerName != null) { + effectiveSessionOptions.setContainerName(containerName); + } + if (effectiveSessionOptions.getAccountName() == null) { + effectiveSessionOptions.setAccountName(accountName); + } } return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, effectiveSessionOptions, accountName, serviceVersion); + perRetryPolicies, configuration, audience, LOGGER, effectiveSessionOptions, serviceVersion); } private void validateSessionMode() { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index 1bd49ac74c2e..896feb80d653 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -98,7 +98,7 @@ public final class BlobServiceAsyncClient { private final BlobContainerEncryptionScope blobContainerEncryptionScope; // only used to pass down to container // clients private final boolean anonymousAccess; - private final SessionMode sessionMode; + private final SessionOptions sessionOptions; /** * Package-private constructor for use by {@link BlobServiceClientBuilder}. @@ -136,7 +136,7 @@ public final class BlobServiceAsyncClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; - this.sessionMode = sessionOptions != null ? sessionOptions.getSessionMode() : SessionMode.NONE; + this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); } /** @@ -161,11 +161,12 @@ public BlobContainerAsyncClient getBlobContainerAsyncClient(String containerName containerName = BlobContainerAsyncClient.ROOT_CONTAINER_NAME; } - SessionOptions containerSessionOptions = sessionMode != null && sessionMode != SessionMode.NONE - ? new SessionOptions().setSessionMode(sessionMode).setContainerName(containerName) - : null; + SessionOptions containerSessionOptions = sessionOptions.setSessionMode(sessionOptions.getSessionMode()) + .setContainerName(containerName) + .setAccountName(getAccountName()); + HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, - getAccountUrl(), getServiceVersion(), getAccountName()); + getAccountUrl(), getServiceVersion()); return new BlobContainerAsyncClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index adf45a102d3c..ff397538de4a 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -153,11 +153,14 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } + SessionOptions containerSessionOptions = sessionOptions != null - ? new SessionOptions().setSessionMode(sessionOptions.getSessionMode()).setContainerName(containerName) + ? new SessionOptions().setSessionMode(sessionOptions.getSessionMode()) + .setContainerName(containerName) + .setAccountName(sessionOptions.getAccountName()) : null; HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, - getAccountUrl(), getServiceVersion(), getAccountName()); + getAccountUrl(), getServiceVersion()); return new BlobContainerClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 877eca9e0071..f758ae4b244c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -149,11 +149,13 @@ public BlobServiceClient buildClient() { } private HttpPipeline constructPipeline() { + // Not setting session options here because it would create a pipeline for the service client that has + // a session, when session is container scoped return (httpPipeline != null) ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, null, null, null); + perRetryPolicies, configuration, audience, LOGGER, null, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 1b65e06353d9..1d9413130043 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -89,9 +89,8 @@ public final class BuilderHelper { * @param configuration Configuration store contain environment settings. * @param logger {@link ClientLogger} used to log any exception. * @param audience {@link BlobAudience} used to determine the audience of the blob. - * @param sessionOptions {@link SessionOptions} containing the session mode. + * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name. * Pass {@code null} to disable session support. - * @param containerName The container name for session scoping. Required when session is active. * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ @@ -100,7 +99,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger, SessionOptions sessionOptions, String accountName, BlobServiceVersion serviceVersion) { + ClientLogger logger, SessionOptions sessionOptions, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -132,7 +131,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare } addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, clientOptions, httpClient, - audience, logger, accountName, serviceVersion); + audience, logger, serviceVersion); if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); @@ -167,7 +166,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, TokenCredential tokenCredential, String endpoint, ClientOptions clientOptions, HttpClient httpClient, - BlobAudience audience, ClientLogger logger, String accountName, BlobServiceVersion serviceVersion) { + BlobAudience audience, ClientLogger logger, BlobServiceVersion serviceVersion) { if (sessionOptions == null || tokenCredential == null) { return; @@ -179,6 +178,7 @@ private static void addSessionPolicyIfEnabled(List policies, } String containerName = sessionOptions.getContainerName(); + String accountName = sessionOptions.getAccountName(); validateSessionOptions(containerName, serviceVersion, effectiveMode, logger); List bearerPolicies = new ArrayList<>(policies); @@ -230,17 +230,17 @@ private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCred * container its own session credential cache while sharing all other policies. * * @param basePipeline The service-level pipeline (used as-is for CreateSession calls). - * @param sessionOptions The session options containing mode and container name. + * @param sessionOptions The session options containing mode, container name, and account name. * @param endpoint The storage account endpoint. * @param serviceVersion The blob service version. - * @param accountName The storage account name. * @return A new pipeline with session support, or {@code basePipeline} unchanged if sessions are not applicable. */ public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, SessionOptions sessionOptions, - String endpoint, BlobServiceVersion serviceVersion, String accountName) { + String endpoint, BlobServiceVersion serviceVersion) { SessionMode sessionMode = sessionOptions != null ? sessionOptions.getSessionMode() : null; String containerName = sessionOptions != null ? sessionOptions.getContainerName() : null; + String accountName = sessionOptions != null ? sessionOptions.getAccountName() : null; // Detect whether the pipeline has bearer auth by scanning for the policy. boolean hasBearerAuth = false; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java index 5a4071cff829..7ab7b2eb7ba9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -15,6 +15,7 @@ public final class SessionOptions { private SessionMode sessionMode; private String containerName; + private String accountName; /** * Creates a new {@link SessionOptions} instance with default values. @@ -62,4 +63,26 @@ public SessionOptions setContainerName(String containerName) { this.containerName = containerName; return this; } + + /** + * Gets the storage account name used for session HMAC signing. + * + * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL). + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the storage account name used for session HMAC signing. When set, this takes precedence + * over the account name parsed from the endpoint URL. This is useful for custom domain URLs + * where the account name cannot be inferred from the hostname. + * + * @param accountName the storage account name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setAccountName(String accountName) { + this.accountName = accountName; + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java index 2b11d9bdc9d6..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, null, null, null); + perRetryPolicies, configuration, audience, LOGGER, null, null); } private BlobServiceVersion getServiceVersion() { 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 46884d368cf5..e01e5c6696a8 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 @@ -78,7 +78,7 @@ 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), null, null, null); + new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -179,8 +179,7 @@ public void customApplicationIdInUAString(String logOptionsUA, String clientOpti HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, new HttpLogOptions().setApplicationId(logOptionsUA), new ClientOptions().setApplicationId(clientOptionsUA), new ApplicationIdUAStringTestClient(expectedUA), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null, - null); + new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -309,7 +308,7 @@ public void customHeadersClientOptions() { HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions().setHeaders(headers), new ClientOptionsHeadersTestClient(headers), new ArrayList<>(), - new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null, null); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -691,8 +690,10 @@ public void wrapWithSessionPolicyNoBearerAuthReturnsSamePipeline() { HttpPipeline sharedKeyPipeline = buildSharedKeyPipeline(); HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(sharedKeyPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, - BlobServiceVersion.getLatest(), "myaccount"); + new SessionOptions().setSessionMode(SessionMode.ALWAYS) + .setContainerName("mycontainer") + .setAccountName("myaccount"), + ENDPOINT, BlobServiceVersion.getLatest()); assertSame(sharedKeyPipeline, result, "Pipeline without bearer auth should be returned unchanged"); } @@ -702,8 +703,10 @@ public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { HttpPipeline bearerPipeline = buildBearerPipeline(); HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.NONE).setContainerName("mycontainer"), ENDPOINT, - BlobServiceVersion.getLatest(), "myaccount"); + new SessionOptions().setSessionMode(SessionMode.NONE) + .setContainerName("mycontainer") + .setAccountName("myaccount"), + ENDPOINT, BlobServiceVersion.getLatest()); assertSame(bearerPipeline, result, "SessionMode.NONE should return the pipeline unchanged"); } @@ -712,9 +715,9 @@ public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { HttpPipeline bearerPipeline = buildBearerPipeline(); - HttpPipeline result - = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, new SessionOptions().setContainerName("mycontainer"), - ENDPOINT, BlobServiceVersion.getLatest(), "myaccount"); + HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, + new SessionOptions().setContainerName("mycontainer").setAccountName("myaccount"), ENDPOINT, + BlobServiceVersion.getLatest()); assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), "Null sessionMode with bearer should resolve to AUTO and add SessionTokenCredentialPolicy"); @@ -725,8 +728,10 @@ public void wrapWithSessionPolicyAlwaysWithBearerAddsSessionPolicy() { HttpPipeline bearerPipeline = buildBearerPipeline(); HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, - BlobServiceVersion.getLatest(), "myaccount"); + new SessionOptions().setSessionMode(SessionMode.ALWAYS) + .setContainerName("mycontainer") + .setAccountName("myaccount"), + ENDPOINT, BlobServiceVersion.getLatest()); assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), "SessionMode.ALWAYS with bearer should add SessionTokenCredentialPolicy"); @@ -739,8 +744,10 @@ public void wrapWithSessionPolicyInsertsSessionPolicyBeforeBearer() { HttpPipeline bearerPipeline = buildBearerPipeline(); HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS).setContainerName("mycontainer"), ENDPOINT, - BlobServiceVersion.getLatest(), "myaccount"); + new SessionOptions().setSessionMode(SessionMode.ALWAYS) + .setContainerName("mycontainer") + .setAccountName("myaccount"), + ENDPOINT, BlobServiceVersion.getLatest()); int sessionIndex = indexOfPolicy(result, "SessionTokenCredentialPolicy"); int bearerIndex = indexOfPolicy(result, "StorageBearerTokenChallengeAuthorizationPolicy"); @@ -758,7 +765,7 @@ private static HttpPipeline buildBearerPipeline() { return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, - new ClientLogger(BuilderHelperTests.class), null, null, null); + new ClientLogger(BuilderHelperTests.class), null, null); } /** @@ -767,7 +774,7 @@ private static HttpPipeline buildBearerPipeline() { 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, null); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); } /** From 92691d1991c70e7d0b7a41374e2e3550633e7d17 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 17:12:49 -0400 Subject: [PATCH 46/84] refactor: SessionTokenCredentialPolicy accepts bearer policy as constructor dependency SessionTokenCredentialPolicy now takes StorageBearerTokenChallengeAuthorizationPolicy as a constructor dependency instead of relying on pipeline ordering to coordinate with the bearer policy. Key changes: - SessionTokenCredentialPolicy delegates to bearerPolicy.process() for non-session requests (non-GetBlob, NONE mode, AUTO first request) and fallback (503). - Added getBearerPolicy() accessor for use in per-container pipeline construction. - BuilderHelper updated to pass bearer policy through to SessionTokenCredentialPolicy constructor in both addSessionPolicyIfEnabled and wrapWithSessionPolicy. - Tests updated to mock bearerPolicy and verify delegation behavior. This is step 1 of the session auth refactor to align with the .NET SDK's SessionAuthenticationPolicy pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/util/BuilderHelper.java | 23 ++++--- .../util/SessionTokenCredentialPolicy.java | 39 +++++++---- .../SessionTokenCredentialPolicyTest.java | 67 ++++++++++++------- 3 files changed, 82 insertions(+), 47 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 1d9413130043..c768811cfb98 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -181,12 +181,15 @@ private static void addSessionPolicyIfEnabled(List policies, String accountName = sessionOptions.getAccountName(); validateSessionOptions(containerName, serviceVersion, effectiveMode, logger); - List bearerPolicies = new ArrayList<>(policies); httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) : Constants.STORAGE_SCOPE; - bearerPolicies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy + = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); + + List bearerPolicies = new ArrayList<>(policies); + bearerPolicies.add(bearerPolicy); HttpPipeline bearerPipeline = new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) @@ -195,8 +198,8 @@ private static void addSessionPolicyIfEnabled(List policies, .tracer(createTracer(clientOptions)) .build(); - SessionTokenCredentialPolicy sessionPolicy - = createSessionPolicy(bearerPipeline, endpoint, accountName, containerName, serviceVersion, effectiveMode); + SessionTokenCredentialPolicy sessionPolicy = createSessionPolicy(bearerPolicy, bearerPipeline, endpoint, + accountName, containerName, serviceVersion, effectiveMode); policies.add(sessionPolicy); } @@ -213,11 +216,13 @@ private static void validateSessionOptions(String containerName, BlobServiceVers } } - private static SessionTokenCredentialPolicy createSessionPolicy(HttpPipeline bearerPipeline, String endpoint, + private static SessionTokenCredentialPolicy createSessionPolicy( + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpPipeline bearerPipeline, String endpoint, String accountName, String containerName, BlobServiceVersion serviceVersion, SessionMode effectiveMode) { BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, serviceVersion, accountName, containerName); - return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); + return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), + effectiveMode); } private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCredential tokenCredential) { @@ -245,10 +250,12 @@ public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, Sess // Detect whether the pipeline has bearer auth by scanning for the policy. boolean hasBearerAuth = false; int bearerIndex = -1; + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy = null; for (int i = 0; i < basePipeline.getPolicyCount(); i++) { if (basePipeline.getPolicy(i) instanceof StorageBearerTokenChallengeAuthorizationPolicy) { hasBearerAuth = true; bearerIndex = i; + bearerPolicy = (StorageBearerTokenChallengeAuthorizationPolicy) basePipeline.getPolicy(i); break; } } @@ -261,8 +268,8 @@ public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, Sess // The base pipeline (with bearer) serves as the bearer-only pipeline for CreateSession calls. BlobSessionClient sessionClient = new BlobSessionClient(basePipeline, endpoint, serviceVersion, accountName, containerName); - SessionTokenCredentialPolicy sessionPolicy - = new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), effectiveMode); + SessionTokenCredentialPolicy sessionPolicy = new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveMode); // Build a new pipeline with session policy inserted before the bearer policy. List policies = new ArrayList<>(); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 23bbd99b57dd..340250684326 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -13,6 +13,7 @@ import com.azure.core.util.CoreUtils; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; import java.util.Map; @@ -21,6 +22,12 @@ /** * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. + *

+ * This policy wraps a {@link StorageBearerTokenChallengeAuthorizationPolicy} and delegates to it when session + * auth does not apply (non-GetBlob requests, {@link SessionMode#NONE}, or the first request in + * {@link SessionMode#AUTO}). When session auth is active, this policy signs the request directly and skips + * the bearer policy. On fallback (e.g., 503 SessionOperationsTemporarilyUnavailable), this policy explicitly + * delegates to the bearer policy. */ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; @@ -31,26 +38,31 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String SESSION_TOKEN_INVALID = "session_token_invalid"; private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; + private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private final StorageSessionCredentialCache sessionCredentialCache; private final SessionMode mode; private final AtomicBoolean autoActivated = new AtomicBoolean(false); - SessionTokenCredentialPolicy(BlobSessionClient sessionClient) { - this( - new StorageSessionCredentialCache(Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null.")), - SessionMode.AUTO); - } - - SessionTokenCredentialPolicy(StorageSessionCredentialCache sessionCredentialCache, SessionMode mode) { + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + StorageSessionCredentialCache sessionCredentialCache, SessionMode mode) { + this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionCredentialCache = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); this.mode = Objects.requireNonNull(mode, "'mode' cannot be null."); } + /** + * Returns the wrapped bearer token policy. Used when constructing per-container pipelines from a service + * pipeline so that the bearer policy can be reused without scanning the pipeline. + */ + StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() { + return bearerPolicy; + } + @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { if (!isGetBlobRequest(context) || !shouldUseSession()) { - return next.process(); + return bearerPolicy.process(context, next); } HttpPipelineNextPolicy retryNext = next.clone(); @@ -75,9 +87,8 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - // Remove session auth so the downstream BearerTokenPolicy adds a bearer token. context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - return retryNext.process(); + return bearerPolicy.process(context, retryNext); } return Mono.just(response); @@ -88,7 +99,7 @@ public Mono process(HttpPipelineCallContext context, HttpPipelineN @Override public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { if (!isGetBlobRequest(context) || !shouldUseSession()) { - return next.processSync(); + return bearerPolicy.processSync(context, next); } HttpPipelineNextSyncPolicy retryNext = next.clone(); @@ -115,7 +126,7 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex response.close(); context.setData(RETRY_CONTEXT_KEY, true); context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - return retryNext.processSync(); + return bearerPolicy.processSync(context, retryNext); } return response; @@ -241,8 +252,8 @@ private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpR /** * Returns true for 503 with SessionOperationsTemporarilyUnavailable error code. - * The session infrastructure is temporarily down, so we strip session auth and let - * the downstream BearerTokenPolicy handle the request with a bearer token. + * The session infrastructure is temporarily down, so we strip session auth and + * delegate to the wrapped bearer policy to handle the request with a bearer token. */ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) { if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index c2c9203c3247..63d5a670dce7 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -11,6 +11,7 @@ import com.azure.core.http.HttpRequest; import com.azure.core.http.HttpResponse; import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; @@ -33,6 +34,7 @@ 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; @@ -47,11 +49,24 @@ public class SessionTokenCredentialPolicyTest { HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; private BlobSessionClient sessionClient; + private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private SessionTokenCredentialPolicy policy; @BeforeEach public void beforeEach() { sessionClient = mock(BlobSessionClient.class); + bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); + + // Default mock behavior: bearer policy delegates to next policy in the pipeline. + when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.process(); + }); + when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.processSync(); + }); + policy = createPolicy(SessionMode.ALWAYS); } @@ -297,7 +312,8 @@ public void policyFallsToBearerOn503SessionUnavailableAsync() { try (HttpResponse actualResponse = policy.process(context, next).block()) { assertEquals(bearerResponse, actualResponse); verify(unavailableResponse, times(1)).close(); - verify(retryNext, times(1)).process(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).process(any(), any()); // Authorization header should have been stripped so bearer policy can add its own String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); assertTrue(authHeader == null || !authHeader.startsWith("Session"), @@ -325,7 +341,8 @@ public void policyFallsToBearerOn503SessionUnavailableSync() { try (HttpResponse actualResponse = policy.processSync(context, next)) { assertEquals(bearerResponse, actualResponse); verify(unavailableResponse, times(1)).close(); - verify(retryNext, times(1)).processSync(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).processSync(any(), any()); String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); assertTrue(authHeader == null || !authHeader.startsWith("Session"), "Session auth should have been stripped but was: " + authHeader); @@ -365,9 +382,9 @@ public void noneModeAlwaysPassesThrough() { try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) { assertEquals(response, actualResponse); - verify(next, times(1)).process(); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); } } @@ -383,9 +400,9 @@ public void noneModeSyncAlwaysPassesThrough() { try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) { assertEquals(response, actualResponse); - verify(next, times(1)).processSync(); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).processSync(any(), any()); verify(sessionClient, times(0)).createSessionSync(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName)); } } @@ -417,7 +434,7 @@ public void autoModePassesThroughFirstRequestThenSignsSecond() { when(firstResponse.getStatusCode()).thenReturn(200); when(secondResponse.getStatusCode()).thenReturn(200); - // First request — should pass through without session + // First request — should delegate to bearer (AUTO first GetBlob) HttpPipelineCallContext context1 = createContext(); HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); when(next1.process()).thenReturn(Mono.just(firstResponse)); @@ -425,7 +442,7 @@ public void autoModePassesThroughFirstRequestThenSignsSecond() { try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { assertEquals(firstResponse, actual1); verify(sessionClient, times(0)).createSessionAsync(); - assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + verify(bearerPolicy, times(1)).process(any(), any()); } // Second request — should use session @@ -451,7 +468,7 @@ public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { when(firstResponse.getStatusCode()).thenReturn(200); when(secondResponse.getStatusCode()).thenReturn(200); - // First request — pass through + // First request — delegate to bearer HttpPipelineCallContext context1 = createContext(); HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); when(next1.processSync()).thenReturn(firstResponse); @@ -459,7 +476,7 @@ public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { assertEquals(firstResponse, actual1); verify(sessionClient, times(0)).createSessionSync(); - assertNull(context1.getHttpRequest().getHeaders().getValue(authHeaderName)); + verify(bearerPolicy, times(1)).processSync(any(), any()); } // Second request — session signed @@ -476,7 +493,7 @@ public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { - return new SessionTokenCredentialPolicy(new StorageSessionCredentialCache(sessionClient), mode); + return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), mode); } private static StorageSessionCredential credentialWithToken(String token) { @@ -542,8 +559,8 @@ public void putBlobRequestSkipsSessionAuth() { policy.process(context, next).block().close(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), - "PUT request should not be signed with session auth"); + // Non-GetBlob requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } @@ -558,8 +575,8 @@ public void listBlobsRequestSkipsSessionAuth() { policy.process(context, next).block().close(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), - "ListBlobs request (restype=container&comp=list) should not be signed with session auth"); + // ListBlobs requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } @@ -574,8 +591,8 @@ public void getBlobPropertiesRequestSkipsSessionAuth() { policy.process(context, next).block().close(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), - "GetBlobProperties (comp=metadata) should not be signed with session auth"); + // GetBlobProperties (comp=metadata) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } @@ -608,8 +625,8 @@ public void containerLevelGetRequestSkipsSessionAuth() { policy.process(context, next).block().close(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), - "Container-level GET (restype=container) should not use session auth"); + // Container-level GET (restype=container) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } @@ -619,22 +636,22 @@ public void autoModeCounterOnlyAdvancesOnGetBlobRequests() { HttpResponse response = mock(HttpResponse.class); when(response.getStatusCode()).thenReturn(200); - // First request: PUT (non-GetBlob) — should not advance AUTO counter + // First request: PUT (non-GetBlob) — should not advance AUTO counter, delegates to bearer HttpPipelineCallContext putContext = createContextForRequest( new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); when(putNext.process()).thenReturn(Mono.just(response)); autoPolicy.process(putContext, putNext).block().close(); - // Second request: GET blob — should be first GetBlob, so AUTO returns false (bearer) + // Second request: GET blob — should be first GetBlob, so AUTO delegates to bearer HttpPipelineCallContext getContext1 = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); HttpPipelineNextPolicy getNext1 = mock(HttpPipelineNextPolicy.class); when(getNext1.process()).thenReturn(Mono.just(response)); Objects.requireNonNull(autoPolicy.process(getContext1, getNext1).block()).close(); - assertNull(getContext1.getHttpRequest().getHeaders().getValue(authHeaderName), - "First GetBlob in AUTO mode should use bearer (counter not advanced by PUT)"); + // Verify bearer policy was called for both passthrough requests + verify(bearerPolicy, times(2)).process(any(), any()); // Third request: GET blob — should now use session (counter was advanced by previous GetBlob) when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); @@ -660,8 +677,8 @@ public void alwaysModeNonGetBlobSkipsSession() { Objects.requireNonNull(policy.process(context, next).block()).close(); - assertNull(context.getHttpRequest().getHeaders().getValue(authHeaderName), - "ALWAYS mode should still skip session auth for non-GetBlob (DELETE) requests"); + // ALWAYS mode non-GetBlob requests delegate to bearer instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } From 825159d4a0a5bc978509d76aa84ed6a073bcffdb Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 21:18:17 -0400 Subject: [PATCH 47/84] refactor: introduce AuthStrategy enum and consolidate analyzeRequest Consolidate isGetBlobRequest() and shouldUseSession() into a single analyzeRequest() method that returns an AuthStrategy enum (USE_BEARER_TOKEN or USE_SESSION_TOKEN), following the .NET SessionAuthenticationPolicy pattern. Also extract response handling into handleSessionResponse() and handleSessionResponseSync() methods for cleaner process()/processSync() flow. The process methods now have a clear structure: 1. analyzeRequest() -> decide strategy 2. USE_BEARER_TOKEN -> delegate to bearer policy 3. USE_SESSION_TOKEN -> acquire session, sign, send, handle response No behavioral changes - all existing tests pass unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../util/SessionTokenCredentialPolicy.java | 190 ++++++++++-------- 1 file changed, 102 insertions(+), 88 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 340250684326..314b6c536fc0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -21,13 +21,15 @@ import java.util.concurrent.atomic.AtomicBoolean; /** - * Policy that acquires container-scoped session credentials and signs requests using the Session auth scheme. + * A pipeline policy that selects between session token and bearer token authentication. *

- * This policy wraps a {@link StorageBearerTokenChallengeAuthorizationPolicy} and delegates to it when session - * auth does not apply (non-GetBlob requests, {@link SessionMode#NONE}, or the first request in - * {@link SessionMode#AUTO}). When session auth is active, this policy signs the request directly and skips - * the bearer policy. On fallback (e.g., 503 SessionOperationsTemporarilyUnavailable), this policy explicitly - * delegates to the bearer policy. + * This policy occupies the authentication policy slot in the pipeline, wrapping the + * {@link StorageBearerTokenChallengeAuthorizationPolicy}. For eligible blob GET requests, + * the policy authenticates with a session token. For all other requests, it delegates to the + * wrapped bearer token policy. + *

+ * Request analysis is performed by {@link #analyzeRequest(HttpPipelineCallContext)} which returns + * an {@link AuthStrategy} indicating the authentication approach to use. */ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; @@ -43,6 +45,16 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private final SessionMode mode; private final AtomicBoolean autoActivated = new AtomicBoolean(false); + /** + * Authentication strategy determined by {@link #analyzeRequest(HttpPipelineCallContext)}. + */ + enum AuthStrategy { + /** Delegate to the wrapped bearer token policy. */ + USE_BEARER_TOKEN, + /** Acquire a session token and sign the request. */ + USE_SESSION_TOKEN + } + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, StorageSessionCredentialCache sessionCredentialCache, SessionMode mode) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); @@ -61,44 +73,20 @@ StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() { @Override public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { - if (!isGetBlobRequest(context) || !shouldUseSession()) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { return bearerPolicy.process(context, next); } HttpPipelineNextPolicy retryNext = next.clone(); return getValidSessionAsync().flatMap(session -> { signRequest(context, session); - return next.process().flatMap(response -> { - handleSessionExpiringHeader(response); - - if (isSessionCredentialRejected(response)) { - invalidateSession(session); - } - - if (shouldRetryRequest(context, response)) { - response.close(); - context.setData(RETRY_CONTEXT_KEY, true); - return getValidSessionAsync().flatMap(refreshed -> { - signRequest(context, refreshed); - return retryNext.process(); - }); - } - - if (shouldFallBackToBearer(context, response)) { - response.close(); - context.setData(RETRY_CONTEXT_KEY, true); - context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - return bearerPolicy.process(context, retryNext); - } - - return Mono.just(response); - }); + return next.process().flatMap(response -> handleSessionResponse(context, response, session, retryNext)); }); } @Override public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { - if (!isGetBlobRequest(context) || !shouldUseSession()) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { return bearerPolicy.processSync(context, next); } @@ -107,6 +95,58 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex signRequest(context, session); HttpResponse response = next.processSync(); + return handleSessionResponseSync(context, response, session, retryNext); + } + + /** + * Analyzes the request to determine whether a session token or bearer token should be used. + *

+ * Session tokens are only used for blob GET download operations that satisfy: + *

    + *
  • HTTP method is GET
  • + *
  • URL has both container name and blob name (not service or container-level)
  • + *
  • No {@code comp} or {@code restype} query parameters (those indicate sub-operations)
  • + *
  • Session mode permits it ({@link SessionMode#ALWAYS}, or {@link SessionMode#AUTO} after first request)
  • + *
+ * GET requests with {@code snapshot} or {@code versionid} parameters are still eligible. + */ + AuthStrategy analyzeRequest(HttpPipelineCallContext context) { + if (mode == SessionMode.NONE) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + + // Must target a specific blob (container + blob name present). + if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // comp= or restype= indicate sub-operations (properties, metadata, list, tags, etc.) + Map queryParams = parts.getUnparsedParameters(); + if (queryParams.containsKey("comp") || queryParams.containsKey("restype")) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // AUTO mode: first eligible GetBlob uses bearer, subsequent requests use session. + if (mode == SessionMode.AUTO && !autoActivated.getAndSet(true)) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + return AuthStrategy.USE_SESSION_TOKEN; + } + + /** + * Handles the response after a session-authenticated async request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextPolicy retryNext) { + handleSessionExpiringHeader(response); if (isSessionCredentialRejected(response)) { @@ -116,82 +156,56 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex if (shouldRetryRequest(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); - - StorageSessionCredential refreshed = getValidSessionSync(); - signRequest(context, refreshed); - return retryNext.processSync(); + return getValidSessionAsync().flatMap(refreshed -> { + signRequest(context, refreshed); + return retryNext.process(); + }); } if (shouldFallBackToBearer(context, response)) { response.close(); context.setData(RETRY_CONTEXT_KEY, true); context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); - return bearerPolicy.processSync(context, retryNext); + return bearerPolicy.process(context, retryNext); } - return response; - } - - Mono getValidSessionAsync() { - return sessionCredentialCache.getValidSessionAsync(); + return Mono.just(response); } /** - * Determines whether this request should use session auth based on the configured mode. - *

- * This method is only called for GetBlob requests — the caller checks {@link #isGetBlobRequest} first, - * so the AUTO counter only advances on eligible operations. - *

    - *
  • {@link SessionMode#NONE}: always returns false — passthrough to bearer.
  • - *
  • {@link SessionMode#ALWAYS}: always returns true — session from first GetBlob request.
  • - *
  • {@link SessionMode#AUTO}: returns false for the first GetBlob request (bearer), true thereafter.
  • - *
+ * Handles the response after a session-authenticated sync request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. */ - private boolean shouldUseSession() { - switch (mode) { - case NONE: - return false; + private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextSyncPolicy retryNext) { - case ALWAYS: - return true; - - case AUTO: - return autoActivated.getAndSet(true); - - default: - return true; - } - } + handleSessionExpiringHeader(response); - /** - * Returns {@code true} if the request is a GetBlob (download) operation. - *

- * A GetBlob request is identified as an HTTP GET to a URL with both a container name and blob name, - * and no {@code comp} or {@code restype} query parameters (those indicate metadata, properties, - * list, or other sub-operations that should use bearer auth). - *

- * GET requests with {@code snapshot} or {@code versionid} query parameters are still considered - * GetBlob operations and are eligible for session auth. - */ - private static boolean isGetBlobRequest(HttpPipelineCallContext context) { - if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { - return false; + if (isSessionCredentialRejected(response)) { + invalidateSession(session); } - BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); - // Must target a specific blob (container + blob name present). - if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { - return false; + StorageSessionCredential refreshed = getValidSessionSync(); + signRequest(context, refreshed); + return retryNext.processSync(); } - // comp= or restype= indicate sub-operations (properties, metadata, list, tags, etc.) - Map queryParams = parts.getUnparsedParameters(); - if (queryParams.containsKey("comp") || queryParams.containsKey("restype")) { - return false; + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.processSync(context, retryNext); } - return true; + return response; + } + + Mono getValidSessionAsync() { + return sessionCredentialCache.getValidSessionAsync(); } StorageSessionCredential getValidSessionSync() { From ede26db1c5b11361cd5fa06b0ddd8577790ea2db Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 21:28:24 -0400 Subject: [PATCH 48/84] refactor: remove redundant restype check from analyzeRequest The restype query parameter check was redundant because requests with restype=container are container-level operations that have no blob name. They are already filtered by the blob name presence check. This aligns more closely with the .NET SessionAuthenticationPolicy which relies on URL structure rather than query parameter checks. The comp check is retained as a safety measure to exclude sub-operations like GetBlobMetadata (comp=metadata) that have a blob name but are not download operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/util/SessionTokenCredentialPolicy.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 314b6c536fc0..9e04278db640 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -126,9 +126,9 @@ AuthStrategy analyzeRequest(HttpPipelineCallContext context) { return AuthStrategy.USE_BEARER_TOKEN; } - // comp= or restype= indicate sub-operations (properties, metadata, list, tags, etc.) + // comp= indicates sub-operations (metadata, tags, etc.) that should use bearer auth. Map queryParams = parts.getUnparsedParameters(); - if (queryParams.containsKey("comp") || queryParams.containsKey("restype")) { + if (queryParams.containsKey("comp")) { return AuthStrategy.USE_BEARER_TOKEN; } From 8ee73275fe1cf18b971f70653899996696d27df2 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 21 Apr 2026 21:43:34 -0400 Subject: [PATCH 49/84] change sessionmode from always to singlespecifciedcontainer, add resolve method --- .../storage/blob/models/SessionMode.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java index e648e6dbd48d..74c25852edfa 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -16,24 +16,29 @@ public enum SessionMode { /** - * The SDK never implicitly creates sessions. Use this mode when calling Create Session - * explicitly or when sending a very small number of requests where the overhead of an - * extra round-trip is not justified. + * Always use bearer token authentication. No session tokens are used. */ NONE, + /** + * Default behavior. This is currently equivalent to {@link #NONE} + */ + AUTO, + + /** * The SDK creates a session on the first request and keeps an active session until it * receives no requests for 5 minutes. */ - ALWAYS, + SINGLE_SPECIFIED_CONTAINER; /** - * The SDK creates a session on the second request and keeps an active session until it - * receives no requests for 5 minutes. This avoids the overhead of session creation for - * one-shot operations while still benefiting from sessions for repeated access. - *

- * This is the default mode. + * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to + * {@link #NONE}; this may change in a future release without breaking callers that + * use {@code resolve()} consistently. */ - AUTO + public SessionMode resolve() { + return this == AUTO ? NONE : this; + } + } From 887992bc3855a1889b4873fb178c978049f62968 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 09:14:22 -0400 Subject: [PATCH 50/84] wrap bearer token in sessioncredentialpolicy --- .../storage/blob/BlobServiceAsyncClient.java | 8 +- .../azure/storage/blob/BlobServiceClient.java | 9 +- .../implementation/util/BuilderHelper.java | 156 +++--------------- .../util/SessionTokenCredentialPolicy.java | 46 +++--- .../storage/blob/models/SessionMode.java | 3 +- .../storage/blob/BuilderHelperTests.java | 85 ++-------- .../azure/storage/blob/ContainerApiTests.java | 2 +- .../storage/blob/ContainerAsyncApiTests.java | 2 +- .../SessionTokenCredentialPolicyTest.java | 94 +++++------ 9 files changed, 111 insertions(+), 294 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index 896feb80d653..3b8a872680b8 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -161,13 +161,7 @@ public BlobContainerAsyncClient getBlobContainerAsyncClient(String containerName containerName = BlobContainerAsyncClient.ROOT_CONTAINER_NAME; } - SessionOptions containerSessionOptions = sessionOptions.setSessionMode(sessionOptions.getSessionMode()) - .setContainerName(containerName) - .setAccountName(getAccountName()); - - HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, - getAccountUrl(), getServiceVersion()); - return new BlobContainerAsyncClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), + return new BlobContainerAsyncClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index ff397538de4a..631ac797b338 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -154,14 +154,7 @@ public BlobContainerClient getBlobContainerClient(String containerName) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } - SessionOptions containerSessionOptions = sessionOptions != null - ? new SessionOptions().setSessionMode(sessionOptions.getSessionMode()) - .setContainerName(containerName) - .setAccountName(sessionOptions.getAccountName()) - : null; - HttpPipeline containerPipeline = BuilderHelper.wrapWithSessionPolicy(getHttpPipeline(), containerSessionOptions, - getAccountUrl(), getServiceVersion()); - return new BlobContainerClient(containerPipeline, getAccountUrl(), getServiceVersion(), getAccountName(), + return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index c768811cfb98..27726e9f2e37 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -71,8 +71,9 @@ public final class BuilderHelper { * authentication support. *

* When {@code sessionOptions} is non-null and the resolved session mode is not {@link SessionMode#NONE}, - * and a {@code tokenCredential} is present, a {@link SessionTokenCredentialPolicy} is added before the - * bearer token policy. The session policy uses a separate bearer-only pipeline for CreateSession calls. + * and a {@code tokenCredential} is present, a single {@link SessionTokenCredentialPolicy} is added as the + * auth policy. The session policy wraps the bearer token policy internally and delegates to it for + * non-session-eligible requests. When sessions are not active, the bearer token policy is added directly. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. * @param tokenCredential {@link TokenCredential} if present. @@ -130,15 +131,24 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } - addSessionPolicyIfEnabled(policies, sessionOptions, tokenCredential, endpoint, clientOptions, httpClient, - audience, logger, serviceVersion); - if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) : Constants.STORAGE_SCOPE; - policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy + = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); + + SessionOptions effectiveSessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + + HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); + BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); + + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); } if (azureSasCredential != null) { @@ -164,134 +174,22 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare .build(); } - private static void addSessionPolicyIfEnabled(List policies, SessionOptions sessionOptions, - TokenCredential tokenCredential, String endpoint, ClientOptions clientOptions, HttpClient httpClient, - BlobAudience audience, ClientLogger logger, BlobServiceVersion serviceVersion) { - - if (sessionOptions == null || tokenCredential == null) { - return; - } - - SessionMode effectiveMode = resolveSessionMode(sessionOptions.getSessionMode(), tokenCredential); - if (effectiveMode == SessionMode.NONE) { - return; - } - - String containerName = sessionOptions.getContainerName(); - String accountName = sessionOptions.getAccountName(); - validateSessionOptions(containerName, serviceVersion, effectiveMode, logger); - - httpsValidation(tokenCredential, "bearer token", endpoint, logger); - String scope = audience != null - ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) - : Constants.STORAGE_SCOPE; - StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy - = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); - - List bearerPolicies = new ArrayList<>(policies); - bearerPolicies.add(bearerPolicy); - - HttpPipeline bearerPipeline - = new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) - .httpClient(httpClient) - .clientOptions(clientOptions) - .tracer(createTracer(clientOptions)) - .build(); - - SessionTokenCredentialPolicy sessionPolicy = createSessionPolicy(bearerPolicy, bearerPipeline, endpoint, - accountName, containerName, serviceVersion, effectiveMode); - - policies.add(sessionPolicy); - } - - private static void validateSessionOptions(String containerName, BlobServiceVersion serviceVersion, - SessionMode effectiveMode, ClientLogger logger) { - if (CoreUtils.isNullOrEmpty(containerName)) { - throw logger.logExceptionAsError( - new IllegalArgumentException("containerName must be set when using SessionMode." + effectiveMode)); - } - if (serviceVersion == null) { - throw logger.logExceptionAsError( - new IllegalArgumentException("serviceVersion must be set when using SessionMode." + effectiveMode)); - } - } - - private static SessionTokenCredentialPolicy createSessionPolicy( - StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpPipeline bearerPipeline, String endpoint, - String accountName, String containerName, BlobServiceVersion serviceVersion, SessionMode effectiveMode) { - BlobSessionClient sessionClient - = new BlobSessionClient(bearerPipeline, endpoint, serviceVersion, accountName, containerName); - return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), - effectiveMode); - } - - private static SessionMode resolveSessionMode(SessionMode sessionMode, TokenCredential tokenCredential) { - return resolveSessionMode(sessionMode, tokenCredential != null); - } - /** - * Wraps an existing pipeline with a per-container {@link SessionTokenCredentialPolicy}. - * Used by {@link com.azure.storage.blob.BlobServiceClient#getBlobContainerClient(String)} to give each - * container its own session credential cache while sharing all other policies. - * - * @param basePipeline The service-level pipeline (used as-is for CreateSession calls). - * @param sessionOptions The session options containing mode, container name, and account name. - * @param endpoint The storage account endpoint. - * @param serviceVersion The blob service version. - * @return A new pipeline with session support, or {@code basePipeline} unchanged if sessions are not applicable. + * Builds a bearer-only {@link HttpPipeline} for CreateSession calls. This pipeline contains + * all pre-auth policies plus the bearer token policy, but no session policy. */ - public static HttpPipeline wrapWithSessionPolicy(HttpPipeline basePipeline, SessionOptions sessionOptions, - String endpoint, BlobServiceVersion serviceVersion) { - - SessionMode sessionMode = sessionOptions != null ? sessionOptions.getSessionMode() : null; - String containerName = sessionOptions != null ? sessionOptions.getContainerName() : null; - String accountName = sessionOptions != null ? sessionOptions.getAccountName() : null; - - // Detect whether the pipeline has bearer auth by scanning for the policy. - boolean hasBearerAuth = false; - int bearerIndex = -1; - StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy = null; - for (int i = 0; i < basePipeline.getPolicyCount(); i++) { - if (basePipeline.getPolicy(i) instanceof StorageBearerTokenChallengeAuthorizationPolicy) { - hasBearerAuth = true; - bearerIndex = i; - bearerPolicy = (StorageBearerTokenChallengeAuthorizationPolicy) basePipeline.getPolicy(i); - break; - } - } - - SessionMode effectiveMode = resolveSessionMode(sessionMode, hasBearerAuth); - if (effectiveMode == SessionMode.NONE || !hasBearerAuth) { - return basePipeline; - } - - // The base pipeline (with bearer) serves as the bearer-only pipeline for CreateSession calls. - BlobSessionClient sessionClient - = new BlobSessionClient(basePipeline, endpoint, serviceVersion, accountName, containerName); - SessionTokenCredentialPolicy sessionPolicy = new SessionTokenCredentialPolicy(bearerPolicy, - new StorageSessionCredentialCache(sessionClient), effectiveMode); - - // Build a new pipeline with session policy inserted before the bearer policy. - List policies = new ArrayList<>(); - for (int i = 0; i < basePipeline.getPolicyCount(); i++) { - if (i == bearerIndex) { - policies.add(sessionPolicy); - } - policies.add(basePipeline.getPolicy(i)); - } - - return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) - .httpClient(basePipeline.getHttpClient()) + private static HttpPipeline buildBearerPipeline(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, + ClientOptions clientOptions) { + List bearerPolicies = new ArrayList<>(preAuthPolicies); + bearerPolicies.add(bearerPolicy); + return new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) .build(); } - private static SessionMode resolveSessionMode(SessionMode sessionMode, boolean hasBearerAuth) { - if (sessionMode != null) { - return sessionMode; - } - return hasBearerAuth ? SessionMode.AUTO : SessionMode.NONE; - } - /** * Gets the default http log option for Storage Blob. * diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 9e04278db640..d3bed2168d64 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -13,12 +13,13 @@ import com.azure.core.util.CoreUtils; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; +import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; /** * A pipeline policy that selects between session token and bearer token authentication. @@ -42,8 +43,7 @@ final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; private final StorageSessionCredentialCache sessionCredentialCache; - private final SessionMode mode; - private final AtomicBoolean autoActivated = new AtomicBoolean(false); + private final SessionOptions sessionOptions; /** * Authentication strategy determined by {@link #analyzeRequest(HttpPipelineCallContext)}. @@ -56,11 +56,11 @@ enum AuthStrategy { } SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, - StorageSessionCredentialCache sessionCredentialCache, SessionMode mode) { + StorageSessionCredentialCache sessionCredentialCache, SessionOptions sessionOptions) { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionCredentialCache = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); - this.mode = Objects.requireNonNull(mode, "'mode' cannot be null."); + this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); } /** @@ -100,18 +100,19 @@ public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNex /** * Analyzes the request to determine whether a session token or bearer token should be used. - *

- * Session tokens are only used for blob GET download operations that satisfy: - *

    - *
  • HTTP method is GET
  • - *
  • URL has both container name and blob name (not service or container-level)
  • - *
  • No {@code comp} or {@code restype} query parameters (those indicate sub-operations)
  • - *
  • Session mode permits it ({@link SessionMode#ALWAYS}, or {@link SessionMode#AUTO} after first request)
  • - *
- * GET requests with {@code snapshot} or {@code versionid} parameters are still eligible. + * Session tokens are only used for blob GET operations in + * {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode targeting the configured container. + * + * @param context the pipeline call context for the request being analyzed. + * @return {@link AuthStrategy#USE_SESSION_TOKEN} if the request is eligible for session-token + * authentication (a GET against a blob in the configured container, with no {@code comp} query + * parameter, while in {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode); + * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise. */ AuthStrategy analyzeRequest(HttpPipelineCallContext context) { - if (mode == SessionMode.NONE) { + SessionMode effectiveMode = sessionOptions.getSessionMode().resolve(); + + if (effectiveMode == SessionMode.NONE) { return AuthStrategy.USE_BEARER_TOKEN; } @@ -121,19 +122,24 @@ AuthStrategy analyzeRequest(HttpPipelineCallContext context) { BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); - // Must target a specific blob (container + blob name present). - if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) || CoreUtils.isNullOrEmpty(parts.getBlobName())) { + // If Service-level request (no container in path) + if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) + && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // If Container level request (container in path but no blob) + if (CoreUtils.isNullOrEmpty(parts.getBlobName())) { return AuthStrategy.USE_BEARER_TOKEN; } - // comp= indicates sub-operations (metadata, tags, etc.) that should use bearer auth. + // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth. Map queryParams = parts.getUnparsedParameters(); if (queryParams.containsKey("comp")) { return AuthStrategy.USE_BEARER_TOKEN; } - // AUTO mode: first eligible GetBlob uses bearer, subsequent requests use session. - if (mode == SessionMode.AUTO && !autoActivated.getAndSet(true)) { + if (parts.getBlobContainerName().compareToIgnoreCase(sessionOptions.getContainerName()) != 0) { return AuthStrategy.USE_BEARER_TOKEN; } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java index 74c25852edfa..8e0ed32abd2c 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -10,7 +10,7 @@ * and authorization cost across many requests by signing them with a lightweight HMAC key instead * of a full bearer token. * {@link #NONE} - * {@link #ALWAYS} + * {@link #SINGLE_SPECIFIED_CONTAINER} * {@link #AUTO} */ public enum SessionMode { @@ -25,7 +25,6 @@ public enum SessionMode { */ AUTO, - /** * The SDK creates a session on the first request and keeps an active session until it * receives no requests for 5 minutes. 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 e01e5c6696a8..6b738f2a92f9 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 @@ -50,6 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -684,92 +685,36 @@ public Mono send(HttpRequest request) { } } - // region wrapWithSessionPolicy tests - @Test - public void wrapWithSessionPolicyNoBearerAuthReturnsSamePipeline() { - HttpPipeline sharedKeyPipeline = buildSharedKeyPipeline(); - - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(sharedKeyPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS) - .setContainerName("mycontainer") - .setAccountName("myaccount"), - ENDPOINT, BlobServiceVersion.getLatest()); - - assertSame(sharedKeyPipeline, result, "Pipeline without bearer auth should be returned unchanged"); - } - - @Test - public void wrapWithSessionPolicySessionModeNoneReturnsSamePipeline() { - HttpPipeline bearerPipeline = buildBearerPipeline(); - - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.NONE) - .setContainerName("mycontainer") - .setAccountName("myaccount"), - ENDPOINT, BlobServiceVersion.getLatest()); - - assertSame(bearerPipeline, result, "SessionMode.NONE should return the pipeline unchanged"); - } + // region buildPipeline session tests @Test - public void wrapWithSessionPolicyNullSessionModeWithBearerDefaultsToAuto() { - HttpPipeline bearerPipeline = buildBearerPipeline(); + public void buildPipelineWithTokenCredentialAlwaysHasSessionPolicy() { + HttpPipeline pipeline = buildBearerPipeline(); - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setContainerName("mycontainer").setAccountName("myaccount"), ENDPOINT, - BlobServiceVersion.getLatest()); - - assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), - "Null sessionMode with bearer should resolve to AUTO and add SessionTokenCredentialPolicy"); + assertTrue(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with tokenCredential should always contain SessionTokenCredentialPolicy"); } @Test - public void wrapWithSessionPolicyAlwaysWithBearerAddsSessionPolicy() { - HttpPipeline bearerPipeline = buildBearerPipeline(); - - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS) - .setContainerName("mycontainer") - .setAccountName("myaccount"), - ENDPOINT, BlobServiceVersion.getLatest()); + public void buildPipelineWithSharedKeyDoesNotHaveSessionPolicy() { + HttpPipeline pipeline = buildSharedKeyPipeline(); - assertTrue(hasPolicyOfType(result, "SessionTokenCredentialPolicy"), - "SessionMode.ALWAYS with bearer should add SessionTokenCredentialPolicy"); - assertEquals(bearerPipeline.getPolicyCount() + 1, result.getPolicyCount(), - "Wrapped pipeline should have exactly one additional policy"); - } - - @Test - public void wrapWithSessionPolicyInsertsSessionPolicyBeforeBearer() { - HttpPipeline bearerPipeline = buildBearerPipeline(); - - HttpPipeline result = BuilderHelper.wrapWithSessionPolicy(bearerPipeline, - new SessionOptions().setSessionMode(SessionMode.ALWAYS) - .setContainerName("mycontainer") - .setAccountName("myaccount"), - ENDPOINT, BlobServiceVersion.getLatest()); - - int sessionIndex = indexOfPolicy(result, "SessionTokenCredentialPolicy"); - int bearerIndex = indexOfPolicy(result, "StorageBearerTokenChallengeAuthorizationPolicy"); - - assertTrue(sessionIndex >= 0, "SessionTokenCredentialPolicy should be present"); - assertTrue(bearerIndex >= 0, "StorageBearerTokenChallengeAuthorizationPolicy should be present"); - assertTrue(sessionIndex < bearerIndex, "SessionTokenCredentialPolicy (index " + sessionIndex - + ") should appear before " + "StorageBearerTokenChallengeAuthorizationPolicy (index " + bearerIndex + ")"); + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with shared key should not contain SessionTokenCredentialPolicy"); } /** - * Helper to build a pipeline with bearer token auth for session wrapping tests. + * Helper to build a pipeline with bearer token auth. */ private static HttpPipeline buildBearerPipeline() { return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, - new ClientLogger(BuilderHelperTests.class), null, null); + new ClientLogger(BuilderHelperTests.class), null, BlobServiceVersion.getLatest()); } /** - * Helper to build a pipeline without bearer token auth (shared key only) for session wrapping tests. + * 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, @@ -807,7 +752,7 @@ private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) @Test public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.ALWAYS); + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) .containerName("mycontainer") @@ -819,7 +764,7 @@ public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { @Test public void containerBuilderWithSessionOptionsAlwaysAndNoContainerNameThrows() { - SessionOptions options = new SessionOptions().setSessionMode(SessionMode.ALWAYS); + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); assertThrows(IllegalArgumentException.class, () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) 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 a4d269b8f769..293d07fe68ef 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 @@ -2183,7 +2183,7 @@ public void sessionAuthUsedForGetBlobOnly() { BlobContainerClient sessionCc = getContainerClientBuilderWithTokenCredential(cc.getBlobContainerUrl(), capturePolicy) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.ALWAYS)) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) .buildClient(); // Download the blob — should use session auth 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 7a395ea0481a..0f31079684a0 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 @@ -2194,7 +2194,7 @@ public void sessionAuthUsedForGetBlobOnly() { BlobContainerAsyncClient sessionCcAsync = getContainerClientBuilderWithTokenCredential(ccAsync.getBlobContainerUrl(), capturePolicy) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.ALWAYS)) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) .buildAsyncClient(); // Download the blob — should use session auth diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 63d5a670dce7..189da0563782 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -11,6 +11,7 @@ import com.azure.core.http.HttpRequest; import com.azure.core.http.HttpResponse; import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -67,7 +68,7 @@ public void beforeEach() { return nextPolicy.processSync(); }); - policy = createPolicy(SessionMode.ALWAYS); + policy = createPolicy(SessionMode.SINGLE_SPECIFIED_CONTAINER); } @Test @@ -425,75 +426,68 @@ public void alwaysModeSignsFirstRequest() { } @Test - public void autoModePassesThroughFirstRequestThenSignsSecond() { + public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() { SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); - HttpResponse firstResponse = mock(HttpResponse.class); - HttpResponse secondResponse = mock(HttpResponse.class); + HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); - when(firstResponse.getStatusCode()).thenReturn(200); - when(secondResponse.getStatusCode()).thenReturn(200); + when(response.getStatusCode()).thenReturn(200); - // First request — should delegate to bearer (AUTO first GetBlob) + // AUTO resolves to NONE, so all requests should delegate to bearer HttpPipelineCallContext context1 = createContext(); HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); - when(next1.process()).thenReturn(Mono.just(firstResponse)); + when(next1.process()).thenReturn(Mono.just(response)); try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { - assertEquals(firstResponse, actual1); - verify(sessionClient, times(0)).createSessionAsync(); + assertEquals(response, actual1); verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); } - // Second request — should use session + // Second GetBlob also delegates to bearer (AUTO == NONE, no session ever) HttpPipelineCallContext context2 = createContext(); HttpPipelineNextPolicy next2 = mock(HttpPipelineNextPolicy.class); - when(next2.clone()).thenReturn(next2); - when(next2.process()).thenReturn(Mono.just(secondResponse)); + when(next2.process()).thenReturn(Mono.just(response)); try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) { - assertEquals(secondResponse, actual2); - verify(sessionClient, times(1)).createSessionAsync(); - assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); } } @Test - public void autoModeSyncPassesThroughFirstRequestThenSignsSecond() { + public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); - HttpResponse firstResponse = mock(HttpResponse.class); - HttpResponse secondResponse = mock(HttpResponse.class); + HttpResponse response = mock(HttpResponse.class); - when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); - when(firstResponse.getStatusCode()).thenReturn(200); - when(secondResponse.getStatusCode()).thenReturn(200); + when(response.getStatusCode()).thenReturn(200); - // First request — delegate to bearer + // AUTO resolves to NONE, so all requests should delegate to bearer HttpPipelineCallContext context1 = createContext(); HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); - when(next1.processSync()).thenReturn(firstResponse); + when(next1.processSync()).thenReturn(response); try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { - assertEquals(firstResponse, actual1); - verify(sessionClient, times(0)).createSessionSync(); + assertEquals(response, actual1); verify(bearerPolicy, times(1)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); } - // Second request — session signed HttpPipelineCallContext context2 = createContext(); HttpPipelineNextSyncPolicy next2 = mock(HttpPipelineNextSyncPolicy.class); - when(next2.clone()).thenReturn(next2); - when(next2.processSync()).thenReturn(secondResponse); + when(next2.processSync()).thenReturn(response); try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) { - assertEquals(secondResponse, actual2); - verify(sessionClient, times(1)).createSessionSync(); - assertTrue(context2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); } } private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { - return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), mode); + SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); + return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), + options); } private static StorageSessionCredential credentialWithToken(String token) { @@ -631,43 +625,31 @@ public void containerLevelGetRequestSkipsSessionAuth() { } @Test - public void autoModeCounterOnlyAdvancesOnGetBlobRequests() { + public void autoModeAlwaysDelegatesToBearerEvenForGetBlobRequests() { SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); HttpResponse response = mock(HttpResponse.class); when(response.getStatusCode()).thenReturn(200); - // First request: PUT (non-GetBlob) — should not advance AUTO counter, delegates to bearer + // PUT request — delegates to bearer (AUTO == NONE) HttpPipelineCallContext putContext = createContextForRequest( new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); when(putNext.process()).thenReturn(Mono.just(response)); autoPolicy.process(putContext, putNext).block().close(); - // Second request: GET blob — should be first GetBlob, so AUTO delegates to bearer - HttpPipelineCallContext getContext1 + // GET blob — also delegates to bearer (AUTO == NONE) + HttpPipelineCallContext getContext = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); - HttpPipelineNextPolicy getNext1 = mock(HttpPipelineNextPolicy.class); - when(getNext1.process()).thenReturn(Mono.just(response)); - Objects.requireNonNull(autoPolicy.process(getContext1, getNext1).block()).close(); + HttpPipelineNextPolicy getNext = mock(HttpPipelineNextPolicy.class); + when(getNext.process()).thenReturn(Mono.just(response)); + Objects.requireNonNull(autoPolicy.process(getContext, getNext).block()).close(); - // Verify bearer policy was called for both passthrough requests verify(bearerPolicy, times(2)).process(any(), any()); - - // Third request: GET blob — should now use session (counter was advanced by previous GetBlob) - when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); - HttpPipelineCallContext getContext2 - = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); - HttpPipelineNextPolicy getNext2 = mock(HttpPipelineNextPolicy.class); - when(getNext2.clone()).thenReturn(getNext2); - when(getNext2.process()).thenReturn(Mono.just(response)); - Objects.requireNonNull(autoPolicy.process(getContext2, getNext2).block()).close(); - - assertTrue(getContext2.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), - "Second GetBlob in AUTO mode should use session auth"); + verify(sessionClient, times(0)).createSessionAsync(); } @Test - public void alwaysModeNonGetBlobSkipsSession() { + public void singleSpecifiedContainerModeNonGetBlobSkipsSession() { HttpPipelineCallContext context = createContextForRequest( new HttpRequest(HttpMethod.DELETE, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); @@ -677,7 +659,7 @@ public void alwaysModeNonGetBlobSkipsSession() { Objects.requireNonNull(policy.process(context, next).block()).close(); - // ALWAYS mode non-GetBlob requests delegate to bearer instead of session auth + // SINGLE_SPECIFIED_CONTAINER mode non-GetBlob requests delegate to bearer instead of session auth verify(bearerPolicy, times(1)).process(any(), any()); verify(sessionClient, times(0)).createSessionAsync(); } From 1f62d31f4ef626a3a3c9e7971d2239f6eb7dd343 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 09:52:15 -0400 Subject: [PATCH 51/84] fix NPE for SessionOptions, sessionoptions always non null --- .../blob/BlobContainerClientBuilder.java | 24 +++++++------------ .../storage/blob/BlobServiceAsyncClient.java | 3 ++- .../azure/storage/blob/BlobServiceClient.java | 3 ++- .../blob/BlobServiceClientBuilder.java | 8 +++---- .../implementation/util/BuilderHelper.java | 4 +++- .../util/SessionTokenCredentialPolicy.java | 2 +- .../storage/blob/models/SessionOptions.java | 20 ++++++++++++---- ...arerTokenChallengeAuthorizationPolicy.java | 12 ---------- 8 files changed, 36 insertions(+), 40 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 39456bc02a25..3599aa855136 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -93,7 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait authorizeRequest(HttpPipelineCallContext context) { - // If another policy (e.g., SessionTokenCredentialPolicy) has already set the Authorization header, - // skip bearer token authorization to avoid overwriting it. - if (hasSessionHeader(context)) { - return Mono.empty(); - } return super.authorizeRequest(context); } @Override public void authorizeRequestSync(HttpPipelineCallContext context) { - if (hasSessionHeader(context)) { - return; - } super.authorizeRequestSync(context); } @@ -160,8 +152,4 @@ static boolean isBearerChallenge(String authenticateHeader) { return (!CoreUtils.isNullOrEmpty(authenticateHeader) && authenticateHeader.regionMatches(true, 0, BEARER_TOKEN_PREFIX, 0, BEARER_TOKEN_PREFIX.length())); } - - private boolean hasSessionHeader(HttpPipelineCallContext context) { - return context.getHttpRequest().getHeaders().get(HttpHeaderName.AUTHORIZATION) != null; - } } From 0a93c2498ddc45bda695a5a5359f736ede10345f Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 10:21:22 -0400 Subject: [PATCH 52/84] add tests for sessiontokencredpolicy and storagesessioncred --- .../SessionTokenCredentialPolicyTest.java | 32 +++++++++++++++++-- .../util/StorageSessionCredentialTest.java | 32 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 189da0563782..367f17210982 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -522,8 +522,6 @@ private static HttpPipelineCallContext createContextForRequest(HttpRequest reque return context; } - // region GetBlob-only filtering tests - @Test public void getBlobRequestUsesSessionAuth() { HttpPipelineCallContext context @@ -542,6 +540,36 @@ public void getBlobRequestUsesSessionAuth() { "GetBlob request should be signed with session auth"); } + @Test + public void getBlobRequestSignedHeaderEqualsCredentialOutput() { + StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT") + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023"); + + HttpPipelineCallContext context = createContextForRequest(request); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(cred)); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + String actual = request.getHeaders().getValue(authHeaderName); + + String expected = cred.generateAuthorizationHeader(request.getUrl(), request.getHttpMethod().toString(), + request.getHeaders()); + assertEquals(expected, actual, + "Policy must stamp the exact Authorization value StorageSessionCredential generates"); + } + @Test public void putBlobRequestSkipsSessionAuth() { HttpPipelineCallContext context = createContextForRequest( diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java index ea6d101e4e42..ba2c87b22c8f 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -118,6 +118,38 @@ public void isExpiredReturnsFalseWhenBeforeExpiration() { assertFalse(credential.isExpired(), "Credential should not be expired when expiration is in the future"); } + @Test + public void signatureMatchesSharedKeyForRealisticBlobDownloadRequest() throws MalformedURLException { + // The session signing protocol is a port of Shared Key. For a representative download + // request, the resulting HMAC must be byte-identical to what StorageSharedKeyCredential + // would produce for the same URL/method/headers. Divergence here is the most likely root + // cause of a 401 InvalidAuthenticationInfo on the session signing path. + String accountName = SessionTestHelper.TEST_ACCOUNT_NAME; + String accountKey = SessionTestHelper.TEST_SESSION_KEY; + + StorageSessionCredential sessionCred + = new StorageSessionCredential("ignored-token", accountKey, OffsetDateTime.now().plusHours(1), accountName); + StorageSharedKeyCredential sharedKeyCred = new StorageSharedKeyCredential(accountName, accountKey); + + URL url = new URL( + "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2025-03-31T00%3A00%3A00.0000000Z"); + 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.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023"); + + String sessionAuth = sessionCred.generateAuthorizationHeader(url, "GET", headers); + String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); + + Map headerMap = headers.stream().collect(Collectors.toMap(h -> h.getName(), h -> h.getValue())); + String sharedKeyAuth = sharedKeyCred.generateAuthorizationHeader(url, "GET", headerMap); + String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); + + assertEquals(sharedKeySignature, sessionSignature, + "Session HMAC must match Shared Key HMAC for an identical Download Blob request"); + } + @Test public void sessionAndSharedKeyProduceSameSignatureForIpStyleUrl() throws MalformedURLException { String accountName = SessionTestHelper.TEST_ACCOUNT_NAME; From c74139771ed684bc52ea6ea3da587d2946fa2211 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 11:04:32 -0400 Subject: [PATCH 53/84] add logic to avoid wrapping Bearertoken, if session is not needed --- .../implementation/util/BuilderHelper.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 100f14685f62..bd92f796d30b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -142,15 +142,21 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare SessionOptions effectiveSessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); - BlobServiceVersion effectiveServiceVersion - = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); - HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); - BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, - effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); + if (effectiveSessionOptions.getSessionMode().resolve() == SessionMode.NONE) { + policies.add(bearerPolicy); + } else { + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, - new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); + BlobSessionClient sessionClient + = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); + + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + } } if (azureSasCredential != null) { From 0ef51e674757f6466bdd14150dc929113e294521 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 16:03:38 -0400 Subject: [PATCH 54/84] add overloaded oauth in blobtestbase to be able to add sessionoptions --- .../test/java/com/azure/storage/blob/BlobTestBase.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 5406fd69e941..cc20f736cf8d 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java @@ -49,6 +49,7 @@ import com.azure.storage.blob.models.LeaseStateType; import com.azure.storage.blob.models.ListBlobContainersOptions; import com.azure.storage.blob.models.PublicAccessType; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.options.BlobBreakLeaseOptions; import com.azure.storage.blob.sas.BlobSasPermission; import com.azure.storage.blob.specialized.BlobAsyncClientBase; @@ -412,8 +413,12 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien } protected BlobServiceClient getOAuthServiceClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceClient(new SessionOptions()); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); From b7e8243ad328142ba31997ed05052c5376be4e84 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 17:35:13 -0400 Subject: [PATCH 55/84] add overloaded getOAuthServiceAsyncClient to be able to pass session options --- .../test/java/com/azure/storage/blob/BlobTestBase.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 cc20f736cf8d..8e34645bdf3a 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 @@ -426,8 +426,12 @@ protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) } protected BlobServiceAsyncClient getOAuthServiceAsyncClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceAsyncClient(new SessionOptions()); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); From e54db679ecf35cd8ad690a9ff5712bc8d732fbe9 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 17:36:29 -0400 Subject: [PATCH 56/84] add custom buildStringToSign to remove `0` from get requests --- .../util/SessionTokenCredentialPolicy.java | 4 +- .../util/StorageSessionCredential.java | 250 +++++++----------- .../azure/storage/blob/ContainerApiTests.java | 70 ++--- .../storage/blob/ContainerAsyncApiTests.java | 74 ++++++ .../SessionTokenCredentialPolicyTest.java | 57 +++- .../util/StorageSessionCredentialTest.java | 187 +++++-------- 6 files changed, 322 insertions(+), 320 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 3cce2d687634..c6c9ff45504e 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -223,9 +223,7 @@ void invalidateSession(StorageSessionCredential target) { } private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { - context.getHttpRequest() - .setHeader(HttpHeaderName.AUTHORIZATION, cred.generateAuthorizationHeader(context.getHttpRequest().getUrl(), - context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders())); + cred.signRequest(context.getHttpRequest()); } private void handleSessionExpiringHeader(HttpResponse response) { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index f56f25f02942..a8aad492f0d0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -6,11 +6,10 @@ import com.azure.core.http.HttpHeader; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; import com.azure.core.util.CoreUtils; -import com.azure.core.util.Header; -import com.azure.storage.common.implementation.StorageImplUtils; - -import java.util.Objects; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.common.StorageSharedKeyCredential; import java.net.URL; import java.text.Collator; @@ -18,197 +17,150 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.Map; +import java.util.Objects; import java.util.TreeMap; -import static com.azure.storage.common.Utility.urlDecode; - /** - * Holds session credentials (token, key, expiration) and signs requests using the Shared Key protocol. - * The Authorization header format is: {@code Session :} + * Holds session credentials and signs requests using the Shared Key string-to-sign with the + * Session scheme prefix. */ final class StorageSessionCredential { private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); + private static final String SESSION_PREFIX = "Session "; private final String sessionToken; private final String sessionKey; private final OffsetDateTime expiration; private final String accountName; + private final StorageSharedKeyCredential sharedKey; - /** - * Creates a StorageSessionCredential with the given session token, key, expiration, and storage account name. - * - * @param sessionToken The opaque session token from Create Session response. - * @param sessionKey The Base64-encoded symmetric key for HMAC signing. - * @param expiration The time when this session expires. - * @param accountName The storage account name associated with the request. - */ - public StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, - String accountName) { + StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, String accountName) { this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); this.expiration = expiration != null ? expiration : OffsetDateTime.now().plusMinutes(5L); this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); + this.sharedKey = new StorageSharedKeyCredential(accountName, sessionKey); } - /** - * Computes an HMAC-SHA256 signature for the given string-to-sign using the session key. - * - * @param stringToSign The string to sign. - * @return The Base64-encoded HMAC-SHA256 signature. - */ - public String computeHmac256(String stringToSign) { - return StorageImplUtils.computeHMac256(sessionKey, stringToSign); - } - - /** - * Generates the Session Authorization header value for a request. - * Format: {@code Session :} - * - * @param requestURL The request URL. - * @param httpMethod The HTTP method (GET, PUT, etc.). - * @param headers The request headers. - * @return The Authorization header value. - */ - public String generateAuthorizationHeader(URL requestURL, String httpMethod, HttpHeaders headers) { - String stringToSign = buildStringToSign(requestURL, httpMethod, headers); - String signature = computeHmac256(stringToSign); - return "Session " + sessionToken + ":" + signature; - } - - public String getSessionToken() { - return sessionToken; - } - - public String getSessionKey() { - return sessionKey; - } - - public OffsetDateTime getExpiration() { - return expiration; - } + void signRequest(HttpRequest request) { + // Pin x-ms-date so the value we sign matches what is on the wire (AddDatePolicy only sets Date). + // Honor any pre-set x-ms-date so callers (e.g., tests, retries) can pin a deterministic value. + if (request.getHeaders().getValue(X_MS_DATE) == null) { + request.setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now())); + } - public boolean isExpired() { - return OffsetDateTime.now().isAfter(expiration); + String stringToSign = buildStringToSign(request); + String signature = sharedKey.computeHmac256(stringToSign); + request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature); } - // ---- String-to-sign logic (Shared Key protocol) ---- - // Ported from StorageSharedKeyCredential.buildStringToSign(). The signing format is identical. - - private String buildStringToSign(URL requestURL, String httpMethod, HttpHeaders headers) { - String contentLength = headers.getValue(HttpHeaderName.CONTENT_LENGTH); - contentLength = "0".equals(contentLength) ? "" : contentLength; - - String dateHeader - = (headers.getValue(X_MS_DATE) != null) ? "" : getStandardHeaderValue(headers, HttpHeaderName.DATE); - + // Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for + // Content-Length. The Session protocol signs the literal value the wire carries. + // + // We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in + // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/ + // implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls + // `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including + // GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization + // then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it + // sees on the wire, so delegating produces a signature mismatch. + // + // TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE, + // delete this method and delegate to sharedKey.generateAuthorizationHeader(...). + // This matches what happens in dotnet: + // https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99 + private String buildStringToSign(HttpRequest request) { + HttpHeaders headers = request.getHeaders(); Collator collator = Collator.getInstance(Locale.ROOT); - return String.join("\n", httpMethod, getStandardHeaderValue(headers, HttpHeaderName.CONTENT_ENCODING), - getStandardHeaderValue(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength, - getStandardHeaderValue(headers, HttpHeaderName.CONTENT_MD5), - getStandardHeaderValue(headers, HttpHeaderName.CONTENT_TYPE), dateHeader, - getStandardHeaderValue(headers, HttpHeaderName.IF_MODIFIED_SINCE), - getStandardHeaderValue(headers, HttpHeaderName.IF_MATCH), - getStandardHeaderValue(headers, HttpHeaderName.IF_NONE_MATCH), - getStandardHeaderValue(headers, HttpHeaderName.IF_UNMODIFIED_SINCE), - getStandardHeaderValue(headers, HttpHeaderName.RANGE), getAdditionalXmsHeaders(headers, collator), - getCanonicalizedResource(requestURL, collator)); - } - private static String getStandardHeaderValue(HttpHeaders headers, HttpHeaderName headerName) { - final Header header = headers.get(headerName); - return header == null ? "" : header.getValue(); + String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH); + // If x-ms-date is present, the Date slot is empty. + String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE); + + return String.join("\n", request.getHttpMethod().toString(), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_ENCODING), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength, + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_MD5), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_TYPE), dateHeader, + getHeaderOrEmpty(headers, HttpHeaderName.IF_MODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.IF_MATCH), getHeaderOrEmpty(headers, HttpHeaderName.IF_NONE_MATCH), + getHeaderOrEmpty(headers, HttpHeaderName.IF_UNMODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.RANGE), canonicalizedXmsHeaders(headers, collator), + canonicalizedResource(request.getUrl(), collator)); } - private static String getAdditionalXmsHeaders(HttpHeaders headers, Collator collator) { - List
xmsHeaders = new ArrayList<>(); + private static String getHeaderOrEmpty(HttpHeaders headers, HttpHeaderName name) { + String value = headers.getValue(name); + return value == null ? "" : value; + } - int stringBuilderSize = 0; + private static String canonicalizedXmsHeaders(HttpHeaders headers, Collator collator) { + List xmsHeaders = new ArrayList<>(); for (HttpHeader header : headers) { - String headerName = header.getName(); - if (!"x-ms-".regionMatches(true, 0, headerName, 0, 5)) { - continue; + if ("x-ms-".regionMatches(true, 0, header.getName(), 0, 5)) { + xmsHeaders.add(header); } - - String headerValue = header.getValue(); - stringBuilderSize += headerName.length() + headerValue.length(); - - xmsHeaders.add(header); } - if (xmsHeaders.isEmpty()) { return ""; } - - final StringBuilder canonicalizedHeaders = new StringBuilder(stringBuilderSize + (2 * xmsHeaders.size()) - 1); - - xmsHeaders.sort((o1, o2) -> collator.compare(o1.getName(), o2.getName())); - - for (Header xmsHeader : xmsHeaders) { - if (canonicalizedHeaders.length() > 0) { - canonicalizedHeaders.append('\n'); + xmsHeaders.sort((a, b) -> collator.compare(a.getName(), b.getName())); + StringBuilder sb = new StringBuilder(); + for (HttpHeader h : xmsHeaders) { + if (sb.length() > 0) { + sb.append('\n'); } - canonicalizedHeaders.append(xmsHeader.getName().toLowerCase(Locale.ROOT)) - .append(':') - .append(xmsHeader.getValue()); + sb.append(h.getName().toLowerCase(Locale.ROOT)).append(':').append(h.getValue()); } - - return canonicalizedHeaders.toString(); + return sb.toString(); } - private String getCanonicalizedResource(URL requestURL, Collator collator) { - String absolutePath = requestURL.getPath(); - if (CoreUtils.isNullOrEmpty(absolutePath)) { - absolutePath = "/"; + private String canonicalizedResource(URL url, Collator collator) { + String path = url.getPath(); + if (CoreUtils.isNullOrEmpty(path)) { + path = "/"; } - - String query = requestURL.getQuery(); + String query = url.getQuery(); if (CoreUtils.isNullOrEmpty(query)) { - return "/" + accountName + absolutePath; + return "/" + accountName + path; } - int stringBuilderSize = 1 + accountName.length() + absolutePath.length() + query.length(); - - TreeMap> pieces = new TreeMap<>(collator); - - StorageImplUtils.parseQueryParameters(query).forEachRemaining(kvp -> { - String key = urlDecode(kvp.getKey()).toLowerCase(Locale.ROOT); - - pieces.compute(key, (k, values) -> { - if (values == null) { - values = new ArrayList<>(); - } - - for (String value : kvp.getValue().split(",")) { - values.add(urlDecode(value)); - } - - return values; - }); - }); + // Sort query parameters with locale-insensitive collation, lower-cased keys. + TreeMap> params = new TreeMap<>(collator); + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = (eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT); + String value = eq < 0 ? "" : pair.substring(eq + 1); + params.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + } - stringBuilderSize += pieces.size(); + StringBuilder sb = new StringBuilder("/").append(accountName).append(path); + for (java.util.Map.Entry> entry : params.entrySet()) { + List values = entry.getValue(); + java.util.Collections.sort(values); + sb.append('\n').append(entry.getKey()).append(':').append(String.join(",", values)); + } + return sb.toString(); + } - StringBuilder canonicalizedResource - = new StringBuilder(stringBuilderSize).append('/').append(accountName).append(absolutePath); + String getSessionToken() { + return sessionToken; + } - for (Map.Entry> queryParam : pieces.entrySet()) { - List queryParamValues = queryParam.getValue(); - queryParamValues.sort(collator); - canonicalizedResource.append('\n').append(queryParam.getKey()).append(':'); + String getSessionKey() { + return sessionKey; + } - int size = queryParamValues.size(); - for (int i = 0; i < size; i++) { - String queryParamValue = queryParamValues.get(i); - if (i > 0) { - canonicalizedResource.append(','); - } + OffsetDateTime getExpiration() { + return expiration; + } - canonicalizedResource.append(queryParamValue); - } - } + String getAccountName() { + return accountName; + } - return canonicalizedResource.toString(); + boolean isExpired() { + return OffsetDateTime.now().isAfter(expiration); } } 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 293d07fe68ef..a0ca9c6426a7 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -4,7 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; -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; @@ -2166,39 +2166,41 @@ public void createSessionWithResponse() { } @Test - public void sessionAuthUsedForGetBlobOnly() { - // Upload a blob using shared key auth + 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); + } + + BlobContainerClient sessionCc = sessionEnabledContainerClient(); + + for (String blobName : blobNames) { + BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); + assertEquals(DATA.getDefaultText(), downloaded.toString()); + } + } + + @Test + public void listBlobsOverSessionEnabledClient() { String blobName = generateBlobName(); - cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), 7); - - // Build a session-enabled container client with token credential - List capturedAuthHeaders = new ArrayList<>(); - HttpPipelinePolicy capturePolicy = (context, next) -> { - String auth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); - if (auth != null) { - capturedAuthHeaders.add(auth); - } - return next.process(); - }; - - BlobContainerClient sessionCc - = getContainerClientBuilderWithTokenCredential(cc.getBlobContainerUrl(), capturePolicy) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) - .buildClient(); - - // Download the blob — should use session auth - capturedAuthHeaders.clear(); - sessionCc.getBlobClient(blobName).downloadContent(); - assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for download"); - assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Session "), - "GetBlob should use Session auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); - - // List blobs — should use bearer auth, not session - capturedAuthHeaders.clear(); - sessionCc.listBlobs().forEach(blob -> { - }); - assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for listBlobs"); - assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Bearer "), - "ListBlobs should use Bearer auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + BlobContainerClient sessionCc = sessionEnabledContainerClient(); + + assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); + } + + private BlobContainerClient sessionEnabledContainerClient() { + System.out.println("[DIAG] sessionEnabledContainerClient: container=" + cc.getBlobContainerName() + " account=" + + cc.getAccountName()); + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index 0f31079684a0..b9794c53cbef 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 @@ -2212,4 +2212,78 @@ public void sessionAuthUsedForGetBlobOnly() { "ListBlobs should use Bearer auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); } + @Test + 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); + } + + java.util.List capturedAuth = java.util.Collections.synchronizedList(new ArrayList<>()); + java.util.List capturedUrls = java.util.Collections.synchronizedList(new ArrayList<>()); + java.util.List capturedStatus = java.util.Collections.synchronizedList(new ArrayList<>()); + HttpPipelinePolicy capture = (ctx, n) -> { + String auth = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String date = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")); + String range = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.RANGE); + String cl = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_LENGTH); + capturedAuth.add((auth == null ? "" : (auth.length() > 30 ? auth.substring(0, 30) + "..." : auth)) + + " date=" + date + " range=" + range + " cl=" + cl); + capturedUrls.add(ctx.getHttpRequest().getHttpMethod() + " " + ctx.getHttpRequest().getUrl().toString()); + return n.process().doOnNext(r -> capturedStatus.add(r.getStatusCode())); + }; + + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + BlobContainerAsyncClient sessionCcAsync = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()) + .addPolicy(capture) + .credential( + com.azure.storage.common.test.shared.StorageCommonTestUtils.getTokenCredential(interceptorManager)) + .buildAsyncClient() + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + + try { + for (String blobName : blobNames) { + StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) + .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) + .verifyComplete(); + } + } finally { + for (int i = 0; i < capturedUrls.size(); i++) { + String s = (i < capturedStatus.size()) ? String.valueOf(capturedStatus.get(i)) : "?"; + System.err.println("[CAPTURE " + i + "] " + s + " " + capturedAuth.get(i) + " " + capturedUrls.get(i)); + } + } + } + + @Test + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(); + + StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements()) + .expectNext(true) + .verifyComplete(); + } + + private BlobContainerAsyncClient sessionEnabledContainerAsyncClient() { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + return getOAuthServiceAsyncClient(sessionOptions).getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 367f17210982..1e9ed280e005 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -541,12 +541,11 @@ public void getBlobRequestUsesSessionAuth() { } @Test - public void getBlobRequestSignedHeaderEqualsCredentialOutput() { + public void getBlobRequestProducesWellFormedSessionAuthHeader() { StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN); HttpRequest request = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); request.getHeaders() - .set(HttpHeaderName.fromString("x-ms-date"), "Mon, 31 Mar 2025 00:00:00 GMT") .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"); @@ -562,12 +561,58 @@ public void getBlobRequestSignedHeaderEqualsCredentialOutput() { policy.process(context, next).block().close(); + // The policy must delegate signing to StorageSessionCredential, producing a Session-scheme + // Authorization header of the form `Session :`. End-to-end signature + // correctness against the live service is covered by ContainerApiTests.downloadBlobOverSessionAuth. String actual = request.getHeaders().getValue(authHeaderName); + assertNotNull(actual, "Authorization header should be set by the policy"); + assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"), + "Authorization should use the Session scheme with the cached session token, but was: " + actual); + String actualSignature = actual.substring(actual.indexOf(':') + 1); + assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"), + "Signature must be base64-encoded, but was: " + actualSignature); + } + + /** + * Guards the workaround in {@link StorageSessionCredential#buildStringToSign}: the Session + * protocol signs the literal {@code Content-Length} value rather than normalizing + * {@code "0" -> ""} like SharedKey does. This is required today because azure-core's + * {@code RestProxyBase} unconditionally adds {@code Content-Length: 0} to body-less GET + * requests. Once that is fixed in azure-core, the buildStringToSign workaround can be removed + * and this test should be updated (or deleted) to reflect the new behavior. + */ + @Test + public void contentLengthZeroIsIncludedInSessionSignature() { + String pinnedDate = "Wed, 22 Apr 2026 20:00:00 GMT"; + + HttpRequest withCl0 + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withCl0.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "0") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withCl0); + String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName)); + + HttpRequest withoutCl + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withoutCl.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withoutCl); + String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); + + assertTrue(!sigWithCl0.equals(sigWithoutCl), + "Session signature must include literal Content-Length value: signing with " + + "Content-Length: 0 must differ from signing without Content-Length"); + } - String expected = cred.generateAuthorizationHeader(request.getUrl(), request.getHttpMethod().toString(), - request.getHeaders()); - assertEquals(expected, actual, - "Policy must stamp the exact Authorization value StorageSessionCredential generates"); + private static String extractSignature(String authHeader) { + return authHeader.substring(authHeader.indexOf(':') + 1); } @Test diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java index ba2c87b22c8f..d5a9e820aa14 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -5,175 +5,106 @@ import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; import com.azure.storage.common.StorageSharedKeyCredential; -import com.azure.storage.common.implementation.StorageImplUtils; import org.junit.jupiter.api.Test; import java.net.MalformedURLException; import java.net.URL; import java.time.OffsetDateTime; -import java.util.Map; -import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class StorageSessionCredentialTest { @Test - public void signRequestWithSessionKey() { - // Given a known session key and a known string-to-sign + public void signRequestUsesSessionScheme() throws MalformedURLException { StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); - 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/mycontainer/myblob"; + credential.signRequest(request); - // When computing HMAC - String signature = credential.computeHmac256(stringToSign); - - // Then it matches the expected HMAC from StorageImplUtils - String expected = StorageImplUtils.computeHMac256(SessionTestHelper.TEST_SESSION_KEY, stringToSign); - - assertEquals(expected, signature); - } - - @Test - public void generateAuthorizationHeaderFormat() throws MalformedURLException { - // Given a session credential - StorageSessionCredential credential = SessionTestHelper.createValidCredential(); - - // And a request URL and headers - URL url = new URL("https://myaccount.blob.core.windows.net/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"); - - // When generating the authorization header - String authHeader = credential.generateAuthorizationHeader(url, "GET", headers); - - // Then it uses the "Session" scheme with the token and a signature + String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertNotNull(authHeader); assertTrue(authHeader.startsWith("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":"), "Authorization header should start with 'Session :' but was: " + authHeader); - - // And the signature portion is a valid Base64 HMAC String signaturePart = authHeader.substring(authHeader.indexOf(':') + 1); - assertTrue(signaturePart.length() > 0, "Signature should not be empty"); + assertFalse(signaturePart.isEmpty(), "Signature should not be empty"); } @Test - public void generateAuthorizationHeaderUsesIpStyleRequestUrl() throws MalformedURLException { - StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, - SessionTestHelper.TEST_SESSION_KEY, OffsetDateTime.now().plusHours(1), SessionTestHelper.TEST_ACCOUNT_NAME); - - 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"); + public void signRequestSetsXmsDateHeader() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); - String authHeader = credential.generateAuthorizationHeader(url, "GET", headers); + assertNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date"))); - // This matches the expected string-to-sign format for an IP-style URL, wherein the first - // accoun - 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); + credential.signRequest(request); - assertEquals("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":" + expectedSignature, authHeader); + assertNotNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")), + "signRequest must set x-ms-date so the signed value matches what is sent on the wire"); } @Test - public void generateAuthorizationHeaderUsesExplicitAccountNameForCustomDomainUrl() throws MalformedURLException { - StorageSessionCredential credential = SessionTestHelper.createCredential(OffsetDateTime.now().plusHours(1), - SessionTestHelper.TEST_ACCOUNT_NAME); - - URL url = new URL("https://cdn.contoso.com/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); + public void signatureMatchesSharedKeyForSameRequest() throws MalformedURLException { + StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); + StorageSharedKeyCredential sharedKeyCred + = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY); + + HttpRequest request = new HttpRequest(HttpMethod.GET, + new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=" + + "2025-03-31T00%3A00%3A00.0000000Z")); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023"); + + sessionCred.signRequest(request); + + String sessionAuth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); - 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/mycontainer/myblob"; - String expectedSignature = credential.computeHmac256(stringToSign); + HttpHeaders headersForSharedKey = request.getHeaders(); + headersForSharedKey.remove(HttpHeaderName.AUTHORIZATION); + String sharedKeyAuth + = sharedKeyCred.generateAuthorizationHeader(request.getUrl(), "GET", headersForSharedKey, false); + String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); - assertEquals("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":" + expectedSignature, authHeader); + assertEquals(sharedKeySignature, sessionSignature, + "Session HMAC must match Shared Key HMAC for the same URL/method/headers"); } @Test public void isExpiredReturnsTrueWhenPastExpiration() { - StorageSessionCredential credential = SessionTestHelper.createExpiredCredential(); - - assertTrue(credential.isExpired(), "Credential should be expired when expiration is in the past"); + assertTrue(SessionTestHelper.createExpiredCredential().isExpired(), + "Credential should be expired when expiration is in the past"); } @Test public void isExpiredReturnsFalseWhenBeforeExpiration() { - StorageSessionCredential credential = SessionTestHelper.createValidCredential(); - - assertFalse(credential.isExpired(), "Credential should not be expired when expiration is in the future"); + assertFalse(SessionTestHelper.createValidCredential().isExpired(), + "Credential should not be expired when expiration is in the future"); } @Test - public void signatureMatchesSharedKeyForRealisticBlobDownloadRequest() throws MalformedURLException { - // The session signing protocol is a port of Shared Key. For a representative download - // request, the resulting HMAC must be byte-identical to what StorageSharedKeyCredential - // would produce for the same URL/method/headers. Divergence here is the most likely root - // cause of a 401 InvalidAuthenticationInfo on the session signing path. - String accountName = SessionTestHelper.TEST_ACCOUNT_NAME; - String accountKey = SessionTestHelper.TEST_SESSION_KEY; - - StorageSessionCredential sessionCred - = new StorageSessionCredential("ignored-token", accountKey, OffsetDateTime.now().plusHours(1), accountName); - StorageSharedKeyCredential sharedKeyCred = new StorageSharedKeyCredential(accountName, accountKey); - - URL url = new URL( - "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2025-03-31T00%3A00%3A00.0000000Z"); - 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.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") - .set(HttpHeaderName.RANGE, "bytes=0-1023"); - - String sessionAuth = sessionCred.generateAuthorizationHeader(url, "GET", headers); - String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); - - Map headerMap = headers.stream().collect(Collectors.toMap(h -> h.getName(), h -> h.getValue())); - String sharedKeyAuth = sharedKeyCred.generateAuthorizationHeader(url, "GET", headerMap); - String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); - - assertEquals(sharedKeySignature, sessionSignature, - "Session HMAC must match Shared Key HMAC for an identical Download Blob request"); - } - - @Test - public void sessionAndSharedKeyProduceSameSignatureForIpStyleUrl() throws MalformedURLException { - String accountName = SessionTestHelper.TEST_ACCOUNT_NAME; - String accountKey = SessionTestHelper.TEST_SESSION_KEY; - - StorageSessionCredential sessionCred - = new StorageSessionCredential("ignored-token", accountKey, OffsetDateTime.now().plusHours(1), accountName); - StorageSharedKeyCredential sharedKeyCred = 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"); - - // Extract just the signature portion from each — the prefix differs (Session vs SharedKey) - String sessionAuth = sessionCred.generateAuthorizationHeader(url, "GET", headers); - String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); - - Map headerMap = headers.stream().collect(Collectors.toMap(h -> h.getName(), h -> h.getValue())); - String sharedKeyAuth = sharedKeyCred.generateAuthorizationHeader(url, "GET", headerMap); - String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); - - assertEquals(sharedKeySignature, sessionSignature, - "Session and SharedKey should produce identical HMAC signatures for IP-style URLs"); + public void getExpirationDefaultsWhenConstructedWithNull() { + OffsetDateTime before = OffsetDateTime.now(); + StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME); + OffsetDateTime after = OffsetDateTime.now(); + + OffsetDateTime expiration = credential.getExpiration(); + assertNotNull(expiration); + assertTrue( + !expiration.isBefore(before.plusMinutes(5L).minusSeconds(1)) + && !expiration.isAfter(after.plusMinutes(5L).plusSeconds(1)), + "Default expiration should be ~5 minutes from construction time, but was " + expiration); } } From e7b935ccd7f567a97976a8de83ab8cca8e716d80 Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 22:05:09 -0400 Subject: [PATCH 57/84] readd versions --- .../java/com/azure/storage/blob/BlobServiceVersion.java | 9 +++++++-- .../datalake/implementation/util/TransformUtils.java | 3 +++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java index efa24e69b84d..75fb74a59a5d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceVersion.java @@ -157,7 +157,12 @@ public enum BlobServiceVersion implements ServiceVersion { /** * Service version {@code 2026-04-06}. */ - V2026_04_06("2026-04-06"); + V2026_04_06("2026-04-06"), + + /** + * Service version {@code 2026-06-06}. + */ + V2026_06_06("2026-06-06"); private final String version; @@ -179,6 +184,6 @@ public String getVersion() { * @return the latest {@link BlobServiceVersion} */ public static BlobServiceVersion getLatest() { - return V2026_04_06; + return V2026_06_06; } } diff --git a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java index 55e8468c6440..fe07d636f95c 100644 --- a/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java +++ b/sdk/storage/azure-storage-file-datalake/src/main/java/com/azure/storage/file/datalake/implementation/util/TransformUtils.java @@ -104,6 +104,9 @@ public static BlobServiceVersion toBlobServiceVersion(DataLakeServiceVersion ver case V2026_04_06: return BlobServiceVersion.V2026_04_06; + case V2026_06_06: + return BlobServiceVersion.V2026_06_06; + default: return null; } From 9d599d775edfeea1b4485ef0aee283ca677c7f8d Mon Sep 17 00:00:00 2001 From: browndav Date: Wed, 22 Apr 2026 22:07:46 -0400 Subject: [PATCH 58/84] readd ci.system.properties --- sdk/storage/azure-storage-common/ci.system.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-common/ci.system.properties b/sdk/storage/azure-storage-common/ci.system.properties index 907af8fd671d..1d1c46cd13b4 100644 --- a/sdk/storage/azure-storage-common/ci.system.properties +++ b/sdk/storage/azure-storage-common/ci.system.properties @@ -1,2 +1,2 @@ -AZURE_LIVE_TEST_SERVICE_VERSION=V2026_04_06 +AZURE_LIVE_TEST_SERVICE_VERSION=V2026_06_06 AZURE_STORAGE_SAS_SERVICE_VERSION=2026-06-06 From 2cd768c554f38586a1da2fadb8a505a3333d4437 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 08:19:03 -0400 Subject: [PATCH 59/84] change session options check for null in BuilderHelper which affected other tests --- .../storage/blob/implementation/util/BuilderHelper.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index bd92f796d30b..61e3c23c8189 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -48,7 +48,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import static com.azure.storage.common.Utility.STORAGE_TRACING_NAMESPACE_VALUE; @@ -140,10 +139,10 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); - SessionOptions effectiveSessionOptions - = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); + SessionOptions effectiveSessionOptions = sessionOptions; - if (effectiveSessionOptions.getSessionMode().resolve() == SessionMode.NONE) { + if (effectiveSessionOptions == null + || effectiveSessionOptions.getSessionMode().resolve() == SessionMode.NONE) { policies.add(bearerPolicy); } else { BlobServiceVersion effectiveServiceVersion From 7754d5010b0259bcf1de2b246185d6fd877559ef Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 10:45:12 -0400 Subject: [PATCH 60/84] add recordings for create sessions, change time to testResource time --- sdk/storage/azure-storage-blob/assets.json | 2 +- .../azure/storage/blob/ContainerApiTests.java | 131 ++++++++++++------ .../storage/blob/ContainerAsyncApiTests.java | 2 +- 3 files changed, 93 insertions(+), 42 deletions(-) diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index f531497084e0..92108b8c51bb 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_4f95fc8478" + "Tag": "java/storage/azure-storage-blob_dbe8c45320" } 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 a0ca9c6426a7..a1548b306526 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 @@ -2159,48 +2159,99 @@ public void createSessionWithResponse() { assertNotNull(sessionResponse); assertNotNull(sessionResponse.getId()); assertNotNull(sessionResponse.getExpiration()); - assertTrue(sessionResponse.getExpiration().isAfter(OffsetDateTime.now())); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); assertNotNull(sessionResponse.getCredentials()); assertNotNull(sessionResponse.getCredentials().getSessionToken()); assertNotNull(sessionResponse.getCredentials().getSessionKey()); } - - @Test - 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); - } - - BlobContainerClient sessionCc = sessionEnabledContainerClient(); - - for (String blobName : blobNames) { - BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); - assertEquals(DATA.getDefaultText(), downloaded.toString()); - } - } - - @Test - public void listBlobsOverSessionEnabledClient() { - String blobName = generateBlobName(); - cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); - - BlobContainerClient sessionCc = sessionEnabledContainerClient(); - - assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); - } - - private BlobContainerClient sessionEnabledContainerClient() { - System.out.println("[DIAG] sessionEnabledContainerClient: container=" + cc.getBlobContainerName() + " account=" - + cc.getAccountName()); - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) - .setContainerName(cc.getBlobContainerName()) - .setAccountName(cc.getAccountName()); - return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); - } +// +// @Test +// @LiveOnly +// 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<>()); +// java.nio.file.Path diagFile = java.nio.file.Paths.get("C:\\repos\\azure-sdk-for-java\\session-diag.txt"); +// try { +// java.nio.file.Files.deleteIfExists(diagFile); +// } catch (java.io.IOException ignored) { +// } +// RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { +// String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); +// String path = req.getUrl().getPath(); +// String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; +// try { +// java.nio.file.Files.write(diagFile, +// java.util.Arrays.asList("WIRE " + req.getHttpMethod() + " " + req.getUrl(), +// " x-ms-date=" + req.getHeaders().getValue("x-ms-date") + " Date=" +// + req.getHeaders().getValue("Date") + " Content-Length=" +// + req.getHeaders().getValue("Content-Length"), +// " Authorization=" +// + (auth == null ? "null" : auth.length() > 60 ? auth.substring(0, 60) + "..." : auth)), +// java.nio.charset.StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE, +// java.nio.file.StandardOpenOption.APPEND); +// } catch (java.io.IOException ignored) { +// } +// if (auth != null && trimmed != null && trimmed.contains("/")) { +// downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); +// } +// }); +// +// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) +// .setContainerName(cc.getBlobContainerName()) +// .setAccountName(cc.getAccountName()); +// BlobContainerClient sessionCc +// = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); +// +// for (String blobName : blobNames) { +// BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); +// assertEquals(DATA.getDefaultText(), downloaded.toString()); +// } +// +// assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), +// "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); +// } +// +// @Test +// @LiveOnly +// public void listBlobsOverSessionEnabledClient() { +// String blobName = generateBlobName(); +// cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); +// +// List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); +// RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { +// String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); +// String query = req.getUrl().getQuery(); +// if (auth != null && query != null && query.contains("comp=list")) { +// listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); +// } +// }); +// +// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) +// .setContainerName(cc.getBlobContainerName()) +// .setAccountName(cc.getAccountName()); +// BlobContainerClient sessionCc +// = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); +// +// assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); +// +// assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); +// assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), +// "Container list operation must use Bearer authorization; saw " + listAuthSchemes); +// } +// +// private BlobContainerClient sessionEnabledContainerClient() { +// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) +// .setContainerName(cc.getBlobContainerName()) +// .setAccountName(cc.getAccountName()); +// return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); +// } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index b9794c53cbef..ffcd611e76b6 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 @@ -2169,7 +2169,7 @@ public void createSessionWithResponse() { assertNotNull(sessionResponse); assertNotNull(sessionResponse.getId()); assertNotNull(sessionResponse.getExpiration()); - assertTrue(sessionResponse.getExpiration().isAfter(OffsetDateTime.now())); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); assertNotNull(sessionResponse.getCredentials()); assertNotNull(sessionResponse.getCredentials().getSessionToken()); assertNotNull(sessionResponse.getCredentials().getSessionKey()); From d01f8f0ab7e90d8788231bef0e28568780601a8f Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 13:06:24 -0400 Subject: [PATCH 61/84] add requestInspectionPolicy and overloaded getoauth client in base test --- .../com/azure/storage/blob/BlobTestBase.java | 25 +++ .../azure/storage/blob/ContainerApiTests.java | 163 ++++++++---------- .../storage/blob/ContainerAsyncApiTests.java | 105 ++++------- .../storage/blob/RequestInspectionPolicy.java | 51 ++++++ 4 files changed, 186 insertions(+), 158 deletions(-) create mode 100644 sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java 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 8e34645bdf3a..b96e458ab8e7 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java @@ -417,11 +417,23 @@ protected BlobServiceClient getOAuthServiceClient() { } protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) { BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient(); } @@ -430,11 +442,24 @@ protected BlobServiceAsyncClient getOAuthServiceAsyncClient() { } protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) { + return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient(); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index a1548b306526..c617da937348 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 @@ -2164,94 +2164,77 @@ public void createSessionWithResponse() { assertNotNull(sessionResponse.getCredentials().getSessionToken()); assertNotNull(sessionResponse.getCredentials().getSessionKey()); } -// -// @Test -// @LiveOnly -// 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<>()); -// java.nio.file.Path diagFile = java.nio.file.Paths.get("C:\\repos\\azure-sdk-for-java\\session-diag.txt"); -// try { -// java.nio.file.Files.deleteIfExists(diagFile); -// } catch (java.io.IOException ignored) { -// } -// RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { -// String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); -// String path = req.getUrl().getPath(); -// String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; -// try { -// java.nio.file.Files.write(diagFile, -// java.util.Arrays.asList("WIRE " + req.getHttpMethod() + " " + req.getUrl(), -// " x-ms-date=" + req.getHeaders().getValue("x-ms-date") + " Date=" -// + req.getHeaders().getValue("Date") + " Content-Length=" -// + req.getHeaders().getValue("Content-Length"), -// " Authorization=" -// + (auth == null ? "null" : auth.length() > 60 ? auth.substring(0, 60) + "..." : auth)), -// java.nio.charset.StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.CREATE, -// java.nio.file.StandardOpenOption.APPEND); -// } catch (java.io.IOException ignored) { -// } -// if (auth != null && trimmed != null && trimmed.contains("/")) { -// downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); -// } -// }); -// -// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) -// .setContainerName(cc.getBlobContainerName()) -// .setAccountName(cc.getAccountName()); -// BlobContainerClient sessionCc -// = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); -// -// for (String blobName : blobNames) { -// BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); -// assertEquals(DATA.getDefaultText(), downloaded.toString()); -// } -// -// assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), -// "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); -// } -// -// @Test -// @LiveOnly -// public void listBlobsOverSessionEnabledClient() { -// String blobName = generateBlobName(); -// cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); -// -// List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); -// RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { -// String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); -// String query = req.getUrl().getQuery(); -// if (auth != null && query != null && query.contains("comp=list")) { -// listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); -// } -// }); -// -// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) -// .setContainerName(cc.getBlobContainerName()) -// .setAccountName(cc.getAccountName()); -// BlobContainerClient sessionCc -// = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); -// -// assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); -// -// assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); -// assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), -// "Container list operation must use Bearer authorization; saw " + listAuthSchemes); -// } -// -// private BlobContainerClient sessionEnabledContainerClient() { -// SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) -// .setContainerName(cc.getBlobContainerName()) -// .setAccountName(cc.getAccountName()); -// return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); -// } + + @Test + @LiveOnly + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + cc.getBlobClient(blobName) + .getBlockBlobClient() + .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + BlobContainerClient sessionCc + = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); + + for (String blobName : blobNames) { + BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); + assertEquals(DATA.getDefaultText(), downloaded.toString()); + } + + assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + @Test + @LiveOnly + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + BlobContainerClient sessionCc + = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); + + assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); + + assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); + assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), + "Container list operation must use Bearer authorization; saw " + listAuthSchemes); + } + + private BlobContainerClient sessionEnabledContainerClient() { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index ffcd611e76b6..661c105b280f 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,7 +4,6 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; -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; @@ -2177,42 +2176,7 @@ public void createSessionWithResponse() { } @Test - public void sessionAuthUsedForGetBlobOnly() { - // Upload a blob using shared key auth - String blobName = generateBlobName(); - ccAsync.getBlobAsyncClient(blobName).getBlockBlobAsyncClient().upload(DATA.getDefaultFlux(), 7).block(); - - // Build a session-enabled container client with token credential - List capturedAuthHeaders = Collections.synchronizedList(new ArrayList<>()); - HttpPipelinePolicy capturePolicy = (context, next) -> { - String auth = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); - if (auth != null) { - capturedAuthHeaders.add(auth); - } - return next.process(); - }; - - BlobContainerAsyncClient sessionCcAsync - = getContainerClientBuilderWithTokenCredential(ccAsync.getBlobContainerUrl(), capturePolicy) - .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) - .buildAsyncClient(); - - // Download the blob — should use session auth - capturedAuthHeaders.clear(); - sessionCcAsync.getBlobAsyncClient(blobName).downloadContent().block(); - assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for download"); - assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Session "), - "GetBlob should use Session auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); - - // List blobs — should use bearer auth, not session - capturedAuthHeaders.clear(); - sessionCcAsync.listBlobs().collectList().block(); - assertFalse(capturedAuthHeaders.isEmpty(), "Expected at least one auth header for listBlobs"); - assertTrue(capturedAuthHeaders.get(capturedAuthHeaders.size() - 1).startsWith("Bearer "), - "ListBlobs should use Bearer auth, got: " + capturedAuthHeaders.get(capturedAuthHeaders.size() - 1)); - } - - @Test + @LiveOnly public void downloadBlobOverSessionAuth() { int blobCount = 5; List blobNames = new ArrayList<>(); @@ -2225,46 +2189,34 @@ public void downloadBlobOverSessionAuth() { blobNames.add(blobName); } - java.util.List capturedAuth = java.util.Collections.synchronizedList(new ArrayList<>()); - java.util.List capturedUrls = java.util.Collections.synchronizedList(new ArrayList<>()); - java.util.List capturedStatus = java.util.Collections.synchronizedList(new ArrayList<>()); - HttpPipelinePolicy capture = (ctx, n) -> { - String auth = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); - String date = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")); - String range = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.RANGE); - String cl = ctx.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_LENGTH); - capturedAuth.add((auth == null ? "" : (auth.length() > 30 ? auth.substring(0, 30) + "..." : auth)) - + " date=" + date + " range=" + range + " cl=" + cl); - capturedUrls.add(ctx.getHttpRequest().getHttpMethod() + " " + ctx.getHttpRequest().getUrl().toString()); - return n.process().doOnNext(r -> capturedStatus.add(r.getStatusCode())); - }; + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) .setContainerName(ccAsync.getBlobContainerName()) .setAccountName(ccAsync.getAccountName()); - BlobContainerAsyncClient sessionCcAsync = new BlobServiceClientBuilder().sessionOptions(sessionOptions) - .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()) - .addPolicy(capture) - .credential( - com.azure.storage.common.test.shared.StorageCommonTestUtils.getTokenCredential(interceptorManager)) - .buildAsyncClient() + BlobContainerAsyncClient sessionCcAsync = getOAuthServiceAsyncClient(sessionOptions, inspect) .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); - try { - for (String blobName : blobNames) { - StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) - .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) - .verifyComplete(); - } - } finally { - for (int i = 0; i < capturedUrls.size(); i++) { - String s = (i < capturedStatus.size()) ? String.valueOf(capturedStatus.get(i)) : "?"; - System.err.println("[CAPTURE " + i + "] " + s + " " + capturedAuth.get(i) + " " + capturedUrls.get(i)); - } + for (String blobName : blobNames) { + StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) + .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) + .verifyComplete(); } + + assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); } @Test + @LiveOnly public void listBlobsOverSessionEnabledClient() { String blobName = generateBlobName(); ccAsync.getBlobAsyncClient(blobName) @@ -2272,11 +2224,28 @@ public void listBlobsOverSessionEnabledClient() { .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) .block(); - BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(); + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + BlobContainerAsyncClient sessionCcAsync = getOAuthServiceAsyncClient(sessionOptions, inspect) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); 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() { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java new file mode 100644 index 000000000000..99d0b115a622 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.storage.blob; + +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +import java.util.function.Consumer; + +/** + * Test-only pipeline policy that lets a test peek at every {@link HttpRequest} as it + * goes on the wire. Registers at {@link HttpPipelinePosition#PER_RETRY} so it sees + * the {@code Authorization} header that the auth policies set. + * + *

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

+ */ +public final class RequestInspectionPolicy implements HttpPipelinePolicy { + private final Consumer inspector; + + public RequestInspectionPolicy(Consumer inspector) { + this.inspector = inspector; + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.process(); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.processSync(); + } +} From 26965a5d7b68afad18e835ec12fb1d2fbb89a22f Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 13:44:11 -0400 Subject: [PATCH 62/84] fix null sessionsoptions issue --- .../implementation/util/BuilderHelper.java | 28 ++++++++----------- .../storage/blob/models/SessionOptions.java | 1 + 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 61e3c23c8189..cdc395abb42b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -139,23 +139,17 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); - SessionOptions effectiveSessionOptions = sessionOptions; - - if (effectiveSessionOptions == null - || effectiveSessionOptions.getSessionMode().resolve() == SessionMode.NONE) { - policies.add(bearerPolicy); - } else { - BlobServiceVersion effectiveServiceVersion - = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); - - HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); - BlobSessionClient sessionClient - = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, - effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); - - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, - new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); - } + SessionOptions effectiveSessionOptions = SessionOptions.orDefault(sessionOptions); + + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + + HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); + BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, + effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); + + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); } if (azureSasCredential != null) { diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java index e4161366771e..b70e682db554 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -19,6 +19,7 @@ public final class SessionOptions { /** * Creates a new {@link SessionOptions} instance with default values. + * Note: This currently only applies when using TokenCredential for GET Blob operations. */ public SessionOptions() { } From 9c5018fedc959292fc250cde56bb9cb84e5b57e1 Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 13:46:42 -0400 Subject: [PATCH 63/84] add fix in storagesessioncredntial for query params --- .../util/StorageSessionCredential.java | 13 ++++++++--- .../util/StorageSessionCredentialTest.java | 23 ++++++++++++++++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java index a8aad492f0d0..d74a4ebc5792 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -10,6 +10,7 @@ import com.azure.core.util.CoreUtils; import com.azure.core.util.DateTimeRfc1123; import com.azure.storage.common.StorageSharedKeyCredential; +import com.azure.storage.common.Utility; import java.net.URL; import java.text.Collator; @@ -127,12 +128,18 @@ private String canonicalizedResource(URL url, Collator collator) { } // Sort query parameters with locale-insensitive collation, lower-cased keys. + // Values must be URL-decoded (and split on commas) to match the canonicalization that the + // service performs; otherwise percent-encoded characters (e.g., %3A in a snapshot timestamp) + // would produce a different HMAC than Shared Key. TreeMap> params = new TreeMap<>(collator); for (String pair : query.split("&")) { int eq = pair.indexOf('='); - String key = (eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT); - String value = eq < 0 ? "" : pair.substring(eq + 1); - params.computeIfAbsent(key, k -> new ArrayList<>()).add(value); + String key = Utility.urlDecode(eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + List decoded = params.computeIfAbsent(key, k -> new ArrayList<>()); + for (String v : rawValue.split(",")) { + decoded.add(Utility.urlDecode(v)); + } } StringBuilder sb = new StringBuilder("/").append(accountName).append(path); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java index d5a9e820aa14..2215af27c1a0 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -7,6 +7,7 @@ import com.azure.core.http.HttpHeaders; import com.azure.core.http.HttpMethod; import com.azure.core.http.HttpRequest; +import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.common.StorageSharedKeyCredential; import org.junit.jupiter.api.Test; @@ -52,8 +53,23 @@ public void signRequestSetsXmsDateHeader() throws MalformedURLException { "signRequest must set x-ms-date so the signed value matches what is sent on the wire"); } + // Regression guard for the URL-decode fix in StorageSessionCredential.canonicalizedResource: + // verifies Session and SharedKey produce the same HMAC for a well-formed GET with an + // encoded query string (e.g. snapshot=...%3A...). + // + // Scope is intentionally narrow. Session and SharedKey legitimately diverge on: + // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits "") + // - Content-Length "0" on GETs (SharedKey normalizes to ""; Session preserves "0" to match + // what azure-core's RestProxyBase puts on the wire — see the comment on + // StorageSessionCredential.buildStringToSign). + // Content-Length is pinned to a realistic non-zero value to bypass both quirks. + // + // DELETE this test once azure-core stops setting Content-Length: 0 on GETs and + // StorageSessionCredential.buildStringToSign is removed in favor of delegating to + // sharedKey.generateAuthorizationHeader(...). At that point this assertion becomes + // tautological (SharedKey vs. SharedKey). @Test - public void signatureMatchesSharedKeyForSameRequest() throws MalformedURLException { + public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException { StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); StorageSharedKeyCredential sharedKeyCred = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY); @@ -62,9 +78,10 @@ public void signatureMatchesSharedKeyForSameRequest() throws MalformedURLExcepti new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=" + "2025-03-31T00%3A00%3A00.0000000Z")); request.getHeaders() - .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-version"), BlobServiceVersion.getLatest().getVersion()) .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") - .set(HttpHeaderName.RANGE, "bytes=0-1023"); + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "1024"); sessionCred.signRequest(request); From 4e90c72084643ddd5132b7835427dd1a8f71907a Mon Sep 17 00:00:00 2001 From: browndav Date: Thu, 23 Apr 2026 15:14:14 -0400 Subject: [PATCH 64/84] add SessionTokenCredPolicy to checks for anonymousAccess --- .../com/azure/storage/blob/BlobServiceClientBuilder.java | 9 +++++++++ .../util/SessionTokenCredentialPolicy.java | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 64fb7fbef87d..b0250ce3ace9 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,6 +30,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.implementation.util.SessionTokenCredentialPolicy; import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; @@ -141,6 +142,10 @@ public BlobServiceClient buildClient() { foundCredential = true; break; } + if (pipeline.getPolicy(i) instanceof SessionTokenCredentialPolicy) { + foundCredential = true; + break; + } } anonymousAccess = !foundCredential; @@ -193,6 +198,10 @@ public BlobServiceAsyncClient buildAsyncClient() { foundCredential = true; break; } + if (pipeline.getPolicy(i) instanceof SessionTokenCredentialPolicy) { + foundCredential = true; + break; + } } anonymousAccess = !foundCredential; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index c6c9ff45504e..24c81f4e3d23 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -32,7 +32,7 @@ * Request analysis is performed by {@link #analyzeRequest(HttpPipelineCallContext)} which returns * an {@link AuthStrategy} indicating the authentication approach to use. */ -final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { +public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); private static final String SESSION_SCHEME = "Session"; From c81999196d9bb1a5681db0149ee40817a8934744 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 24 Apr 2026 13:45:46 -0400 Subject: [PATCH 65/84] remove constructor for BlobSessionClients that uses parse url --- .../util/BlobSessionClient.java | 7 ------ .../util/BlobSessionClientTests.java | 24 +++++++++---------- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index 28bfde61ad6d..cb009a920067 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -7,7 +7,6 @@ import com.azure.core.http.rest.Response; import com.azure.core.util.Context; import com.azure.storage.blob.BlobServiceVersion; -import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.implementation.AzureBlobStorageImpl; import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; import com.azure.storage.blob.implementation.models.AuthenticationType; @@ -28,12 +27,6 @@ final class BlobSessionClient { private final String accountName; private final String containerName; - BlobSessionClient(HttpPipeline bearerPipeline, String serviceEndpoint, BlobServiceVersion serviceVersion, - String containerName) { - this(bearerPipeline, serviceEndpoint, serviceVersion, BlobUrlParts.parse(serviceEndpoint).getAccountName(), - containerName); - } - BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, String containerName) { this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java index 0489b6ad7a15..154ef16cb7bd 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -30,7 +30,7 @@ public void createSessionReturnsTokenAndKey() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), cc.getBlobContainerName()); + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); StorageSessionCredential credential = sessionClient.createSessionSync(); @@ -44,9 +44,9 @@ public void createSessionReturnsTokenAndKey() { public void createSessionAsyncReturnsTokenAndKey() { BlobContainerAsyncClient oauthCc = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); - BlobSessionClient sessionClient - = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { assertNotNull(credential); @@ -61,7 +61,7 @@ public void createSessionSyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - cc.getBlobContainerName()); + ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); StorageSessionCredential credential = sessionClient.createSessionSync(); @@ -77,7 +77,7 @@ public void createSessionAsyncUsesProvidedHttpPipeline() { AtomicInteger policyInvocationCount = new AtomicInteger(); BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), - ccAsync.getBlobContainerName()); + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { assertNotNull(credential); @@ -101,9 +101,9 @@ public void createSessionWithUserDelegationSas() { BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); - BlobSessionClient sessionClient - = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), sasCc.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName()); StorageSessionCredential credential = sessionClient.createSessionSync(); @@ -125,9 +125,9 @@ public void createSessionAsyncWithUserDelegationSas() { = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) .buildClient(); - BlobSessionClient sessionClient - = new BlobSessionClient(sasCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), - BlobServiceVersion.getLatest(), ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { assertNotNull(credential); From a988e5bd9460b6dc6be937d28f3b43729acb1cc7 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 24 Apr 2026 17:03:43 -0400 Subject: [PATCH 66/84] linting issues, remove SessionOptions from service methods --- .../com/azure/storage/blob/BlobServiceAsyncClient.java | 10 +--------- .../java/com/azure/storage/blob/BlobServiceClient.java | 9 +-------- .../azure/storage/blob/BlobServiceClientBuilder.java | 6 +++--- .../util/SessionTokenCredentialPolicy.java | 1 - 4 files changed, 5 insertions(+), 21 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java index d5a899ccdc78..3254b5da630d 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceAsyncClient.java @@ -22,9 +22,7 @@ import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.models.ServicesGetAccountInfoHeaders; -import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; -import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobCorsRule; @@ -35,7 +33,6 @@ import com.azure.storage.blob.models.KeyInfo; import com.azure.storage.blob.models.ListBlobContainersOptions; import com.azure.storage.blob.models.PublicAccessType; -import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.TaggedBlobItem; import com.azure.storage.blob.models.UserDelegationKey; @@ -57,7 +54,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -99,7 +95,6 @@ public final class BlobServiceAsyncClient { private final BlobContainerEncryptionScope blobContainerEncryptionScope; // only used to pass down to container // clients private final boolean anonymousAccess; - private final SessionOptions sessionOptions; /** * Package-private constructor for use by {@link BlobServiceClientBuilder}. @@ -113,12 +108,10 @@ public final class BlobServiceAsyncClient { * @param encryptionScope Encryption scope used during encryption of the blob's data on the server, pass * {@code null} to allow the service to use its own encryption. * @param anonymousAccess Whether the client was built with anonymousAccess - * @param sessionOptions Session options for session-based authentication. */ BlobServiceAsyncClient(HttpPipeline pipeline, String url, BlobServiceVersion serviceVersion, String accountName, CpkInfo customerProvidedKey, EncryptionScope encryptionScope, - BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess, - SessionOptions sessionOptions) { + BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess) { /* Check to make sure the uri is valid. We don't want the error to occur later in the generated layer when the sas token has already been applied. */ try { @@ -137,7 +130,6 @@ public final class BlobServiceAsyncClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; - this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 7fc8532c6bb0..641c40b1a0a0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -27,10 +27,7 @@ import com.azure.storage.blob.implementation.models.ServicesGetPropertiesHeaders; import com.azure.storage.blob.implementation.models.ServicesGetStatisticsHeaders; import com.azure.storage.blob.implementation.models.ServicesGetUserDelegationKeyHeaders; -import com.azure.storage.blob.implementation.util.BuilderHelper; import com.azure.storage.blob.implementation.util.ModelHelper; -import com.azure.storage.blob.models.SessionMode; -import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.BlobContainerItem; import com.azure.storage.blob.models.BlobContainerListDetails; @@ -63,7 +60,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.concurrent.Callable; import java.util.function.BiFunction; import java.util.stream.Collectors; @@ -95,7 +91,6 @@ public final class BlobServiceClient { private final BlobContainerEncryptionScope blobContainerEncryptionScope; // only used to pass down to container // clients private final boolean anonymousAccess; - private final SessionOptions sessionOptions; /** * Package-private constructor for use by {@link BlobServiceClientBuilder}. @@ -112,8 +107,7 @@ public final class BlobServiceClient { */ BlobServiceClient(HttpPipeline pipeline, String url, BlobServiceVersion serviceVersion, String accountName, CpkInfo customerProvidedKey, EncryptionScope encryptionScope, - BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess, - SessionOptions sessionOptions) { + BlobContainerEncryptionScope blobContainerEncryptionScope, boolean anonymousAccess) { /* Check to make sure the uri is valid. We don't want the error to occur later in the generated layer when the sas token has already been applied. */ try { @@ -132,7 +126,6 @@ public final class BlobServiceClient { this.encryptionScope = encryptionScope; this.blobContainerEncryptionScope = blobContainerEncryptionScope; this.anonymousAccess = anonymousAccess; - this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index b0250ce3ace9..3cefa0395364 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -150,7 +150,7 @@ public BlobServiceClient buildClient() { anonymousAccess = !foundCredential; return new BlobServiceClient(pipeline, endpoint, serviceVersion, accountName, customerProvidedKey, - encryptionScope, blobContainerEncryptionScope, anonymousAccess, sessionOptions); + encryptionScope, blobContainerEncryptionScope, anonymousAccess); } private HttpPipeline constructPipeline() { @@ -162,7 +162,7 @@ private HttpPipeline constructPipeline() { } /** - * Creates a {@link BlobServiceAsyncClient}based on options set in the builder. Every time + * Creates a {@link BlobServiceAsyncClient} based on options set in the builder. Every time * {@code buildAsyncClient()} is called, a new instance of {@link BlobServiceAsyncClient} is created. * * @return a {@link BlobServiceAsyncClient} created from the configurations in this builder. @@ -206,7 +206,7 @@ public BlobServiceAsyncClient buildAsyncClient() { anonymousAccess = !foundCredential; return new BlobServiceAsyncClient(pipeline, endpoint, serviceVersion, accountName, customerProvidedKey, - encryptionScope, blobContainerEncryptionScope, anonymousAccess, sessionOptions); + encryptionScope, blobContainerEncryptionScope, anonymousAccess); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 24c81f4e3d23..5d646ffb7df4 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -17,7 +17,6 @@ import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; import reactor.core.publisher.Mono; -import java.util.Locale; import java.util.Map; import java.util.Objects; From 84061e8a810cb0b8c7328fcd335c2059a06d773c Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 24 Apr 2026 17:04:31 -0400 Subject: [PATCH 67/84] add comments to policyrefreshNearExpiry test --- .../implementation/util/SessionTokenCredentialPolicyTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 1e9ed280e005..033f9e3bb0f8 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -102,9 +102,13 @@ public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); when(sessionClient.createSessionSync()).thenReturn(nearExpiry); + // This is a Reactor quirk where Mono.just() emits synchronously on subscribe, so the refresh happens + // immediately when the cache determines the credential is near expiry when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed)); + // Cold call to getValidSessionSync triggers session creation via createSessionSync StorageSessionCredential initial = policy.getValidSessionSync(); + // Trigger refresh, which uses sessionClient.createSessionAsync() to get the refreshed session StorageSessionCredential duringRefresh = policy.getValidSessionSync(); StorageSessionCredential afterRefresh = policy.getValidSessionSync(); From edc4ebe3b6b35fb7dbcb259f1e492185930d7876 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 24 Apr 2026 17:08:45 -0400 Subject: [PATCH 68/84] fix linting issues --- .../src/test/java/com/azure/storage/blob/BuilderHelperTests.java | 1 - 1 file changed, 1 deletion(-) 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 6b738f2a92f9..5c3984960d4a 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 @@ -53,7 +53,6 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; From a66b67f42bedafd15b84dfede5d5992113729d87 Mon Sep 17 00:00:00 2001 From: browndav Date: Fri, 24 Apr 2026 19:16:00 -0400 Subject: [PATCH 69/84] add check for container name --- .../implementation/util/SessionTokenCredentialPolicy.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 5d646ffb7df4..6bbb3d2dc457 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -59,7 +59,13 @@ enum AuthStrategy { this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); this.sessionCredentialCache = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); - this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + + if (this.sessionOptions.getSessionMode().resolve() == SessionMode.SINGLE_SPECIFIED_CONTAINER + && CoreUtils.isNullOrEmpty(this.sessionOptions.getContainerName())) { + throw new IllegalArgumentException( + "Container name must be specified when using SINGLE_SPECIFIED_CONTAINER session mode."); + } } /** From 433595591da945f2825f37834e8ac1cc0b4e4f6d Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 13:41:37 -0400 Subject: [PATCH 70/84] fix return javadoc for SessionMode --- .../src/main/java/com/azure/storage/blob/models/SessionMode.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java index 8e0ed32abd2c..1a87ea5845fe 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -35,6 +35,7 @@ public enum SessionMode { * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to * {@link #NONE}; this may change in a future release without breaking callers that * use {@code resolve()} consistently. + * @return returns the effective session mode, never {@code AUTO} */ public SessionMode resolve() { return this == AUTO ? NONE : this; From 72a8bb85b1a4288056ec4ae364af34ae1d60426b Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 14:09:58 -0400 Subject: [PATCH 71/84] add LOGGER and appropriate error throwing in BlobSessionClient --- .../blob/implementation/util/BlobSessionClient.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index cb009a920067..14f8872e7b2b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -6,6 +6,8 @@ import com.azure.core.http.HttpPipeline; import com.azure.core.http.rest.Response; import com.azure.core.util.Context; +import com.azure.core.util.logging.ClientLogger; +import com.azure.storage.blob.BlobClientBuilder; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.implementation.AzureBlobStorageImpl; import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; @@ -23,6 +25,7 @@ */ final class BlobSessionClient { + private static final ClientLogger LOGGER = new ClientLogger(BlobSessionClient.class); private final AzureBlobStorageImpl azureBlobStorage; private final String accountName; private final String containerName; @@ -58,12 +61,12 @@ StorageSessionCredential createSessionSync() { private StorageSessionCredential toCredential(Response response) { CreateSessionResponse session = response.getValue(); if (session == null) { - throw new IllegalStateException("CreateSession response did not contain a session payload."); + throw LOGGER.logExceptionAsError(new IllegalStateException("CreateSession response did not contain a session payload.")); } SessionCredentials creds = session.getCredentials(); if (creds == null) { - throw new IllegalStateException("CreateSession response did not contain HMAC session credentials."); + throw LOGGER.logExceptionAsError(new IllegalStateException("CreateSession response did not contain HMAC session credentials.")); } return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), accountName); From b3a8a1698b0f1d86a443e715a606449879292d4f Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 16:29:22 -0400 Subject: [PATCH 72/84] changes based on feedback from isabelle --- .../blob/implementation/util/BlobSessionClient.java | 6 ++++-- .../blob/implementation/util/BuilderHelper.java | 12 +++++------- ...orageBearerTokenChallengeAuthorizationPolicy.java | 1 + 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index 14f8872e7b2b..da97f4aa49e6 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -61,12 +61,14 @@ StorageSessionCredential createSessionSync() { private StorageSessionCredential toCredential(Response response) { CreateSessionResponse session = response.getValue(); if (session == null) { - throw LOGGER.logExceptionAsError(new IllegalStateException("CreateSession response did not contain a session payload.")); + 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.")); + throw LOGGER.logExceptionAsError( + new IllegalStateException("CreateSession response did not contain HMAC session credentials.")); } return new StorageSessionCredential(creds.getSessionToken(), creds.getSessionKey(), session.getExpiration(), accountName); diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index cdc395abb42b..e7f397e4c03b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -69,11 +69,6 @@ public final class BuilderHelper { /** * Constructs a {@link HttpPipeline} from values passed from a builder, with optional session-based * authentication support. - *

- * When {@code sessionOptions} is non-null and the resolved session mode is not {@link SessionMode#NONE}, - * and a {@code tokenCredential} is present, a single {@link SessionTokenCredentialPolicy} is added as the - * auth policy. The session policy wraps the bearer token policy internally and delegates to it for - * non-session-eligible requests. When sessions are not active, the bearer token policy is added directly. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. * @param tokenCredential {@link TokenCredential} if present. @@ -90,8 +85,7 @@ public final class BuilderHelper { * @param configuration Configuration store contain environment settings. * @param logger {@link ClientLogger} used to log any exception. * @param audience {@link BlobAudience} used to determine the audience of the blob. - * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name. - * Pass {@code null} to disable session support. + * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication. * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ @@ -131,6 +125,10 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } + // When sessionOptions is non-null and the resolved session mode is not SessionMode.NONE, and a tokenCredential is + // present, a single essionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer + // token policy internally and delegates to it for non-session-eligible requests. When sessions are not active, + // the bearer token policy is added directly. if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null diff --git a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java index 42bf2c872e4b..b76ce78021b0 100644 --- a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java @@ -46,6 +46,7 @@ public StorageBearerTokenChallengeAuthorizationPolicy(TokenCredential credential @Override public Mono authorizeRequest(HttpPipelineCallContext context) { + // Delegate to superclass to maintain previous behavior return super.authorizeRequest(context); } From c96f4606c3a6d4cd7a3e9ac1065848a8bf0fb488 Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 17:57:18 -0400 Subject: [PATCH 73/84] add single retry for all 401 errors --- .../util/SessionTokenCredentialPolicy.java | 29 +++++-------------- .../azure/storage/blob/ContainerApiTests.java | 25 ++++++++-------- .../storage/blob/ContainerAsyncApiTests.java | 26 ++++++++--------- .../SessionTokenCredentialPolicyTest.java | 27 +++++++++-------- 4 files changed, 45 insertions(+), 62 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 6bbb3d2dc457..932f9fef7908 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -34,10 +34,7 @@ public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); - private static final String SESSION_SCHEME = "Session"; private static final String SESSION_EXPIRING = "session_expiring"; - private static final String SESSION_EXPIRED = "session_expired"; - private static final String SESSION_TOKEN_INVALID = "session_token_invalid"; private static final String SESSION_OPS_UNAVAILABLE = "SessionOperationsTemporarilyUnavailable"; private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; @@ -160,7 +157,7 @@ private Mono handleSessionResponse(HttpPipelineCallContext context handleSessionExpiringHeader(response); - if (isSessionCredentialRejected(response)) { + if (isUnauthorizedResponse(response)) { invalidateSession(session); } @@ -192,7 +189,7 @@ private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, handleSessionExpiringHeader(response); - if (isSessionCredentialRejected(response)) { + if (isUnauthorizedResponse(response)) { invalidateSession(session); } @@ -239,30 +236,18 @@ private void handleSessionExpiringHeader(HttpResponse response) { } /** - * Returns true for any 401 where the session service rejected the credential (expired or invalid token). + * Returns true when the session-authenticated request was rejected as unauthorized. * Used to decide whether to invalidate the cached session. */ - private static boolean isSessionCredentialRejected(HttpResponse response) { - if (response.getStatusCode() != 401) { - return false; - } - String wwwAuth = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE); - return wwwAuth != null - && wwwAuth.startsWith(SESSION_SCHEME) - && (wwwAuth.contains(SESSION_EXPIRED) || wwwAuth.contains(SESSION_TOKEN_INVALID)); + private static boolean isUnauthorizedResponse(HttpResponse response) { + return response.getStatusCode() == 401; } /** - * Returns true only for 401 session_expired — the only error that warrants an automatic retry - * with a refreshed session. session_token_invalid is not retryable because the token itself is - * bad (not just expired), so a new session is needed but the current request should fail. + * Returns true for 401 responses where the request should be retried once with a refreshed session. */ private static boolean isRetryableSessionFailure(HttpResponse response) { - if (response.getStatusCode() != 401) { - return false; - } - String wwwAuth = response.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE); - return wwwAuth != null && wwwAuth.startsWith(SESSION_SCHEME) && wwwAuth.contains(SESSION_EXPIRED); + return response.getStatusCode() == 401; } private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpResponse response) { 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 c617da937348..aae918e3cf2e 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -4,6 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +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; @@ -2188,23 +2189,25 @@ public void downloadBlobOverSessionAuth() { } }); - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) - .setContainerName(cc.getBlobContainerName()) - .setAccountName(cc.getAccountName()); - BlobContainerClient sessionCc - = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); for (String blobName : blobNames) { BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); assertEquals(DATA.getDefaultText(), downloaded.toString()); } - assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), + // 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 + // 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()); @@ -2218,11 +2221,7 @@ public void listBlobsOverSessionEnabledClient() { } }); - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) - .setContainerName(cc.getBlobContainerName()) - .setAccountName(cc.getAccountName()); - BlobContainerClient sessionCc - = getOAuthServiceClient(sessionOptions, inspect).getBlobContainerClient(cc.getBlobContainerName()); + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); @@ -2231,10 +2230,10 @@ public void listBlobsOverSessionEnabledClient() { "Container list operation must use Bearer authorization; saw " + listAuthSchemes); } - private BlobContainerClient sessionEnabledContainerClient() { + private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) { SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) .setContainerName(cc.getBlobContainerName()) .setAccountName(cc.getAccountName()); - return getOAuthServiceClient(sessionOptions).getBlobContainerClient(cc.getBlobContainerName()); + return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName()); } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index 661c105b280f..a0aed567ee7e 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,6 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +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; @@ -2199,11 +2200,7 @@ public void downloadBlobOverSessionAuth() { } }); - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) - .setContainerName(ccAsync.getBlobContainerName()) - .setAccountName(ccAsync.getAccountName()); - BlobContainerAsyncClient sessionCcAsync = getOAuthServiceAsyncClient(sessionOptions, inspect) - .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); for (String blobName : blobNames) { StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) @@ -2211,12 +2208,18 @@ public void downloadBlobOverSessionAuth() { .verifyComplete(); } - assertEquals(blobCount, downloadAuthSchemes.stream().filter("Session"::equals).count(), + // 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 + // 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) @@ -2233,11 +2236,7 @@ public void listBlobsOverSessionEnabledClient() { } }); - SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) - .setContainerName(ccAsync.getBlobContainerName()) - .setAccountName(ccAsync.getAccountName()); - BlobContainerAsyncClient sessionCcAsync = getOAuthServiceAsyncClient(sessionOptions, inspect) - .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements()) .expectNext(true) @@ -2248,11 +2247,12 @@ public void listBlobsOverSessionEnabledClient() { "Container list operation must use Bearer authorization; saw " + listAuthSchemes); } - private BlobContainerAsyncClient sessionEnabledContainerAsyncClient() { + private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) { SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) .setContainerName(ccAsync.getBlobContainerName()) .setAccountName(ccAsync.getAccountName()); - return getOAuthServiceAsyncClient(sessionOptions).getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + return getOAuthServiceAsyncClient(sessionOptions, policies) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 033f9e3bb0f8..46938f7630cc 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -270,30 +270,29 @@ public void policyReturns403WithoutRetry() { } @Test - public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() { + public void policyRetriesAny401WithNewSession() { HttpPipelineCallContext context = createContext(); HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); - HttpResponse invalidResponse = mock(HttpResponse.class); + HttpResponse unauthorizedResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); when(next.clone()).thenReturn(retryNext); - when(next.process()).thenReturn(Mono.just(invalidResponse)); - when(invalidResponse.getStatusCode()).thenReturn(401); - when(invalidResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) - .thenReturn("Session error=session_token_invalid"); + when(next.process()).thenReturn(Mono.just(unauthorizedResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(unauthorizedResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getStatusCode()).thenReturn(200); try (HttpResponse actualResponse = policy.process(context, next).block()) { - // Returns the 401 as-is — no retry - assertEquals(invalidResponse, actualResponse); + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(unauthorizedResponse, times(1)).close(); verify(next, times(1)).process(); - verify(retryNext, times(0)).process(); - verify(invalidResponse, times(0)).close(); - - // But the session was invalidated so the next request gets a fresh session - StorageSessionCredential nextSession = policy.getValidSessionAsync().block(); - assertEquals(SECOND_TOKEN, nextSession.getSessionToken()); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); } } From ca7eedb279abb5657e830350a932011ddcbbc3a4 Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 18:36:05 -0400 Subject: [PATCH 74/84] remove unused imports --- .../storage/blob/implementation/util/BlobSessionClient.java | 1 - .../azure/storage/blob/implementation/util/BuilderHelper.java | 1 - .../implementation/util/SessionTokenCredentialPolicyTest.java | 1 - 3 files changed, 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java index da97f4aa49e6..00b3e376b826 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -7,7 +7,6 @@ import com.azure.core.http.rest.Response; import com.azure.core.util.Context; import com.azure.core.util.logging.ClientLogger; -import com.azure.storage.blob.BlobClientBuilder; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.implementation.AzureBlobStorageImpl; import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index e7f397e4c03b..b31bdf401fcc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -31,7 +31,6 @@ import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; -import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 46938f7630cc..297df60bdf4a 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -33,7 +33,6 @@ 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; From 019787a052d082954d3d14daf8c8f93943f53960 Mon Sep 17 00:00:00 2001 From: browndav Date: Sun, 26 Apr 2026 19:26:00 -0400 Subject: [PATCH 75/84] add suppression for SessionTokenPolicy for linting --- sdk/storage/azure-storage-blob/checkstyle-suppressions.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml index 90d7f65ae375..a5ed026c13b5 100644 --- a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml +++ b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml @@ -6,6 +6,7 @@ + From 08a8d39eb3d818b13ee8dc61b626291bdbedde12 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 27 Apr 2026 17:06:17 -0400 Subject: [PATCH 76/84] fix ubuntu tests hanging by removing local dns bypass --- eng/common/pipelines/templates/steps/verify-agent-os.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml index a9109cf51803..5c5fe54c957d 100644 --- a/eng/common/pipelines/templates/steps/verify-agent-os.yml +++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml @@ -14,5 +14,3 @@ steps: filePath: ${{ parameters.ScriptDirectory }}/Verify-AgentOS.ps1 arguments: > -AgentImage "${{ parameters.AgentImage }}" - - - template: /eng/common/pipelines/templates/steps/bypass-local-dns.yml From 342eb1579ff265129b6b16efac617ceb25c8b00c Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 27 Apr 2026 20:49:32 -0400 Subject: [PATCH 77/84] add session for blob client with tests --- .../azure/storage/blob/BlobClientBuilder.java | 51 ++++++++++++++++--- .../com/azure/storage/blob/BlobApiTests.java | 47 +++++++++++++++++ .../azure/storage/blob/BlobAsyncApiTests.java | 46 +++++++++++++++++ .../storage/blob/BuilderHelperTests.java | 40 +++++++++++++++ .../azure/storage/blob/ContainerApiTests.java | 49 ++++++++++++++++++ .../storage/blob/ContainerAsyncApiTests.java | 50 ++++++++++++++++++ 6 files changed, 276 insertions(+), 7 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 2598621b09fa..e8c7a9671aed 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -31,6 +31,8 @@ import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; import com.azure.storage.common.implementation.connectionstring.StorageConnectionString; @@ -92,6 +94,7 @@ public final class BlobClientBuilder private Configuration configuration; private BlobServiceVersion version; private BlobAudience audience; + private SessionOptions sessionOptions = new SessionOptions(); /** * Creates a builder instance that is able to configure and construct {@link BlobClient BlobClients} and {@link @@ -133,6 +136,8 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } + validateSessionMode(); + /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier to read and debug. @@ -189,18 +194,34 @@ public BlobAsyncClient buildAsyncClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, blobName, snapshot, customerProvidedKey, encryptionScope, versionId); } - private HttpPipeline constructPipeline() { - return (httpPipeline != null) - ? httpPipeline - : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, - endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER, null, null); + private HttpPipeline constructPipeline(String containerName, BlobServiceVersion serviceVersion) { + if (httpPipeline != null) { + return httpPipeline; + } + + if (containerName != null) { + sessionOptions.setContainerName(containerName); + } + if (sessionOptions.getAccountName() == null) { + sessionOptions.setAccountName(accountName); + } + + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, sessionOptions, serviceVersion); + } + + private void validateSessionMode() { + if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) { + throw LOGGER.logExceptionAsError(new IllegalArgumentException( + "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); + } } /** @@ -650,4 +671,20 @@ public BlobClientBuilder audience(BlobAudience audience) { this.audience = audience; return this; } + + /** + * Sets the {@link SessionOptions} that controls how the SDK manages session-based authentication for this blob. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them with a lightweight + * HMAC key instead of a full bearer token. When the session mode within the options is set to a value other than + * {@link SessionMode#NONE}, this builder's configured container name is used when the options don't specify one. + * + * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * when identity-based authentication (bearer token) is configured. + * @return the updated BlobClientBuilder object. + */ + public BlobClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java index 50a9eb63ef21..52b54b0bb5b2 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; @@ -120,6 +123,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -3177,6 +3181,49 @@ public void audienceFromString() { assertTrue(aadBlob.exists()); } + @Test + @LiveOnly + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobClient blobClient = cc.getBlobClient(blobName); + blobClient.getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) + .buildClient(); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), 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..fcf0c25603ac 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; @@ -2965,6 +2968,49 @@ public void audienceErrorBearerChallengeRetry() { StepVerifier.create(aadBlob.getProperties()).assertNext(Assertions::assertNotNull).verifyComplete(); } + @Test + @LiveOnly + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob = getBlobClientBuilderWithTokenCredential(blobClient.getBlobUrl(), inspect) + .sessionOptions(new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER)) + .buildAsyncClient(); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), 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/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java index 5c3984960d4a..370b33e88424 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java @@ -747,6 +747,46 @@ private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) // endregion + // region BlobClientBuilder sessionOptions tests + + @Test + public void blobBuilderWithSingleSpecifiedContainerSessionBuilds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void blobBuilderWithSingleSpecifiedContainerSessionAndNoContainerNameThrows() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertThrows(IllegalArgumentException.class, + () -> new BlobClientBuilder().endpoint(ENDPOINT) + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void blobBuilderWithoutSessionOptionsBuilds() { + assertDoesNotThrow(() -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient()); + } + + // endregion + // region BlobContainerClientBuilder sessionOptions tests @Test 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 aae918e3cf2e..e17c753c1758 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -4,6 +4,7 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.util.BinaryData; import com.azure.core.http.rest.PagedIterable; @@ -34,6 +35,7 @@ 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; @@ -64,7 +66,10 @@ import org.junit.jupiter.params.provider.ValueSource; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Base64; @@ -80,6 +85,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; @@ -2204,6 +2210,49 @@ public void downloadBlobOverSessionAuth() { "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); } + @Test + @LiveOnly + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + cc.getBlobClient(blobName).getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), 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 // This test validates that listing blobs with a session-enabled client uses Bearer authorization because 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 a0aed567ee7e..04ef7ce1009b 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,12 +4,14 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedFlux; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.TestMode; import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; @@ -25,6 +27,7 @@ import com.azure.storage.blob.specialized.BlockBlobAsyncClient; import com.azure.storage.blob.specialized.PageBlobAsyncClient; import com.azure.storage.common.test.shared.TestHttpClientType; +import com.azure.storage.common.implementation.Constants; import com.azure.storage.common.test.shared.extensions.LiveOnly; import com.azure.storage.common.test.shared.extensions.PlaybackOnly; import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion; @@ -42,7 +45,10 @@ import reactor.test.StepVerifier; import reactor.util.function.Tuple2; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.Duration; import java.time.OffsetDateTime; import java.util.*; @@ -2216,6 +2222,50 @@ public void downloadBlobOverSessionAuth() { "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); } + @Test + @LiveOnly + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob = sessionEnabledContainerAsyncClient(inspect).getBlobAsyncClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), 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 // This test validates that listing blobs with a session-enabled client uses Bearer authorization because From 8429d1ac50cd5656649c442d35b63adba7d6ca95 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 27 Apr 2026 21:10:24 -0400 Subject: [PATCH 78/84] create unified http transport between data requests and session request, add ResourceLock for live tests --- .../implementation/util/BuilderHelper.java | 24 ++++++++++++++++--- .../com/azure/storage/blob/BlobApiTests.java | 2 ++ .../azure/storage/blob/BlobAsyncApiTests.java | 2 ++ .../azure/storage/blob/ContainerApiTests.java | 6 +++++ .../storage/blob/ContainerAsyncApiTests.java | 6 +++++ 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index b31bdf401fcc..5406b5ce46c1 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -24,6 +24,7 @@ import com.azure.core.util.ClientOptions; import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; +import com.azure.core.util.HttpClientOptions; import com.azure.core.util.TracingOptions; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; @@ -124,8 +125,14 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } + // Session credentials are bound to the client's network context. When the caller doesn't provide an + // HttpClient, create one default instance and share it between CreateSession and data requests instead of + // letting each pipeline create its own transport. + HttpClient effectiveHttpClient + = tokenCredential == null ? httpClient : getOrCreateHttpClient(httpClient, clientOptions); + // When sessionOptions is non-null and the resolved session mode is not SessionMode.NONE, and a tokenCredential is - // present, a single essionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer + // present, a single SessionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer // token policy internally and delegates to it for non-session-eligible requests. When sessions are not active, // the bearer token policy is added directly. if (tokenCredential != null) { @@ -141,7 +148,8 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare BlobServiceVersion effectiveServiceVersion = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); - HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, httpClient, clientOptions); + HttpPipeline bearerPipeline + = buildBearerPipeline(policies, bearerPolicy, effectiveHttpClient, clientOptions); BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); @@ -166,7 +174,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new ScrubEtagPolicy()); return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) - .httpClient(httpClient) + .httpClient(effectiveHttpClient) .clientOptions(clientOptions) .tracer(createTracer(clientOptions)) .build(); @@ -188,6 +196,16 @@ private static HttpPipeline buildBearerPipeline(List preAuth .build(); } + private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) { + if (httpClient != null) { + return httpClient; + } + + return clientOptions instanceof HttpClientOptions + ? HttpClient.createDefault((HttpClientOptions) clientOptions) + : HttpClient.createDefault(); + } + /** * Gets the default http log option for Storage Blob. * 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 52b54b0bb5b2..b531ef00e67e 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 @@ -84,6 +84,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -3183,6 +3184,7 @@ public void audienceFromString() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { String blobName = generateBlobName(); byte[] data = getRandomByteArray(4 * Constants.KB + 17); 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 fcf0c25603ac..118415e3be2f 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 @@ -79,6 +79,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -2970,6 +2971,7 @@ public void audienceErrorBearerChallengeRetry() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { String blobName = generateBlobName(); byte[] data = getRandomByteArray(4 * Constants.KB + 17); 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 e17c753c1758..d9eec2e921e3 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 @@ -60,6 +60,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -2144,6 +2145,7 @@ public void getBlobContainerUrlEncodesContainerName() { // Need to create a container client test here to test that sessions have been enabled and used @Test + @ResourceLock("BlobSessionAuth") public void createSession() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); CreateSessionResponse response = oauthCc.createSession(); @@ -2157,6 +2159,7 @@ public void createSession() { } @Test + @ResourceLock("BlobSessionAuth") public void createSessionWithResponse() { BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); Response response = oauthCc.createSessionWithResponse(null, Context.NONE); @@ -2174,6 +2177,7 @@ public void createSessionWithResponse() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobOverSessionAuth() { int blobCount = 5; List blobNames = new ArrayList<>(); @@ -2212,6 +2216,7 @@ public void downloadBlobOverSessionAuth() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { String blobName = generateBlobName(); byte[] data = getRandomByteArray(4 * Constants.KB + 17); @@ -2255,6 +2260,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { @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() { 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 04ef7ce1009b..f4e743a65d8b 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 @@ -36,6 +36,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -2152,6 +2153,7 @@ public void getBlobContainerUrlEncodesContainerName() { } @Test + @ResourceLock("BlobSessionAuth") public void createSession() { BlobContainerAsyncClient oauthCcAsync = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); @@ -2166,6 +2168,7 @@ public void createSession() { } @Test + @ResourceLock("BlobSessionAuth") public void createSessionWithResponse() { BlobContainerAsyncClient oauthCcAsync = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); @@ -2184,6 +2187,7 @@ public void createSessionWithResponse() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobOverSessionAuth() { int blobCount = 5; List blobNames = new ArrayList<>(); @@ -2224,6 +2228,7 @@ public void downloadBlobOverSessionAuth() { @Test @LiveOnly + @ResourceLock("BlobSessionAuth") public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { String blobName = generateBlobName(); byte[] data = getRandomByteArray(4 * Constants.KB + 17); @@ -2268,6 +2273,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { @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() { From 4e75168f1f9eb5df07ceced3a4a2220314164768 Mon Sep 17 00:00:00 2001 From: browndav Date: Mon, 27 Apr 2026 21:24:14 -0400 Subject: [PATCH 79/84] test multiple concurrency --- .../src/test/java/com/azure/storage/blob/BlobApiTests.java | 2 +- .../src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java | 2 +- .../src/test/java/com/azure/storage/blob/ContainerApiTests.java | 2 +- .../java/com/azure/storage/blob/ContainerAsyncApiTests.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 b531ef00e67e..b0fac6bbc9d6 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java @@ -3216,7 +3216,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { Files.deleteIfExists(outFile.toPath()); sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, - new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), null, null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, false, null, null); assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); 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 118415e3be2f..e8a74ac57729 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java @@ -3003,7 +3003,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { Files.deleteIfExists(outFile.toPath()); StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, - new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), null, null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, false)).expectNextCount(1).verifyComplete(); Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); 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 d9eec2e921e3..76195bb37083 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java @@ -2245,7 +2245,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { try { sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, - new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, false, null, null); assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); 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 f4e743a65d8b..ee3579271283 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -2258,7 +2258,7 @@ public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { try { StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, - new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(1), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, false)).expectNextCount(1).verifyComplete(); Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); From 920d5894ddf619b30f9223580a2facec534b7c69 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 28 Apr 2026 11:28:36 -0400 Subject: [PATCH 80/84] add branching if for bearer policy --- .../storage/blob/implementation/util/BuilderHelper.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 5406b5ce46c1..8b29c24b24b5 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -32,6 +32,7 @@ import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; @@ -153,8 +154,12 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare BlobSessionClient sessionClient = new BlobSessionClient(bearerPipeline, endpoint, effectiveServiceVersion, effectiveSessionOptions.getAccountName(), effectiveSessionOptions.getContainerName()); - policies.add(new SessionTokenCredentialPolicy(bearerPolicy, - new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + if (effectiveSessionOptions.getSessionMode() == SessionMode.NONE) { + policies.add(bearerPolicy); + } else { + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, + new StorageSessionCredentialCache(sessionClient), effectiveSessionOptions)); + } } if (azureSasCredential != null) { From 589e632440f8183b06a37b39247c7cbeb36a3b7f Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 28 Apr 2026 13:44:39 -0400 Subject: [PATCH 81/84] refactor validateSessionMode to builderhelper --- .../com/azure/storage/blob/BlobClientBuilder.java | 9 +-------- .../storage/blob/BlobContainerClientBuilder.java | 11 ++--------- .../blob/implementation/util/BuilderHelper.java | 8 ++++++++ 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index e8c7a9671aed..a099f0aab5c5 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -136,7 +136,7 @@ public BlobClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - validateSessionMode(); + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier @@ -217,13 +217,6 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion perRetryPolicies, configuration, audience, LOGGER, sessionOptions, serviceVersion); } - private void validateSessionMode() { - if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) { - throw LOGGER.logExceptionAsError(new IllegalArgumentException( - "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); - } - } - /** * Sets the {@link CustomerProvidedKey customer provided key} that is used to encrypt blob contents on the server. * diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 3599aa855136..1f0b003f01cc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -127,7 +127,7 @@ public BlobContainerClient buildClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - validateSessionMode(); + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier @@ -170,7 +170,7 @@ public BlobContainerAsyncClient buildAsyncClient() { new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set")); } - validateSessionMode(); + BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER); /* Implicit and explicit root container access are functionally equivalent, but explicit references are easier @@ -202,13 +202,6 @@ private HttpPipeline constructPipeline(String containerName, BlobServiceVersion perRetryPolicies, configuration, audience, LOGGER, sessionOptions, serviceVersion); } - private void validateSessionMode() { - if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) { - throw LOGGER.logExceptionAsError(new IllegalArgumentException( - "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); - } - } - /** * Sets the service endpoint, additionally parses it for information (SAS token, container name) * diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 8b29c24b24b5..0732045db6ad 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -29,6 +29,7 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; +import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; @@ -293,4 +294,11 @@ public static Tracer createTracer(ClientOptions clientOptions) { public static void logCredentialChange(ClientLogger logger, String newCredentialType) { logger.info("Credential set to '{}' when it was previously configured.", newCredentialType); } + + public static void validateSessionMode(SessionOptions sessionOptions, String containerName, ClientLogger logger) { + if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE && CoreUtils.isNullOrEmpty(containerName)) { + throw logger.logExceptionAsError(new IllegalArgumentException( + "containerName must be set when using SessionMode." + sessionOptions.getSessionMode())); + } + } } From b13d12b3e3a389e8d71f3c9783a1dbc82da64470 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 28 Apr 2026 13:47:32 -0400 Subject: [PATCH 82/84] add revert to bearertoken for 400 errors --- .../util/SessionTokenCredentialPolicy.java | 11 ++-- .../SessionTokenCredentialPolicyTest.java | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java index 932f9fef7908..e7d875389856 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -259,16 +259,19 @@ private static boolean shouldRetryRequest(HttpPipelineCallContext context, HttpR } /** - * Returns true for 503 with SessionOperationsTemporarilyUnavailable error code. - * The session infrastructure is temporarily down, so we strip session auth and - * delegate to the wrapped bearer policy to handle the request with a bearer token. + * Returns true for responses where retrying with bearer authentication can preserve + * request compatibility when session authentication is unavailable or rejected. */ private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) { if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { return false; } - return isSessionUnavailable(response); + return isBadRequest(response) || isSessionUnavailable(response); + } + + private static boolean isBadRequest(HttpResponse response) { + return response.getStatusCode() == 400; } private static boolean isSessionUnavailable(HttpResponse response) { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java index 297df60bdf4a..020284e56f78 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -324,6 +324,31 @@ public void policyFallsToBearerOn503SessionUnavailableAsync() { } } + @Test + public void policyFallsToBearerOn400Async() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse badRequestResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(badRequestResponse)); + when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); + when(badRequestResponse.getStatusCode()).thenReturn(400); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(bearerResponse, actualResponse); + verify(badRequestResponse, times(1)).close(); + verify(bearerPolicy, times(1)).process(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + @Test public void policyFallsToBearerOn503SessionUnavailableSync() { HttpPipelineCallContext context = createContext(); @@ -352,6 +377,31 @@ public void policyFallsToBearerOn503SessionUnavailableSync() { } } + @Test + public void policyFallsToBearerOn400Sync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse badRequestResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(badRequestResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(badRequestResponse.getStatusCode()).thenReturn(400); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(badRequestResponse, times(1)).close(); + verify(bearerPolicy, times(1)).processSync(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + @Test public void policyReturns503ServerBusyWithoutBearerFallback() { HttpPipelineCallContext context = createContext(); From 464474611ce5b0b8a13c021f91118fa2be6769e6 Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 28 Apr 2026 13:58:19 -0400 Subject: [PATCH 83/84] remove unused imports --- .../azure/storage/blob/implementation/util/BuilderHelper.java | 1 - 1 file changed, 1 deletion(-) diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 0732045db6ad..914794f2bde1 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -29,7 +29,6 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; -import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; From c5f92ec4d61c78847965a254fb8aa79bdc27cf9e Mon Sep 17 00:00:00 2001 From: browndav Date: Tue, 28 Apr 2026 16:56:27 -0400 Subject: [PATCH 84/84] readd comment to bearertokechallenge --- .../policy/StorageBearerTokenChallengeAuthorizationPolicy.java | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java index b76ce78021b0..8459755589d9 100644 --- a/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/policy/StorageBearerTokenChallengeAuthorizationPolicy.java @@ -52,6 +52,7 @@ public Mono authorizeRequest(HttpPipelineCallContext context) { @Override public void authorizeRequestSync(HttpPipelineCallContext context) { + // Delegate to superclass to maintain previous behavior super.authorizeRequestSync(context); }