Skip to content

Commit 692c3e9

Browse files
authored
Enabled tenant discovery for Tables clients (Azure#28522)
* Added a new authorization policy for tenant discovery. * Updated client builders. * Added `V2020_12_06` to `TableServiceVersion`. * Updated CHANGELOG. * Removed unused imports. * Fixed issues. * Added tests. * Added test recordings * Updated test-resources.json for live test setup * Removed unused imports. * Fixed tests. * Fixed more test issues. * Disabled failing tests for TableClient creation using a SAS token. * Removed unused imports. * Applied PR feedback. * Removed mentions to TableBearerTokenChallengeAuthorizationPolicy from JavaDoc in public classes.
1 parent 3e6bc3e commit 692c3e9

19 files changed

+732
-61
lines changed

sdk/tables/azure-data-tables/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## 12.3.0-beta.1 (Unreleased)
44

55
### Features Added
6+
- TenantId can now be discovered through the service OAuth challenge response, when using a `TokenCredential` for authorization against a+ Storage Table Service.
7+
- Added method `enableTenantDiscovery(boolean)` to `TableClientBuilder` and `TableServiceClientBuilder`. If `true` is set, the resulting client will attempt an initial unauthorized request to the service to prompt an OAuth challenge containing the tenantId of the resource. This tenantId will then be used by the `TokenCredential`.
68

79
### Breaking Changes
810

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/BuilderHelper.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import com.azure.core.http.policy.AddDatePolicy;
1515
import com.azure.core.http.policy.AddHeadersPolicy;
1616
import com.azure.core.http.policy.AzureSasCredentialPolicy;
17-
import com.azure.core.http.policy.BearerTokenAuthenticationPolicy;
1817
import com.azure.core.http.policy.HttpLogOptions;
1918
import com.azure.core.http.policy.HttpLoggingPolicy;
2019
import com.azure.core.http.policy.HttpPipelinePolicy;
@@ -32,6 +31,7 @@
3231
import com.azure.data.tables.implementation.StorageAuthenticationSettings;
3332
import com.azure.data.tables.implementation.StorageConnectionString;
3433
import com.azure.data.tables.implementation.StorageConstants;
34+
import com.azure.data.tables.implementation.TableBearerTokenChallengeAuthorizationPolicy;
3535

3636
import java.util.ArrayList;
3737
import java.util.List;
@@ -55,7 +55,7 @@ static HttpPipeline buildPipeline(AzureNamedKeyCredential azureNamedKeyCredentia
5555
HttpLogOptions logOptions, ClientOptions clientOptions, HttpClient httpClient,
5656
List<HttpPipelinePolicy> perCallAdditionalPolicies,
5757
List<HttpPipelinePolicy> perRetryAdditionalPolicies, Configuration configuration,
58-
ClientLogger logger) {
58+
ClientLogger logger, boolean enableTenantDiscovery) {
5959
configuration = (configuration == null) ? Configuration.getGlobalConfiguration() : configuration;
6060
logOptions = (logOptions == null) ? new HttpLogOptions() : logOptions;
6161

@@ -108,7 +108,8 @@ static HttpPipeline buildPipeline(AzureNamedKeyCredential azureNamedKeyCredentia
108108
} else if (sasToken != null) {
109109
credentialPolicy = new AzureSasCredentialPolicy(new AzureSasCredential(sasToken), false);
110110
} else if (tokenCredential != null) {
111-
credentialPolicy = new BearerTokenAuthenticationPolicy(tokenCredential, StorageConstants.STORAGE_SCOPE);
111+
credentialPolicy = new TableBearerTokenChallengeAuthorizationPolicy(tokenCredential,
112+
enableTenantDiscovery, StorageConstants.STORAGE_SCOPE);
112113
} else {
113114
throw logger.logExceptionAsError(
114115
new IllegalStateException("A form of authentication is required to create a client. Use a builder's "

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/TableClientBuilder.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ public final class TableClientBuilder implements
104104
private TableServiceVersion version;
105105
private RetryPolicy retryPolicy;
106106
private RetryOptions retryOptions;
107+
private boolean enableTenantDiscovery;
107108

108109
/**
109110
* Creates a builder instance that is able to configure and construct {@link TableClient} and
@@ -197,7 +198,7 @@ public TableAsyncClient buildAsyncClient() {
197198
HttpPipeline pipeline = (httpPipeline != null) ? httpPipeline : BuilderHelper.buildPipeline(
198199
namedKeyCredential != null ? namedKeyCredential : azureNamedKeyCredential, azureSasCredential,
199200
tokenCredential, sasToken, endpoint, retryPolicy, retryOptions, httpLogOptions, clientOptions, httpClient,
200-
perCallPolicies, perRetryPolicies, configuration, logger);
201+
perCallPolicies, perRetryPolicies, configuration, logger, enableTenantDiscovery);
201202

202203
return new TableAsyncClient(tableName, pipeline, endpoint, serviceVersion, TABLES_SERIALIZER,
203204
TRANSACTIONAL_BATCH_SERIALIZER);
@@ -573,4 +574,20 @@ public TableClientBuilder tableName(String tableName) {
573574

574575
return this;
575576
}
577+
578+
/**
579+
* Enabled tenant discovery when authenticating with the Storage Table Service. This is disabled by default.
580+
* <p>
581+
* Enable this if there is a chance for your application and the Storage account it communicates with to reside in
582+
* different tenants. If this is enabled, clients created using this builder will make an unauthorized initial
583+
* service request that will be met with a {@code 401} response containing an authentication challenge, which
584+
* will be subsequently used to retrieve an access token to authorize all further requests with.
585+
*
586+
* @return The updated {@link TableClientBuilder}.
587+
*/
588+
public TableClientBuilder enableTenantDiscovery() {
589+
this.enableTenantDiscovery = true;
590+
591+
return this;
592+
}
576593
}

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/TableServiceAsyncClient.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,9 @@ public String generateAccountSas(TableAccountSasSignatureValues tableAccountSasS
191191
}
192192

193193
/**
194-
* Gets a {@link TableAsyncClient} instance for the table in the account with the provided {@code tableName}.
194+
* Gets a {@link TableAsyncClient} instance for the table in the account with the provided {@code tableName}. The
195+
* resulting {@link TableAsyncClient} will use the same {@link HttpPipeline pipeline} and
196+
* {@link TableServiceVersion service version} as this {@link TableServiceAsyncClient}.
195197
*
196198
* @param tableName The name of the table.
197199
*

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/TableServiceClient.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ public String generateAccountSas(TableAccountSasSignatureValues tableAccountSasS
113113
}
114114

115115
/**
116-
* Gets a {@link TableClient} instance for the table in the account with the provided {@code tableName}.
116+
* Gets a {@link TableClient} instance for the table in the account with the provided {@code tableName}. The
117+
* resulting {@link TableClient} will use the same {@link HttpPipeline pipeline} and
118+
* {@link TableServiceVersion service version} as this {@link TableServiceClient}.
117119
*
118120
* @param tableName The name of the table.
119121
*

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/TableServiceClientBuilder.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public final class TableServiceClientBuilder implements
9999
private String sasToken;
100100
private RetryPolicy retryPolicy;
101101
private RetryOptions retryOptions;
102+
private boolean enableTenantDiscovery;
102103

103104
/**
104105
* Creates a builder instance that is able to configure and construct {@link TableServiceClient} and
@@ -192,7 +193,7 @@ public TableServiceAsyncClient buildAsyncClient() {
192193
HttpPipeline pipeline = (httpPipeline != null) ? httpPipeline : BuilderHelper.buildPipeline(
193194
namedKeyCredential != null ? namedKeyCredential : azureNamedKeyCredential, azureSasCredential,
194195
tokenCredential, sasToken, endpoint, retryPolicy, retryOptions, httpLogOptions, clientOptions, httpClient,
195-
perCallPolicies, perRetryPolicies, configuration, logger);
196+
perCallPolicies, perRetryPolicies, configuration, logger, enableTenantDiscovery);
196197

197198
return new TableServiceAsyncClient(pipeline, endpoint, serviceVersion, serializerAdapter);
198199
}
@@ -543,4 +544,20 @@ public TableServiceClientBuilder clientOptions(ClientOptions clientOptions) {
543544

544545
return this;
545546
}
547+
548+
/**
549+
* Enabled tenant discovery when authenticating with the Storage Table Service. This is disabled by default.
550+
* <p>
551+
* Enable this if there is a chance for your application and the Storage account it communicates with to reside in
552+
* different tenants. If this is enabled, clients created using this builder will make an unauthorized initial
553+
* service request that will be met with a {@code 401} response containing an authentication challenge, which
554+
* will be subsequently used to retrieve an access token to authorize all further requests with.
555+
*
556+
* @return The updated {@link TableServiceClientBuilder}.
557+
*/
558+
public TableServiceClientBuilder enableTenantDiscovery() {
559+
this.enableTenantDiscovery = true;
560+
561+
return this;
562+
}
546563
}

sdk/tables/azure-data-tables/src/main/java/com/azure/data/tables/TableServiceVersion.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ public enum TableServiceVersion implements ServiceVersion {
1111
/**
1212
* API version 2019-02-02
1313
*/
14-
V2019_02_02("2019-02-02");
14+
V2019_02_02("2019-02-02"),
15+
16+
/**
17+
* API version 2020_12_06
18+
*/
19+
V2020_12_06("2020-12-06");
1520

1621
private final String version;
1722

@@ -33,7 +38,7 @@ public String getVersion() {
3338
* @return The latest REST API version supported by this client library.
3439
*/
3540
public static TableServiceVersion getLatest() {
36-
return V2019_02_02;
41+
return V2020_12_06;
3742
}
3843

3944
static TableServiceVersion fromString(String version) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
package com.azure.data.tables.implementation;
4+
5+
import com.azure.core.credential.TokenCredential;
6+
import com.azure.core.credential.TokenRequestContext;
7+
import com.azure.core.http.HttpPipelineCallContext;
8+
import com.azure.core.http.HttpResponse;
9+
import com.azure.core.http.policy.BearerTokenAuthenticationPolicy;
10+
import com.azure.core.util.CoreUtils;
11+
import reactor.core.publisher.Mono;
12+
13+
import java.net.URI;
14+
import java.net.URISyntaxException;
15+
import java.util.Collections;
16+
import java.util.HashMap;
17+
import java.util.Locale;
18+
import java.util.Map;
19+
20+
/**
21+
* A policy that authenticates requests with the Storage Table Service and supports challenges including tenantId
22+
* discovery. The content added by this policy is leveraged in {@link TokenCredential} to get and set the correct
23+
* "Authorization" header value.
24+
*
25+
* @see TokenCredential
26+
* @see BearerTokenAuthenticationPolicy
27+
*/
28+
public class TableBearerTokenChallengeAuthorizationPolicy extends BearerTokenAuthenticationPolicy {
29+
private static final String BEARER_TOKEN_PREFIX = "Bearer ";
30+
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
31+
private String[] scopes;
32+
private volatile String tenantId;
33+
private boolean enableTenantDiscovery;
34+
35+
/**
36+
* Creates a {@link TableBearerTokenChallengeAuthorizationPolicy}.
37+
*
38+
* @param credential The token credential to authenticate the request.
39+
*/
40+
public TableBearerTokenChallengeAuthorizationPolicy(TokenCredential credential, boolean enableTenantDiscovery,
41+
String... scopes) {
42+
super(credential, scopes);
43+
this.scopes = scopes;
44+
this.enableTenantDiscovery = enableTenantDiscovery;
45+
}
46+
47+
/**
48+
* Extracts attributes off the bearer challenge in the authentication header.
49+
*
50+
* @param authenticateHeader The authentication header containing the challenge.
51+
* @param authChallengePrefix The authentication challenge name.
52+
*
53+
* @return A challenge attributes map.
54+
*/
55+
private static Map<String, String> extractChallengeAttributes(String authenticateHeader,
56+
String authChallengePrefix) {
57+
if (!isBearerChallenge(authenticateHeader, authChallengePrefix)) {
58+
return Collections.emptyMap();
59+
}
60+
61+
authenticateHeader =
62+
authenticateHeader.toLowerCase(Locale.ROOT).replace(authChallengePrefix.toLowerCase(Locale.ROOT), "");
63+
64+
String[] attributes = authenticateHeader.split(" ");
65+
Map<String, String> attributeMap = new HashMap<>();
66+
67+
for (String pair : attributes) {
68+
String[] keyValue = pair.split("=");
69+
70+
attributeMap.put(keyValue[0].replaceAll("\"", ""), keyValue[1].replaceAll("\"", ""));
71+
}
72+
73+
return attributeMap;
74+
}
75+
76+
/**
77+
* Verifies whether a challenge is bearer or not.
78+
*
79+
* @param authenticateHeader The authentication header containing all the challenges.
80+
* @param authChallengePrefix The authentication challenge name.
81+
*
82+
* @return A boolean indicating if the challenge is a bearer challenge or not.
83+
*/
84+
private static boolean isBearerChallenge(String authenticateHeader, String authChallengePrefix) {
85+
return (!CoreUtils.isNullOrEmpty(authenticateHeader)
86+
&& authenticateHeader.toLowerCase(Locale.ROOT).startsWith(authChallengePrefix.toLowerCase(Locale.ROOT)));
87+
}
88+
89+
@Override
90+
public Mono<Void> authorizeRequest(HttpPipelineCallContext context) {
91+
return Mono.defer(() -> {
92+
if (this.tenantId != null || !enableTenantDiscovery) {
93+
TokenRequestContext tokenRequestContext = new TokenRequestContext()
94+
.addScopes(this.scopes)
95+
.setTenantId(this.tenantId);
96+
97+
return setAuthorizationHeader(context, tokenRequestContext);
98+
}
99+
100+
return Mono.empty();
101+
});
102+
}
103+
104+
@Override
105+
public Mono<Boolean> authorizeRequestOnChallenge(HttpPipelineCallContext context, HttpResponse response) {
106+
return Mono.defer(() -> {
107+
Map<String, String> challengeAttributes =
108+
extractChallengeAttributes(response.getHeaderValue(WWW_AUTHENTICATE), BEARER_TOKEN_PREFIX);
109+
110+
String authorizationUriString = challengeAttributes.get("authorization_uri");
111+
final URI authorizationUri;
112+
113+
try {
114+
authorizationUri = new URI(authorizationUriString);
115+
} catch (URISyntaxException e) {
116+
// The challenge authorization URI is invalid.
117+
return Mono.just(false);
118+
}
119+
120+
this.tenantId = authorizationUri.getPath().split("/")[1];
121+
122+
TokenRequestContext tokenRequestContext = new TokenRequestContext()
123+
.addScopes(this.scopes)
124+
.setTenantId(this.tenantId);
125+
126+
return setAuthorizationHeader(context, tokenRequestContext)
127+
.then(Mono.just(true));
128+
});
129+
}
130+
}

sdk/tables/azure-data-tables/src/test/java/com/azure/data/tables/TableAsyncClientTest.java

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.azure.core.http.policy.RetryPolicy;
1010
import com.azure.core.http.rest.Response;
1111
import com.azure.core.test.utils.TestResourceNamer;
12+
import com.azure.core.util.Configuration;
1213
import com.azure.data.tables.models.ListEntitiesOptions;
1314
import com.azure.data.tables.models.TableAccessPolicy;
1415
import com.azure.data.tables.models.TableEntity;
@@ -24,10 +25,13 @@
2425
import com.azure.data.tables.sas.TableSasPermission;
2526
import com.azure.data.tables.sas.TableSasProtocol;
2627
import com.azure.data.tables.sas.TableSasSignatureValues;
28+
import com.azure.identity.ClientSecretCredential;
29+
import com.azure.identity.ClientSecretCredentialBuilder;
2730
import org.junit.jupiter.api.AfterAll;
2831
import org.junit.jupiter.api.Assertions;
2932
import org.junit.jupiter.api.Assumptions;
3033
import org.junit.jupiter.api.BeforeAll;
34+
import org.junit.jupiter.api.Disabled;
3135
import org.junit.jupiter.api.Test;
3236
import reactor.test.StepVerifier;
3337

@@ -86,6 +90,44 @@ public void createTable() {
8690
.verify();
8791
}
8892

93+
/**
94+
* Tests that a table and entity can be created while having a different tenant ID than the one that will be
95+
* provided in the authentication challenge.
96+
*/
97+
@Test
98+
public void createTableWithMultipleTenants() {
99+
// Arrange
100+
final String tableName2 = testResourceNamer.randomName("tableName", 20);
101+
102+
// The tenant ID does not matter as the correct on will be extracted from the authentication challenge in
103+
// contained in the response the server provides to a first "naive" unauthenticated request.
104+
final ClientSecretCredential credential = new ClientSecretCredentialBuilder()
105+
.clientId(Configuration.getGlobalConfiguration().get("AZURE_TABLES_CLIENT_ID", "clientId"))
106+
.clientSecret(Configuration.getGlobalConfiguration().get("AZURE_TABLES_CLIENT_SECRET", "clientSecret"))
107+
.tenantId(testResourceNamer.randomUuid())
108+
.build();
109+
110+
final TableAsyncClient tableClient2 =
111+
getClientBuilder(tableName2, Configuration.getGlobalConfiguration().get("AZURE_TABLES_ENDPOINT",
112+
"https://tablestests.table.core.windows.com"), credential, true).buildAsyncClient();
113+
114+
// Act & Assert
115+
// This request will use the tenant ID extracted from the previous request.
116+
StepVerifier.create(tableClient2.createTable())
117+
.assertNext(Assertions::assertNotNull)
118+
.expectComplete()
119+
.verify();
120+
121+
final String partitionKeyValue = testResourceNamer.randomName("partitionKey", 20);
122+
final String rowKeyValue = testResourceNamer.randomName("rowKey", 20);
123+
final TableEntity tableEntity = new TableEntity(partitionKeyValue, rowKeyValue);
124+
125+
// All other requests will also use the tenant ID obtained from the auth challenge.
126+
StepVerifier.create(tableClient2.createEntity(tableEntity))
127+
.expectComplete()
128+
.verify();
129+
}
130+
89131
@Test
90132
public void createTableWithResponse() {
91133
// Arrange
@@ -1001,9 +1043,12 @@ public void generateSasTokenWithAllParameters() {
10011043
}
10021044

10031045
@Test
1046+
@Disabled
1047+
// Disabling as this currently fails and prevents merging https://github.com/Azure/azure-sdk-for-java/pull/28522.
1048+
// TODO: Will fix in a separate PR. -vicolina
10041049
public void canUseSasTokenToCreateValidTableClient() {
1005-
// SAS tokens at the table level have not been working with Cosmos endpoints. Will re-enable once this is fixed.
1006-
// - vicolina
1050+
// SAS tokens at the table level have not been working with Cosmos endpoints.
1051+
// TODO: Will re-enable once the above is fixed. -vicolina
10071052
Assumptions.assumeFalse(IS_COSMOS_TEST, "Skipping Cosmos test.");
10081053

10091054
final OffsetDateTime expiryTime = OffsetDateTime.of(2021, 12, 12, 0, 0, 0, 0, ZoneOffset.UTC);

0 commit comments

Comments
 (0)