-
Notifications
You must be signed in to change notification settings - Fork 1.4k
[KeyVault] Challenge based authentication parallel fix, including tests #9059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
53b7157
913248e
9d4c8c6
c8eb7e3
287b247
8f13098
aca8600
adbbf23
48ef2e9
f89ca0c
bf1d4a4
48db09d
0a776b9
12b14c4
c923099
f07e346
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # Ignoring the core files since they're auto-generated. Eventually, the auto-generated code will be on par with our current eslint rules, but in the mean time we should ignore them. | ||
| src/core | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| src/core | ||
| /src/core/* | ||
| !/src/core/challengeBasedAuthenticationPolicy.ts |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,29 +2,48 @@ | |
| // Licensed under the MIT License. See License.txt in the project root for license information. | ||
|
|
||
| import { TokenCredential } from "@azure/core-http"; | ||
| import { BaseRequestPolicy, RequestPolicy, RequestPolicyOptions, RequestPolicyFactory } from "@azure/core-http"; | ||
| import { | ||
| BaseRequestPolicy, | ||
| RequestPolicy, | ||
| RequestPolicyOptions, | ||
| RequestPolicyFactory | ||
| } from "@azure/core-http"; | ||
| import { Constants } from "@azure/core-http"; | ||
| import { HttpOperationResponse } from "@azure/core-http"; | ||
| import { HttpHeaders } from "@azure/core-http"; | ||
| import { WebResource } from "@azure/core-http"; | ||
| import { AccessTokenCache, ExpiringAccessTokenCache } from "@azure/core-http"; | ||
|
|
||
| type ValidParsedWWWAuthenticateProperties = | ||
| // "authorization_uri" was used in the track 1 version of KeyVault. | ||
| // This is not a relevant property anymore, since the service is consistently answering with "authorization". | ||
| // | "authorization_uri" | ||
| | "authorization" | ||
| // Even though the service is moving to "scope", both "resource" and "scope" should be supported. | ||
| | "resource" | ||
| | "scope"; | ||
|
|
||
| type ParsedWWWAuthenticate = { | ||
| [Key in ValidParsedWWWAuthenticateProperties]?: string; | ||
| }; | ||
|
|
||
| /** | ||
| * Representation of the Authentication Challenge | ||
| */ | ||
| export class AuthenticationChallenge { | ||
| constructor(public authorization: string, public scope: string) { | ||
| } | ||
| constructor(public authorization: string, public scope: string) {} | ||
|
|
||
| /** | ||
| * Checks that this AuthenticationChallenge is equal to another one given. | ||
| * Only compares the scope. | ||
| * This is exactly what C# is doing, as we can see here: | ||
| * https://github.com/Azure/azure-sdk-for-net/blob/70e54b878ff1d01a45266fb3674a396b4ab9c1d2/sdk/keyvault/Azure.Security.KeyVault.Shared/src/ChallengeBasedAuthenticationPolicy.cs#L143-L147 | ||
| * @param other The other AuthenticationChallenge | ||
| */ | ||
| public equalTo(other: AuthenticationChallenge | undefined) { | ||
| if (!other) { | ||
| return false; | ||
| } | ||
| return this.authorization === other.authorization && this.scope === other.scope; | ||
| return other | ||
| ? this.scope.toLowerCase() === other.scope.toLowerCase() && | ||
| this.authorization.toLowerCase() === other.authorization.toLowerCase() | ||
| : false; | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -45,16 +64,52 @@ export class AuthenticationChallengeCache { | |
| * | ||
| * @param credential The TokenCredential implementation that can supply the challenge token. | ||
| */ | ||
| export function challengeBasedAuthenticationPolicy(credential: TokenCredential): RequestPolicyFactory { | ||
| export function challengeBasedAuthenticationPolicy( | ||
| credential: TokenCredential | ||
| ): RequestPolicyFactory { | ||
| const tokenCache: AccessTokenCache = new ExpiringAccessTokenCache(); | ||
| const challengeCache = new AuthenticationChallengeCache(); | ||
| return { | ||
| create: (nextPolicy: RequestPolicy, options: RequestPolicyOptions) => { | ||
| return new ChallengeBasedAuthenticationPolicy(nextPolicy, options, credential, tokenCache, challengeCache); | ||
| return new ChallengeBasedAuthenticationPolicy( | ||
| nextPolicy, | ||
| options, | ||
| credential, | ||
| tokenCache, | ||
| challengeCache | ||
| ); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Parses an WWW-Authenticate response. | ||
| * This transforms a string value like: | ||
| * `Bearer authorization="some_authorization", resource="https://some.url"` | ||
| * into an object like: | ||
| * `{ authorization: "some_authorization", resource: "https://some.url" }` | ||
| * @param wwwAuthenticate string value in the WWW-Authenticate header | ||
| */ | ||
| export function parseWWWAuthenticate(wwwAuthenticate: string): ParsedWWWAuthenticate { | ||
| // First we split the string by either `, ` or ` `. | ||
| const parts = wwwAuthenticate.split(/,* +/); | ||
| // Then we only keep the strings with an equal sign after a word and before a quote. | ||
| // also splitting these sections by their equal sign | ||
| const keyValues = parts.reduce<string[][]>( | ||
| (parts, str) => (str.match(/\w="/) ? [...parts, str.split("=")] : parts), | ||
| [] | ||
| ); | ||
| // Then we transform these key-value pairs back into an object. | ||
| const parsed = keyValues.reduce<ParsedWWWAuthenticate>( | ||
| (result, [key, value]: string[]) => ({ | ||
| ...result, | ||
| [key]: value.slice(1, -1) | ||
| }), | ||
| {} | ||
| ); | ||
| return parsed; | ||
| } | ||
|
|
||
| /** | ||
| * | ||
| * Provides a RequestPolicy that can request a token from a TokenCredential | ||
|
|
@@ -63,6 +118,9 @@ export function challengeBasedAuthenticationPolicy(credential: TokenCredential): | |
| * | ||
| */ | ||
| export class ChallengeBasedAuthenticationPolicy extends BaseRequestPolicy { | ||
| private parseWWWAuthenticate: ( | ||
| wwwAuthenticate: string | ||
| ) => ParsedWWWAuthenticate = parseWWWAuthenticate; | ||
|
|
||
| /** | ||
| * Creates a new ChallengeBasedAuthenticationPolicy object. | ||
|
|
@@ -82,102 +140,102 @@ export class ChallengeBasedAuthenticationPolicy extends BaseRequestPolicy { | |
| super(nextPolicy, options); | ||
| } | ||
|
|
||
| private parseWWWAuthenticate(www_authenticate: string): { | ||
| authorization: string, | ||
| resource: string | ||
| } { | ||
| const returnValue = { | ||
| authorization: "", | ||
| resource: "" | ||
| }; | ||
| // Parses an authentication message like: | ||
| // ``` | ||
| // Bearer authorization="some_authorization", resource="https://some.url" | ||
| // ``` | ||
| let spaceSep = www_authenticate.split(" "); | ||
|
|
||
| // Split the KV comma-separated list | ||
| for (const spaceItem of spaceSep) { | ||
| const commaSep = spaceItem.split(","); | ||
| for (const commaItem of commaSep) { | ||
| // Split the key/value pairs | ||
| const kv = commaItem.split("="); | ||
| const key = kv[0].trim(); | ||
| const removeQuotes = (x: string): string => x.trim().replace(/['"]+/g, ''); | ||
| if (key == "authorization" || key == "authorization_uri") { | ||
| returnValue.authorization = removeQuotes(kv[1]); | ||
| } else if (key == "resource" || key == "scope") { | ||
| returnValue.resource = removeQuotes(kv[1]); | ||
| } | ||
| } | ||
| /** | ||
| * Gets or updates the token from the token cache into the headers of the received web resource. | ||
| */ | ||
| private async loadToken(webResource: WebResource): Promise<void> { | ||
| let accessToken = this.tokenCache.getCachedToken(); | ||
|
|
||
| // If there's no cached token in the cache, we try to get a new one. | ||
| if (accessToken === undefined) { | ||
| const receivedToken = await this.credential.getToken(this.challengeCache.challenge!.scope); | ||
| accessToken = receivedToken || undefined; | ||
| this.tokenCache.setCachedToken(accessToken); | ||
| } | ||
|
|
||
| if (accessToken) { | ||
| webResource.headers.set( | ||
| Constants.HeaderConstants.AUTHORIZATION, | ||
| `Bearer ${accessToken.token}` | ||
| ); | ||
| } | ||
| return returnValue; | ||
| } | ||
|
|
||
| /** | ||
| * Applies the Bearer token to the request through the Authorization header. | ||
| * @param webResource | ||
| * Parses the given WWW-Authenticate header, generates a new AuthenticationChallenge, | ||
| * then if the challenge is different from the one cached, resets the token and forces | ||
| * a re-authentication, otherwise continues with the existing challenge and token. | ||
| * @param wwwAuthenticate Value of the incoming WWW-Authenticate header. | ||
| * @param webResource Ongoing HTTP request. | ||
| */ | ||
| public async sendRequest( | ||
| private async regenerateChallenge( | ||
| wwwAuthenticate: string, | ||
| webResource: WebResource | ||
| ): Promise<HttpOperationResponse> { | ||
| if (!webResource.headers) webResource.headers = new HttpHeaders(); | ||
| // The challenge based authentication will contain both: | ||
| // - An authorization URI with a token, | ||
| // - The resource to which that token is valid against (also called the scope). | ||
| const parsedWWWAuth = this.parseWWWAuthenticate(wwwAuthenticate); | ||
| const authorization = parsedWWWAuth.authorization!; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. feels weird to do all these ! operations to assert something exists. If we know it exists, why make it optional on the type? If we don't know that it exists, why not add some checks so we don't blow up at runtime?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll add an exception case if we don't receive the values that we expect! Thank you!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added this: if (!(authorization && resource)) {
return this._nextPolicy.sendRequest(webResource);
} |
||
| const resource = parsedWWWAuth.resource! || parsedWWWAuth.scope!; | ||
|
|
||
| if (!(authorization && resource)) { | ||
| return this._nextPolicy.sendRequest(webResource); | ||
| } | ||
|
|
||
| const challenge = new AuthenticationChallenge(authorization, resource + "/.default"); | ||
|
|
||
| // Either if there's no cached challenge at this point (could have happen in parallel), | ||
| // or if the cached challenge has a different scope, | ||
| // we store the just received challenge and reset the cached token, to force a re-authentication. | ||
| if (!this.challengeCache.challenge?.equalTo(challenge)) { | ||
| this.challengeCache.setCachedChallenge(challenge); | ||
| this.tokenCache.setCachedToken(undefined); | ||
| } | ||
|
|
||
| await this.loadToken(webResource); | ||
| return this._nextPolicy.sendRequest(webResource); | ||
| } | ||
|
|
||
| // Ensure that we're about to use a secure connection | ||
| /** | ||
| * Applies the Bearer token to the request through the Authorization header. | ||
| * @param webResource Ongoing HTTP request. | ||
| */ | ||
| public async sendRequest(webResource: WebResource): Promise<HttpOperationResponse> { | ||
| // Ensure that we're about to use a secure connection. | ||
| if (!webResource.url.startsWith("https:")) { | ||
| throw new Error("The resource address for authorization must use the 'https' protocol."); | ||
| } | ||
|
|
||
| const originalBody = webResource.body; | ||
| // The next request will happen differently whether we have a challenge or not. | ||
| let response: HttpOperationResponse; | ||
|
|
||
| if (this.challengeCache.challenge == undefined) { | ||
| // Use a blank to start the challenge | ||
| // If there's no challenge in cache, a blank body will start the challenge. | ||
| const originalBody = webResource.body; | ||
| webResource.body = ""; | ||
| response = await this._nextPolicy.sendRequest(webResource); | ||
| webResource.body = originalBody; | ||
| } else { | ||
| // or use the cached token if we have one | ||
| await this.authenticateRequest(webResource); | ||
| // If we did have a challenge in memory, | ||
| // we attempt to load the token from the cache into the request before we try to send the request. | ||
| await this.loadToken(webResource); | ||
| response = await this._nextPolicy.sendRequest(webResource); | ||
| } | ||
|
|
||
| const response = await this._nextPolicy.sendRequest(webResource); | ||
|
|
||
| if (response.status == 401) { | ||
| webResource.body = originalBody; | ||
|
|
||
| let www_authenticate = response.headers.get("WWW-Authenticate"); | ||
|
|
||
| if (www_authenticate) { | ||
| // The challenge based authentication will contain both an authorization URI with a token, | ||
| // and the resource to which that token is valid against (also called the scope). | ||
| const { authorization, resource } = this.parseWWWAuthenticate(www_authenticate); | ||
| const challenge = new AuthenticationChallenge(authorization, resource + "/.default") | ||
|
|
||
| if (!challenge.equalTo(this.challengeCache.challenge)) { | ||
| this.challengeCache.setCachedChallenge(challenge); | ||
| this.tokenCache.setCachedToken(undefined); | ||
|
|
||
| await this.authenticateRequest(webResource); | ||
| return this._nextPolicy.sendRequest(webResource); | ||
| } | ||
| return response; | ||
| } | ||
| return response; | ||
| } else { | ||
| // If we don't receive a response with a 401 status code, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can everything from here until the end be moved up after the line
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we know it will never return 401? Seems like we are being extra cautious if we double check like this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's say we're already authenticated, but the server answers with 401 when our code tries to: await this.loadToken(webResource);
response = await this._nextPolicy.sendRequest(webResource);Seems possible! I'm assuming that if the challenge or token have some kind of expiration, this might help us correctly re-authenticate.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, sure
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (I'm seeing an A-zure pun and I don't know what to do with it 😄) |
||
| // then we can assume this response has nothing to do with the challenge authentication process. | ||
| if (response.status !== 401) { | ||
| return response; | ||
| } | ||
| } | ||
|
|
||
| private async authenticateRequest(webResource: WebResource): Promise<void> { | ||
| let accessToken = this.tokenCache.getCachedToken(); | ||
| if (accessToken === undefined) { | ||
| accessToken = (await this.credential.getToken(this.challengeCache.challenge!.scope)) || undefined; | ||
| this.tokenCache.setCachedToken(accessToken); | ||
| // If the response status is 401, we only re-authenticate if the WWW-Authenticate header is present. | ||
| const wwwAuthenticate = response.headers.get("WWW-Authenticate"); | ||
| if (!wwwAuthenticate) { | ||
| return response; | ||
| } | ||
|
|
||
| if (accessToken) { | ||
| webResource.headers.set( | ||
| Constants.HeaderConstants.AUTHORIZATION, | ||
| `Bearer ${accessToken.token}` | ||
| ); | ||
| } | ||
| // We re-generate the challenge and see if we have to re-authenticate. | ||
| return await this.regenerateChallenge(wwwAuthenticate, webResource); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file was missing in certificates.