Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ public BlobClient buildClient() {
new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set"));
}

BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER);
if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
containerName = sessionOptions.getContainerName();
}

BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER);

/*
Expand Down Expand Up @@ -185,6 +190,12 @@ public BlobAsyncClient buildAsyncClient() {
new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set"));
}

BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER);
if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
containerName = sessionOptions.getContainerName();
}
BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER);

/*
Implicit and explicit root container access are functionally equivalent, but explicit references are easier
to read and debug.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ public BlobContainerClient buildClient() {
new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set"));
}

BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER);
if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
containerName = sessionOptions.getContainerName();
}

BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER);

/*
Expand Down Expand Up @@ -170,6 +175,11 @@ public BlobContainerAsyncClient buildAsyncClient() {
new IllegalArgumentException("Customer provided key and encryption " + "scope cannot both be set"));
}

BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER);
if (CoreUtils.isNullOrEmpty(containerName) && !CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
containerName = sessionOptions.getContainerName();
}

BuilderHelper.validateSessionMode(sessionOptions, containerName, LOGGER);

/*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,13 @@ public BlobServiceClient buildClient() {
}

private HttpPipeline constructPipeline() {
return (httpPipeline != null)
? httpPipeline
: BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
perRetryPolicies, configuration, audience, LOGGER, sessionOptions, null);
if (httpPipeline != null) {
return httpPipeline;
}
BuilderHelper.applyEnvironmentSessionDefaults(sessionOptions, configuration, LOGGER);
return BuilderHelper.buildPipeline(storageSharedKeyCredential, tokenCredential, azureSasCredential, sasToken,
endpoint, retryOptions, coreRetryOptions, logOptions, clientOptions, httpClient, perCallPolicies,
perRetryPolicies, configuration, audience, LOGGER, sessionOptions, null);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import static com.azure.storage.common.Utility.STORAGE_TRACING_NAMESPACE_VALUE;
Expand All @@ -61,6 +62,20 @@ public final class BuilderHelper {
private static final String CLIENT_NAME;
private static final String CLIENT_VERSION;

/**
* Environment variable / configuration key that, when set, selects the {@link SessionMode}
* to use on a builder that has not been explicitly configured (i.e. still using
* {@link SessionMode#AUTO}). Accepted values are the names of {@link SessionMode}
* (case-insensitive): {@code NONE}, {@code AUTO}, {@code SINGLE_SPECIFIED_CONTAINER}.
*/
public static final String PROPERTY_AZURE_STORAGE_SESSION_MODE = "AZURE_STORAGE_SESSION_MODE";

/**
* Environment variable / configuration key that, when set, supplies the container name to
* scope the session to on a builder where it has not been explicitly configured.
*/
public static final String PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME = "AZURE_STORAGE_SESSION_CONTAINER_NAME";

static {
Map<String, String> properties = CoreUtils.getProperties("azure-storage-blob.properties");
CLIENT_NAME = properties.getOrDefault("name", "UnknownName");
Expand All @@ -72,22 +87,22 @@ public final class BuilderHelper {
* 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 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.
* @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,
Expand Down Expand Up @@ -241,8 +256,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)) {
Expand Down Expand Up @@ -287,7 +302,7 @@ 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) {
Expand All @@ -300,4 +315,54 @@ public static void validateSessionMode(SessionOptions sessionOptions, String con
"containerName must be set when using SessionMode." + sessionOptions.getSessionMode()));
}
}

/**
* Applies environment / configuration based defaults to the supplied {@link SessionOptions}.
* <p>
* This is a fallback that only fills in values the caller has not explicitly configured on the
* builder, so explicit programmatic configuration always wins:
* <ul>
* <li>{@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is consulted only when
* {@link SessionOptions#getSessionMode()} is still {@link SessionMode#AUTO} (the default).
* The env var value is matched case-insensitively against the names of {@link SessionMode}.</li>
* <li>{@link #PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME} is consulted only when
* {@link SessionOptions#getContainerName()} is {@code null} or empty.</li>
* </ul>
* Mutates {@code sessionOptions} in place.
*
* @param sessionOptions the options instance to populate; must not be {@code null}.
* @param configuration the configuration store to read from; if {@code null}, the global
* configuration is used.
* @param logger {@link ClientLogger} used to log any exception.
* @throws IllegalArgumentException if {@link #PROPERTY_AZURE_STORAGE_SESSION_MODE} is set to a
* value that does not name a known {@link SessionMode}.
*/
public static void applyEnvironmentSessionDefaults(SessionOptions sessionOptions, Configuration configuration,
ClientLogger logger) {
Configuration effectiveConfiguration
= (configuration == null) ? Configuration.getGlobalConfiguration() : configuration;

if (sessionOptions.getSessionMode() == SessionMode.AUTO) {
String envMode = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_MODE);
if (!CoreUtils.isNullOrEmpty(envMode)) {
SessionMode parsed;
try {
parsed = SessionMode.valueOf(envMode.trim().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
throw logger.logExceptionAsError(new IllegalArgumentException("Invalid value '" + envMode
+ "' for environment variable " + PROPERTY_AZURE_STORAGE_SESSION_MODE
+ ". Allowed values are: NONE, AUTO, SINGLE_SPECIFIED_CONTAINER.", ex));
}
sessionOptions.setSessionMode(parsed);
}
}

if (sessionOptions.getSessionMode().resolve() != SessionMode.NONE
&& CoreUtils.isNullOrEmpty(sessionOptions.getContainerName())) {
String envContainer = effectiveConfiguration.get(PROPERTY_AZURE_STORAGE_SESSION_CONTAINER_NAME);
if (!CoreUtils.isNullOrEmpty(envContainer)) {
sessionOptions.setContainerName(envContainer.trim());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,26 +56,31 @@ void signRequest(HttpRequest request) {
request.setHeader(HttpHeaderName.AUTHORIZATION, SESSION_PREFIX + sessionToken + ":" + signature);
}

// Mirrors StorageSharedKeyCredential.buildStringToSign but does NOT replace "0" with "" for
// Content-Length. The Session protocol signs the literal value the wire carries.
// Mirrors StorageSharedKeyCredential.buildStringToSign. The server canonicalizes
// Content-Length: 0 to "" before computing its HMAC (matching the documented Shared Key
// canonicalization), so we must do the same here to produce a matching signature.
//
// We inline this rather than delegate to StorageSharedKeyCredential because of a quirk in
// azure-core's RestProxyBase.configRequest (sdk/core/azure-core/src/main/java/com/azure/core/
// implementation/http/rest/RestProxyBase.java, line 305): it unconditionally calls
// `request.setHeader(HttpHeaderName.CONTENT_LENGTH, "0")` for body-less requests including
// GETs (an RFC 7230 violation; .NET's transports skip it). SharedKey's canonicalization
// then normalizes "0" -> "" in the string-to-sign, but the server signs the literal "0" it
// sees on the wire, so delegating produces a signature mismatch.
//
// TODO: once RestProxyBase.java:305 is changed to skip Content-Length: 0 for GET/DELETE,
// delete this method and delegate to sharedKey.generateAuthorizationHeader(...).
// This matches what happens in dotnet:
// https://github.com/Azure/azure-sdk-for-net/blob/57598097b0ba056de7d90e5b1624d6c529cd3d60/sdk/core/Azure.Core/src/Pipeline/HttpWebRequestTransport.cs#L94-L99
// TODO (azure-core, RFC hygiene only — does NOT affect Storage signing correctness):
// azure-core's RestProxyBase.configRequest (sdk/core/azure-core/.../RestProxyBase.java)
// unconditionally sets Content-Length: 0 on body-less requests, including GETs. Per
// RFC 7230 §3.3.2 a user agent SHOULD NOT send a Content-Length header when the request
// has no body and the method does not anticipate one (.NET's transports skip it). This
// does NOT cause a signing mismatch here — the server normalizes "0" -> "" and our local
// normalization above matches — so it is purely an RFC-hygiene issue. The Content-Length
// normalization in this method should remain in place even if azure-core is fixed: it
// reflects the documented Shared Key canonicalization rule, not a workaround for
// azure-core behavior. Track the azure-core fix separately if pursued.

private String buildStringToSign(HttpRequest request) {
HttpHeaders headers = request.getHeaders();
Collator collator = Collator.getInstance(Locale.ROOT);

String contentLength = getHeaderOrEmpty(headers, HttpHeaderName.CONTENT_LENGTH);
// Normalize "0" to "" to match the server's canonicalization (matches
// StorageSharedKeyCredential.buildStringToSign).
if ("0".equals(contentLength)) {
contentLength = "";
}
// If x-ms-date is present, the Date slot is empty.
String dateHeader = headers.getValue(X_MS_DATE) != null ? "" : getHeaderOrEmpty(headers, HttpHeaderName.DATE);

Expand Down
Loading
Loading