Skip to content

Commit bf7f3c1

Browse files
authored
[Tables] Enable cross-tenant authentication (Azure#21678)
### Packages impacted by this PR @azure/core-client @azure/data-tables @azure/core-rest-pipeline ### Issues associated with this PR N/A ### Describe the problem that is addressed by this PR This PR adds support for Storage Challenge authentication, this enables cross-tenant authentication for Tables SDK, which is the following scenario: Storage Account (TenantA) Service Principal (TenantB) 1. Initial call grabs a token for service principal in TenantB which is its home tenant 2. The request reaches the Storage Account and is rejected with 401 with the accompanying WWW-Authenticate header which contains the tenant id for where the Storage Account is located (**TenantA**) 3. We handle the challenge and request a new token, this time for **TenantA** which we extracted from the challenge information. Also the challenge may contain information about the correct scopes to use, if present, we use them to get the new token as well 4. Re-try the original request with the new token. Implementation is based on the storage [Bearer Challenge spec](https://docs.microsoft.com/en-us/rest/api/storageservices/authorize-with-azure-active-directory#bearer-challenge) ### What are the possible designs available to address the problem? If there are more than one possible design, why was the one in this PR chosen? I considered the following alternatives: 1. Enable this challenge logic by default in bearerTokenAuthenticationPolicy so that all libraries could leverage this functionality. However, each service seems to implement the challenge slightly differently. For example KeyVault requires an initial request with empty body to be sent to get the challenge. Storage uses differtent charaters to separate values. 2. Add the challenge callback in the Tables package. However I realized that the Storage packages need the same functionality, but they are still in corev1, so it would be beneficial if the callbacks live in core-client and can be accessed in the future by Storage when they migrate to **corev2** saving extra work on their side 3. Chosen approach is to create an authentication policy that wraps the bearerTokenAuthenticationPolicy from core-rest-pipeline and feeds it a new challenge handling callback, inspired on what the Storage team has in place at the moment. This new policy `storageChallengeAuthenticationPolicy` lives in core-client to be accessible by the Storage packages when they make the move to corev2 ### Are there test cases added in this PR? _(If not, why?)_ Yes ### Provide a list of related PRs _(if any)_ N/A ### Command used to generate this PR:**_(Applicable only to SDK release request PRs)_ `rushx generate:client` ### Checklists - [x] Added impacted package name to the issue description - [x] Does this PR needs any fixes in the SDK Generator?** _(If so, create an Issue in the [Autorest/typescript](https://github.com/Azure/autorest.typescript) repository and link it here)_ - [x] Added a changelog (if necessary)
1 parent 144a5f9 commit bf7f3c1

23 files changed

+710
-35
lines changed

sdk/core/core-client/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Added a new property endpoint in ServiceClientOptions and mark the baseUri as deprecated to encourage people to use endpoint. See issue link [here](https://github.com/Azure/autorest.typescript/issues/1337)
88
- Upgraded our `@azure/core-tracing` dependency to version 1.0
9+
- Add callbacks to support Storage challenge authentication [PR#21678](https://github.com/Azure/azure-sdk-for-js/pull/21678)
910

1011
## 1.5.0 (2022-02-03)
1112

sdk/core/core-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@azure/core-auth": "^1.3.0",
7373
"@azure/core-rest-pipeline": "^1.5.0",
7474
"@azure/core-tracing": "^1.0.0",
75+
"@azure/core-util": "^1.0.0",
7576
"@azure/logger": "^1.0.0",
7677
"tslib": "^2.2.0"
7778
},

sdk/core/core-client/review/core-client.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export interface AdditionalPolicyConfig {
2727
// @public
2828
export function authorizeRequestOnClaimChallenge(onChallengeOptions: AuthorizeRequestOnChallengeOptions): Promise<boolean>;
2929

30+
// @public
31+
export const authorizeRequestOnTenantChallenge: (challengeOptions: AuthorizeRequestOnChallengeOptions) => Promise<boolean>;
32+
3033
// @public
3134
export interface BaseMapper {
3235
constraints?: MapperConstraints;
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import {
5+
AuthorizeRequestOnChallengeOptions,
6+
PipelineRequest,
7+
PipelineResponse,
8+
} from "@azure/core-rest-pipeline";
9+
10+
import { GetTokenOptions } from "@azure/core-auth";
11+
12+
/**
13+
* A set of constants used internally when processing requests.
14+
*/
15+
const Constants = {
16+
DefaultScope: "/.default",
17+
/**
18+
* Defines constants for use with HTTP headers.
19+
*/
20+
HeaderConstants: {
21+
/**
22+
* The Authorization header.
23+
*/
24+
AUTHORIZATION: "authorization",
25+
},
26+
};
27+
28+
/**
29+
* Defines a callback to handle auth challenge for Storage APIs.
30+
* This implements the bearer challenge process described here: https://docs.microsoft.com/rest/api/storageservices/authorize-with-azure-active-directory#bearer-challenge
31+
* Handling has specific features for storage that departs to the general AAD challenge docs.
32+
**/
33+
export const authorizeRequestOnTenantChallenge: (
34+
challengeOptions: AuthorizeRequestOnChallengeOptions
35+
) => Promise<boolean> = async (challengeOptions) => {
36+
const requestOptions = requestToOptions(challengeOptions.request);
37+
const challenge = getChallenge(challengeOptions.response);
38+
if (challenge) {
39+
const challengeInfo: Challenge = parseChallenge(challenge);
40+
const challengeScopes = buildScopes(challengeOptions, challengeInfo);
41+
const tenantId = extractTenantId(challengeInfo);
42+
const accessToken = await challengeOptions.getAccessToken(challengeScopes, {
43+
...requestOptions,
44+
tenantId,
45+
});
46+
47+
if (!accessToken) {
48+
return false;
49+
}
50+
51+
challengeOptions.request.headers.set(
52+
Constants.HeaderConstants.AUTHORIZATION,
53+
`Bearer ${accessToken.token}`
54+
);
55+
return true;
56+
}
57+
return false;
58+
};
59+
60+
/**
61+
* Extracts the tenant id from the challenge information
62+
* The tenant id is contained in the authorization_uri as the first
63+
* path part.
64+
*/
65+
function extractTenantId(challengeInfo: Challenge): string {
66+
const parsedAuthUri = new URL(challengeInfo.authorization_uri);
67+
const pathSegments = parsedAuthUri.pathname.split("/");
68+
const tenantId = pathSegments[1];
69+
70+
return tenantId;
71+
}
72+
73+
/**
74+
* Builds the authentication scopes based on the information that comes in the
75+
* challenge information. Scopes url is present in the resource_id, if it is empty
76+
* we keep using the original scopes.
77+
*/
78+
function buildScopes(
79+
challengeOptions: AuthorizeRequestOnChallengeOptions,
80+
challengeInfo: Challenge
81+
): string[] {
82+
if (!challengeInfo.resource_uri) {
83+
return challengeOptions.scopes;
84+
}
85+
86+
const challengeScopes = new URL(challengeInfo.resource_uri);
87+
challengeScopes.pathname = Constants.DefaultScope;
88+
return [challengeScopes.toString()];
89+
}
90+
91+
/**
92+
* We will retrieve the challenge only if the response status code was 401,
93+
* and if the response contained the header "WWW-Authenticate" with a non-empty value.
94+
*/
95+
function getChallenge(response: PipelineResponse): string | undefined {
96+
const challenge = response.headers.get("WWW-Authenticate");
97+
if (response.status === 401 && challenge) {
98+
return challenge;
99+
}
100+
return;
101+
}
102+
103+
/**
104+
* Challenge structure
105+
*/
106+
interface Challenge {
107+
authorization_uri: string;
108+
resource_uri?: string;
109+
}
110+
111+
/**
112+
* Converts: `Bearer a="b" c="d"`.
113+
* Into: `[ { a: 'b', c: 'd' }]`.
114+
*
115+
* @internal
116+
*/
117+
function parseChallenge(challenge: string): Challenge {
118+
const bearerChallenge = challenge.slice("Bearer ".length);
119+
const challengeParts = `${bearerChallenge.trim()} `.split(" ").filter((x) => x);
120+
const keyValuePairs = challengeParts.map((keyValue) =>
121+
(([key, value]) => ({ [key]: value }))(keyValue.trim().split("="))
122+
);
123+
// Key-value pairs to plain object:
124+
return keyValuePairs.reduce((a, b) => ({ ...a, ...b }), {} as Challenge);
125+
}
126+
127+
/**
128+
* Extracts the options form a Pipeline Request for later re-use
129+
*/
130+
function requestToOptions(request: PipelineRequest): GetTokenOptions {
131+
return {
132+
abortSignal: request.abortSignal,
133+
requestOptions: {
134+
timeout: request.timeout,
135+
},
136+
tracingOptions: request.tracingOptions,
137+
};
138+
}

sdk/core/core-client/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,4 @@ export {
5454
SerializationPolicyOptions,
5555
} from "./serializationPolicy";
5656
export { authorizeRequestOnClaimChallenge } from "./authorizeRequestOnClaimChallenge";
57+
export { authorizeRequestOnTenantChallenge } from "./authorizeRequestOnTenantChallenge";

0 commit comments

Comments
 (0)