/
challengeBasedAuthenticationPolicy.ts
245 lines (220 loc) · 8.94 KB
/
challengeBasedAuthenticationPolicy.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
/* eslint-disable @azure/azure-sdk/ts-use-interface-parameters */
import { TokenCredential } 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 { 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) {}
/**
* 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): boolean {
return other
? this.scope.toLowerCase() === other.scope.toLowerCase() &&
this.authorization.toLowerCase() === other.authorization.toLowerCase()
: false;
}
}
/**
* Helps keep a copy of any previous authentication challenges,
* so that we can compare on any further request.
*/
export class AuthenticationChallengeCache {
public challenge?: AuthenticationChallenge;
public setCachedChallenge(challenge: AuthenticationChallenge): void {
this.challenge = challenge;
}
}
/**
* Creates a new ChallengeBasedAuthenticationPolicy factory.
*
* @param credential - The TokenCredential implementation that can supply the challenge token.
*/
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
);
}
};
}
/**
* 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[][]>(
(acc, str) => (str.match(/\w="/) ? [...acc, str.split("=")] : acc),
[]
);
// 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
* implementation and then apply it to the Authorization header of a request
* as a Bearer token.
*
*/
export class ChallengeBasedAuthenticationPolicy extends BaseRequestPolicy {
private parseWWWAuthenticate: (
wwwAuthenticate: string
) => ParsedWWWAuthenticate = parseWWWAuthenticate;
/**
* Creates a new ChallengeBasedAuthenticationPolicy object.
*
* @param nextPolicy - The next RequestPolicy in the request pipeline.
* @param options - Options for this RequestPolicy.
* @param credential - The TokenCredential implementation that can supply the bearer token.
* @param tokenCache - The cache for the most recent AccessToken returned by the TokenCredential.
*/
constructor(
nextPolicy: RequestPolicy,
options: RequestPolicyOptions,
private credential: TokenCredential,
private tokenCache: AccessTokenCache,
private challengeCache: AuthenticationChallengeCache
) {
super(nextPolicy, options);
}
/**
* 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}`
);
}
}
/**
* 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.
*/
private async regenerateChallenge(
wwwAuthenticate: string,
webResource: WebResource
): Promise<HttpOperationResponse> {
// 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!;
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);
}
/**
* 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.");
}
// The next request will happen differently whether we have a challenge or not.
let response: HttpOperationResponse;
if (
this.challengeCache.challenge === undefined ||
this.challengeCache.challenge === undefined
) {
// 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 {
// 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);
}
// If we don't receive a response with a 401 status code,
// then we can assume this response has nothing to do with the challenge authentication process.
if (response.status !== 401) {
return response;
}
// 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;
}
// We re-generate the challenge and see if we have to re-authenticate.
return this.regenerateChallenge(wwwAuthenticate, webResource);
}
}