diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 8cad139f33ff..92108b8c51bb 100644 --- a/sdk/storage/azure-storage-blob/assets.json +++ b/sdk/storage/azure-storage-blob/assets.json @@ -2,5 +2,5 @@ "AssetsRepo": "Azure/azure-sdk-assets", "AssetsRepoPrefixPath": "java", "TagPrefix": "java/storage/azure-storage-blob", - "Tag": "java/storage/azure-storage-blob_47f4243e59" + "Tag": "java/storage/azure-storage-blob_dbe8c45320" } diff --git a/sdk/storage/azure-storage-blob/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..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); + perRetryPolicies, configuration, audience, LOGGER, null, null); } /** diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index b86fe4e76b2f..d79a68c24a96 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java @@ -28,6 +28,9 @@ import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1691,11 +1694,39 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } - // private boolean validateNoTime(BlobRequestConditions modifiedRequestConditions) { - // if (modifiedRequestConditions == null) { - // return true; - // } - // return modifiedRequestConditions.getIfModifiedSince() == null - // && modifiedRequestConditions.getIfUnmodifiedSince() == null; - // } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing the {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Mono createSession() { + return createSessionWithResponse().flatMap(FluxUtil::toMono); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return A {@link Mono} containing a {@link Response} with the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Mono> createSessionWithResponse() { + try { + return withContext(this::createSessionWithResponse); + } catch (RuntimeException ex) { + return monoError(LOGGER, ex); + } + } + + Mono> createSessionWithResponse(Context context) { + context = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + return this.azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, null, null, context) + .map(response -> new SimpleResponse<>(response, response.getValue())); + } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java index 64de81617f9c..426e38c46b76 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClient.java @@ -31,6 +31,9 @@ import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; import com.azure.storage.blob.implementation.models.ListBlobsHierarchySegmentResponse; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.util.BlobConstants; import com.azure.storage.blob.implementation.util.BlobSasImplUtil; import com.azure.storage.blob.implementation.util.ModelHelper; @@ -1509,4 +1512,37 @@ public String generateSas(BlobServiceSasSignatureValues blobServiceSasSignatureV .generateSas(SasImplUtils.extractSharedKeyCredential(getHttpPipeline()), stringToSignHandler, context); } + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @return The {@link CreateSessionResponse} with session credentials. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + CreateSessionResponse createSession() { + return createSessionWithResponse(null, Context.NONE).getValue(); + } + + /** + * Creates a session scoped to this container. The session provides temporary credentials (a session token and + * session key) that can be used to sign subsequent requests using the Shared Key protocol. + * + * @param timeout An optional timeout value beyond which a {@link RuntimeException} will be raised. + * @param context Additional context that is passed through the Http pipeline during the service call. + * @return A {@link Response} containing the {@link CreateSessionResponse}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + Response createSessionWithResponse(Duration timeout, Context context) { + Context finalContext = context == null ? Context.NONE : context; + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Callable> operation = () -> { + Response response = this.azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, finalContext); + return new SimpleResponse<>(response, response.getValue()); + }; + + return sendRequest(operation, timeout, BlobStorageException.class); + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java index 5ca6281bb1fb..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 @@ -32,6 +32,8 @@ import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; import com.azure.storage.blob.models.CustomerProvidedKey; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.connectionstring.StorageAuthenticationSettings; import com.azure.storage.common.implementation.connectionstring.StorageConnectionString; @@ -91,6 +93,7 @@ public final class BlobContainerClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. When the session mode within the options + * is set to a value other than {@link SessionMode#NONE}, + * {@link #containerName(String) containerName} must also be set. + * + * @param sessionOptions The session options to use. If {@code null}, defaults to {@link SessionMode#AUTO} + * when identity-based authentication (bearer token) is configured. + * @return the updated BlobContainerClientBuilder object. + */ + public BlobContainerClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 3ad51c9a9b5f..641c40b1a0a0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -147,6 +147,7 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } + return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 5fb46965824f..3cefa0395364 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,6 +30,8 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.implementation.util.SessionTokenCredentialPolicy; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; @@ -93,6 +95,7 @@ public final class BlobServiceClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. This setting is passed to container + * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}. + * + * @param sessionOptions The session options for the HTTP pipeline. + * @return the updated BlobServiceClientBuilder object. + */ + public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index 7fd2af96e4df..8bc27e750abc 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java @@ -46,6 +46,8 @@ import com.azure.storage.blob.implementation.models.ContainersSetAccessPolicyHeaders; import com.azure.storage.blob.implementation.models.ContainersSetMetadataHeaders; import com.azure.storage.blob.implementation.models.ContainersSubmitBatchHeaders; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.implementation.models.FilterBlobSegment; import com.azure.storage.blob.implementation.models.FilterBlobsIncludeItem; import com.azure.storage.blob.implementation.models.ListBlobsFlatSegmentResponse; @@ -938,6 +940,26 @@ Response getAccountInfoNoCustomHeadersSync(@HostParam("url") String url, @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Mono> createSession(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); + + @Post("/{containerName}") + @ExpectedResponses({ 201 }) + @UnexpectedResponseExceptionType(BlobStorageExceptionInternal.class) + Response createSessionSync(@HostParam("url") String url, + @PathParam("containerName") String containerName, @QueryParam("restype") String restype, + @QueryParam("comp") String comp, @QueryParam("timeout") Integer timeout, + @HeaderParam("x-ms-version") String version, @HeaderParam("x-ms-client-request-id") String requestId, + @BodyParam("application/xml") CreateSessionConfiguration createSessionConfiguration, + @HeaderParam("Accept") String accept, Context context); } /** @@ -6707,4 +6729,159 @@ public Response getAccountInfoNoCustomHeadersWithResponse(String container throw ModelHelper.mapToBlobStorageException(internalException); } } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + return FluxUtil + .withContext(context -> createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, + requestId, context)) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response} on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono> createSessionWithResponseAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service + .createSession(this.client.getUrl(), containerName, restype, comp, timeout, this.client.getVersion(), + requestId, createSessionConfiguration, accept, context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body on successful completion of {@link Mono}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Mono createSessionAsync(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + return createSessionWithResponseAsync(containerName, createSessionConfiguration, timeout, requestId, context) + .onErrorMap(BlobStorageExceptionInternal.class, ModelHelper::mapToBlobStorageException) + .flatMap(res -> Mono.justOrEmpty(res.getValue())); + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @param context The context to associate with this operation. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response body along with {@link Response}. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public Response createSessionWithResponse(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId, Context context) { + try { + final String restype = "container"; + final String comp = "session"; + final String accept = "application/xml"; + return service.createSessionSync(this.client.getUrl(), containerName, restype, comp, timeout, + this.client.getVersion(), requestId, createSessionConfiguration, accept, context); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } + + /** + * The Create Session operation enables users to create a session scoped to a container. + * + * @param containerName The container name. + * @param createSessionConfiguration The createSessionConfiguration parameter. + * @param timeout The timeout parameter is expressed in seconds. For more information, see <a + * href="https://learn.microsoft.com/rest/api/storageservices/setting-timeouts-for-blob-service-operations">Setting + * Timeouts for Blob Service Operations.</a>. + * @param requestId Provides a client-generated, opaque value with a 1 KB character limit that is recorded in the + * analytics logs when storage analytics logging is enabled. + * @throws IllegalArgumentException thrown if parameters fail the validation. + * @throws BlobStorageExceptionInternal thrown if the request is rejected by server. + * @throws RuntimeException all other wrapped checked exceptions if the request fails to be sent. + * @return the response. + */ + @ServiceMethod(returns = ReturnType.SINGLE) + public CreateSessionResponse createSession(String containerName, + CreateSessionConfiguration createSessionConfiguration, Integer timeout, String requestId) { + try { + return createSessionWithResponse(containerName, createSessionConfiguration, timeout, requestId, + Context.NONE).getValue(); + } catch (BlobStorageExceptionInternal internalException) { + throw ModelHelper.mapToBlobStorageException(internalException); + } + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java new file mode 100644 index 000000000000..76a92bba45e3 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/AuthenticationType.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Generated; +import com.azure.core.util.ExpandableStringEnum; +import java.util.Collection; + +/** + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ +public final class AuthenticationType extends ExpandableStringEnum { + /** + * Static value HMAC for AuthenticationType. + */ + @Generated + public static final AuthenticationType HMAC = fromString("HMAC"); + + /** + * Creates a new instance of AuthenticationType value. + * + * @deprecated Use the {@link #fromString(String)} factory method. + */ + @Generated + @Deprecated + public AuthenticationType() { + } + + /** + * Creates or finds a AuthenticationType from its string representation. + * + * @param name a name to look for. + * @return the corresponding AuthenticationType. + */ + @Generated + public static AuthenticationType fromString(String name) { + return fromString(name, AuthenticationType.class); + } + + /** + * Gets known AuthenticationType values. + * + * @return known AuthenticationType values. + */ + @Generated + public static Collection values() { + return values(AuthenticationType.class); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java new file mode 100644 index 000000000000..b52e86f169cd --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionConfiguration.java @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionConfiguration model. + */ +@Fluent +public final class CreateSessionConfiguration implements XmlSerializable { + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /** + * Creates an instance of CreateSessionConfiguration class. + */ + @Generated + public CreateSessionConfiguration() { + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionConfiguration object itself. + */ + @Generated + public CreateSessionConfiguration setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionConfiguration from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionConfiguration if the XmlReader was pointing to an instance of it, or null if + * it was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionConfiguration. + */ + @Generated + public static CreateSessionConfiguration fromXml(XmlReader xmlReader, String rootElementName) + throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionRequest" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionConfiguration deserializedCreateSessionConfiguration = new CreateSessionConfiguration(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionConfiguration.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionConfiguration; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java new file mode 100644 index 000000000000..610080c98fd4 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/CreateSessionResponse.java @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import java.time.OffsetDateTime; +import java.util.Objects; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The CreateSessionResponse model. + */ +@Fluent +public final class CreateSessionResponse implements XmlSerializable { + /* + * A unique identifier for the created session. + */ + @Generated + private String id; + + /* + * The time when the session will expire. The format follows RFC 1123. + */ + @Generated + private DateTimeRfc1123 expiration; + + /* + * The type of authentication required to create the session. The only type currently supported is HMAC. + */ + @Generated + private AuthenticationType authenticationType; + + /* + * The Credentials property. + */ + @Generated + private SessionCredentials credentials; + + /** + * Creates an instance of CreateSessionResponse class. + */ + @Generated + public CreateSessionResponse() { + } + + /** + * Get the id property: A unique identifier for the created session. + * + * @return the id value. + */ + @Generated + public String getId() { + return this.id; + } + + /** + * Set the id property: A unique identifier for the created session. + * + * @param id the id value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setId(String id) { + this.id = id; + return this; + } + + /** + * Get the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @return the expiration value. + */ + @Generated + public OffsetDateTime getExpiration() { + if (this.expiration == null) { + return null; + } + return this.expiration.getDateTime(); + } + + /** + * Set the expiration property: The time when the session will expire. The format follows RFC 1123. + * + * @param expiration the expiration value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setExpiration(OffsetDateTime expiration) { + if (expiration == null) { + this.expiration = null; + } else { + this.expiration = new DateTimeRfc1123(expiration); + } + return this; + } + + /** + * Get the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @return the authenticationType value. + */ + @Generated + public AuthenticationType getAuthenticationType() { + return this.authenticationType; + } + + /** + * Set the authenticationType property: The type of authentication required to create the session. The only type + * currently supported is HMAC. + * + * @param authenticationType the authenticationType value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setAuthenticationType(AuthenticationType authenticationType) { + this.authenticationType = authenticationType; + return this; + } + + /** + * Get the credentials property: The Credentials property. + * + * @return the credentials value. + */ + @Generated + public SessionCredentials getCredentials() { + return this.credentials; + } + + /** + * Set the credentials property: The Credentials property. + * + * @param credentials the credentials value to set. + * @return the CreateSessionResponse object itself. + */ + @Generated + public CreateSessionResponse setCredentials(SessionCredentials credentials) { + this.credentials = credentials; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("Id", this.id); + xmlWriter.writeStringElement("Expiration", Objects.toString(this.expiration, null)); + xmlWriter.writeStringElement("AuthenticationType", + this.authenticationType == null ? null : this.authenticationType.toString()); + xmlWriter.writeXml(this.credentials, "Credentials"); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of CreateSessionResponse from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of CreateSessionResponse if the XmlReader was pointing to an instance of it, or null if it + * was pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the CreateSessionResponse. + */ + @Generated + public static CreateSessionResponse fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "CreateSessionResult" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + CreateSessionResponse deserializedCreateSessionResponse = new CreateSessionResponse(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("Id".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.id = reader.getStringElement(); + } else if ("Expiration".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.expiration = reader.getNullableElement(DateTimeRfc1123::new); + } else if ("AuthenticationType".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.authenticationType + = AuthenticationType.fromString(reader.getStringElement()); + } else if ("Credentials".equals(elementName.getLocalPart())) { + deserializedCreateSessionResponse.credentials = SessionCredentials.fromXml(reader, "Credentials"); + } else { + reader.skipElement(); + } + } + + return deserializedCreateSessionResponse; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java new file mode 100644 index 000000000000..ed427a221aa7 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/models/SessionCredentials.java @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// Code generated by Microsoft (R) AutoRest Code Generator. + +package com.azure.storage.blob.implementation.models; + +import com.azure.core.annotation.Fluent; +import com.azure.core.annotation.Generated; +import com.azure.xml.XmlReader; +import com.azure.xml.XmlSerializable; +import com.azure.xml.XmlToken; +import com.azure.xml.XmlWriter; +import javax.xml.namespace.QName; +import javax.xml.stream.XMLStreamException; + +/** + * The SessionCredentials model. + */ +@Fluent +public final class SessionCredentials implements XmlSerializable { + /* + * An opaque token used to authorize subsequent requests in the session. Must be treated as a security credential. + */ + @Generated + private String sessionToken; + + /* + * Only returned when AuthenticationType is HMAC. A symmetric encryption key used to sign requests in the session + * using the Shared Key protocol. + */ + @Generated + private String sessionKey; + + /** + * Creates an instance of SessionCredentials class. + */ + @Generated + public SessionCredentials() { + } + + /** + * Get the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @return the sessionToken value. + */ + @Generated + public String getSessionToken() { + return this.sessionToken; + } + + /** + * Set the sessionToken property: An opaque token used to authorize subsequent requests in the session. Must be + * treated as a security credential. + * + * @param sessionToken the sessionToken value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionToken(String sessionToken) { + this.sessionToken = sessionToken; + return this; + } + + /** + * Get the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @return the sessionKey value. + */ + @Generated + public String getSessionKey() { + return this.sessionKey; + } + + /** + * Set the sessionKey property: Only returned when AuthenticationType is HMAC. A symmetric encryption key used to + * sign requests in the session using the Shared Key protocol. + * + * @param sessionKey the sessionKey value to set. + * @return the SessionCredentials object itself. + */ + @Generated + public SessionCredentials setSessionKey(String sessionKey) { + this.sessionKey = sessionKey; + return this; + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter) throws XMLStreamException { + return toXml(xmlWriter, null); + } + + @Generated + @Override + public XmlWriter toXml(XmlWriter xmlWriter, String rootElementName) throws XMLStreamException { + rootElementName = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + xmlWriter.writeStartElement(rootElementName); + xmlWriter.writeStringElement("SessionToken", this.sessionToken); + xmlWriter.writeStringElement("SessionKey", this.sessionKey); + return xmlWriter.writeEndElement(); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader) throws XMLStreamException { + return fromXml(xmlReader, null); + } + + /** + * Reads an instance of SessionCredentials from the XmlReader. + * + * @param xmlReader The XmlReader being read. + * @param rootElementName Optional root element name to override the default defined by the model. Used to support + * cases where the model can deserialize from different root element names. + * @return An instance of SessionCredentials if the XmlReader was pointing to an instance of it, or null if it was + * pointing to XML null. + * @throws XMLStreamException If an error occurs while reading the SessionCredentials. + */ + @Generated + public static SessionCredentials fromXml(XmlReader xmlReader, String rootElementName) throws XMLStreamException { + String finalRootElementName + = rootElementName == null || rootElementName.isEmpty() ? "Credentials" : rootElementName; + return xmlReader.readObject(finalRootElementName, reader -> { + SessionCredentials deserializedSessionCredentials = new SessionCredentials(); + while (reader.nextElement() != XmlToken.END_ELEMENT) { + QName elementName = reader.getElementName(); + + if ("SessionToken".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionToken = reader.getStringElement(); + } else if ("SessionKey".equals(elementName.getLocalPart())) { + deserializedSessionCredentials.sessionKey = reader.getStringElement(); + } else { + reader.skipElement(); + } + } + + return deserializedSessionCredentials; + }); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java new file mode 100644 index 000000000000..cb009a920067 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BlobSessionClient.java @@ -0,0 +1,71 @@ +// 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 accountName; + private final String containerName; + + BlobSessionClient(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, String accountName, + String containerName) { + this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) + .url(url) + .version(serviceVersion.getVersion()) + .buildClient(); + this.accountName = accountName; + this.containerName = containerName; + } + + Mono createSessionAsync() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + return azureBlobStorage.getContainers() + .createSessionWithResponseAsync(containerName, config, null, null) + .map(this::toCredential); + } + + StorageSessionCredential createSessionSync() { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + + Response response = azureBlobStorage.getContainers() + .createSessionWithResponse(containerName, config, null, null, Context.NONE); + return toCredential(response); + } + + private StorageSessionCredential toCredential(Response response) { + CreateSessionResponse session = response.getValue(); + if (session == null) { + throw 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); + } +} 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..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 @@ -28,8 +28,11 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; +import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; @@ -64,7 +67,13 @@ 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 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. @@ -81,6 +90,9 @@ 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 serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -88,7 +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) { + ClientLogger logger, SessionOptions sessionOptions, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -124,7 +136,20 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare String scope = audience != null ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) : Constants.STORAGE_SCOPE; - policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy + = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); + + SessionOptions effectiveSessionOptions = SessionOptions.orDefault(sessionOptions); + + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + + HttpPipeline bearerPipeline = buildBearerPipeline(policies, bearerPolicy, 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) { @@ -150,6 +175,22 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare .build(); } + /** + * Builds a bearer-only {@link HttpPipeline} for CreateSession calls. This pipeline contains + * all pre-auth policies plus the bearer token policy, but no session policy. + */ + private static HttpPipeline buildBearerPipeline(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, + ClientOptions clientOptions) { + List bearerPolicies = new ArrayList<>(preAuthPolicies); + bearerPolicies.add(bearerPolicy); + return new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) + .httpClient(httpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) + .build(); + } + /** * 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 new file mode 100644 index 000000000000..6bbb3d2dc457 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.CoreUtils; +import com.azure.storage.blob.BlobUrlParts; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import reactor.core.publisher.Mono; + +import java.util.Map; +import java.util.Objects; + +/** + * A pipeline policy that selects between session token and bearer token authentication. + *

+ * This policy occupies the authentication policy slot in the pipeline, wrapping the + * {@link StorageBearerTokenChallengeAuthorizationPolicy}. For eligible blob GET requests, + * the policy authenticates with a session token. For all other requests, it delegates to the + * wrapped bearer token policy. + *

+ * Request analysis is performed by {@link #analyzeRequest(HttpPipelineCallContext)} which returns + * an {@link AuthStrategy} indicating the authentication approach to use. + */ +public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { + private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; + private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); + private static final String SESSION_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; + private final StorageSessionCredentialCache sessionCredentialCache; + private final SessionOptions sessionOptions; + + /** + * Authentication strategy determined by {@link #analyzeRequest(HttpPipelineCallContext)}. + */ + enum AuthStrategy { + /** Delegate to the wrapped bearer token policy. */ + USE_BEARER_TOKEN, + /** Acquire a session token and sign the request. */ + USE_SESSION_TOKEN + } + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + StorageSessionCredentialCache sessionCredentialCache, SessionOptions sessionOptions) { + this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); + this.sessionCredentialCache + = Objects.requireNonNull(sessionCredentialCache, "'sessionCredentialCache' cannot be null."); + this.sessionOptions = SessionOptions.orDefault(sessionOptions); + + if (this.sessionOptions.getSessionMode().resolve() == SessionMode.SINGLE_SPECIFIED_CONTAINER + && CoreUtils.isNullOrEmpty(this.sessionOptions.getContainerName())) { + throw new IllegalArgumentException( + "Container name must be specified when using SINGLE_SPECIFIED_CONTAINER session mode."); + } + } + + /** + * Returns the wrapped bearer token policy. Used when constructing per-container pipelines from a service + * pipeline so that the bearer policy can be reused without scanning the pipeline. + */ + StorageBearerTokenChallengeAuthorizationPolicy getBearerPolicy() { + return bearerPolicy; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + return bearerPolicy.process(context, next); + } + + HttpPipelineNextPolicy retryNext = next.clone(); + return getValidSessionAsync().flatMap(session -> { + signRequest(context, session); + return next.process().flatMap(response -> handleSessionResponse(context, response, session, retryNext)); + }); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (analyzeRequest(context) == AuthStrategy.USE_BEARER_TOKEN) { + return bearerPolicy.processSync(context, next); + } + + HttpPipelineNextSyncPolicy retryNext = next.clone(); + StorageSessionCredential session = getValidSessionSync(); + signRequest(context, session); + + HttpResponse response = next.processSync(); + return handleSessionResponseSync(context, response, session, retryNext); + } + + /** + * Analyzes the request to determine whether a session token or bearer token should be used. + * Session tokens are only used for blob GET operations in + * {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode targeting the configured container. + * + * @param context the pipeline call context for the request being analyzed. + * @return {@link AuthStrategy#USE_SESSION_TOKEN} if the request is eligible for session-token + * authentication (a GET against a blob in the configured container, with no {@code comp} query + * parameter, while in {@link SessionMode#SINGLE_SPECIFIED_CONTAINER} mode); + * {@link AuthStrategy#USE_BEARER_TOKEN} otherwise. + */ + AuthStrategy analyzeRequest(HttpPipelineCallContext context) { + SessionMode effectiveMode = sessionOptions.getSessionMode().resolve(); + + if (effectiveMode == SessionMode.NONE) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + if (context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + BlobUrlParts parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + + // If Service-level request (no container in path) + if (CoreUtils.isNullOrEmpty(parts.getBlobContainerName()) + && CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // If Container level request (container in path but no blob) + if (CoreUtils.isNullOrEmpty(parts.getBlobName())) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth. + Map queryParams = parts.getUnparsedParameters(); + if (queryParams.containsKey("comp")) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + if (parts.getBlobContainerName().compareToIgnoreCase(sessionOptions.getContainerName()) != 0) { + return AuthStrategy.USE_BEARER_TOKEN; + } + + return AuthStrategy.USE_SESSION_TOKEN; + } + + /** + * Handles the response after a session-authenticated async request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextPolicy retryNext) { + + handleSessionExpiringHeader(response); + + if (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); + } + + /** + * Handles the response after a session-authenticated sync request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response, + StorageSessionCredential session, HttpPipelineNextSyncPolicy retryNext) { + + handleSessionExpiringHeader(response); + + if (isSessionCredentialRejected(response)) { + invalidateSession(session); + } + + if (shouldRetryRequest(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + + StorageSessionCredential refreshed = getValidSessionSync(); + signRequest(context, refreshed); + return retryNext.processSync(); + } + + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.processSync(context, retryNext); + } + + return response; + } + + Mono getValidSessionAsync() { + return sessionCredentialCache.getValidSessionAsync(); + } + + StorageSessionCredential getValidSessionSync() { + return sessionCredentialCache.getValidSessionSync(); + } + + void invalidateSession(StorageSessionCredential target) { + sessionCredentialCache.invalidateSession(target); + } + + private void signRequest(HttpPipelineCallContext context, StorageSessionCredential cred) { + cred.signRequest(context.getHttpRequest()); + } + + private void handleSessionExpiringHeader(HttpResponse response) { + String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); + if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { + sessionCredentialCache.refreshSessionInBackground(); + } + } + + /** + * Returns true 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 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)); + } + + /** + * 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 false; + } + + return isRetryableSessionFailure(response); + } + + /** + * 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. + */ + 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/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java new file mode 100644 index 000000000000..d74a4ebc5792 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredential.java @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.common.StorageSharedKeyCredential; +import com.azure.storage.common.Utility; + +import java.net.URL; +import java.text.Collator; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Holds session credentials and signs requests using the Shared Key string-to-sign with the + * Session scheme prefix. + */ +final class StorageSessionCredential { + + private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); + private static final String SESSION_PREFIX = "Session "; + + private final String sessionToken; + private final String sessionKey; + private final OffsetDateTime expiration; + private final String accountName; + private final StorageSharedKeyCredential sharedKey; + + StorageSessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiration, String accountName) { + this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); + this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); + this.expiration = expiration != null ? expiration : OffsetDateTime.now().plusMinutes(5L); + this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); + this.sharedKey = new StorageSharedKeyCredential(accountName, sessionKey); + } + + void signRequest(HttpRequest request) { + // Pin x-ms-date so the value we sign matches what is on the wire (AddDatePolicy only sets Date). + // Honor any pre-set x-ms-date so callers (e.g., tests, retries) can pin a deterministic value. + if (request.getHeaders().getValue(X_MS_DATE) == null) { + request.setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now())); + } + + String stringToSign = buildStringToSign(request); + String signature = sharedKey.computeHmac256(stringToSign); + request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature); + } + + // Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for + // Content-Length. The Session protocol signs the literal value the wire carries. + // + // We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in + // azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/ + // implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls + // `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including + // GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization + // then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it + // sees on the wire, so delegating produces a signature mismatch. + // + // TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE, + // delete this method and delegate to sharedKey.generateAuthorizationHeader(...). + // This matches what happens in dotnet: + // https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99 + private String buildStringToSign(HttpRequest request) { + HttpHeaders headers = request.getHeaders(); + Collator collator = Collator.getInstance(Locale.ROOT); + + String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH); + // If x-ms-date is present, the Date slot is empty. + String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE); + + return String.join("\n", request.getHttpMethod().toString(), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_ENCODING), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LANGUAGE), contentLength, + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_MD5), + getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_TYPE), dateHeader, + getHeaderOrEmpty(headers, HttpHeaderName.IF_MODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.IF_MATCH), getHeaderOrEmpty(headers, HttpHeaderName.IF_NONE_MATCH), + getHeaderOrEmpty(headers, HttpHeaderName.IF_UNMODIFIED_SINCE), + getHeaderOrEmpty(headers, HttpHeaderName.RANGE), canonicalizedXmsHeaders(headers, collator), + canonicalizedResource(request.getUrl(), collator)); + } + + private static String getHeaderOrEmpty(HttpHeaders headers, HttpHeaderName name) { + String value = headers.getValue(name); + return value == null ? "" : value; + } + + private static String canonicalizedXmsHeaders(HttpHeaders headers, Collator collator) { + List xmsHeaders = new ArrayList<>(); + for (HttpHeader header : headers) { + if ("x-ms-".regionMatches(true, 0, header.getName(), 0, 5)) { + xmsHeaders.add(header); + } + } + if (xmsHeaders.isEmpty()) { + return ""; + } + xmsHeaders.sort((a, b) -> collator.compare(a.getName(), b.getName())); + StringBuilder sb = new StringBuilder(); + for (HttpHeader h : xmsHeaders) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(h.getName().toLowerCase(Locale.ROOT)).append(':').append(h.getValue()); + } + return sb.toString(); + } + + private String canonicalizedResource(URL url, Collator collator) { + String path = url.getPath(); + if (CoreUtils.isNullOrEmpty(path)) { + path = "/"; + } + String query = url.getQuery(); + if (CoreUtils.isNullOrEmpty(query)) { + return "/" + accountName + path; + } + + // Sort query parameters with locale-insensitive collation, lower-cased keys. + // Values must be URL-decoded (and split on commas) to match the canonicalization that the + // service performs; otherwise percent-encoded characters (e.g., %3A in a snapshot timestamp) + // would produce a different HMAC than Shared Key. + TreeMap> params = new TreeMap<>(collator); + for (String pair : query.split("&")) { + int eq = pair.indexOf('='); + String key = Utility.urlDecode(eq < 0 ? pair : pair.substring(0, eq)).toLowerCase(Locale.ROOT); + String rawValue = eq < 0 ? "" : pair.substring(eq + 1); + List decoded = params.computeIfAbsent(key, k -> new ArrayList<>()); + for (String v : rawValue.split(",")) { + decoded.add(Utility.urlDecode(v)); + } + } + + StringBuilder sb = new StringBuilder("/").append(accountName).append(path); + for (java.util.Map.Entry> entry : params.entrySet()) { + List values = entry.getValue(); + java.util.Collections.sort(values); + sb.append('\n').append(entry.getKey()).append(':').append(String.join(",", values)); + } + return sb.toString(); + } + + String getSessionToken() { + return sessionToken; + } + + String getSessionKey() { + return sessionKey; + } + + OffsetDateTime getExpiration() { + return expiration; + } + + String getAccountName() { + return accountName; + } + + boolean isExpired() { + return OffsetDateTime.now().isAfter(expiration); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java new file mode 100644 index 000000000000..594f976207df --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialCache.java @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Cache for container-scoped storage session credentials. + */ +final class StorageSessionCredentialCache { + private static final ClientLogger LOGGER = new ClientLogger(StorageSessionCredentialCache.class); + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + + private final BlobSessionClient sessionClient; + private final Object creationLock = new Object(); + private volatile StorageSessionCredential credential; + private volatile OffsetDateTime nextRefreshTime; + private volatile boolean refreshing; + private volatile Mono inflightCreation; + + StorageSessionCredentialCache(BlobSessionClient sessionClient) { + this.sessionClient = Objects.requireNonNull(sessionClient, "'sessionClient' cannot be null."); + } + + Mono getValidSessionAsync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return Mono.just(current); + } + + return startSessionCreationAsync(); + } + + StorageSessionCredential getValidSessionSync() { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. + Mono inFlight = inflightCreation; + if (inFlight != null) { + StorageSessionCredential refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + + synchronized (creationLock) { + current = credential; + now = OffsetDateTime.now(); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshSessionInBackground(); + } + return current; + } + + StorageSessionCredential created = sessionClient.createSessionSync(); + setActiveCredential(created); + return created; + } + } + + void invalidateSession(StorageSessionCredential target) { + synchronized (creationLock) { + if (credential == target) { + credential = null; + nextRefreshTime = null; + refreshing = false; + } + inflightCreation = null; + } + } + + void refreshSessionInBackground() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + if (!isUsable(credential, now) || !isRefreshDue(now) || refreshing) { + return; + } + refreshing = true; + } + + startSessionCreationAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + private Mono startSessionCreationAsync() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(); + StorageSessionCredential current = credential; + if (isUsable(current, now) && !isRefreshDue(now)) { + return Mono.just(current); + } + + if (inflightCreation != null) { + return inflightCreation; + } + + refreshing = true; + + inflightCreation = sessionClient.createSessionAsync().doOnNext(cred -> { + synchronized (creationLock) { + setActiveCredential(cred); + } + }).doFinally(ignored -> { + synchronized (creationLock) { + inflightCreation = null; + refreshing = false; + } + }).cache(); + + return inflightCreation; + } + } + + private void setActiveCredential(StorageSessionCredential newCredential) { + credential = newCredential; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(), newCredential.getExpiration()); + refreshing = false; + } + + private static boolean isUsable(StorageSessionCredential cred, OffsetDateTime now) { + return cred != null && !now.isAfter(cred.getExpiration()); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { + long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); + if (availableMillis <= 0) { + return now; + } + + double refreshPoint + = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); + return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java new file mode 100644 index 000000000000..8e0ed32abd2c --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Defines the session management strategy used by the SDK when sending requests to a container. + *

+ * A session is a temporary security context scoped to a container that amortizes authentication + * and authorization cost across many requests by signing them with a lightweight HMAC key instead + * of a full bearer token. + * {@link #NONE} + * {@link #SINGLE_SPECIFIED_CONTAINER} + * {@link #AUTO} + */ +public enum SessionMode { + + /** + * Always use bearer token authentication. No session tokens are used. + */ + NONE, + + /** + * Default behavior. This is currently equivalent to {@link #NONE} + */ + AUTO, + + /** + * The SDK creates a session on the first request and keeps an active session until it + * receives no requests for 5 minutes. + */ + SINGLE_SPECIFIED_CONTAINER; + + /** + * Resolves {@link #AUTO} to its current effective mode. Today {@code AUTO} maps to + * {@link #NONE}; this may change in a future release without breaking callers that + * use {@code resolve()} consistently. + */ + public SessionMode resolve() { + return this == AUTO ? NONE : this; + } + +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java new file mode 100644 index 000000000000..b70e682db554 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Options bag that configures session-based authentication on blob storage builders. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. + * + * @see SessionMode + */ +public final class SessionOptions { + + private SessionMode sessionMode = SessionMode.AUTO; + private String containerName; + private String accountName; + + /** + * Creates a new {@link SessionOptions} instance with default values. + * Note: This currently only applies when using TokenCredential for GET Blob operations. + */ + public SessionOptions() { + } + + /** + * Returns {@code options} if non-null, otherwise a freshly constructed {@link SessionOptions} + * with default values. Use this helper instead of inlining {@code opts != null ? opts : new SessionOptions()} + * so default construction stays in one place. + * + * @param options the options instance to validate; may be {@code null}. + * @return {@code options} if non-null; a new default {@link SessionOptions} otherwise. + */ + public static SessionOptions orDefault(SessionOptions options) { + return options != null ? options : new SessionOptions(); + } + + /** + * Gets the session mode. + * + * @return the {@link SessionMode}; defaults to {@link SessionMode#AUTO}. + */ + public SessionMode getSessionMode() { + return sessionMode; + } + + /** + * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#AUTO}. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode == null ? SessionMode.AUTO : sessionMode; + return this; + } + + /** + * Gets the container name that the session is scoped to. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name that the session is scoped to. This is required when the session mode + * is not {@link SessionMode#NONE}. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the storage account name used for session HMAC signing. + * + * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL). + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the storage account name used for session HMAC signing. When set, this takes precedence + * over the account name parsed from the endpoint URL. This is useful for custom domain URLs + * where the account name cannot be inferred from the hostname. + * + * @param accountName the storage account name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setAccountName(String accountName) { + this.accountName = accountName; + return this; + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java index 54fd3682e72c..42e12896bc4b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java @@ -242,7 +242,7 @@ private HttpPipeline getHttpPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + perRetryPolicies, configuration, audience, LOGGER, null, null); } private BlobServiceVersion getServiceVersion() { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java index 99d925bff60c..b96e458ab8e7 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java @@ -49,6 +49,7 @@ import com.azure.storage.blob.models.LeaseStateType; import com.azure.storage.blob.models.ListBlobContainersOptions; import com.azure.storage.blob.models.PublicAccessType; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.options.BlobBreakLeaseOptions; import com.azure.storage.blob.sas.BlobSasPermission; import com.azure.storage.blob.specialized.BlobAsyncClientBase; @@ -196,7 +197,11 @@ public void beforeTest() { TestProxySanitizerType.HEADER), new TestProxySanitizer("x-ms-rename-source", "((?<=http://|https://)([^/?]+)|sig=(.*))", "REDACTED", TestProxySanitizerType.HEADER), - new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL))); + new TestProxySanitizer("skoid=([^&]+)", "REDACTED", TestProxySanitizerType.URL), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"), + new TestProxySanitizer("(?.*?)", "REDACTED", + TestProxySanitizerType.BODY_REGEX).setGroupForReplace("secret"))); } // Ignore changes to the order of query parameters and wholly ignore the 'sv' (service version) query parameter @@ -408,20 +413,53 @@ protected Mono setupContainerLeaseConditionAsync(BlobContainerAsyncClien } protected BlobServiceClient getOAuthServiceClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceClient(new SessionOptions()); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions) { + return getOAuthServiceClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceClient getOAuthServiceClient(SessionOptions sessionOptions, HttpPipelinePolicy... policies) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildClient(); } protected BlobServiceAsyncClient getOAuthServiceAsyncClient() { - BlobServiceClientBuilder builder - = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); + return getOAuthServiceAsyncClient(new SessionOptions()); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions) { + return getOAuthServiceAsyncClient(sessionOptions, (HttpPipelinePolicy[]) null); + } + + protected BlobServiceAsyncClient getOAuthServiceAsyncClient(SessionOptions sessionOptions, + HttpPipelinePolicy... policies) { + BlobServiceClientBuilder builder = new BlobServiceClientBuilder().sessionOptions(sessionOptions) + .endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()); instrument(builder); + if (policies != null) { + for (HttpPipelinePolicy policy : policies) { + if (policy != null) { + builder.addPolicy(policy); + } + } + } + return builder.credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)).buildAsyncClient(); } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BuilderHelperTests.java index 0af01b5fe437..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 @@ -22,6 +22,8 @@ import com.azure.core.util.Header; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionMode; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -48,6 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -72,10 +75,10 @@ private static HttpRequest request(String url) { */ @Test public void freshDateAppliedOnRetry() { - HttpPipeline pipeline - = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, REQUEST_RETRY_OPTIONS, null, - BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new FreshDateTestClient(), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, + REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new FreshDateTestClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -176,7 +179,7 @@ public void customApplicationIdInUAString(String logOptionsUA, String clientOpti HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, new HttpLogOptions().setApplicationId(logOptionsUA), new ClientOptions().setApplicationId(clientOptionsUA), new ApplicationIdUAStringTestClient(expectedUA), - new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + new ArrayList<>(), new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -305,7 +308,7 @@ public void customHeadersClientOptions() { HttpPipeline pipeline = BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions().setHeaders(headers), new ClientOptionsHeadersTestClient(headers), new ArrayList<>(), - new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class)); + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); StepVerifier.create(pipeline.send(request(ENDPOINT))) .assertNext(it -> assertEquals(200, it.getStatusCode())) @@ -680,4 +683,115 @@ public Mono send(HttpRequest request) { return Mono.just(new MockHttpResponse(request, 200)); } } + + // region buildPipeline session tests + + @Test + public void buildPipelineWithTokenCredentialAlwaysHasSessionPolicy() { + HttpPipeline pipeline = buildBearerPipeline(); + + assertTrue(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with tokenCredential should always contain SessionTokenCredentialPolicy"); + } + + @Test + public void buildPipelineWithSharedKeyDoesNotHaveSessionPolicy() { + HttpPipeline pipeline = buildSharedKeyPipeline(); + + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + "Pipeline with shared key should not contain SessionTokenCredentialPolicy"); + } + + /** + * Helper to build a pipeline with bearer token auth. + */ + private static HttpPipeline buildBearerPipeline() { + return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), null, BlobServiceVersion.getLatest()); + } + + /** + * Helper to build a pipeline without bearer token auth (shared key only). + */ + private static HttpPipeline buildSharedKeyPipeline() { + return BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, + BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); + } + + /** + * Checks whether the pipeline contains a policy whose simple class name matches the given name. + */ + private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first policy whose simple class name matches, or -1 if not found. + */ + private static int indexOfPolicy(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return i; + } + } + return -1; + } + + // endregion + + // region BlobContainerClientBuilder sessionOptions tests + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsAlwaysAndNoContainerNameThrows() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER); + + assertThrows(IllegalArgumentException.class, + () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithSessionOptionsNoneAndNoContainerNameSucceeds() { + SessionOptions options = new SessionOptions().setSessionMode(SessionMode.NONE); + + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .sessionOptions(options) + .buildClient()); + } + + @Test + public void containerBuilderWithNoSessionOptionsSucceeds() { + assertDoesNotThrow(() -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient()); + } + + // endregion } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index f46116acdbb5..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 @@ -4,12 +4,14 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.util.BinaryData; import com.azure.core.http.rest.PagedIterable; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -33,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; @@ -63,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; @@ -2128,4 +2133,108 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } + + // Need to create a container client test here to test that sessions have been enabled and used + + @Test + 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(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + 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<>()); + 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 04ebc06dc2b6..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 @@ -12,6 +12,7 @@ import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.*; import com.azure.storage.blob.options.BlobContainerCreateOptions; import com.azure.storage.blob.options.BlobParallelUploadOptions; @@ -2142,4 +2143,116 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } + + @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(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + @LiveOnly + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.SINGLE_SPECIFIED_CONTAINER) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + BlobContainerAsyncClient sessionCcAsync = getOAuthServiceAsyncClient(sessionOptions, inspect) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + + 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) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + 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() { + 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/RequestInspectionPolicy.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java new file mode 100644 index 000000000000..99d0b115a622 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.storage.blob; + +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +import java.util.function.Consumer; + +/** + * Test-only pipeline policy that lets a test peek at every {@link HttpRequest} as it + * goes on the wire. Registers at {@link HttpPipelinePosition#PER_RETRY} so it sees + * the {@code Authorization} header that the auth policies set. + * + *

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

+ */ +public final class RequestInspectionPolicy implements HttpPipelinePolicy { + private final Consumer inspector; + + public RequestInspectionPolicy(Consumer inspector) { + this.inspector = inspector; + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.process(); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.processSync(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java new file mode 100644 index 000000000000..154ef16cb7bd --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/BlobSessionClientTests.java @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.storage.blob.BlobContainerAsyncClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobContainerClientBuilder; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.BlobTestBase; +import com.azure.storage.blob.sas.BlobContainerSasPermission; +import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; +import com.azure.storage.common.test.shared.StorageCommonTestUtils; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class BlobSessionClientTests extends BlobTestBase { + + @Test + public void createSessionReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + BlobSessionClient sessionClient + = new BlobSessionClient(oauthCc.getHttpPipeline(), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + } + + @Test + public void createSessionAsyncReturnsTokenAndKey() { + BlobContainerAsyncClient oauthCc + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + BlobSessionClient sessionClient = new BlobSessionClient(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + }).verifyComplete(); + } + + @Test + public void createSessionSyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), cc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(1, policyInvocationCount.get()); + } + + @Test + public void createSessionAsyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + BlobSessionClient sessionClient = new BlobSessionClient(createOAuthPipeline(policyInvocationCount), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + }).verifyComplete(); + + assertEquals(1, policyInvocationCount.get()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()); + + BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); + + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), sasCc.getBlobContainerName()); + + StorageSessionCredential credential = sessionClient.createSessionSync(); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionAsyncWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClient sasCc + = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) + .buildClient(); + + BlobSessionClient sessionClient = new BlobSessionClient(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName(), ccAsync.getBlobContainerName()); + + StepVerifier.create(sessionClient.createSessionAsync()).assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiration()); + assertEquals(false, credential.isExpired()); + }).verifyComplete(); + } + + private String generateUserDelegationContainerSas(BlobContainerClient containerClient) { + BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true) + .setWritePermission(true) + .setCreatePermission(true) + .setListPermission(true); + BlobServiceSasSignatureValues sasValues + = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions); + + return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient() + .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1))); + } + + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) { + HttpPipelinePolicy policy = (context, next) -> { + policyInvocationCount.incrementAndGet(); + return next.process(); + }; + + BlobServiceClientBuilder builder + = new BlobServiceClientBuilder().endpoint(ENVIRONMENT.getPrimaryAccount().getBlobEndpoint()) + .credential(StorageCommonTestUtils.getTokenCredential(interceptorManager)) + .addPolicy(policy); + + instrument(builder); + return builder.buildClient().getHttpPipeline(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java new file mode 100644 index 000000000000..592fc5f22241 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTestHelper.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import java.time.OffsetDateTime; + +/** + * Shared test constants and factories for session-based auth tests. + */ +final class SessionTestHelper { + + // A valid Base64-encoded 32-byte key for testing + static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + static final String TEST_ACCOUNT_NAME = "myaccount"; + static final String TEST_CONTAINER_NAME = "testcontainer"; + + static StorageSessionCredential createCredential(OffsetDateTime expiration) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, TEST_ACCOUNT_NAME); + } + + static StorageSessionCredential createCredential(OffsetDateTime expiration, String accountName) { + return new StorageSessionCredential(TEST_SESSION_TOKEN, TEST_SESSION_KEY, expiration, accountName); + } + + static StorageSessionCredential createValidCredential() { + return createCredential(OffsetDateTime.now().plusHours(1)); + } + + static StorageSessionCredential createExpiredCredential() { + return createCredential(OffsetDateTime.now().minusMinutes(5)); + } + + private SessionTestHelper() { + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java new file mode 100644 index 000000000000..033f9e3bb0f8 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -0,0 +1,763 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SessionTokenCredentialPolicyTest { + + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token"; + HttpHeaderName authHeaderName = HttpHeaderName.AUTHORIZATION; + + private BlobSessionClient sessionClient; + private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private SessionTokenCredentialPolicy policy; + + @BeforeEach + public void beforeEach() { + sessionClient = mock(BlobSessionClient.class); + bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); + + // Default mock behavior: bearer policy delegates to next policy in the pipeline. + when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.process(); + }); + when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.processSync(); + }); + + policy = createPolicy(SessionMode.SINGLE_SPECIFIED_CONTAINER); + } + + @Test + public void policyCreatesSessionOnFirstAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + + StorageSessionCredential credential = policy.getValidSessionAsync().block(); + + assertNotNull(credential); + assertEquals(FIRST_TOKEN, credential.getSessionToken()); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyReturnsCachedSessionOnConcurrentAsyncAccess() { + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + + List results + = Flux.range(0, 5).flatMap(ignored -> policy.getValidSessionAsync()).collectList().block(); + + assertNotNull(results); + assertEquals(5, results.size()); + results.forEach(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void policyRefreshesNearExpiryWithoutBlockingSyncRequests() { + StorageSessionCredential nearExpiry = credentialWithToken(FIRST_TOKEN, OffsetDateTime.now().plusSeconds(2)); + StorageSessionCredential refreshed = credentialWithToken(SECOND_TOKEN); + + when(sessionClient.createSessionSync()).thenReturn(nearExpiry); + // This is a Reactor quirk where Mono.just() emits synchronously on subscribe, so the refresh happens + // immediately when the cache determines the credential is near expiry + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(refreshed)); + + // Cold call to getValidSessionSync triggers session creation via createSessionSync + StorageSessionCredential initial = policy.getValidSessionSync(); + // Trigger refresh, which uses sessionClient.createSessionAsync() to get the refreshed session + StorageSessionCredential duringRefresh = policy.getValidSessionSync(); + StorageSessionCredential afterRefresh = policy.getValidSessionSync(); + + assertEquals(FIRST_TOKEN, initial.getSessionToken()); + assertEquals(FIRST_TOKEN, duringRefresh.getSessionToken()); + assertEquals(SECOND_TOKEN, afterRefresh.getSessionToken()); + verify(sessionClient, times(1)).createSessionSync(); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void concurrentSyncAccessOnlyCreatesOneSession() throws Exception { + when(sessionClient.createSessionSync()).thenAnswer(invocation -> { + Thread.sleep(100); + return credentialWithToken(FIRST_TOKEN); + }).thenReturn(credentialWithToken(SECOND_TOKEN)); + + int threadCount = 5; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + List> tasks = IntStream.range(0, threadCount) + .mapToObj(i -> (Callable) policy::getValidSessionSync) + .collect(Collectors.toList()); + + List> futures = executor.invokeAll(tasks); + for (Future future : futures) { + assertEquals(FIRST_TOKEN, future.get().getSessionToken()); + } + + verify(sessionClient, times(1)).createSessionSync(); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void policySignsRequestWithSessionCredential() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(response, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + verify(next, times(1)).process(); + } + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceAsync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).process(); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + } + + @Test + public void policyInvalidatesSessionAndRetriesOnceSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)) + .thenReturn(credentialWithToken(SECOND_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(initialResponse); + when(retryNext.processSync()).thenReturn(retriedResponse); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(retriedResponse, actualResponse); + assertTrue( + context.getHttpRequest().getHeaders().getValue("Authorization").startsWith("Session " + SECOND_TOKEN)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + } + } + + @Test + public void policyOnlyRetriesOncePerRequest() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))) + .thenReturn(Mono.just(credentialWithToken(SECOND_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(initialResponse)); + when(retryNext.process()).thenReturn(Mono.just(retriedResponse)); + when(initialResponse.getStatusCode()).thenReturn(401); + when(initialResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + when(retriedResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getHeaderValue(HttpHeaderName.WWW_AUTHENTICATE)) + .thenReturn("Session error=session_expired"); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(retriedResponse, actualResponse); + verify(retryNext, times(1)).process(); + verify(sessionClient, times(2)).createSessionAsync(); + } + } + + @Test + public void policyReturns403WithoutRetry() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse forbiddenResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(forbiddenResponse)); + when(forbiddenResponse.getStatusCode()).thenReturn(403); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(forbiddenResponse, actualResponse); + verify(next, times(1)).process(); + verify(retryNext, times(0)).process(); + verify(forbiddenResponse, times(0)).close(); + verify(sessionClient, times(1)).createSessionAsync(); + } + } + + @Test + public void policyReturnsSessionTokenInvalidWithoutRetryButInvalidatesSession() { + HttpPipelineCallContext context = createContext(); + 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"); + + 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()); + } + } + + @Test + public void policyFallsToBearerOn503SessionUnavailableAsync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(unavailableResponse)); + when(retryNext.process()).thenReturn(Mono.just(bearerResponse)); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).process(any(), any()); + // Authorization header should have been stripped so bearer policy can add its own + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyFallsToBearerOn503SessionUnavailableSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionSync()).thenReturn(credentialWithToken(FIRST_TOKEN)); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(unavailableResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(unavailableResponse.getStatusCode()).thenReturn(503); + when(unavailableResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))) + .thenReturn("SessionOperationsTemporarilyUnavailable"); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(unavailableResponse, times(1)).close(); + // Verify that the bearer policy was invoked for fallback + verify(bearerPolicy, times(1)).processSync(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue("Authorization"); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + @Test + public void policyReturns503ServerBusyWithoutBearerFallback() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpPipelineNextPolicy retryNext = mock(HttpPipelineNextPolicy.class); + HttpResponse busyResponse = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(retryNext); + when(next.process()).thenReturn(Mono.just(busyResponse)); + when(busyResponse.getStatusCode()).thenReturn(503); + when(busyResponse.getHeaderValue(HttpHeaderName.fromString("x-ms-error-code"))).thenReturn("ServerBusy"); + + try (HttpResponse actualResponse = policy.process(context, next).block()) { + // ServerBusy 503 is not session-specific — return as-is for retry policy to handle + assertEquals(busyResponse, actualResponse); + verify(retryNext, times(0)).process(); + verify(busyResponse, times(0)).close(); + } + } + + @Test + public void noneModeAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = nonePolicy.process(context, next).block()) { + assertEquals(response, actualResponse); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + } + + @Test + public void noneModeSyncAlwaysPassesThrough() { + SessionTokenCredentialPolicy nonePolicy = createPolicy(SessionMode.NONE); + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.processSync()).thenReturn(response); + when(response.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = nonePolicy.processSync(context, next)) { + assertEquals(response, actualResponse); + // Verify bearer policy was invoked (session delegates to bearer in NONE mode) + verify(bearerPolicy, times(1)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + } + + @Test + public void alwaysModeSignsFirstRequest() { + // The default `policy` in setUp is ALWAYS — verify it signs the very first request + HttpPipelineCallContext context = createContext(); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session ")); + verify(sessionClient, times(1)).createSessionAsync(); + } + + @Test + public void autoModeResolvesToNoneAndAlwaysDelegatesToBearer() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + + when(response.getStatusCode()).thenReturn(200); + + // AUTO resolves to NONE, so all requests should delegate to bearer + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextPolicy next1 = mock(HttpPipelineNextPolicy.class); + when(next1.process()).thenReturn(Mono.just(response)); + + try (HttpResponse actual1 = autoPolicy.process(context1, next1).block()) { + assertEquals(response, actual1); + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + // Second GetBlob also delegates to bearer (AUTO == NONE, no session ever) + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextPolicy next2 = mock(HttpPipelineNextPolicy.class); + when(next2.process()).thenReturn(Mono.just(response)); + + try (HttpResponse actual2 = autoPolicy.process(context2, next2).block()) { + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + } + + @Test + public void autoModeSyncResolvesToNoneAndAlwaysDelegatesToBearer() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + + when(response.getStatusCode()).thenReturn(200); + + // AUTO resolves to NONE, so all requests should delegate to bearer + HttpPipelineCallContext context1 = createContext(); + HttpPipelineNextSyncPolicy next1 = mock(HttpPipelineNextSyncPolicy.class); + when(next1.processSync()).thenReturn(response); + + try (HttpResponse actual1 = autoPolicy.processSync(context1, next1)) { + assertEquals(response, actual1); + verify(bearerPolicy, times(1)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + + HttpPipelineCallContext context2 = createContext(); + HttpPipelineNextSyncPolicy next2 = mock(HttpPipelineNextSyncPolicy.class); + when(next2.processSync()).thenReturn(response); + + try (HttpResponse actual2 = autoPolicy.processSync(context2, next2)) { + assertEquals(response, actual2); + verify(bearerPolicy, times(2)).processSync(any(), any()); + verify(sessionClient, times(0)).createSessionSync(); + } + } + + private SessionTokenCredentialPolicy createPolicy(SessionMode mode) { + SessionOptions options = new SessionOptions().setSessionMode(mode).setContainerName("mycontainer"); + return new SessionTokenCredentialPolicy(bearerPolicy, new StorageSessionCredentialCache(sessionClient), + options); + } + + private static StorageSessionCredential credentialWithToken(String token) { + return credentialWithToken(token, OffsetDateTime.now().plusHours(1)); + } + + private static StorageSessionCredential credentialWithToken(String token, OffsetDateTime expiration) { + return new StorageSessionCredential(token, SessionTestHelper.TEST_SESSION_KEY, expiration, + SessionTestHelper.TEST_ACCOUNT_NAME); + } + + private static HttpPipelineCallContext createContext() { + return createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + } + + private static HttpPipelineCallContext createContextForUrl(String url) { + return createContextForRequest(new HttpRequest(HttpMethod.GET, url)); + } + + private static HttpPipelineCallContext createContextForRequest(HttpRequest request) { + HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); + Map data = new ConcurrentHashMap<>(); + + when(context.getHttpRequest()).thenReturn(request); + when(context.getData(anyString())) + .thenAnswer(invocation -> Optional.ofNullable(data.get(invocation.getArgument(0)))); + doAnswer(invocation -> { + data.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(context).setData(anyString(), org.mockito.ArgumentMatchers.any()); + + return context; + } + + @Test + public void getBlobRequestUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob request should be signed with session auth"); + } + + @Test + public void getBlobRequestProducesWellFormedSessionAuthHeader() { + StorageSessionCredential cred = credentialWithToken(FIRST_TOKEN); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023"); + + HttpPipelineCallContext context = createContextForRequest(request); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(cred)); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + // The policy must delegate signing to StorageSessionCredential, producing a Session-scheme + // Authorization header of the form `Session :`. End-to-end signature + // correctness against the live service is covered by ContainerApiTests.downloadBlobOverSessionAuth. + String actual = request.getHeaders().getValue(authHeaderName); + assertNotNull(actual, "Authorization header should be set by the policy"); + assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"), + "Authorization should use the Session scheme with the cached session token, but was: " + actual); + String actualSignature = actual.substring(actual.indexOf(':') + 1); + assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"), + "Signature must be base64-encoded, but was: " + actualSignature); + } + + /** + * Guards the workaround in {@link StorageSessionCredential#buildStringToSign}: the Session + * protocol signs the literal {@code Content-Length} value rather than normalizing + * {@code "0" -> ""} like SharedKey does. This is required today because azure-core's + * {@code RestProxyBase} unconditionally adds {@code Content-Length: 0} to body-less GET + * requests. Once that is fixed in azure-core, the buildStringToSign workaround can be removed + * and this test should be updated (or deleted) to reflect the new behavior. + */ + @Test + public void contentLengthZeroIsIncludedInSessionSignature() { + String pinnedDate = "Wed, 22 Apr 2026 20:00:00 GMT"; + + HttpRequest withCl0 + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withCl0.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "0") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withCl0); + String sigWithCl0 = extractSignature(withCl0.getHeaders().getValue(authHeaderName)); + + HttpRequest withoutCl + = new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + withoutCl.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), "2025-01-05") + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.fromString("x-ms-date"), pinnedDate); + credentialWithToken(FIRST_TOKEN).signRequest(withoutCl); + String sigWithoutCl = extractSignature(withoutCl.getHeaders().getValue(authHeaderName)); + + assertTrue(!sigWithCl0.equals(sigWithoutCl), + "Session signature must include literal Content-Length value: signing with " + + "Content-Length: 0 must differ from signing without Content-Length"); + } + + private static String extractSignature(String authHeader) { + return authHeader.substring(authHeader.indexOf(':') + 1); + } + + @Test + public void putBlobRequestSkipsSessionAuth() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // Non-GetBlob requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void listBlobsRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container&comp=list"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // ListBlobs requests delegate to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobPropertiesRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob?comp=metadata"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // GetBlobProperties (comp=metadata) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void getBlobWithSnapshotUsesSessionAuth() { + HttpPipelineCallContext context = createContextForUrl( + "https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=2021-01-01T00:00:00Z"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + policy.process(context, next).block().close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob with snapshot should still use session auth"); + } + + @Test + public void containerLevelGetRequestSkipsSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer?restype=container"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + policy.process(context, next).block().close(); + + // Container-level GET (restype=container) delegates to bearer policy instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void autoModeAlwaysDelegatesToBearerEvenForGetBlobRequests() { + SessionTokenCredentialPolicy autoPolicy = createPolicy(SessionMode.AUTO); + HttpResponse response = mock(HttpResponse.class); + when(response.getStatusCode()).thenReturn(200); + + // PUT request — delegates to bearer (AUTO == NONE) + HttpPipelineCallContext putContext = createContextForRequest( + new HttpRequest(HttpMethod.PUT, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy putNext = mock(HttpPipelineNextPolicy.class); + when(putNext.process()).thenReturn(Mono.just(response)); + autoPolicy.process(putContext, putNext).block().close(); + + // GET blob — also delegates to bearer (AUTO == NONE) + HttpPipelineCallContext getContext + = createContextForUrl("https://myaccount.blob.core.windows.net/mycontainer/myblob"); + HttpPipelineNextPolicy getNext = mock(HttpPipelineNextPolicy.class); + when(getNext.process()).thenReturn(Mono.just(response)); + Objects.requireNonNull(autoPolicy.process(getContext, getNext).block()).close(); + + verify(bearerPolicy, times(2)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void singleSpecifiedContainerModeNonGetBlobSkipsSession() { + HttpPipelineCallContext context = createContextForRequest( + new HttpRequest(HttpMethod.DELETE, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(next.process()).thenReturn(Mono.just(response)); + + Objects.requireNonNull(policy.process(context, next).block()).close(); + + // SINGLE_SPECIFIED_CONTAINER mode non-GetBlob requests delegate to bearer instead of session auth + verify(bearerPolicy, times(1)).process(any(), any()); + verify(sessionClient, times(0)).createSessionAsync(); + } + + @Test + public void ipStyleEndpointGetBlobUsesSessionAuth() { + HttpPipelineCallContext context + = createContextForUrl("https://127.0.0.1:10000/devstoreaccount1/mycontainer/myblob"); + HttpPipelineNextPolicy next = mock(HttpPipelineNextPolicy.class); + HttpResponse response = mock(HttpResponse.class); + + when(sessionClient.createSessionAsync()).thenReturn(Mono.just(credentialWithToken(FIRST_TOKEN))); + when(next.clone()).thenReturn(next); + when(next.process()).thenReturn(Mono.just(response)); + when(response.getStatusCode()).thenReturn(200); + + Objects.requireNonNull(policy.process(context, next).block()).close(); + + assertTrue(context.getHttpRequest().getHeaders().getValue(authHeaderName).startsWith("Session "), + "GetBlob on IP-style endpoint should use session auth"); + } + + // endregion +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java new file mode 100644 index 000000000000..2215af27c1a0 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/StorageSessionCredentialTest.java @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.common.StorageSharedKeyCredential; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URL; +import java.time.OffsetDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class StorageSessionCredentialTest { + + @Test + public void signRequestUsesSessionScheme() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); + + credential.signRequest(request); + + String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertNotNull(authHeader); + assertTrue(authHeader.startsWith("Session " + SessionTestHelper.TEST_SESSION_TOKEN + ":"), + "Authorization header should start with 'Session :' but was: " + authHeader); + String signaturePart = authHeader.substring(authHeader.indexOf(':') + 1); + assertFalse(signaturePart.isEmpty(), "Signature should not be empty"); + } + + @Test + public void signRequestSetsXmsDateHeader() throws MalformedURLException { + StorageSessionCredential credential = SessionTestHelper.createValidCredential(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob")); + + assertNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date"))); + + credential.signRequest(request); + + assertNotNull(request.getHeaders().getValue(HttpHeaderName.fromString("x-ms-date")), + "signRequest must set x-ms-date so the signed value matches what is sent on the wire"); + } + + // Regression guard for the URL-decode fix in StorageSessionCredential.canonicalizedResource: + // verifies Session and SharedKey produce the same HMAC for a well-formed GET with an + // encoded query string (e.g. snapshot=...%3A...). + // + // Scope is intentionally narrow. Session and SharedKey legitimately diverge on: + // - missing Content-Length (SharedKey emits literal "null" via String.join; Session emits "") + // - Content-Length "0" on GETs (SharedKey normalizes to ""; Session preserves "0" to match + // what azure-core's RestProxyBase puts on the wire — see the comment on + // StorageSessionCredential.buildStringToSign). + // Content-Length is pinned to a realistic non-zero value to bypass both quirks. + // + // DELETE this test once azure-core stops setting Content-Length: 0 on GETs and + // StorageSessionCredential.buildStringToSign is removed in favor of delegating to + // sharedKey.generateAuthorizationHeader(...). At that point this assertion becomes + // tautological (SharedKey vs. SharedKey). + @Test + public void canonicalizationMatchesSharedKeyForEncodedQuery() throws MalformedURLException { + StorageSessionCredential sessionCred = SessionTestHelper.createValidCredential(); + StorageSharedKeyCredential sharedKeyCred + = new StorageSharedKeyCredential(SessionTestHelper.TEST_ACCOUNT_NAME, SessionTestHelper.TEST_SESSION_KEY); + + HttpRequest request = new HttpRequest(HttpMethod.GET, + new URL("https://myaccount.blob.core.windows.net/mycontainer/myblob?snapshot=" + + "2025-03-31T00%3A00%3A00.0000000Z")); + request.getHeaders() + .set(HttpHeaderName.fromString("x-ms-version"), BlobServiceVersion.getLatest().getVersion()) + .set(HttpHeaderName.fromString("x-ms-client-request-id"), "11111111-2222-3333-4444-555555555555") + .set(HttpHeaderName.RANGE, "bytes=0-1023") + .set(HttpHeaderName.CONTENT_LENGTH, "1024"); + + sessionCred.signRequest(request); + + String sessionAuth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String sessionSignature = sessionAuth.substring(sessionAuth.indexOf(':') + 1); + + HttpHeaders headersForSharedKey = request.getHeaders(); + headersForSharedKey.remove(HttpHeaderName.AUTHORIZATION); + String sharedKeyAuth + = sharedKeyCred.generateAuthorizationHeader(request.getUrl(), "GET", headersForSharedKey, false); + String sharedKeySignature = sharedKeyAuth.substring(sharedKeyAuth.indexOf(':') + 1); + + assertEquals(sharedKeySignature, sessionSignature, + "Session HMAC must match Shared Key HMAC for the same URL/method/headers"); + } + + @Test + public void isExpiredReturnsTrueWhenPastExpiration() { + assertTrue(SessionTestHelper.createExpiredCredential().isExpired(), + "Credential should be expired when expiration is in the past"); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + assertFalse(SessionTestHelper.createValidCredential().isExpired(), + "Credential should not be expired when expiration is in the future"); + } + + @Test + public void getExpirationDefaultsWhenConstructedWithNull() { + OffsetDateTime before = OffsetDateTime.now(); + StorageSessionCredential credential = new StorageSessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME); + OffsetDateTime after = OffsetDateTime.now(); + + OffsetDateTime expiration = credential.getExpiration(); + assertNotNull(expiration); + assertTrue( + !expiration.isBefore(before.plusMinutes(5L).minusSeconds(1)) + && !expiration.isAfter(after.plusMinutes(5L).plusSeconds(1)), + "Default expiration should be ~5 minutes from construction time, but was " + expiration); + } +} diff --git a/sdk/storage/azure-storage-blob/swagger/README.md b/sdk/storage/azure-storage-blob/swagger/README.md index 292d2f7c231d..e3f3f4ead37b 100644 --- a/sdk/storage/azure-storage-blob/swagger/README.md +++ b/sdk/storage/azure-storage-blob/swagger/README.md @@ -16,7 +16,7 @@ autorest ### Code generation settings ``` yaml use: '@autorest/java@4.1.63' -input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/15d7f54a5389d5906ffb4e56bb2f38fe5525c0d3/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-06-06/blob.json +input-file: https://raw.githubusercontent.com/nickliu-msft/azure-rest-api-specs/013866b01623e6f2cc6c313b44c9c6460de3e91e/specification/storage/data-plane/Microsoft.BlobStorage/stable/2026-10-06/blob.json java: true output-folder: ../ namespace: com.azure.storage.blob diff --git a/sdk/storage/azure-storage-common/src/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..42bf2c872e4b 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,11 @@ public StorageBearerTokenChallengeAuthorizationPolicy(TokenCredential credential @Override public Mono authorizeRequest(HttpPipelineCallContext context) { - // Delegate to superclass to maintain previous behavior return super.authorizeRequest(context); } @Override public void authorizeRequestSync(HttpPipelineCallContext context) { - // Delegate to superclass to maintain previous behavior super.authorizeRequestSync(context); } 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); + } }