diff --git a/.gitignore b/.gitignore index 997c0e0648a2..5cac5c04a890 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ stress-test-addons* # Temp typespec files TempTypeSpecFiles/ + +# Azure Artifacts Credential Provider runtime +.azure-artifacts/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f9de9fca603..73cffade0293 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,14 @@ For detailed instructions, refer to the [Maven Credential Provider documentation > **Note:** For Maven Azure DevOps pipeline authentication, use the [MavenAuthenticate@0](https://learn.microsoft.com/azure/devops/pipelines/tasks/reference/maven-authenticate-v0) pipeline task. +##### Matching CI behavior locally + +All Maven dependency and artifact resolution already uses the Azure Artifacts feed by default via `` declarations in the project POMs. However, Maven plugins and extensions do not honor POM-level repositories. To route plugin traffic through the Azure Artifacts feed as well (matching CI behavior), copy the mirror settings: + +```bash +cp eng/settings.xml ~/.m2/settings.xml +``` + ##### Troubleshooting 401 Unauthorized errors If you encounter a `401 Unauthorized` error when running Maven commands: diff --git a/eng/common/pipelines/templates/steps/verify-agent-os.yml b/eng/common/pipelines/templates/steps/verify-agent-os.yml index a9109cf51803..5c5fe54c957d 100644 --- a/eng/common/pipelines/templates/steps/verify-agent-os.yml +++ b/eng/common/pipelines/templates/steps/verify-agent-os.yml @@ -14,5 +14,3 @@ steps: filePath: ${{ parameters.ScriptDirectory }}/Verify-AgentOS.ps1 arguments: > -AgentImage "${{ parameters.AgentImage }}" - - - template: /eng/common/pipelines/templates/steps/bypass-local-dns.yml diff --git a/eng/pipelines/templates/jobs/ci.yml b/eng/pipelines/templates/jobs/ci.yml index de57f9f06c27..860e5a0b43cd 100644 --- a/eng/pipelines/templates/jobs/ci.yml +++ b/eng/pipelines/templates/jobs/ci.yml @@ -209,6 +209,11 @@ jobs: parameters: PackagePropertiesFolder: $(Build.ArtifactStagingDirectory)/PackageInfo + - task: PipAuthenticate@1 + displayName: 'Pip Authenticate to Azure Artifacts' + inputs: + artifactFeeds: 'public/azure-sdk-for-python' + - script: | python -m pip install markdown2==2.4.6 BeautifulSoup4==4.11.1 displayName: 'pip install markdown2 and BeautifulSoup4' diff --git a/eng/pipelines/templates/steps/maven-authenticate.yml b/eng/pipelines/templates/steps/maven-authenticate.yml index 906102779e59..8aeb866d5209 100644 --- a/eng/pipelines/templates/steps/maven-authenticate.yml +++ b/eng/pipelines/templates/steps/maven-authenticate.yml @@ -1,6 +1,13 @@ steps: + # Copy mirror settings to default Maven location so all requests go through CFS + - pwsh: | + $m2Dir = if ($env:USERPROFILE) { "$env:USERPROFILE\.m2" } else { "$HOME/.m2" } + New-Item -ItemType Directory -Force -Path $m2Dir | Out-Null + Copy-Item -Path "$(Build.SourcesDirectory)/eng/settings.xml" -Destination "$m2Dir/settings.xml" + displayName: 'Setup Maven mirror settings' + # Authenticate with Azure Artifacts feeds - # Repo URLs are defined in azure-sdk-parent pom.xml with id 'azure-sdk-for-java' + # MavenAuthenticate adds entries to ~/.m2/settings.xml matching mirror id 'azure-sdk-for-java' - task: MavenAuthenticate@0 displayName: 'Maven Authenticate' inputs: diff --git a/eng/settings.xml b/eng/settings.xml new file mode 100644 index 000000000000..f77985887676 --- /dev/null +++ b/eng/settings.xml @@ -0,0 +1,13 @@ + + + + azure-sdk-for-java + Azure Artifacts Maven Mirror + https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-java/maven/v1 + external:*,!confluent,!repository.spring.milestone + + + diff --git a/sdk/storage/azure-storage-blob/assets.json b/sdk/storage/azure-storage-blob/assets.json index 8cad139f33ff..2b811303caf8 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_4615078286" } diff --git a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml index 90d7f65ae375..a5ed026c13b5 100644 --- a/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml +++ b/sdk/storage/azure-storage-blob/checkstyle-suppressions.xml @@ -6,6 +6,7 @@ + diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java index 1d0ac36d4ce8..386a2a671632 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobClientBuilder.java @@ -189,18 +189,20 @@ public BlobAsyncClient buildAsyncClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, blobName, snapshot, customerProvidedKey, encryptionScope, versionId); } - private HttpPipeline constructPipeline() { - return (httpPipeline != null) - ? httpPipeline - : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, - endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + private HttpPipeline constructPipeline(String containerName, BlobServiceVersion serviceVersion) { + if (httpPipeline != null) { + return httpPipeline; + } + + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, null, serviceVersion); } /** @@ -650,4 +652,5 @@ public BlobClientBuilder audience(BlobAudience audience) { this.audience = audience; return this; } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerAsyncClient.java index 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..98e927af64a0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobContainerClientBuilder.java @@ -133,7 +133,7 @@ public BlobContainerClient buildClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobContainerClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); @@ -174,18 +174,19 @@ public BlobContainerAsyncClient buildAsyncClient() { BlobServiceVersion serviceVersion = version != null ? version : BlobServiceVersion.getLatest(); - HttpPipeline pipeline = constructPipeline(); + HttpPipeline pipeline = constructPipeline(blobContainerName, serviceVersion); return new BlobContainerAsyncClient(pipeline, endpoint, serviceVersion, accountName, blobContainerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } - private HttpPipeline constructPipeline() { - return (httpPipeline != null) - ? httpPipeline - : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, - endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + private HttpPipeline constructPipeline(String containerName, BlobServiceVersion serviceVersion) { + if (httpPipeline != null) { + return httpPipeline; + } + return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, + endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, + perRetryPolicies, configuration, audience, LOGGER, null, serviceVersion); } /** @@ -606,4 +607,5 @@ public BlobContainerClientBuilder audience(BlobAudience audience) { this.audience = audience; return this; } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java index 3ad51c9a9b5f..641c40b1a0a0 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClient.java @@ -147,6 +147,7 @@ public BlobContainerClient getBlobContainerClient(String containerName) { if (CoreUtils.isNullOrEmpty(containerName)) { containerName = BlobContainerClient.ROOT_CONTAINER_NAME; } + return new BlobContainerClient(getHttpPipeline(), getAccountUrl(), getServiceVersion(), getAccountName(), containerName, customerProvidedKey, encryptionScope, blobContainerEncryptionScope); } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java index 5fb46965824f..d18707d9efc8 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/BlobServiceClientBuilder.java @@ -30,6 +30,8 @@ import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.models.EncryptionScope; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.implementation.util.SessionTokenCredentialPolicy; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.BlobAudience; import com.azure.storage.blob.models.BlobContainerEncryptionScope; import com.azure.storage.blob.models.CpkInfo; @@ -93,6 +95,7 @@ public final class BlobServiceClientBuilder implements TokenCredentialTrait + * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. This setting is passed to container + * clients created via {@link BlobServiceClient#getBlobContainerClient(String)}. If the options do not + * specify a {@link com.azure.storage.blob.models.SessionProvider}, the SDK creates a built-in provider + * scoped to this service client's pipeline. + * + * @param sessionOptions The session options for the HTTP pipeline. + * @return the updated BlobServiceClientBuilder object. + */ + public BlobServiceClientBuilder sessionOptions(SessionOptions sessionOptions) { + this.sessionOptions = sessionOptions != null ? sessionOptions : new SessionOptions(); + return this; + } } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/ContainersImpl.java index 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/BuilderHelper.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java index 0866d310981c..01b5d5cc82ac 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/BuilderHelper.java @@ -24,12 +24,17 @@ import com.azure.core.util.ClientOptions; import com.azure.core.util.Configuration; import com.azure.core.util.CoreUtils; +import com.azure.core.util.HttpClientOptions; import com.azure.core.util.TracingOptions; import com.azure.core.util.logging.ClientLogger; import com.azure.core.util.tracing.Tracer; import com.azure.core.util.tracing.TracerProvider; +import com.azure.storage.blob.BlobServiceVersion; import com.azure.storage.blob.BlobUrlParts; import com.azure.storage.blob.models.BlobAudience; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionProvider; import com.azure.storage.common.StorageSharedKeyCredential; import com.azure.storage.common.implementation.BuilderUtils; import com.azure.storage.common.implementation.Constants; @@ -64,23 +69,26 @@ public final class BuilderHelper { } /** - * Constructs a {@link HttpPipeline} from values passed from a builder. + * Constructs a {@link HttpPipeline} from values passed from a builder, with optional session-based + * authentication support. * * @param storageSharedKeyCredential {@link StorageSharedKeyCredential} if present. - * @param tokenCredential {@link TokenCredential} if present. - * @param azureSasCredential {@link AzureSasCredential} if present. - * @param sasToken SAS token if present. - * @param endpoint The endpoint for the client. - * @param retryOptions Storage's retry options to set in the retry policy. - * @param coreRetryOptions Core's retry options to set in the retry policy. - * @param logOptions Logging options to set in the logging policy. - * @param clientOptions Client options. - * @param httpClient HttpClient to use in the builder. - * @param perCallPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per call. - * @param perRetryPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per retry. - * @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 tokenCredential {@link TokenCredential} if present. + * @param azureSasCredential {@link AzureSasCredential} if present. + * @param sasToken SAS token if present. + * @param endpoint The endpoint for the client. + * @param retryOptions Storage's retry options to set in the retry policy. + * @param coreRetryOptions Core's retry options to set in the retry policy. + * @param logOptions Logging options to set in the logging policy. + * @param clientOptions Client options. + * @param httpClient HttpClient to use in the builder. + * @param perCallPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per call. + * @param perRetryPolicies Additional {@link HttpPipelinePolicy policies} to set in the pipeline per retry. + * @param configuration Configuration store contain environment settings. + * @param logger {@link ClientLogger} used to log any exception. + * @param audience {@link BlobAudience} used to determine the audience of the blob. + * @param sessionOptions {@link SessionOptions} containing the session mode, container name, and account name for session-based authentication. + * @param serviceVersion The service version for session creation. Required when session is active. * @return A new {@link HttpPipeline} from the passed values. */ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageSharedKeyCredential, @@ -88,7 +96,7 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare RequestRetryOptions retryOptions, RetryOptions coreRetryOptions, HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient, List perCallPolicies, List perRetryPolicies, Configuration configuration, BlobAudience audience, - ClientLogger logger) { + ClientLogger logger, SessionOptions sessionOptions, BlobServiceVersion serviceVersion) { CredentialValidator.validateCredentialsNotAmbiguous(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, logger); @@ -119,12 +127,36 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new StorageSharedKeyCredentialPolicy(storageSharedKeyCredential)); } + // Session credentials are bound to the client's network context. When the caller doesn't provide an + // HttpClient, create one default instance and share it between CreateSession and data requests instead of + // letting each pipeline create its own transport. + HttpClient effectiveHttpClient + = tokenCredential == null ? httpClient : getOrCreateHttpClient(httpClient, clientOptions); + + // When the resolved session mode is enabled and a tokenCredential is + // present, a single SessionTokenCredentialPolicy is added as the auth policy. The session policy wraps the bearer + // token policy internally and delegates to it for non-session-eligible requests. When sessions are not active, + // the bearer token policy is added directly. if (tokenCredential != null) { httpsValidation(tokenCredential, "bearer token", endpoint, logger); String scope = audience != null ? ((audience.toString().endsWith("/") ? audience + ".default" : audience + "/.default")) : Constants.STORAGE_SCOPE; - policies.add(new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope)); + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy + = new StorageBearerTokenChallengeAuthorizationPolicy(tokenCredential, scope); + + if (sessionOptions == null || sessionOptions.getSessionMode() == SessionMode.DISABLED) { + policies.add(bearerPolicy); + } else { + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + SessionProvider sessionProvider = sessionOptions.getSessionProvider(); + if (sessionProvider == null) { + sessionProvider = createDefaultSessionProvider(policies, bearerPolicy, effectiveHttpClient, + clientOptions, endpoint, effectiveServiceVersion, sessionOptions.getAccountName()); + } + policies.add(new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, sessionOptions)); + } } if (azureSasCredential != null) { @@ -144,12 +176,47 @@ public static HttpPipeline buildPipeline(StorageSharedKeyCredential storageShare policies.add(new ScrubEtagPolicy()); return new HttpPipelineBuilder().policies(policies.toArray(new HttpPipelinePolicy[0])) + .httpClient(effectiveHttpClient) + .clientOptions(clientOptions) + .tracer(createTracer(clientOptions)) + .build(); + } + + /** + * Builds a bearer-only {@link HttpPipeline} for CreateSession calls. This pipeline contains + * all pre-auth policies plus the bearer token policy, but no session policy. + */ + private static HttpPipeline buildBearerPipeline(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, + ClientOptions clientOptions) { + List bearerPolicies = new ArrayList<>(preAuthPolicies); + bearerPolicies.add(bearerPolicy); + return new HttpPipelineBuilder().policies(bearerPolicies.toArray(new HttpPipelinePolicy[0])) .httpClient(httpClient) .clientOptions(clientOptions) .tracer(createTracer(clientOptions)) .build(); } + private static SessionProvider createDefaultSessionProvider(List preAuthPolicies, + StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, HttpClient httpClient, ClientOptions clientOptions, + String endpoint, BlobServiceVersion serviceVersion, String accountName) { + HttpPipeline bearerPipeline = buildBearerPipeline(preAuthPolicies, bearerPolicy, httpClient, clientOptions); + BlobServiceVersion effectiveServiceVersion + = serviceVersion != null ? serviceVersion : BlobServiceVersion.getLatest(); + return new TokenCredentialSessionProvider(bearerPipeline, endpoint, effectiveServiceVersion, accountName); + } + + private static HttpClient getOrCreateHttpClient(HttpClient httpClient, ClientOptions clientOptions) { + if (httpClient != null) { + return httpClient; + } + + return clientOptions instanceof HttpClientOptions + ? HttpClient.createDefault((HttpClientOptions) clientOptions) + : HttpClient.createDefault(); + } + /** * Gets the default http log option for Storage Blob. * @@ -180,8 +247,8 @@ public static String getEndpoint(BlobUrlParts parts) throws MalformedURLExceptio * Validates that the client is properly configured to use https. * * @param objectToCheck The object to check for. - * @param objectName The name of the object. - * @param endpoint The endpoint for the client. + * @param objectName The name of the object. + * @param endpoint The endpoint for the client. */ public static void httpsValidation(Object objectToCheck, String objectName, String endpoint, ClientLogger logger) { if (objectToCheck != null && !BlobUrlParts.parse(endpoint).getScheme().equals(Constants.HTTPS)) { @@ -226,10 +293,11 @@ public static Tracer createTracer(ClientOptions clientOptions) { /** * Logs information about credential changes in builders. * - * @param logger The logger to use. + * @param logger The logger to use. * @param newCredentialType The credential type being set. */ public static void logCredentialChange(ClientLogger logger, String newCredentialType) { logger.info("Credential set to '{}' when it was previously configured.", newCredentialType); } + } diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java new file mode 100644 index 000000000000..1e9cb163af1a --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicy.java @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.exception.HttpResponseException; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.core.util.logging.ClientLogger; +import com.azure.storage.blob.BlobUrlParts; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.common.StorageSharedKeyCredential; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import reactor.core.publisher.Mono; + +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 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. + */ +public final class SessionTokenCredentialPolicy implements HttpPipelinePolicy { + private static final ClientLogger LOGGER = new ClientLogger(SessionTokenCredentialPolicy.class); + private static final String RETRY_CONTEXT_KEY = "azure-storage-blob-session-auth-retried"; + private static final HttpHeaderName X_MS_AUTH_INFO = HttpHeaderName.fromString("x-ms-auth-info"); + private static final HttpHeaderName X_MS_DATE = HttpHeaderName.fromString("x-ms-date"); + private static final String SESSION_EXPIRING = "session_expiring"; + private static final String SESSION_PREFIX = "Session "; + private static final Duration SESSION_ACQUISITION_COOLDOWN = Duration.ofMinutes(5); + + private final StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private final SessionProvider sessionProvider; + private final SessionOptions sessionOptions; + private final Clock clock; + private final ConcurrentHashMap accountCooldowns = new ConcurrentHashMap<>(); + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + SessionProvider sessionProvider, SessionOptions sessionOptions) { + this(bearerPolicy, sessionProvider, sessionOptions, Clock.systemUTC()); + } + + SessionTokenCredentialPolicy(StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy, + SessionProvider sessionProvider, SessionOptions sessionOptions, Clock clock) { + this.bearerPolicy = Objects.requireNonNull(bearerPolicy, "'bearerPolicy' cannot be null."); + this.sessionProvider = Objects.requireNonNull(sessionProvider, "'sessionProvider' cannot be null."); + this.sessionOptions = Objects.requireNonNull(sessionOptions, "'sessionOptions' cannot be null."); + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.process(context, next); + } + if (isAccountInCooldown(requestContext.getAccountName())) { + return bearerPolicy.process(context, next); + } + + HttpPipelineNextPolicy retryNext = next.clone(); + Mono sessionMono; + try { + sessionMono = sessionProvider.getSessionAsync(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + return bearerPolicy.process(context, next); + } + + return sessionMono.onErrorResume(error -> { + handleSessionAcquisitionFailure(requestContext, error); + return Mono.empty(); + }).flatMap(session -> { + signRequest(context, session); + return next.process() + .flatMap(response -> handleSessionResponse(context, response, session, requestContext, retryNext)); + }).switchIfEmpty(Mono.defer(() -> bearerPolicy.process(context, next))); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + SessionRequestContext requestContext = resolveSessionRequest(context); + if (requestContext == null) { + return bearerPolicy.processSync(context, next); + } + if (isAccountInCooldown(requestContext.getAccountName())) { + return bearerPolicy.processSync(context, next); + } + + HttpPipelineNextSyncPolicy retryNext = next.clone(); + SessionCredential session; + try { + session = sessionProvider.getSession(requestContext); + } catch (RuntimeException ex) { + handleSessionAcquisitionFailure(requestContext, ex); + return bearerPolicy.processSync(context, next); + } + signRequest(context, session); + + HttpResponse response = next.processSync(); + return handleSessionResponseSync(context, response, session, requestContext, retryNext); + } + + private SessionRequestContext resolveSessionRequest(HttpPipelineCallContext context) { + if (sessionOptions.getSessionMode() == SessionMode.DISABLED + || context.getHttpRequest().getHttpMethod() != HttpMethod.GET) { + return null; + } + + BlobUrlParts parts; + try { + parts = BlobUrlParts.parse(context.getHttpRequest().getUrl()); + } catch (RuntimeException ex) { + LOGGER.warning("Unable to resolve session authentication context from request URL. Using bearer token.", + ex); + return null; + } + + String containerName = getOverrideOrDefault(sessionOptions.getContainerName(), parts.getBlobContainerName()); + String accountName = getOverrideOrDefault(sessionOptions.getAccountName(), parts.getAccountName()); + + // comp indicates sub-operations (metadata, tags, etc.) that should use bearer auth. + if (CoreUtils.isNullOrEmpty(containerName) + || CoreUtils.isNullOrEmpty(parts.getBlobName()) + || parts.getUnparsedParameters().containsKey("comp")) { + return null; + } + + return new SessionRequestContext().setContainerName(containerName).setAccountName(accountName); + } + + private static String getOverrideOrDefault(String override, String defaultValue) { + return CoreUtils.isNullOrEmpty(override) ? defaultValue : override; + } + + /** + * Handles the response after a session-authenticated async request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private Mono handleSessionResponse(HttpPipelineCallContext context, HttpResponse response, + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextPolicy retryNext) { + + handleSessionExpiringHeader(response, requestContext); + + if (response.getStatusCode() == 401) { + logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); + } + + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.process(context, retryNext); + } + + return Mono.just(response); + } + + /** + * Handles the response after a session-authenticated sync request. Inspects for + * session-expiring hints, retryable failures, and fallback conditions. + */ + private HttpResponse handleSessionResponseSync(HttpPipelineCallContext context, HttpResponse response, + SessionCredential session, SessionRequestContext requestContext, HttpPipelineNextSyncPolicy retryNext) { + + handleSessionExpiringHeader(response, requestContext); + + if (response.getStatusCode() == 401) { + logSessionInvalidation(requestContext, sessionProvider.invalidateSession(requestContext, session)); + } + + if (shouldFallBackToBearer(context, response)) { + response.close(); + context.setData(RETRY_CONTEXT_KEY, true); + context.getHttpRequest().getHeaders().remove(HttpHeaderName.AUTHORIZATION); + return bearerPolicy.processSync(context, retryNext); + } + + return response; + } + + private void signRequest(HttpPipelineCallContext context, SessionCredential credential) { + if (context.getHttpRequest().getHeaders().getValue(X_MS_DATE) == null) { + context.getHttpRequest().setHeader(X_MS_DATE, DateTimeRfc1123.toRfc1123String(OffsetDateTime.now())); + } + + StorageSharedKeyCredential sharedKey + = new StorageSharedKeyCredential(credential.getAccountName(), credential.getSessionKey()); + boolean contentLengthMissing + = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.CONTENT_LENGTH) == null; + if (contentLengthMissing) { + context.getHttpRequest().setHeader(HttpHeaderName.CONTENT_LENGTH, "0"); + } + + String sharedKeyAuthorization; + try { + sharedKeyAuthorization = sharedKey.generateAuthorizationHeader(context.getHttpRequest().getUrl(), + context.getHttpRequest().getHttpMethod().toString(), context.getHttpRequest().getHeaders(), false); + } finally { + if (contentLengthMissing) { + context.getHttpRequest().getHeaders().remove(HttpHeaderName.CONTENT_LENGTH); + } + } + String signature = sharedKeyAuthorization.substring(sharedKeyAuthorization.indexOf(':') + 1); + context.getHttpRequest() + .setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + credential.getSessionToken() + ":" + signature); + } + + private void handleSessionExpiringHeader(HttpResponse response, SessionRequestContext requestContext) { + String authInfo = response.getHeaderValue(X_MS_AUTH_INFO); + if (authInfo != null && authInfo.contains(SESSION_EXPIRING)) { + sessionProvider.refreshSession(requestContext); + } + } + + private static void logSessionInvalidation(SessionRequestContext requestContext, boolean invalidated) { + if (invalidated) { + LOGGER.warning( + "Session authentication was rejected with HTTP 401 for container '{}'. " + + "The cached session was invalidated and the request will proceed using bearer token.", + requestContext.getContainerName()); + } else { + LOGGER.verbose( + "Session authentication was rejected with HTTP 401 for container '{}', but the cached " + + "session was already invalidated. The request will proceed using bearer token.", + requestContext.getContainerName()); + } + } + + /** + * Returns true for responses where retrying with bearer authentication can preserve + * request compatibility when session authentication is unavailable or rejected. + */ + private static boolean shouldFallBackToBearer(HttpPipelineCallContext context, HttpResponse response) { + if (Boolean.TRUE.equals(context.getData(RETRY_CONTEXT_KEY).orElse(false))) { + return false; + } + + int statusCode = response.getStatusCode(); + return statusCode == 400 || statusCode == 401; + } + + private void handleSessionAcquisitionFailure(SessionRequestContext requestContext, Throwable error) { + Throwable current = error; + while (current != null && !(current instanceof HttpResponseException)) { + current = current.getCause(); + } + + if (current != null && ((HttpResponseException) current).getResponse() != null) { + HttpResponse response = ((HttpResponseException) current).getResponse(); + int statusCode = response.getStatusCode(); + if (statusCode == 400 || statusCode == 403 || (statusCode >= 500 && statusCode <= 599)) { + if (beginAccountCooldown(requestContext.getAccountName())) { + LOGGER.warning( + "Session acquisition failed with HTTP {}. Suppressing session acquisition for this account " + + "for five minutes and using bearer token.", + statusCode); + } + return; + } + } + + LOGGER.warning("Unable to obtain a session credential. Using bearer token.", error); + } + + private boolean isAccountInCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime cooldownUntil = accountCooldowns.get(key); + if (cooldownUntil == null) { + return false; + } + + OffsetDateTime now = OffsetDateTime.now(clock); + if (now.isBefore(cooldownUntil)) { + return true; + } + + accountCooldowns.remove(key, cooldownUntil); + return false; + } + + private boolean beginAccountCooldown(String accountName) { + String key = normalize(accountName); + OffsetDateTime now = OffsetDateTime.now(clock); + OffsetDateTime cooldownUntil = now.plus(SESSION_ACQUISITION_COOLDOWN); + AtomicBoolean cooldownStarted = new AtomicBoolean(); + accountCooldowns.compute(key, (ignored, currentExpirationTime) -> { + if (currentExpirationTime != null && now.isBefore(currentExpirationTime)) { + return currentExpirationTime; + } + + cooldownStarted.set(true); + return cooldownUntil; + }); + return cooldownStarted.get(); + } + + private static String normalize(String accountName) { + return CoreUtils.isNullOrEmpty(accountName) ? "" : accountName.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java new file mode 100644 index 000000000000..b9fe401321d9 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProvider.java @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.rest.Response; +import com.azure.core.util.Context; +import com.azure.core.util.CoreUtils; +import com.azure.core.util.logging.ClientLogger; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.implementation.AzureBlobStorageImpl; +import com.azure.storage.blob.implementation.AzureBlobStorageImplBuilder; +import com.azure.storage.blob.implementation.models.AuthenticationType; +import com.azure.storage.blob.implementation.models.CreateSessionConfiguration; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; +import com.azure.storage.blob.implementation.models.SessionCredentials; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.common.implementation.util.AutoRefreshingCache; +import reactor.core.publisher.Mono; + +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Built-in {@link SessionProvider} implementation that creates sessions via the CreateSession REST + * API and manages their lifecycle: per-container caching, proactive background refresh, idle + * eviction, and compare-and-invalidate race safety. + * + *

Caching model

+ *

+ * One {@link AutoRefreshingCache} of {@link SessionCredential} per container (keyed by a + * lowercase-normalized name) is maintained, allowing a single {@link TokenCredentialSessionProvider} to serve + * many containers without creating a new session for every request. Entries are opportunistically + * evicted once they have not been accessed for {@value #IDLE_EVICTION_THRESHOLD_MINUTES} minutes. + * + *

Invalidation

+ *

+ * {@link #invalidateSession} performs a compare-and-swap: only the first caller presenting a given + * rejected credential succeeds; later callers presenting the same instance return {@code false}. + * This prevents a stale 401 response from evicting a credential that was already replaced by a + * concurrent refresh. + * + *

Background refresh

+ *

+ * {@link #refreshSession} forces an immediate background refresh even when the client's own + * jittered refresh timer has not yet elapsed, ensuring the service's + * {@code x-ms-auth-info: session_expiring} hint is always honoured. + * + *

+ * Follows the same constructor pattern as {@link com.azure.storage.blob.BlobContainerClient}: + * takes an {@link HttpPipeline} (bearer-only, no session policy) and builds an + * {@link AzureBlobStorageImpl} internally. + */ +final class TokenCredentialSessionProvider implements SessionProvider { + + static final int IDLE_EVICTION_THRESHOLD_MINUTES = 5; + + private static final ClientLogger LOGGER = new ClientLogger(TokenCredentialSessionProvider.class); + private static final Duration IDLE_EVICTION_THRESHOLD = Duration.ofMinutes(IDLE_EVICTION_THRESHOLD_MINUTES); + // Defensive fallback expiration for a malformed/absent service response. + private static final Duration DEFAULT_EXPIRATION_OFFSET = Duration.ofMinutes(5L); + + private final AzureBlobStorageImpl azureBlobStorage; + private final String accountName; + private final Clock clock; + private final ConcurrentHashMap containerSessionCaches = new ConcurrentHashMap<>(); + + TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String accountName) { + this(bearerPipeline, url, serviceVersion, accountName, Clock.systemUTC()); + } + + /** Package-private constructor that accepts an injectable clock for deterministic testing. */ + TokenCredentialSessionProvider(HttpPipeline bearerPipeline, String url, BlobServiceVersion serviceVersion, + String accountName, Clock clock) { + this.azureBlobStorage = new AzureBlobStorageImplBuilder().pipeline(bearerPipeline) + .url(url) + .version(serviceVersion.getVersion()) + .buildClient(); + this.accountName = accountName; + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + @Override + public Mono getSessionAsync(SessionRequestContext context) { + return Mono.defer(() -> { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + ContainerSessionCache containerSessionCache = updateCache(container, resolvedAccount); + return containerSessionCache.cache.getValidValueAsync() + .doOnNext(containerSessionCache::setSessionCredential); + }); + } + + @Override + public SessionCredential getSession(SessionRequestContext context) { + String container = requireContainerName(context); + String resolvedAccount = resolveAccountName(context); + ContainerSessionCache containerSessionCache = updateCache(container, resolvedAccount); + SessionCredential cred = containerSessionCache.cache.getValidValueSync(); + containerSessionCache.setSessionCredential(cred); + return cred; + } + + @Override + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + if (context == null) { + return false; + } + String key = normalize(context.getContainerName()); + ContainerSessionCache containerSessionCache = containerSessionCaches.get(key); + return containerSessionCache != null && containerSessionCache.invalidateSession(rejectedCredential); + } + + @Override + public void refreshSession(SessionRequestContext context) { + if (context == null) { + return; + } + String key = normalize(context.getContainerName()); + ContainerSessionCache containerSessionCache = containerSessionCaches.get(key); + if (containerSessionCache != null) { + containerSessionCache.cache.forceRefreshValueInBackground(); + } + } + + private String requireContainerName(SessionRequestContext context) { + String containerName = context == null ? null : context.getContainerName(); + if (CoreUtils.isNullOrEmpty(containerName)) { + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("'context.getContainerName()' cannot be null or empty.")); + } + return containerName; + } + + private String resolveAccountName(SessionRequestContext context) { + String contextAccountName = context == null ? null : context.getAccountName(); + String resolvedAccountName = CoreUtils.isNullOrEmpty(accountName) ? contextAccountName : accountName; + if (CoreUtils.isNullOrEmpty(resolvedAccountName)) { + throw LOGGER.logExceptionAsError( + new IllegalArgumentException("The account name could not be resolved from the request URL.")); + } + return resolvedAccountName; + } + + private ContainerSessionCache updateCache(String containerName, String resolvedAccountName) { + String key = normalize(containerName); + OffsetDateTime now = OffsetDateTime.now(clock); + ContainerSessionCache containerSessionCache = containerSessionCaches.compute(key, (k, existing) -> { + if (existing == null) { + return new ContainerSessionCache(this, clock, containerName, resolvedAccountName, now); + } + existing.lastAccess = now; + return existing; + }); + evictStaleCaches(); + return containerSessionCache; + } + + private void evictStaleCaches() { + OffsetDateTime now = OffsetDateTime.now(clock); + containerSessionCaches.forEach((key, cache) -> { + if (Duration.between(cache.lastAccess, now).compareTo(IDLE_EVICTION_THRESHOLD) >= 0) { + containerSessionCaches.remove(key, cache); + } + }); + } + + private Mono createSessionAsync(String container, String resolvedAccountName) { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + return azureBlobStorage.getContainers() + .createSessionWithResponseAsync(container, config, null, null) + .map(response -> toCredential(response, resolvedAccountName)); + } + + private SessionCredential createSessionSync(String container, String resolvedAccountName) { + CreateSessionConfiguration config + = new CreateSessionConfiguration().setAuthenticationType(AuthenticationType.HMAC); + Response response + = azureBlobStorage.getContainers().createSessionWithResponse(container, config, null, null, Context.NONE); + return toCredential(response, resolvedAccountName); + } + + private SessionCredential toCredential(Response response, String resolvedAccountName) { + CreateSessionResponse session = response.getValue(); + if (session == null) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("CreateSession response did not contain a session payload.")); + } + + SessionCredentials creds = session.getCredentials(); + if (creds == null) { + throw LOGGER.logExceptionAsError( + new IllegalStateException("CreateSession response did not contain HMAC session credentials.")); + } + + OffsetDateTime expiration = session.getExpiration(); + if (expiration == null) { + expiration = OffsetDateTime.now().plus(DEFAULT_EXPIRATION_OFFSET); + } + return new SessionCredential(creds.getSessionToken(), creds.getSessionKey(), expiration, resolvedAccountName); + } + + private static String normalize(String name) { + return CoreUtils.isNullOrEmpty(name) ? "" : name.trim().toLowerCase(Locale.ROOT); + } + + private static final class ContainerSessionCache { + final AutoRefreshingCache cache; + volatile OffsetDateTime lastAccess; + private SessionCredential currentSessionCredential; + + private ContainerSessionCache(TokenCredentialSessionProvider provider, Clock clock, String containerName, + String resolvedAccountName, OffsetDateTime lastAccess) { + this.cache = createCache(provider, clock, containerName, resolvedAccountName); + this.lastAccess = lastAccess; + } + + private static AutoRefreshingCache createCache(TokenCredentialSessionProvider provider, + Clock clock, String containerName, String resolvedAccountName) { + AutoRefreshingCache.ValueProvider valueProvider + = new AutoRefreshingCache.ValueProvider() { + @Override + public Mono createAsync() { + return provider.createSessionAsync(containerName, resolvedAccountName); + } + + @Override + public SessionCredential createSync() { + return provider.createSessionSync(containerName, resolvedAccountName); + } + }; + return new AutoRefreshingCache<>(valueProvider, SessionCredential::getExpiresAt, clock); + } + + synchronized void setSessionCredential(SessionCredential credential) { + currentSessionCredential = credential; + } + + synchronized boolean invalidateSession(SessionCredential credential) { + if (currentSessionCredential != credential) { + return false; + } + cache.invalidateValue(credential); + currentSessionCredential = null; + return true; + } + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java new file mode 100644 index 000000000000..9258b2e7ffe7 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionCredential.java @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import java.time.OffsetDateTime; +import java.util.Objects; + +/** + * Represents a session credential used to authenticate blob storage requests via the lightweight, + * per-container session authentication scheme. + *

+ * This is modeled after {@code com.azure.core.credential.AccessToken}: a small, immutable holder for the + * session token, session key, and expiration returned by the storage service's CreateSession operation (or + * by a customer-supplied {@link SessionProvider}). Actual request signing is performed internally using the + * fixed HMAC scheme the service defines for session authentication; this type only carries the data needed + * to do so. + * + * @see SessionProvider + */ +public final class SessionCredential { + + private final String sessionToken; + private final String sessionKey; + private final OffsetDateTime expiresAt; + private final String accountName; + + /** + * Creates a new {@link SessionCredential}. + * + * @param sessionToken the session token issued by the service (or a custom {@link SessionProvider}). + * @param sessionKey the Base64-encoded session key used to sign requests. + * @param expiresAt the instant at which this session credential expires. + * @param accountName the storage account name this session credential is scoped to. + * @throws NullPointerException if {@code sessionToken}, {@code sessionKey}, or {@code accountName} is + * {@code null}. + */ + public SessionCredential(String sessionToken, String sessionKey, OffsetDateTime expiresAt, String accountName) { + this.sessionToken = Objects.requireNonNull(sessionToken, "'sessionToken' cannot be null."); + this.sessionKey = Objects.requireNonNull(sessionKey, "'sessionKey' cannot be null."); + this.expiresAt = Objects.requireNonNull(expiresAt, "'expiresAt' cannot be null."); + this.accountName = Objects.requireNonNull(accountName, "'accountName' cannot be null."); + } + + /** + * Gets the session token. + * + * @return the session token. + */ + public String getSessionToken() { + return sessionToken; + } + + /** + * Gets the Base64-encoded session key used to sign requests. + * + * @return the session key. + */ + public String getSessionKey() { + return sessionKey; + } + + /** + * Gets the instant at which this session credential expires. + * + * @return the expiration instant. + */ + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + /** + * Gets the storage account name this session credential is scoped to. + * + * @return the account name. + */ + public String getAccountName() { + return accountName; + } + + /** + * Gets whether this session credential is expired. + * + * @return {@code true} if the current time is after {@link #getExpiresAt()}; {@code false} otherwise. + */ + public boolean isExpired() { + return OffsetDateTime.now().isAfter(expiresAt); + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java new file mode 100644 index 000000000000..d47c1628fe67 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionMode.java @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Defines whether the SDK uses session-based authentication when sending requests to a container. + *

+ * A session is a temporary security context scoped to a container that amortizes authentication + * and authorization cost across many requests by signing them with a lightweight HMAC key instead + * of a full bearer token. + * {@link #ENABLED} + * {@link #DISABLED} + */ +public enum SessionMode { + + /** + * The SDK creates a session on the first eligible request and keeps an active session until it + * receives no requests for 5 minutes. This is the default. If session creation or use fails for + * any reason, the SDK transparently falls back to bearer token authentication. + */ + ENABLED, + + /** + * Always use bearer token authentication. No session tokens are ever created or used. + */ + DISABLED + +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java new file mode 100644 index 000000000000..ad0feafee2ed --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionOptions.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Options bag that configures session-based authentication for a + * {@link com.azure.storage.blob.BlobServiceClientBuilder}. + *

+ * Sessions amortize authentication and authorization cost across many requests by signing them + * with a lightweight HMAC key instead of a full bearer token. + * + * @see SessionMode + */ +public final class SessionOptions { + + private SessionMode sessionMode = SessionMode.ENABLED; + private String containerName; + private String accountName; + private SessionProvider sessionProvider; + + /** + * Creates a new {@link SessionOptions} instance with default values. + * This only applies to clients created from a {@link com.azure.storage.blob.BlobServiceClientBuilder} + * configured with a TokenCredential, and to eligible GET Blob operations made by clients derived from + * that service client. + */ + public SessionOptions() { + } + + /** + * Gets the session mode. + * + * @return the {@link SessionMode}; defaults to {@link SessionMode#ENABLED}. + */ + public SessionMode getSessionMode() { + return sessionMode; + } + + /** + * Sets the session mode. Passing {@code null} resets the mode to {@link SessionMode#ENABLED}. + * + * @param sessionMode the {@link SessionMode} to set. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionMode(SessionMode sessionMode) { + this.sessionMode = sessionMode == null ? SessionMode.ENABLED : sessionMode; + return this; + } + + /** + * Gets the container name override used when it cannot be resolved from the request URL. + * + * @return the container name, or {@code null} if not set. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name override used when it cannot be resolved from the request URL. + * + * @param containerName the container name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the storage account name used for session HMAC signing. + * + * @return the account name, or {@code null} if not set (will be parsed from the endpoint URL). + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the storage account name used for session HMAC signing. When set, this takes precedence + * over the account name parsed from the endpoint URL. This is useful for custom domain URLs + * where the account name cannot be inferred from the hostname. + * + * @param accountName the storage account name. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setAccountName(String accountName) { + this.accountName = accountName; + return this; + } + + /** + * Gets the custom provider used to obtain session credentials. + * + * @return the custom {@link SessionProvider}, or {@code null} to use the built-in provider. + */ + public SessionProvider getSessionProvider() { + return sessionProvider; + } + + /** + * Sets the custom provider used to obtain session credentials. When set, the provider is called directly + * for each eligible request: the SDK does not layer additional caching on top of a custom provider, so + * the provider is responsible for its own caching and refresh strategy. The SDK retains ownership of + * HMAC request signing, bearer-token fallback, and account-level acquisition cooldown. + * The same provider instance may be supplied to multiple service client builders to share its cache. + * When {@code null}, the built-in provider is used, which calls the storage service's CreateSession REST + * API and manages per-container credential caching, proactive refresh, and idle eviction automatically. + * + * @param sessionProvider the custom {@link SessionProvider}, or {@code null} to use the built-in provider. + * @return the updated {@link SessionOptions} object. + */ + public SessionOptions setSessionProvider(SessionProvider sessionProvider) { + this.sessionProvider = sessionProvider; + return this; + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java new file mode 100644 index 000000000000..24ef8e536061 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionProvider.java @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +import reactor.core.publisher.Mono; + +/** + * Provides and manages cached {@link SessionCredential session credentials} for storage containers. + *

+ * Implement this interface to bring your own session creation and caching logic - for example, proxying + * CreateSession calls through another service or sharing a credential cache across clients - while still + * benefiting from this SDK's request signing and account-level cooldown handling. Set an instance via + * {@link SessionOptions#setSessionProvider(SessionProvider)}, then pass those options to + * {@link com.azure.storage.blob.BlobServiceClientBuilder#sessionOptions(SessionOptions)}, to have it used in + * place of the default, built-in provider (which calls the storage service's CreateSession REST API directly + * and manages its own per-container caching). + *

+ * This is modeled loosely on {@code com.azure.core.credential.TokenCredential} from azure-core: a small, + * request-parameterized factory for a credential-bearing value. Unlike {@code TokenCredential}, it is + * deliberately not annotated {@code @FunctionalInterface} - both {@link #getSessionAsync} and + * {@link #getSession} are true abstract methods, so implementers must provide efficient sync and async paths + * independently rather than being forced to accept a default, blocking-on-async implementation of the + * synchronous method (or vice versa). + * + *

Lifecycle

+ *

+ * A {@link SessionProvider} implementation is expected to support the full session lifecycle: + *

    + *
  1. Retrieve - {@link #getSessionAsync} / {@link #getSession} return a usable + * {@link SessionCredential} for the container described by the request context, minting or refreshing one + * as needed.
  2. + *
  3. Invalidate - {@link #invalidateSession} is called when the service rejects a + * previously-issued credential with HTTP 401, giving the implementation the opportunity to evict it so the + * next retrieval mints a fresh one.
  4. + *
  5. Refresh - {@link #refreshSession} is called when the service signals (via an + * {@code x-ms-auth-info: session_expiring} response header) that the current session is about to stop being + * honored, giving the implementation the opportunity to proactively refresh it in the background.
  6. + *
+ *

+ * Regardless of the provider used, the SDK always retains ownership of HMAC request signing, bearer-token + * fallback for ineligible requests, and account-level acquisition cooldown (suppressing further session + * acquisition attempts for an account for a period after a 400/403/5xx failure). A {@link SessionProvider} + * implementation is only responsible for producing, invalidating, and refreshing credentials - never for + * signing requests or deciding when to fall back to bearer authentication. + * + *

Thread safety

+ *

+ * Implementations must be thread-safe: {@link #getSessionAsync}, {@link #getSession}, + * {@link #invalidateSession}, and {@link #refreshSession} may all be invoked concurrently from multiple + * pipeline threads. In particular, {@link #invalidateSession} must perform its compare-and-invalidate as a + * single atomic operation (see its documentation for details), and {@link #refreshSession} must not block. + * + *

Scoping

+ *

+ * A single {@link SessionProvider} instance may be asked to serve many different containers (and, in + * principle, multiple accounts) over its lifetime; the {@link SessionRequestContext} passed to each method + * call identifies which container (and account) the call applies to. Applications may reuse one provider + * instance across service clients when they intentionally want those clients to share the provider's cache. + * + * @see SessionCredential + * @see SessionRequestContext + * @see SessionOptions + */ +public interface SessionProvider { + + /** + * Asynchronously returns a valid cached {@link SessionCredential} for the container described by + * {@code context}, creating or refreshing the credential when needed. + * + * @param context the request-scoped parameters (e.g. container name) the session should be created for. + * @return a {@link Mono} that emits the resulting {@link SessionCredential}. + */ + Mono getSessionAsync(SessionRequestContext context); + + /** + * Synchronously returns a valid cached {@link SessionCredential} for the container described by + * {@code context}, creating or refreshing the credential when needed. + * + * @param context the request-scoped parameters (e.g. container name) the session should be created for. + * @return the resulting {@link SessionCredential}. + */ + SessionCredential getSession(SessionRequestContext context); + + /** + * Attempts a compare-and-invalidate on the credential currently held for the container described by + * {@code context}: if {@code rejectedCredential} is still the active credential, it is atomically + * replaced so the next call to {@link #getSession} or {@link #getSessionAsync} returns a fresh one. + *

+ * Thread safety: Implementations must treat the compare and the invalidate as a + * single atomic operation. Exactly one thread presenting the same {@code rejectedCredential} should + * succeed in invalidating it; all later threads presenting the same instance must return {@code false}. + *

+ * Warning semantics: The SDK logs a one-time warning when this returns {@code true} + * (the first invalidation for a given rejected credential) and a verbose message when it returns + * {@code false} (already replaced). + * + * @param context the request-scoped parameters (container, account) identifying the session scope. + * @param rejectedCredential the credential the service rejected with HTTP 401. + * @return {@code true} if this call invalidated the credential (first invalidator wins); + * {@code false} if the credential was already replaced. + */ + boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential); + + /** + * Non-blocking hint that the service has indicated the current session for the container described by + * {@code context} is about to expire (signalled via an {@code x-ms-auth-info: session_expiring} + * response header). Implementations should trigger a proactive background refresh immediately so the + * next request uses a fresh session without an inline latency penalty. + *

+ * Non-blocking contract: This method is called from both synchronous and + * asynchronous response-processing paths and must return immediately without waiting for the + * refresh to complete. It must not throw. + * + * @param context the request-scoped parameters (container, account) identifying the session scope. + */ + void refreshSession(SessionRequestContext context); +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java new file mode 100644 index 000000000000..081595413c25 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/models/SessionRequestContext.java @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.models; + +/** + * Carries the request-scoped parameters needed to obtain a {@link SessionCredential}, such as the target + * container and account. + *

+ * Both {@code containerName} and {@code accountName} are optional: they are resolved automatically from the + * request URL in the common case, and are only needed here when that automatic resolution isn't possible or + * isn't correct - for example, a custom domain URL that a {@link SessionProvider} implementation cannot + * parse the account name from, or a proxying scenario where the effective container differs from the one on + * the wire. A {@link SessionProvider} implementation should treat either value as a hint that may be absent + * rather than something it can always rely on. + *

+ * This exists so a single {@link SessionProvider} instance can be asked for a session that is scoped to a + * specific container at call time, rather than being permanently bound to one container at construction + * time - allowing one provider to serve sessions for many containers. + * + * @see SessionProvider + */ +public final class SessionRequestContext { + + private String containerName; + private String accountName; + + /** + * Creates a new {@link SessionRequestContext}. + */ + public SessionRequestContext() { + } + + /** + * Gets the name of the container the session should be scoped to, if known. + * + * @return the container name, or {@code null} if not resolved/known for this request. + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the name of the container the session should be scoped to. + * + * @param containerName the container name. + * @return the updated {@link SessionRequestContext} object. + */ + public SessionRequestContext setContainerName(String containerName) { + this.containerName = containerName; + return this; + } + + /** + * Gets the name of the storage account the session should be scoped to, if known. + * + * @return the account name, or {@code null} if not resolved/known for this request. + */ + public String getAccountName() { + return accountName; + } + + /** + * Sets the name of the storage account the session should be scoped to. + * + * @param accountName the account name. + * @return the updated {@link SessionRequestContext} object. + */ + public SessionRequestContext setAccountName(String accountName) { + this.accountName = accountName; + return this; + } +} diff --git a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java index 54fd3682e72c..42e12896bc4b 100644 --- a/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java +++ b/sdk/storage/azure-storage-blob/src/main/java/com/azure/storage/blob/specialized/SpecializedBlobClientBuilder.java @@ -242,7 +242,7 @@ private HttpPipeline getHttpPipeline() { ? httpPipeline : BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken, endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies, - perRetryPolicies, configuration, audience, LOGGER); + perRetryPolicies, configuration, audience, LOGGER, null, null); } private BlobServiceVersion getServiceVersion() { diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java index 50a9eb63ef21..fb8cff8e359e 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobApiTests.java @@ -6,6 +6,7 @@ import com.azure.core.http.HttpAuthorization; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; import com.azure.core.http.RequestConditions; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.Response; @@ -50,6 +51,8 @@ import com.azure.storage.blob.models.ObjectReplicationStatus; import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.SyncCopyStatusType; import com.azure.storage.blob.options.BlobBeginCopyOptions; @@ -81,6 +84,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -120,6 +124,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -3177,6 +3182,51 @@ public void audienceFromString() { assertTrue(aadBlob.exists()); } + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobClient blobClient = cc.getBlobClient(blobName); + blobClient.getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob + = getOAuthServiceClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerClient(cc.getBlobContainerName()) + .getBlobClient(blobName); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, + false, null, null); + + assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } + @RequiredServiceVersion(clazz = BlobServiceVersion.class, min = "2025-07-05") @Test @LiveOnly diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java index 049e4254e92a..d6b588d2f10f 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobAsyncApiTests.java @@ -7,6 +7,7 @@ import com.azure.core.http.HttpAuthorization; import com.azure.core.http.HttpHeaderName; import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.Response; import com.azure.core.test.TestMode; @@ -48,6 +49,8 @@ import com.azure.storage.blob.models.ObjectReplicationStatus; import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.options.BlobBeginCopyOptions; import com.azure.storage.blob.options.BlobCopyFromUrlOptions; import com.azure.storage.blob.options.BlobDownloadToFileOptions; @@ -76,6 +79,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.CsvSource; @@ -2965,6 +2969,51 @@ public void audienceErrorBearerChallengeRetry() { StepVerifier.create(aadBlob.getProperties()).assertNext(Assertions::assertNotNull).verifyComplete(); } + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob + = getOAuthServiceAsyncClient(new SessionOptions().setSessionMode(SessionMode.ENABLED), inspect) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()) + .getBlobAsyncClient(blobName); + + File outFile = new File(prefix + "-session-download.tmp"); + createdFiles.add(outFile); + Files.deleteIfExists(outFile.toPath()); + + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, null, + false)).expectNextCount(1).verifyComplete(); + + Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } + @Test public void audienceFromString() { String url = String.format("https://%s.blob.core.windows.net/", ccAsync.getAccountName()); diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/BlobTestBase.java index 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..79e4171ae974 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,11 @@ import com.azure.core.util.Header; import com.azure.core.util.logging.ClientLogger; import com.azure.storage.blob.implementation.util.BuilderHelper; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; import com.azure.storage.blob.specialized.AppendBlobClient; import com.azure.storage.blob.specialized.BlockBlobClient; import com.azure.storage.blob.specialized.PageBlobClient; @@ -41,14 +46,19 @@ import java.net.MalformedURLException; import java.net.URL; import java.time.Duration; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -72,10 +82,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 +186,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 +315,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 +690,138 @@ public Mono send(HttpRequest request) { return Mono.just(new MockHttpResponse(request, 200)); } } + + // region buildPipeline session tests + + @ParameterizedTest(name = "{0}") + @MethodSource("pipelinesWithoutSessionsSupplier") + public void pipelinesWithoutSessionsDoNotContainSessionPolicy(String scenario, + Supplier pipelineSupplier, boolean expectsBearerPolicy) { + HttpPipeline pipeline = pipelineSupplier.get(); + + assertFalse(hasPolicyOfType(pipeline, "SessionTokenCredentialPolicy"), + scenario + " should not contain SessionTokenCredentialPolicy"); + assertEquals(expectsBearerPolicy, hasPolicyOfType(pipeline, "StorageBearerTokenChallengeAuthorizationPolicy"), + scenario + " bearer policy expectation mismatch"); + } + + @Test + public void serviceBuilderUsesBuiltInSessionProviderByDefault() { + BlobServiceClient client = new BlobServiceClientBuilder().endpoint(ENDPOINT) + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient(); + + assertTrue(hasPolicyOfType(client.getHttpPipeline(), "SessionTokenCredentialPolicy")); + } + + @Test + public void customSessionProviderIsWiredIntoPipelineWithResolvedRequestContext() { + AtomicReference receivedContext = new AtomicReference<>(); + SessionCredential credential = new SessionCredential("session-token", + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==", OffsetDateTime.now().plusMinutes(5), "account"); + SessionProvider provider = new SessionProvider() { + @Override + public Mono getSessionAsync(SessionRequestContext context) { + receivedContext.set(context); + return Mono.just(credential); + } + + @Override + public SessionCredential getSession(SessionRequestContext context) { + receivedContext.set(context); + return credential; + } + + @Override + public boolean invalidateSession(SessionRequestContext context, SessionCredential rejectedCredential) { + return false; + } + + @Override + public void refreshSession(SessionRequestContext context) { + } + }; + SessionOptions options = new SessionOptions().setSessionProvider(provider); + HttpPipeline pipeline = BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + REQUEST_RETRY_OPTIONS, null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), options, null); + StepVerifier.create(pipeline.send(new HttpRequest(HttpMethod.GET, ENDPOINT + "container/blob"))) + .expectNextCount(1) + .verifyComplete(); + + assertNotNull(receivedContext.get(), "Custom session provider should have been invoked by the pipeline"); + assertEquals("container", receivedContext.get().getContainerName()); + assertEquals("account", receivedContext.get().getAccountName()); + } + + private static Stream pipelinesWithoutSessionsSupplier() { + return Stream.of( + Arguments.of("null session options", (Supplier) () -> buildTokenPipeline(null), true), + Arguments.of("sessions disabled", + (Supplier) () -> buildTokenPipeline( + new SessionOptions().setSessionMode(SessionMode.DISABLED)), + true), + Arguments.of("shared key credential", (Supplier) BuilderHelperTests::buildSharedKeyPipeline, + false), + Arguments.of("standalone BlobClientBuilder", + (Supplier) () -> new BlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient() + .getHttpPipeline(), + true), + Arguments.of("standalone BlobContainerClientBuilder", + (Supplier) () -> new BlobContainerClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildClient() + .getHttpPipeline(), + true), + Arguments.of("standalone SpecializedBlobClientBuilder", + (Supplier) () -> new SpecializedBlobClientBuilder().endpoint(ENDPOINT) + .containerName("mycontainer") + .blobName("myblob") + .credential(new MockTokenCredential()) + .httpClient(new NoOpHttpClient()) + .buildBlockBlobClient() + .getHttpPipeline(), + true)); + } + + /** + * Helper to build a pipeline with bearer token auth and the given session options. + */ + private static HttpPipeline buildTokenPipeline(SessionOptions sessionOptions) { + return BuilderHelper.buildPipeline(null, new MockTokenCredential(), null, null, ENDPOINT, + new RequestRetryOptions(), null, BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), + new NoOpHttpClient(), new ArrayList<>(), new ArrayList<>(), null, null, + new ClientLogger(BuilderHelperTests.class), sessionOptions, BlobServiceVersion.getLatest()); + } + + /** + * Helper to build a pipeline without bearer token auth (shared key only). + */ + private static HttpPipeline buildSharedKeyPipeline() { + return BuilderHelper.buildPipeline(CREDENTIALS, null, null, null, ENDPOINT, new RequestRetryOptions(), null, + BuilderHelper.getDefaultHttpLogOptions(), new ClientOptions(), new NoOpHttpClient(), new ArrayList<>(), + new ArrayList<>(), null, null, new ClientLogger(BuilderHelperTests.class), null, null); + } + + /** + * Checks whether the pipeline contains a policy whose simple class name matches the given name. + */ + private static boolean hasPolicyOfType(HttpPipeline pipeline, String simpleClassName) { + for (int i = 0; i < pipeline.getPolicyCount(); i++) { + if (pipeline.getPolicy(i).getClass().getSimpleName().equals(simpleClassName)) { + return true; + } + } + return false; + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerApiTests.java index f46116acdbb5..01f77a552a35 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,22 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import com.azure.core.util.BinaryData; import com.azure.core.http.rest.PagedIterable; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.utils.MockTokenCredential; import com.azure.core.util.Context; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.AccessTier; import com.azure.storage.blob.models.AppendBlobItem; import com.azure.storage.blob.models.BlobAccessPolicy; @@ -31,8 +41,11 @@ import com.azure.storage.blob.models.ListBlobsOptions; import com.azure.storage.blob.models.ObjectReplicationPolicy; import com.azure.storage.blob.models.ObjectReplicationStatus; +import com.azure.storage.blob.models.ParallelTransferOptions; import com.azure.storage.blob.models.PublicAccessType; import com.azure.storage.blob.models.RehydratePriority; +import com.azure.storage.blob.models.SessionMode; +import com.azure.storage.blob.models.SessionOptions; import com.azure.storage.blob.models.StorageAccountInfo; import com.azure.storage.blob.models.TaggedBlobItem; import com.azure.storage.blob.options.BlobContainerCreateOptions; @@ -53,16 +66,23 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.OffsetDateTime; import java.util.Arrays; import java.util.Base64; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -74,6 +94,7 @@ import java.util.stream.Stream; import static com.azure.storage.common.implementation.StorageImplUtils.INVALID_VERSION_HEADER_MESSAGE; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -2128,4 +2149,349 @@ public void getBlobContainerUrlEncodesContainerName() { // then: // assertThrows(BlobStorageException.class, () -> // } + + // Need to create a container client test here to test that sessions have been enabled and used + + @Test + @ResourceLock("BlobSessionAuth") + public void createSession() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + CreateSessionResponse response = oauthCc.createSession(); + + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSessionWithResponse() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + Response response = oauthCc.createSessionWithResponse(null, Context.NONE); + + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + cc.getBlobClient(blobName) + .getBlockBlobClient() + .upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); + + for (String blobName : blobNames) { + BinaryData downloaded = sessionCc.getBlobClient(blobName).downloadContent(); + assertEquals(DATA.getDefaultText(), downloaded.toString()); + } + + // Greater than or equal to because there might be a retry that has a Session token as well if test is run with + // listBlobsOverSessionEnabledClient() + assertTrue(downloadAuthSchemes.size() >= blobCount, + "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + cc.getBlobClient(blobName).getBlockBlobClient().upload(new ByteArrayInputStream(data), data.length); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, + null, false, null, null); + + assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } finally { + Files.deleteIfExists(outFile.toPath()); + } + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // This test validates that listing blobs with a session-enabled client uses Bearer authorization because + // List Blobs is a container-level GET request, not a blob-level GET request so it users Bearer tokens instead of session tokens. + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerClient sessionCc = sessionEnabledContainerClient(inspect); + + assertTrue(sessionCc.listBlobs().stream().anyMatch(b -> b.getName().equals(blobName))); + + assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); + assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), + "Container list operation must use Bearer authorization; saw " + listAuthSchemes); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Verifies that the cached session token rotates on its own while a client keeps issuing blob GET requests. + // A session credential is short-lived (~5 minutes) and the credential cache fetches a fresh one in the + // background before the current one expires. This test keeps issuing small GETs across more than one session + // lifetime and asserts that the session token observed on the wire changes at least once (rotation happened). + public void sessionTokenRotates() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Continuously issue small GET requests for slightly longer than one session lifetime so we are + // guaranteed to cross at least one background rotation boundary while requests are in flight. + long testDurationMillis = 6 * 60 * 1000L; + long pollIntervalMillis = 10 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // Each GET is small (the default test data) and must succeed with the expected content. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + sleepIfRunningAgainstService(pollIntervalMillis); + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the test window; only saw " + distinctTokens); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Verifies that, while a client hammers the service with small, rapid, back-to-back blob GET requests, the + // cached session token rotates and every download still succeeds. The service legitimately returns transient + // "session_token_invalid" (401, network-context-mismatch) responses while it rotates a session's binding; the + // SDK recovers from those by invalidating the session, creating a fresh one, and retrying, so the caller's GET + // never fails. We therefore assert the contract the SDK can actually honor - every download returns the + // correct content (no invalid-token failure ever surfaces to the caller) and the token rotates at least once - + // rather than asserting the wire never carries a 401, which the service does not guarantee. (Recovered + // invalid-token responses are still recorded and surfaced in the failure message below for diagnostics.) + public void sessionTokenRotatesWithoutInvalidTokenGets() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Continuously issue small GET requests, back-to-back with no delay, for slightly longer than one + // session lifetime so we are guaranteed to cross at least one rotation boundary while a high volume of + // requests are in flight. + long testDurationMillis = 6 * 60 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // Each GET must succeed with the expected content. If a transient invalid-token 401 reaches the wire, + // the SDK's retry transparently recovers it, so this download still returns the blob - the caller + // never observes a failure. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the test window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the test window; saw tokens " + distinctTokens + + " and transparently-recovered invalid-token responses " + inspect.getInvalidAuthStatuses()); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // Simulates a slow-polling client that issues a single small blob GET roughly every 30 seconds. Because the + // requests are sparse, the client can go a long time between responses and may miss the service's proactive + // "x-ms-auth-info: session_expiring" hint window entirely - so it can end up signing a request with a token + // that has expired purely due to the passage of time. This verifies the SDK handles that gracefully: every + // download still returns the correct content (the cache rotates to a fresh session - proactively via its own + // refresh timer when it can, or via the one-shot 401 retry as a backstop when an expired token slips onto the + // wire) and the session token observed on the wire rotates at least once over the multi-lifetime window. + public void sessionTokenRotatesWithSparsePolling() { + String blobName = generateBlobName(); + cc.getBlobClient(blobName).getBlockBlobClient().upload(DATA.getDefaultInputStream(), DATA.getDefaultDataSize()); + + SessionGetInspectionPolicy inspect = new SessionGetInspectionPolicy(blobName); + BlobClient sessionBlob = sessionEnabledContainerClient(inspect).getBlobClient(blobName); + + // Poll once every ~30s for longer than two session lifetimes (~5 min each) so we are guaranteed to cross + // multiple expiry boundaries. The wide gap between requests is what makes it possible to land a request + // on an already-expired token: the proactive refresh point can come due during the idle gap, and the + // first request after it - 30s later - may be signed just after the token has lapsed. + long testDurationMillis = 11 * 60 * 1000L; + long pollIntervalMillis = 30 * 1000L; + long deadline = System.currentTimeMillis() + testDurationMillis; + int getCount = 0; + + while (System.currentTimeMillis() < deadline) { + // The caller must never observe a failure: each sparse GET returns the expected content, whether the + // cached token was still valid, was proactively rotated, or had to be re-acquired after a 401. Any + // expired-token use is recovered transparently by the policy's single retry with a fresh session. + assertEquals(DATA.getDefaultText(), sessionBlob.downloadContent().toString()); + getCount++; + sleepIfRunningAgainstService(pollIntervalMillis); + } + + assertTrue(getCount > 1, "Expected to issue multiple blob GET requests over the sparse-polling window"); + assertFalse(inspect.getSessionTokens().isEmpty(), "Expected blob GETs to be signed with Session tokens"); + + Set distinctTokens = new HashSet<>(inspect.getSessionTokens()); + assertTrue(distinctTokens.size() >= 2, + "Expected the session token to rotate at least once over the sparse-polling window; only saw " + + distinctTokens); + } + + /** + * Test-only pipeline policy that watches blob-level GET requests for a single blob and records, at the wire + * level (PER_RETRY), the Session token used to sign each request and any invalid-token (401/403) responses + * those requests receive. Used to assert that session tokens rotate over time without any request ever being + * signed with an expired/invalid token. + */ + private static final class SessionGetInspectionPolicy implements HttpPipelinePolicy { + private final String blobName; + private final List sessionTokens = Collections.synchronizedList(new ArrayList<>()); + private final List invalidAuthStatuses = Collections.synchronizedList(new ArrayList<>()); + + SessionGetInspectionPolicy(String blobName) { + this.blobName = blobName; + } + + private boolean isBlobGet(HttpRequest request) { + String path = request.getUrl().getPath(); + String query = request.getUrl().getQuery(); + return request.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp=")); + } + + private void onRequest(HttpRequest request) { + if (!isBlobGet(request)) { + return; + } + String auth = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + if (auth != null && auth.startsWith("Session ")) { + // Header form is "Session :" - extract just the token. + int tokenStart = "Session ".length(); + int sigSeparator = auth.indexOf(':', tokenStart); + sessionTokens + .add(sigSeparator < 0 ? auth.substring(tokenStart) : auth.substring(tokenStart, sigSeparator)); + } + } + + private void onResponse(HttpRequest request, int statusCode) { + if (isBlobGet(request) && (statusCode == 401 || statusCode == 403)) { + invalidAuthStatuses.add(statusCode); + } + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + onRequest(context.getHttpRequest()); + return next.process().doOnNext(response -> onResponse(context.getHttpRequest(), response.getStatusCode())); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + onRequest(context.getHttpRequest()); + HttpResponse response = next.processSync(); + onResponse(context.getHttpRequest(), response.getStatusCode()); + return response; + } + + List getSessionTokens() { + return sessionTokens; + } + + List getInvalidAuthStatuses() { + return invalidAuthStatuses; + } + } + + private BlobContainerClient sessionEnabledContainerClient(HttpPipelinePolicy... policies) { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.ENABLED) + .setContainerName(cc.getBlobContainerName()) + .setAccountName(cc.getAccountName()); + return getOAuthServiceClient(sessionOptions, policies).getBlobContainerClient(cc.getBlobContainerName()); + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java index 04ebc06dc2b6..cfa71c25e97b 100644 --- a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/ContainerAsyncApiTests.java @@ -4,14 +4,18 @@ package com.azure.storage.blob; import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.policy.HttpPipelinePolicy; import com.azure.core.http.rest.PagedFlux; import com.azure.core.http.rest.PagedResponse; import com.azure.core.http.rest.Response; import com.azure.core.test.TestMode; import com.azure.core.test.utils.MockTokenCredential; +import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.polling.PollerFlux; import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.implementation.models.CreateSessionResponse; import com.azure.storage.blob.models.*; import com.azure.storage.blob.options.BlobContainerCreateOptions; import com.azure.storage.blob.options.BlobParallelUploadOptions; @@ -23,6 +27,7 @@ import com.azure.storage.blob.specialized.BlockBlobAsyncClient; import com.azure.storage.blob.specialized.PageBlobAsyncClient; import com.azure.storage.common.test.shared.TestHttpClientType; +import com.azure.storage.common.implementation.Constants; import com.azure.storage.common.test.shared.extensions.LiveOnly; import com.azure.storage.common.test.shared.extensions.PlaybackOnly; import com.azure.storage.common.test.shared.extensions.RequiredServiceVersion; @@ -31,6 +36,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -40,7 +46,10 @@ import reactor.test.StepVerifier; import reactor.util.function.Tuple2; +import java.io.File; +import java.io.IOException; import java.net.URL; +import java.nio.file.Files; import java.time.Duration; import java.time.OffsetDateTime; import java.util.*; @@ -2142,4 +2151,164 @@ public void getBlobContainerUrlEncodesContainerName() { assertTrue(containerClient.getBlobContainerUrl().contains("my%20container")); } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSession() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSession()).assertNext(response -> { + assertNotNull(response); + assertNotNull(response.getId()); + assertNotNull(response.getExpiration()); + assertNotNull(response.getCredentials()); + assertNotNull(response.getCredentials().getSessionToken()); + assertNotNull(response.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + @ResourceLock("BlobSessionAuth") + public void createSessionWithResponse() { + BlobContainerAsyncClient oauthCcAsync + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + StepVerifier.create(oauthCcAsync.createSessionWithResponse()).assertNext(response -> { + assertResponseStatusCode(response, 201); + CreateSessionResponse sessionResponse = response.getValue(); + assertNotNull(sessionResponse); + assertNotNull(sessionResponse.getId()); + assertNotNull(sessionResponse.getExpiration()); + assertTrue(sessionResponse.getExpiration().isAfter(testResourceNamer.now())); + assertNotNull(sessionResponse.getCredentials()); + assertNotNull(sessionResponse.getCredentials().getSessionToken()); + assertNotNull(sessionResponse.getCredentials().getSessionKey()); + }).verifyComplete(); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobOverSessionAuth() { + int blobCount = 5; + List blobNames = new ArrayList<>(); + for (int i = 0; i < blobCount; i++) { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + blobNames.add(blobName); + } + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String trimmed = path != null && path.startsWith("/") ? path.substring(1) : path; + if (auth != null && trimmed != null && trimmed.contains("/")) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); + + for (String blobName : blobNames) { + StepVerifier.create(sessionCcAsync.getBlobAsyncClient(blobName).downloadContent()) + .assertNext(downloaded -> assertEquals(DATA.getDefaultText(), downloaded.toString())) + .verifyComplete(); + } + + // Greater than or equal to because there might be a retry that has a Session token as well if test is run with + // listBlobsOverSessionEnabledClient() + assertTrue(downloadAuthSchemes.size() >= blobCount, + "Expected to observe at least one download request per blob; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all blob downloads to be authenticated with Session scheme; saw " + downloadAuthSchemes); + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + public void downloadBlobToFileInChunksOverSessionAuth() throws IOException { + String blobName = generateBlobName(); + byte[] data = getRandomByteArray(4 * Constants.KB + 17); + int downloadBlockSize = Constants.KB; + + BlobAsyncClient blobClient = ccAsync.getBlobAsyncClient(blobName); + blobClient.upload(BinaryData.fromBytes(data), true).block(); + + List downloadAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String path = req.getUrl().getPath(); + String query = req.getUrl().getQuery(); + if (auth != null + && req.getHttpMethod() == HttpMethod.GET + && path != null + && path.endsWith("/" + blobName) + && (query == null || !query.contains("comp="))) { + downloadAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobAsyncClient sessionBlob = sessionEnabledContainerAsyncClient(inspect).getBlobAsyncClient(blobName); + File outFile = File.createTempFile(prefix, ".tmp"); + outFile.deleteOnExit(); + Files.deleteIfExists(outFile.toPath()); + + try { + StepVerifier.create(sessionBlob.downloadToFileWithResponse(outFile.toPath().toString(), null, + new ParallelTransferOptions().setBlockSizeLong((long) downloadBlockSize).setMaxConcurrency(2), null, + null, false)).expectNextCount(1).verifyComplete(); + + Assertions.assertArrayEquals(data, Files.readAllBytes(outFile.toPath())); + assertTrue(downloadAuthSchemes.size() > 1, + "Expected multiple chunked download requests; saw " + downloadAuthSchemes); + assertTrue(downloadAuthSchemes.stream().allMatch("Session"::equals), + "Expected all chunked blob downloads to use Session auth; saw " + downloadAuthSchemes); + } finally { + Files.deleteIfExists(outFile.toPath()); + } + } + + @Test + @LiveOnly + @ResourceLock("BlobSessionAuth") + // This test validates that listing blobs with a session-enabled client uses Bearer authorization because + // List Blobs is a container-level GET request, not a blob-level GET request. + public void listBlobsOverSessionEnabledClient() { + String blobName = generateBlobName(); + ccAsync.getBlobAsyncClient(blobName) + .getBlockBlobAsyncClient() + .upload(DATA.getDefaultFlux(), DATA.getDefaultDataSize()) + .block(); + + List listAuthSchemes = Collections.synchronizedList(new ArrayList<>()); + RequestInspectionPolicy inspect = new RequestInspectionPolicy(req -> { + String auth = req.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + String query = req.getUrl().getQuery(); + if (auth != null && query != null && query.contains("comp=list")) { + listAuthSchemes.add(auth.startsWith("Session ") ? "Session" : "Bearer"); + } + }); + + BlobContainerAsyncClient sessionCcAsync = sessionEnabledContainerAsyncClient(inspect); + + StepVerifier.create(sessionCcAsync.listBlobs().filter(b -> b.getName().equals(blobName)).hasElements()) + .expectNext(true) + .verifyComplete(); + + assertFalse(listAuthSchemes.isEmpty(), "Expected to observe at least one list request"); + assertTrue(listAuthSchemes.stream().allMatch("Bearer"::equals), + "Container list operation must use Bearer authorization; saw " + listAuthSchemes); + } + + private BlobContainerAsyncClient sessionEnabledContainerAsyncClient(HttpPipelinePolicy... policies) { + SessionOptions sessionOptions = new SessionOptions().setSessionMode(SessionMode.ENABLED) + .setContainerName(ccAsync.getBlobContainerName()) + .setAccountName(ccAsync.getAccountName()); + return getOAuthServiceAsyncClient(sessionOptions, policies) + .getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + } + } diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java new file mode 100644 index 000000000000..99d0b115a622 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/RequestInspectionPolicy.java @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.storage.blob; + +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpPipelinePosition; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.http.policy.HttpPipelinePolicy; +import reactor.core.publisher.Mono; + +import java.util.function.Consumer; + +/** + * Test-only pipeline policy that lets a test peek at every {@link HttpRequest} as it + * goes on the wire. Registers at {@link HttpPipelinePosition#PER_RETRY} so it sees + * the {@code Authorization} header that the auth policies set. + * + *

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

+ */ +public final class RequestInspectionPolicy implements HttpPipelinePolicy { + private final Consumer inspector; + + public RequestInspectionPolicy(Consumer inspector) { + this.inspector = inspector; + } + + @Override + public HttpPipelinePosition getPipelinePosition() { + return HttpPipelinePosition.PER_RETRY; + } + + @Override + public Mono process(HttpPipelineCallContext context, HttpPipelineNextPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.process(); + } + + @Override + public HttpResponse processSync(HttpPipelineCallContext context, HttpPipelineNextSyncPolicy next) { + if (inspector != null) { + inspector.accept(context.getHttpRequest()); + } + return next.processSync(); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java new file mode 100644 index 000000000000..b2f0b9a88f4c --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionCredentialTest.java @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.common.test.shared.session.SessionTestHelper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SessionCredentialTest { + + @Test + public void isExpiredReturnsTrueWhenPastExpiration() { + assertTrue(createCredential(SessionTestHelper.createExpiredExpiration()).isExpired()); + } + + @Test + public void isExpiredReturnsFalseWhenBeforeExpiration() { + assertFalse(createCredential(SessionTestHelper.createValidExpiration()).isExpired()); + } + + @Test + public void constructorRejectsNullExpiration() { + assertThrows(NullPointerException.class, () -> new SessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, + SessionTestHelper.TEST_SESSION_KEY, null, SessionTestHelper.TEST_ACCOUNT_NAME)); + } + + private static SessionCredential createCredential(java.time.OffsetDateTime expiration) { + return new SessionCredential(SessionTestHelper.TEST_SESSION_TOKEN, SessionTestHelper.TEST_SESSION_KEY, + expiration, SessionTestHelper.TEST_ACCOUNT_NAME); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java new file mode 100644 index 000000000000..86eb9e91c869 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionProviderTests.java @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.common.test.shared.session.CreateSessionMockHttpClient; +import com.azure.storage.common.test.shared.session.SessionTestHelper; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Small, focused tests for the public {@link SessionProvider} contract implemented by + * {@link TokenCredentialSessionProvider}. + *

+ * These verify that {@link TokenCredentialSessionProvider#getSessionAsync(SessionRequestContext)} and + * {@link TokenCredentialSessionProvider#getSession(SessionRequestContext)} route the CreateSession REST call to the + * container named on the {@link SessionRequestContext}, proving out the "per-request container" seam that + * backs the BYO {@link SessionProvider} extension point, and that a context missing a container + * name is rejected rather than silently falling back to some default. This complements (and does not + * duplicate) {@code TokenCredentialSessionProviderTests}, which exercises these same paths against the live service, + * and + * {@code TokenCredentialSessionProviderCacheTest}, which fakes the transport wholesale to test per-container cache + * timing behavior. Here {@link TokenCredentialSessionProvider} is real and only the transport is faked, so the + * container name + * actually placed on the wire is what's being verified. + */ +public class SessionProviderTests { + + @Test + public void getSessionAsyncUsesContainerFromContext() { + CreateSessionMockHttpClient httpClient = new CreateSessionMockHttpClient(); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(httpClient); + + SessionRequestContext context + = new SessionRequestContext().setContainerName(SessionTestHelper.TEST_CONTAINER_NAME) + .setAccountName(SessionTestHelper.TEST_ACCOUNT_NAME); + + StepVerifier.create(sessionProvider.getSessionAsync(context)).assertNext(credential -> { + assertNotNull(credential); + assertEquals("session-token-for-testcontainer", credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + }).verifyComplete(); + + assertEquals(SessionTestHelper.TEST_CONTAINER_NAME, httpClient.getRequestedContainer()); + } + + @Test + public void getSessionSyncUsesContainerFromContext() { + CreateSessionMockHttpClient httpClient = new CreateSessionMockHttpClient(); + TokenCredentialSessionProvider sessionProvider = createSessionProvider(httpClient); + + SessionRequestContext context + = new SessionRequestContext().setContainerName(SessionTestHelper.TEST_CONTAINER_NAME) + .setAccountName(SessionTestHelper.TEST_ACCOUNT_NAME); + + SessionCredential credential = sessionProvider.getSession(context); + + assertNotNull(credential); + assertEquals("session-token-for-testcontainer", credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertEquals(SessionTestHelper.TEST_CONTAINER_NAME, httpClient.getRequestedContainer()); + } + + @Test + public void missingContextContainerThrowsSync() { + TokenCredentialSessionProvider sessionProvider = createSessionProvider(new CreateSessionMockHttpClient()); + + // There is no constructor-supplied fallback container: a context with no container name must be + // rejected rather than silently degrading to some default. + SessionRequestContext context = new SessionRequestContext(); + + assertThrows(IllegalArgumentException.class, () -> sessionProvider.getSession(context)); + } + + @Test + public void missingContextContainerThrowsAsync() { + TokenCredentialSessionProvider sessionProvider = createSessionProvider(new CreateSessionMockHttpClient()); + + SessionRequestContext context = new SessionRequestContext(); + + StepVerifier.create(sessionProvider.getSessionAsync(context)).verifyError(IllegalArgumentException.class); + } + + private static TokenCredentialSessionProvider createSessionProvider(CreateSessionMockHttpClient httpClient) { + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); + return new TokenCredentialSessionProvider(pipeline, + "https://" + SessionTestHelper.TEST_ACCOUNT_NAME + ".blob.core.windows.net", BlobServiceVersion.getLatest(), + SessionTestHelper.TEST_ACCOUNT_NAME); + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java new file mode 100644 index 000000000000..46e4942fb6d2 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/SessionTokenCredentialPolicyTest.java @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpPipelineCallContext; +import com.azure.core.http.HttpPipelineNextPolicy; +import com.azure.core.http.HttpPipelineNextSyncPolicy; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.storage.blob.models.BlobStorageException; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionOptions; +import com.azure.storage.blob.models.SessionProvider; +import com.azure.storage.common.policy.StorageBearerTokenChallengeAuthorizationPolicy; +import com.azure.storage.common.test.shared.session.SessionTestHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class SessionTokenCredentialPolicyTest { + + private static final String FIRST_TOKEN = "first-session-token"; + + private SessionProvider sessionProvider; + private StorageBearerTokenChallengeAuthorizationPolicy bearerPolicy; + private SessionTokenCredentialPolicy policy; + + @BeforeEach + public void beforeEach() { + sessionProvider = mock(SessionProvider.class); + bearerPolicy = mock(StorageBearerTokenChallengeAuthorizationPolicy.class); + + // Default mock behavior: bearer policy delegates to next policy in the pipeline. + when(bearerPolicy.process(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.process(); + }); + when(bearerPolicy.processSync(any(), any())).thenAnswer(invocation -> { + HttpPipelineNextSyncPolicy nextPolicy = invocation.getArgument(1); + return nextPolicy.processSync(); + }); + + policy = createPolicy(); + } + + @Test + public void sessionAcquisitionServerFailureStartsAccountCooldown() { + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)); + + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(200) // first request: acquisition fails, bearer fallback + .thenReturn(200); // second request: cooldown active, bearer fallback + HttpPipeline pipeline = buildPipeline(transport); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Session acquisition is attempted only once; the cooldown suppresses the second attempt. + verify(sessionProvider, times(1)).getSessionAsync(any()); + } + + @Test + public void sessionAcquisitionCooldownExpiresAfterFiveMinutes() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + policy = createPolicy(clock); + BlobStorageException serverFailure + = new BlobStorageException("CreateSession failed.", new MockHttpResponse(null, 500), null); + + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.error(serverFailure)) // first call: acquisition fails + .thenReturn(Mono.just(credentialWithToken())); // third call: cooldown expired + + SequencedMockHttpClient transport + = new SequencedMockHttpClient().thenReturn(200).thenReturn(200).thenReturn(200); + HttpPipeline pipeline = buildPipeline(transport); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + clock.advance(Duration.ofMinutes(5)); + + StepVerifier.create(pipeline.send(blobGetRequest())) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + verify(sessionProvider, times(2)).getSessionAsync(any()); + } + + @Test + public void policySignsRequestWithSessionCredential() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(200); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + assertTrue(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION).startsWith("Session " + FIRST_TOKEN), + "Expected request to be signed with a session credential."); + } + + /** + * Verifies that a 401 from the service invalidates the cached session and retries the request + * using bearer authentication. No WWW-Authenticate header is required to trigger this fallback; + * any 401 from a session-authenticated request unconditionally falls back to bearer. + */ + @Test + public void policyInvalidatesSessionAndFallsBackToBearerAsync() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(401) // session auth returns 401 + .thenReturn(200); // bearer retry succeeds + + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Session auth was stripped before the bearer retry. + assertNull(request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + // Transport received two dispatches: one for session auth, one for bearer retry. + assertEquals(2, transport.getRequestCount()); + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, times(1)).invalidateSession(any(), any()); + verify(bearerPolicy, times(1)).process(any(), any()); + } + + @Test + public void policyReturns403WithoutRetry() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(403); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(403, r.getStatusCode())) + .verifyComplete(); + + assertEquals(1, transport.getRequestCount()); + verify(bearerPolicy, times(0)).process(any(), any()); + } + + @Test + public void policyReturnsDataRequest503WithoutBearerFallbackAsync() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(503); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(503, r.getStatusCode())) + .verifyComplete(); + + // 503 is not a bearer-fallback trigger; the response is returned as-is. + assertEquals(1, transport.getRequestCount()); + verify(bearerPolicy, times(0)).process(any(), any()); + } + + @Test + public void policyFallsToBearerOn400Async() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(400).thenReturn(200); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + assertEquals(2, transport.getRequestCount()); + verify(bearerPolicy, times(1)).process(any(), any()); + String authHeader = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + + @Test + public void sessionExpiringHintForcesBackgroundRefreshEvenWhenTimerNotDue() { + HttpRequest request = blobGetRequest(); + HttpHeaders responseHeaders + = new HttpHeaders().set(HttpHeaderName.fromString("x-ms-auth-info"), "session_expiring"); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(200, responseHeaders); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // The service hint must trigger a proactive background refresh call, even though the client's + // own refresh timer had not yet elapsed. Dropping the hint here is what previously let the session + // be used past the rotation boundary, surfacing as a 401 "session_token_invalid" (network context + // mismatch). The refresh itself is delegated to the provider via refreshSession, distinct from the + // single getSessionAsync call used to obtain the credential for this request. + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, times(1)).refreshSession(any()); + } + + @Test + public void noSessionExpiringHintDoesNotForceBackgroundRefresh() { + HttpRequest request = blobGetRequest(); + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(200); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(credentialWithToken())); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // Without the hint and with a fresh session, only the initial get is made and no refresh occurs. + verify(sessionProvider, times(1)).getSessionAsync(any()); + verify(sessionProvider, never()).refreshSession(any()); + } + + @Test + public void getBlobRequestProducesWellFormedSessionAuthHeader() { + SessionCredential cred = credentialWithToken(); + HttpRequest request + = new HttpRequest(HttpMethod.GET, "https://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"); + + SequencedMockHttpClient transport = new SequencedMockHttpClient().thenReturn(200); + when(sessionProvider.getSessionAsync(any())).thenReturn(Mono.just(cred)); + + StepVerifier.create(buildPipeline(transport).send(request)) + .assertNext(r -> assertEquals(200, r.getStatusCode())) + .verifyComplete(); + + // The policy adapts Shared Key signing to the Session authorization scheme. + String actual = request.getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertNotNull(actual, "Authorization header should be set by the policy"); + assertTrue(actual.startsWith("Session " + FIRST_TOKEN + ":"), + "Authorization should use the Session scheme with the cached session token, but was: " + actual); + String actualSignature = actual.substring(actual.indexOf(':') + 1); + assertTrue(actualSignature.matches("[A-Za-z0-9+/]+={0,2}"), + "Signature must be base64-encoded, but was: " + actualSignature); + } + + // Sync tests use a minimal mock next-policy because the real pipeline doesn't expose sync invocation. + + @Test + public void policyInvalidatesSessionAndFallsBackToBearerSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse initialResponse = mock(HttpResponse.class); + HttpResponse retriedResponse = mock(HttpResponse.class); + + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(initialResponse); + when(retryNext.processSync()).thenReturn(retriedResponse); + when(initialResponse.getStatusCode()).thenReturn(401); + when(retriedResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(retriedResponse, actualResponse); + assertNull(context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION)); + verify(initialResponse, times(1)).close(); + verify(next, times(1)).processSync(); + verify(retryNext, times(1)).processSync(); + verify(sessionProvider, times(1)).invalidateSession(any(), any()); + } + } + + @Test + public void policyReturnsDataRequest503WithoutBearerFallbackSync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse unavailableResponse = mock(HttpResponse.class); + + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(unavailableResponse); + when(unavailableResponse.getStatusCode()).thenReturn(503); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(unavailableResponse, actualResponse); + verify(unavailableResponse, times(0)).close(); + verify(bearerPolicy, times(0)).processSync(any(), any()); + verify(retryNext, times(0)).processSync(); + } + } + + @Test + public void policyFallsToBearerOn400Sync() { + HttpPipelineCallContext context = createContext(); + HttpPipelineNextSyncPolicy next = mock(HttpPipelineNextSyncPolicy.class); + HttpPipelineNextSyncPolicy retryNext = mock(HttpPipelineNextSyncPolicy.class); + HttpResponse badRequestResponse = mock(HttpResponse.class); + HttpResponse bearerResponse = mock(HttpResponse.class); + + when(sessionProvider.getSession(any())).thenReturn(credentialWithToken()); + when(next.clone()).thenReturn(retryNext); + when(next.processSync()).thenReturn(badRequestResponse); + when(retryNext.processSync()).thenReturn(bearerResponse); + when(badRequestResponse.getStatusCode()).thenReturn(400); + when(bearerResponse.getStatusCode()).thenReturn(200); + + try (HttpResponse actualResponse = policy.processSync(context, next)) { + assertEquals(bearerResponse, actualResponse); + verify(badRequestResponse, times(1)).close(); + verify(bearerPolicy, times(1)).processSync(any(), any()); + String authHeader = context.getHttpRequest().getHeaders().getValue(HttpHeaderName.AUTHORIZATION); + assertTrue(authHeader == null || !authHeader.startsWith("Session"), + "Session auth should have been stripped but was: " + authHeader); + } + } + + // Helpers + + private HttpPipeline buildPipeline(SequencedMockHttpClient transport) { + return new HttpPipelineBuilder().httpClient(transport).policies(policy).build(); + } + + private static HttpRequest blobGetRequest() { + return new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob"); + } + + private SessionTokenCredentialPolicy createPolicy() { + return createPolicy(Clock.systemUTC()); + } + + private SessionTokenCredentialPolicy createPolicy(Clock clock) { + SessionOptions options = new SessionOptions().setContainerName("mycontainer"); + return new SessionTokenCredentialPolicy(bearerPolicy, sessionProvider, options, clock); + } + + private static SessionCredential credentialWithToken() { + return credentialWithToken(OffsetDateTime.now().plusHours(1)); + } + + private static SessionCredential credentialWithToken(OffsetDateTime expiration) { + return new SessionCredential(FIRST_TOKEN, SessionTestHelper.TEST_SESSION_KEY, expiration, + SessionTestHelper.TEST_ACCOUNT_NAME); + } + + private static HttpPipelineCallContext createContext() { + return createContextForRequest( + new HttpRequest(HttpMethod.GET, "https://myaccount.blob.core.windows.net/mycontainer/myblob")); + } + + private static HttpPipelineCallContext createContextForRequest(HttpRequest request) { + HttpPipelineCallContext context = mock(HttpPipelineCallContext.class); + Map data = new ConcurrentHashMap<>(); + + when(context.getHttpRequest()).thenReturn(request); + when(context.getData(anyString())) + .thenAnswer(invocation -> Optional.ofNullable(data.get(invocation.getArgument(0)))); + doAnswer(invocation -> { + data.put(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(context).setData(anyString(), org.mockito.ArgumentMatchers.any()); + + return context; + } + + private static final class SequencedMockHttpClient implements HttpClient { + private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); + private final AtomicInteger requestCount = new AtomicInteger(); + + private SequencedMockHttpClient thenReturn(int statusCode) { + return thenReturn(statusCode, new HttpHeaders()); + } + + private SequencedMockHttpClient thenReturn(int statusCode, HttpHeaders headers) { + responses.add(new ResponseSpec(statusCode, headers)); + return this; + } + + @Override + public Mono send(HttpRequest request) { + requestCount.incrementAndGet(); + ResponseSpec response = responses.poll(); + if (response == null) { + return Mono.error(new IllegalStateException("No response configured for request.")); + } + return Mono.just(new MockHttpResponse(request, response.statusCode, response.headers)); + } + + private int getRequestCount() { + return requestCount.get(); + } + + private static final class ResponseSpec { + private final int statusCode; + private final HttpHeaders headers; + + private ResponseSpec(int statusCode, HttpHeaders headers) { + this.statusCode = statusCode; + this.headers = headers; + } + } + } + + private static final class MutableClock extends Clock { + private final ZoneId zone; + private Instant instant; + + private MutableClock(Instant instant) { + this(instant, ZoneOffset.UTC); + } + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId newZone) { + return new MutableClock(instant, newZone); + } + + @Override + public Instant instant() { + return instant; + } + + private void advance(Duration duration) { + instant = instant.plus(duration); + } + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java new file mode 100644 index 000000000000..3bee50bd0c08 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderCacheTest.java @@ -0,0 +1,521 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.blob.implementation.util; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpPipeline; +import com.azure.core.http.HttpPipelineBuilder; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import com.azure.core.util.DateTimeRfc1123; +import com.azure.storage.blob.BlobServiceVersion; +import com.azure.storage.blob.models.SessionCredential; +import com.azure.storage.blob.models.SessionRequestContext; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Deterministic, network-free tests for {@link TokenCredentialSessionProvider}'s time-based, per-container caching + * behavior. + *

+ * These tests drive {@link TokenCredentialSessionProvider} with an injectable {@link Clock} and a fake HTTP transport + * ({@link ControllableHttpClient}) so the expiry, proactive-refresh, and per-container independence logic + * can be exercised without sleeping or hitting the service. Unlike {@code SessionProviderSeamTest} (which + * verifies the container name is placed correctly on the wire), these tests focus on cache timing: which + * token is returned when, and how many CreateSession calls are made. Account-level acquisition cooldown is + * covered separately by {@code SessionTokenCredentialPolicyTest}. + */ +public class TokenCredentialSessionProviderCacheTest { + + private static final String ACCOUNT_NAME = "myaccount"; + private static final String CONTAINER_A = "container-a"; + private static final String CONTAINER_B = "container-b"; + private static final String FIRST_TOKEN = "first-session-token"; + private static final String SECOND_TOKEN = "second-session-token"; + + // A session's usable lifetime in these tests (the service issues ~5 minute sessions). + private static final Duration SESSION_LIFETIME = Duration.ofMinutes(5); + + /** + * A request returns a good (valid) token. The clock then advances past the token's expiration. The next + * request must detect that the cached token is expired purely due to the passage of time and request a + * brand-new session rather than reuse or send the expired one. + */ + @Test + public void expiredByTimeOnSecondRequestCreatesNewSession() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + // First request: cold cache mints a good token and uses it. + SessionCredential firstRequest = provider.getSession(contextFor(CONTAINER_A)); + assertEquals(FIRST_TOKEN, firstRequest.getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + // Time advances past the first token's expiration with no traffic in between. + clock.advance(SESSION_LIFETIME.plusSeconds(1)); + + // Second request: the cached token is expired by time, so a new session is created instead of reused. + SessionCredential secondRequest = provider.getSession(contextFor(CONTAINER_A)); + assertEquals(SECOND_TOKEN, secondRequest.getSessionToken()); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * When the service has NOT sent a {@code session_expiring} hint, the cache must still refresh + * automatically once its own jittered timer elapses (while the current token is still usable), serving + * the current token until the refreshed one is ready. + */ + @Test + public void automaticBackgroundRefreshFiresWithoutServiceHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + // First request: cold cache mints the initial token. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + // Advance to a point guaranteed to be past the jittered refresh time (80-100% of lifetime minus the + // 5s safety buffer => at most lifetime-5s) but still before hard expiry, so the token remains usable. + clock.advance(SESSION_LIFETIME.minusSeconds(2)); + + // Second request: token still usable, refresh timer elapsed, no service hint => automatic background + // refresh. The current token is served while the refresh happens. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + + // Third request: the background refresh has swapped in the new token, which is now served. The + // refresh runs on a background subscription, so poll briefly rather than asserting immediately. + assertEquals(SECOND_TOKEN, waitForToken(() -> provider.getSession(contextFor(CONTAINER_A))).getSessionToken()); + // Still only one inline creation and one background refresh overall (no over-eager churn). + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Two different containers must refresh completely independently: advancing the clock past one + * container's jittered refresh point must trigger a background refresh for that container only, leaving + * the other container's still-fresh session untouched. + */ + @Test + public void independentContainersRefreshIndependently() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, "refreshed-a", now(clock).plus(SESSION_LIFETIME.multipliedBy(2))); + httpClient.enqueue(CONTAINER_B, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + // Mint an initial session for each container. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(SECOND_TOKEN, provider.getSession(contextFor(CONTAINER_B)).getSessionToken()); + + // Advance past container A's jittered refresh window (both containers were minted at the same time, + // so this is also past B's refresh window by clock time - but B must only refresh once *it* is + // accessed, not merely because time passed). + clock.advance(SESSION_LIFETIME.minusSeconds(2)); + + // Touching container A triggers its background refresh. The refresh runs on a background + // subscription, so poll briefly rather than asserting the call count immediately. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + waitForCallCount(httpClient); + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + + // Container B has not been touched since the clock advanced, so it must not have refreshed - proving + // the two containers' caches operate independently rather than sharing one refresh timer. + assertEquals(1, httpClient.getCallCount(CONTAINER_B)); + } + + /** + * Guards against over-eager refreshing: while the token is comfortably before its jittered refresh point + * and no service hint has arrived, repeated requests must reuse the same cached token and never trigger a + * refresh. + */ + @Test + public void noRefreshBeforeJitterWindowWithoutServiceHint() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + // First request mints the token. + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + + // Advance only slightly - well before the earliest jittered refresh point (80% of lifetime). + clock.advance(Duration.ofSeconds(30)); + + // Several more requests reuse the same token; no refresh is triggered. + for (int i = 0; i < 3; i++) { + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + } + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * The async path on a cold cache must mint a value through the async CreateSession call and emit exactly + * one element before completing. + */ + @Test + public void coldCacheCreatesValueAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Once the async path has cached a usable value, later async requests made before the jittered refresh + * window must replay that cached value rather than creating a second one. + */ + @Test + public void cachedValueIsReusedOnLaterAsyncRequests() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + // Advance well short of the earliest jittered refresh point (80% of lifetime). + clock.advance(Duration.ofSeconds(30)); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * A failed creation must surface to the caller as an error signal rather than an empty completion, and it + * must not poison the cache: the in-flight creation is cleared so a later request can retry successfully. + */ + @Test + public void creationFailurePropagatesAndAllowsRetryAsync() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueueFailure(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))).verifyError(); + + // The failure left no cached value behind, so the retry mints a fresh one. + StepVerifier.create(provider.getSessionAsync(contextFor(CONTAINER_A))) + .assertNext(credential -> assertEquals(FIRST_TOKEN, credential.getSessionToken())) + .verifyComplete(); + + assertEquals(2, httpClient.getCallCount(CONTAINER_A)); + } + + /** + * Container names must be matched case-insensitively: a container looked up with different casing must + * reuse the same cache entry rather than minting a duplicate session. + */ + @Test + public void containerNameLookupIsCaseInsensitive() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + assertEquals(FIRST_TOKEN, provider.getSession(contextFor(CONTAINER_A)).getSessionToken()); + assertEquals(FIRST_TOKEN, + provider.getSession(contextFor(CONTAINER_A.toUpperCase(Locale.ROOT))).getSessionToken()); + + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + @Test + public void refreshAndInvalidationAreOwnedByProvider() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + httpClient.enqueue(CONTAINER_A, FIRST_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, SECOND_TOKEN, now(clock).plus(SESSION_LIFETIME)); + httpClient.enqueue(CONTAINER_A, "third-session-token", now(clock).plus(SESSION_LIFETIME)); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + SessionRequestContext context = contextFor(CONTAINER_A); + + SessionCredential first = provider.getSession(context); + provider.refreshSession(context); + SessionCredential second = waitForToken(() -> provider.getSession(context)); + + assertFalse(provider.invalidateSession(context, first)); + assertTrue(provider.invalidateSession(context, second)); + assertFalse(provider.invalidateSession(context, second)); + assertEquals("third-session-token", provider.getSession(context).getSessionToken()); + } + + /** + * Concurrent async callers arriving while a creation is still in flight must join that single in-flight + * creation instead of each triggering their own, and all of them must observe the same value. + */ + @Test + public void concurrentAsyncRequestsShareASingleInFlightCreation() { + MutableClock clock = new MutableClock(Instant.parse("2026-06-19T00:00:00Z")); + ControllableHttpClient httpClient = new ControllableHttpClient(); + // A pending response models a CreateSession call that is still outstanding. + Sinks.One pendingResponse = httpClient.preparePendingResponse(CONTAINER_A); + TokenCredentialSessionProvider provider = createProvider(httpClient, clock); + + Mono first = provider.getSessionAsync(contextFor(CONTAINER_A)); + Mono second = provider.getSessionAsync(contextFor(CONTAINER_A)); + + AtomicReference firstResult = new AtomicReference<>(); + AtomicReference secondResult = new AtomicReference<>(); + CountDownLatch firstLatch = new CountDownLatch(1); + CountDownLatch secondLatch = new CountDownLatch(1); + first.subscribe(cred -> { + firstResult.set(cred); + firstLatch.countDown(); + }); + second.subscribe(cred -> { + secondResult.set(cred); + secondLatch.countDown(); + }); + + // Only one CreateSession call was made even though two callers subscribed. + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + + pendingResponse.tryEmitValue(httpClient.buildResponseFor(now(clock).plus(SESSION_LIFETIME))); + + awaitLatch(firstLatch); + awaitLatch(secondLatch); + + assertEquals(FIRST_TOKEN, firstResult.get().getSessionToken()); + assertEquals(FIRST_TOKEN, secondResult.get().getSessionToken()); + assertEquals(1, httpClient.getCallCount(CONTAINER_A)); + } + + private static void awaitLatch(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for async result."); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** + * Repeatedly invokes {@code supplier} (which triggers a synchronous cache lookup that may itself kick + * off a background refresh subscription) until it observes {@code expectedToken} or a timeout elapses. + * Background refreshes complete on a separate subscription from the caller that triggered them, so + * asserting on the very next call without allowing for that latency would be flaky. + */ + private static SessionCredential waitForToken(Supplier supplier) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + SessionCredential last; + do { + last = supplier.get(); + if (TokenCredentialSessionProviderCacheTest.SECOND_TOKEN.equals(last.getSessionToken())) { + return last; + } + sleepBriefly(); + } while (System.nanoTime() < deadline); + return last; + } + + private static void waitForCallCount(ControllableHttpClient httpClient) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (httpClient.getCallCount(TokenCredentialSessionProviderCacheTest.CONTAINER_A) < 2 + && System.nanoTime() < deadline) { + sleepBriefly(); + } + } + + private static void sleepBriefly() { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static TokenCredentialSessionProvider createProvider(HttpClient httpClient, Clock clock) { + HttpPipeline pipeline = new HttpPipelineBuilder().httpClient(httpClient).build(); + return new TokenCredentialSessionProvider(pipeline, "https://" + ACCOUNT_NAME + ".blob.core.windows.net", + BlobServiceVersion.getLatest(), ACCOUNT_NAME, clock); + } + + private static SessionRequestContext contextFor(String containerName) { + return new SessionRequestContext().setContainerName(containerName).setAccountName(ACCOUNT_NAME); + } + + private static OffsetDateTime now(Clock clock) { + return OffsetDateTime.now(clock); + } + + /** + * A fake transport that parses the container name out of the CreateSession request's path and returns + * pre-configured (token, expiration) pairs in FIFO order for that container, so cache timing behavior can + * be tested deterministically without a real service. Expirations are supplied by the test relative to + * the {@link MutableClock} under test, so the cache's expiry/refresh math lines up with the injected + * clock rather than the real one. + */ + private static final class ControllableHttpClient implements HttpClient { + private final Map> queuedByContainer = new ConcurrentHashMap<>(); + private final Map callCountByContainer = new ConcurrentHashMap<>(); + private final Map> pendingByContainer = new ConcurrentHashMap<>(); + private final Map lastRequestByContainer = new ConcurrentHashMap<>(); + + void enqueue(String container, String token, OffsetDateTime expiresAt) { + queuedByContainer.computeIfAbsent(container, k -> new ArrayDeque<>()) + .add(new CredentialConfig(token, expiresAt, false)); + } + + void enqueueFailure() { + queuedByContainer + .computeIfAbsent(TokenCredentialSessionProviderCacheTest.CONTAINER_A, k -> new ArrayDeque<>()) + .add(new CredentialConfig(null, null, true)); + } + + /** + * Registers a pending (not-yet-completed) response for the given container: the next request for + * that container will receive this response only once the returned sink is completed, modeling a + * CreateSession call that is still in flight. + */ + Sinks.One preparePendingResponse(String container) { + Sinks.One sink = Sinks.one(); + pendingByContainer.put(container, sink); + return sink; + } + + /** + * Builds a CreateSession success response for the most recent pending request captured for the + * given container, for use with {@link #preparePendingResponse(String)}. + */ + HttpResponse buildResponseFor(OffsetDateTime expiresAt) { + HttpRequest request = lastRequestByContainer.get(TokenCredentialSessionProviderCacheTest.CONTAINER_A); + return buildResponse(request, TokenCredentialSessionProviderCacheTest.FIRST_TOKEN, expiresAt); + } + + int getCallCount(String container) { + return callCountByContainer.getOrDefault(container, 0); + } + + @Override + public Mono send(HttpRequest request) { + String path = request.getUrl().getPath(); + String container = path.startsWith("/") ? path.substring(1) : path; + + callCountByContainer.merge(container, 1, Integer::sum); + + Sinks.One pending = pendingByContainer.remove(container); + if (pending != null) { + lastRequestByContainer.put(container, request); + return pending.asMono(); + } + + Deque queue = queuedByContainer.get(container); + CredentialConfig config = queue == null || queue.isEmpty() ? null : queue.poll(); + if (config == null) { + return Mono.error(new IllegalStateException("No queued CreateSession response for " + container)); + } + if (config.failure) { + return Mono.error(new IllegalStateException("CreateSession failed.")); + } + + return Mono.just(buildResponse(request, config.token, config.expiresAt)); + } + + private static HttpResponse buildResponse(HttpRequest request, String token, OffsetDateTime expiresAt) { + String expiration = new DateTimeRfc1123(expiresAt).toString(); + String body = "" + "" + + "test-session-id" + "" + expiration + "" + + "HMAC" + "" + "" + token + + "" + "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA==" + + "" + ""; + + return new MockHttpResponse(request, 201, body.getBytes(StandardCharsets.UTF_8)).addHeader("Content-Type", + "application/xml"); + } + + private static final class CredentialConfig { + private final String token; + private final OffsetDateTime expiresAt; + private final boolean failure; + + private CredentialConfig(String token, OffsetDateTime expiresAt, boolean failure) { + this.token = token; + this.expiresAt = expiresAt; + this.failure = failure; + } + } + } + + /** + * A {@link Clock} whose instant can be advanced, allowing deterministic control of the cache's notion + * of "now" without sleeping. + */ + private static final class MutableClock extends Clock { + private final ZoneId zone; + private Instant instant; + + MutableClock(Instant instant) { + this(instant, ZoneOffset.UTC); + } + + private MutableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId newZone) { + return new MutableClock(instant, newZone); + } + + @Override + public Instant instant() { + return instant; + } + + void advance(Duration duration) { + instant = instant.plus(duration); + } + } +} diff --git a/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java new file mode 100644 index 000000000000..adccbc85ffe1 --- /dev/null +++ b/sdk/storage/azure-storage-blob/src/test/java/com/azure/storage/blob/implementation/util/TokenCredentialSessionProviderTests.java @@ -0,0 +1,185 @@ +// 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.models.SessionCredential; +import com.azure.storage.blob.models.SessionRequestContext; +import com.azure.storage.blob.sas.BlobContainerSasPermission; +import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; +import com.azure.storage.common.test.shared.StorageCommonTestUtils; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +public class TokenCredentialSessionProviderTests extends BlobTestBase { + + @Test + public void createSessionReturnsTokenAndKey() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + } + + @Test + public void createSessionAsyncReturnsTokenAndKey() { + BlobContainerAsyncClient oauthCc + = getOAuthServiceAsyncClient().getBlobContainerAsyncClient(ccAsync.getBlobContainerName()); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(oauthCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + }) + .verifyComplete(); + } + + @Test + public void createSessionSyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(cc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertEquals(1, policyInvocationCount.get()); + } + + @Test + public void createSessionAsyncUsesProvidedHttpPipeline() { + AtomicInteger policyInvocationCount = new AtomicInteger(); + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider( + createOAuthPipeline(policyInvocationCount), ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), + BlobServiceVersion.getLatest(), ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + // assertEquals(AuthenticationType.HMAC, session.getAuthenticationType()); + }) + .verifyComplete(); + + assertEquals(1, policyInvocationCount.get()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(cc.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClientBuilder builder = new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()); + + BlobContainerClient sasCc = instrument(builder.sasToken(sas)).buildClient(); + + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + SessionCredential credential + = sessionProvider.getSession(new SessionRequestContext().setContainerName(sasCc.getBlobContainerName())); + + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertFalse(credential.isExpired()); + } + + @Disabled("Service does not yet support User Delegation SAS for Create Session — returns InvalidSessionAuthenticationType") + @Test + public void createSessionAsyncWithUserDelegationSas() { + BlobContainerClient oauthCc = getOAuthServiceClient().getBlobContainerClient(ccAsync.getBlobContainerName()); + + String sas = generateUserDelegationContainerSas(oauthCc); + + BlobContainerClient sasCc + = instrument(new BlobContainerClientBuilder().endpoint(oauthCc.getBlobContainerUrl()).sasToken(sas)) + .buildClient(); + + TokenCredentialSessionProvider sessionProvider = new TokenCredentialSessionProvider(sasCc.getHttpPipeline(), + ENVIRONMENT.getPrimaryAccount().getBlobEndpoint(), BlobServiceVersion.getLatest(), + ENVIRONMENT.getPrimaryAccount().getName()); + + StepVerifier + .create(sessionProvider + .getSessionAsync(new SessionRequestContext().setContainerName(ccAsync.getBlobContainerName()))) + .assertNext(credential -> { + assertNotNull(credential); + assertNotNull(credential.getSessionToken()); + assertNotNull(credential.getSessionKey()); + assertNotNull(credential.getExpiresAt()); + assertFalse(credential.isExpired()); + }) + .verifyComplete(); + } + + private String generateUserDelegationContainerSas(BlobContainerClient containerClient) { + BlobContainerSasPermission permissions = new BlobContainerSasPermission().setReadPermission(true) + .setWritePermission(true) + .setCreatePermission(true) + .setListPermission(true); + BlobServiceSasSignatureValues sasValues + = new BlobServiceSasSignatureValues(testResourceNamer.now().plusDays(1), permissions); + + return containerClient.generateUserDelegationSas(sasValues, getOAuthServiceClient() + .getUserDelegationKey(testResourceNamer.now().minusDays(1), testResourceNamer.now().plusDays(1))); + } + + private HttpPipeline createOAuthPipeline(AtomicInteger policyInvocationCount) { + 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/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/implementation/util/AutoRefreshingCache.java b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java new file mode 100644 index 000000000000..83d9acd8ca49 --- /dev/null +++ b/sdk/storage/azure-storage-common/src/main/java/com/azure/storage/common/implementation/util/AutoRefreshingCache.java @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.common.implementation.util; + +import com.azure.core.util.logging.ClientLogger; +import reactor.core.publisher.Mono; + +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +/** + * Cache for container-scoped storage session credentials. + *

+ * {@code T} is not required to implement any particular interface; the caller supplies a + * {@link Function} that extracts the expiration instant from a value, decoupling this cache from any + * specific credential shape. + */ +public final class AutoRefreshingCache { + public interface ValueProvider { + Mono createAsync(); + + T createSync(); + } + + private static final ClientLogger LOGGER = new ClientLogger(AutoRefreshingCache.class); + private static final Duration SAFETY_BUFFER = Duration.ofSeconds(5); + private static final double JITTER_WINDOW_START_RATIO = 0.8d; + + private final ValueProvider valueProvider; + private final Function expirationExtractor; + private final Clock clock; + private final Object creationLock = new Object(); + private volatile T value; + private volatile OffsetDateTime nextRefreshTime; + private volatile boolean refreshing; + private volatile Mono inflightCreation; + + public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor) { + this(valueProvider, expirationExtractor, Clock.systemUTC()); + } + + public AutoRefreshingCache(ValueProvider valueProvider, Function expirationExtractor, + Clock clock) { + this.valueProvider = Objects.requireNonNull(valueProvider, "'valueProvider' cannot be null."); + this.expirationExtractor = Objects.requireNonNull(expirationExtractor, "'expirationExtractor' cannot be null."); + this.clock = Objects.requireNonNull(clock, "'clock' cannot be null."); + } + + public Mono getValidValueAsync() { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshValueInBackground(); + } + return Mono.just(current); + } + + return startSessionCreationAsync(); + } + + public T getValidValueSync() { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value; + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshValueInBackground(); + } + return current; + } + + // Join in-flight async creation outside the lock to avoid deadlock with doOnNext. + Mono inFlight = inflightCreation; + if (inFlight != null) { + T refreshed = inFlight.block(); + if (refreshed != null) { + return refreshed; + } + } + + synchronized (creationLock) { + current = value; + now = OffsetDateTime.now(clock); + if (isUsable(current, now)) { + if (isRefreshDue(now)) { + refreshValueInBackground(); + } + return current; + } + + T created = valueProvider.createSync(); + setActiveValue(created); + return created; + } + } + + public void invalidateValue(T target) { + synchronized (creationLock) { + if (value == target) { + value = null; + nextRefreshTime = null; + refreshing = false; + } + inflightCreation = null; + } + } + + public void refreshValueInBackground() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(clock); + if (!isUsable(value, now) || !isRefreshDue(now) || refreshing) { + return; + } + refreshing = true; + } + + startSessionCreationAsync().subscribe(ignored -> { + }, error -> LOGGER.warning("Background session refresh failed.", error)); + } + + public void forceRefreshValueInBackground() { + synchronized (creationLock) { + if (isUsable(value, OffsetDateTime.now(clock))) { + nextRefreshTime = OffsetDateTime.now(clock); + } + } + + refreshValueInBackground(); + } + + private Mono startSessionCreationAsync() { + synchronized (creationLock) { + OffsetDateTime now = OffsetDateTime.now(clock); + T current = value; + if (isUsable(current, now) && !isRefreshDue(now)) { + return Mono.just(current); + } + + if (inflightCreation != null) { + return inflightCreation; + } + + refreshing = true; + + AtomicReference> creationReference = new AtomicReference<>(); + Mono creation = valueProvider.createAsync().doOnNext(cred -> { + synchronized (creationLock) { + setActiveValue(cred); + } + }).doFinally(ignored -> { + synchronized (creationLock) { + if (inflightCreation == creationReference.get()) { + inflightCreation = null; + refreshing = false; + } + } + }).cache(); + creationReference.set(creation); + inflightCreation = creation; + + return inflightCreation; + } + } + + private void setActiveValue(T newValue) { + value = newValue; + nextRefreshTime = computeRefreshTime(OffsetDateTime.now(clock), expirationExtractor.apply(newValue)); + refreshing = false; + // Clear the in-flight marker here (not just in doFinally). doFinally only runs once the Mono + // reaches its terminal signal, but a downstream subscriber's onNext handler (e.g. inspecting the + // HTTP response for a "session expiring" hint) can run synchronously before that terminal signal + // is emitted. If a forced background refresh is triggered from within that onNext handler, it + // must see this creation as no-longer-in-flight so it starts a fresh one instead of returning the + // same (already-delivering) cached Mono. + inflightCreation = null; + } + + private boolean isUsable(T value, OffsetDateTime now) { + return value != null && !now.isAfter(expirationExtractor.apply(value)); + } + + private boolean isRefreshDue(OffsetDateTime now) { + OffsetDateTime refresh = nextRefreshTime; + return refresh != null && !now.isBefore(refresh); + } + + private static OffsetDateTime computeRefreshTime(OffsetDateTime now, OffsetDateTime expiration) { + long availableMillis = Duration.between(now, expiration.minus(SAFETY_BUFFER)).toMillis(); + if (availableMillis <= 0) { + return now; + } + + double refreshPoint + = JITTER_WINDOW_START_RATIO + (1.0 - JITTER_WINDOW_START_RATIO) * ThreadLocalRandom.current().nextDouble(); + return now.plus(Duration.ofMillis((long) (availableMillis * refreshPoint))); + } +} diff --git a/sdk/storage/azure-storage-common/src/main/java/module-info.java b/sdk/storage/azure-storage-common/src/main/java/module-info.java index f5f1cfa99b2b..41560c81dca7 100644 --- a/sdk/storage/azure-storage-common/src/main/java/module-info.java +++ b/sdk/storage/azure-storage-common/src/main/java/module-info.java @@ -25,4 +25,5 @@ exports com.azure.storage.common.implementation.connectionstring to // FIXME this should not be a long-term solution com.azure.data.tables, com.azure.storage.blob, com.azure.storage.blob.cryptography, com.azure.storage.file.share, com.azure.storage.file.datalake, com.azure.storage.queue; + exports com.azure.storage.common.implementation.util to com.azure.storage.blob; //FIXME this should not be a long-term solution } diff --git a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/CreateSessionMockHttpClient.java b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/CreateSessionMockHttpClient.java new file mode 100644 index 000000000000..b9587583bcd6 --- /dev/null +++ b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/CreateSessionMockHttpClient.java @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.common.test.shared.session; + +import com.azure.core.http.HttpClient; +import com.azure.core.http.HttpHeaderName; +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpRequest; +import com.azure.core.http.HttpResponse; +import com.azure.core.test.http.MockHttpResponse; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; + +/** + * Mock transport that returns a CreateSession response and records the requested container. + */ +public final class CreateSessionMockHttpClient implements HttpClient { + private volatile String requestedContainer; + + @Override + public Mono send(HttpRequest request) { + String path = request.getUrl().getPath(); + requestedContainer = path.startsWith("/") ? path.substring(1) : path; + + String body = "" + "" + + "test-session-id" + "Wed, 09 Sep 2099 00:00:00 GMT" + + "HMAC" + "" + "session-token-for-" + + requestedContainer + "" + "" + SessionTestHelper.TEST_SESSION_KEY + + "" + "" + ""; + HttpHeaders headers = new HttpHeaders().set(HttpHeaderName.CONTENT_TYPE, "application/xml"); + return Mono.just(new MockHttpResponse(request, 201, headers, body.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Gets the container from the most recent CreateSession request. + * + * @return The requested container. + */ + public String getRequestedContainer() { + return requestedContainer; + } +} diff --git a/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/SessionTestHelper.java b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/SessionTestHelper.java new file mode 100644 index 000000000000..76ffdb304988 --- /dev/null +++ b/sdk/storage/azure-storage-common/src/test-shared/java/com/azure/storage/common/test/shared/session/SessionTestHelper.java @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.storage.common.test.shared.session; + +import java.time.OffsetDateTime; + +/** + * Shared constants and expiration fixtures for session authentication tests. + */ +public final class SessionTestHelper { + public static final String TEST_SESSION_KEY = "dGVzdFNlc3Npb25LZXkxMjM0NTY3ODkwMTIzNDU2Nzg5MA=="; + public static final String TEST_SESSION_TOKEN = "test-session-token-abc123"; + public static final String TEST_ACCOUNT_NAME = "myaccount"; + public static final String TEST_CONTAINER_NAME = "testcontainer"; + + /** + * Creates an expiration time in the future. + * + * @return A valid session expiration time. + */ + public static OffsetDateTime createValidExpiration() { + return OffsetDateTime.now().plusHours(1); + } + + /** + * Creates an expiration time in the past. + * + * @return An expired session expiration time. + */ + public static OffsetDateTime createExpiredExpiration() { + return OffsetDateTime.now().minusMinutes(5); + } + + private SessionTestHelper() { + } +} 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); + } }