-
Notifications
You must be signed in to change notification settings - Fork 1.8k
/
Copy pathazure.ts
185 lines (163 loc) · 5.09 KB
/
azure.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
import { type Document } from '../../bson';
import { MongoNetworkTimeoutError } from '../../error';
import { get } from '../../utils';
import { MongoCryptAzureKMSRequestError } from '../errors';
import { type KMSProviders } from './index';
const MINIMUM_TOKEN_REFRESH_IN_MILLISECONDS = 6000;
/** Base URL for getting Azure tokens. */
export const AZURE_BASE_URL = 'http://169.254.169.254/metadata/identity/oauth2/token?';
/**
* The access token that libmongocrypt expects for Azure kms.
*/
interface AccessToken {
accessToken: string;
}
/**
* The response from the azure idms endpoint, including the `expiresOnTimestamp`.
* `expiresOnTimestamp` is needed for caching.
*/
interface AzureTokenCacheEntry extends AccessToken {
accessToken: string;
expiresOnTimestamp: number;
}
/**
* @internal
*/
export class AzureCredentialCache {
cachedToken: AzureTokenCacheEntry | null = null;
async getToken(closeSignal: AbortSignal): Promise<AccessToken> {
if (this.cachedToken == null || this.needsRefresh(this.cachedToken)) {
this.cachedToken = await this._getToken(closeSignal);
}
return { accessToken: this.cachedToken.accessToken };
}
needsRefresh(token: AzureTokenCacheEntry): boolean {
const timeUntilExpirationMS = token.expiresOnTimestamp - Date.now();
return timeUntilExpirationMS <= MINIMUM_TOKEN_REFRESH_IN_MILLISECONDS;
}
/**
* exposed for testing
*/
resetCache() {
this.cachedToken = null;
}
/**
* exposed for testing
*/
_getToken(closeSignal: AbortSignal): Promise<AzureTokenCacheEntry> {
return fetchAzureKMSToken(undefined, closeSignal);
}
}
/** @internal */
export const tokenCache = new AzureCredentialCache();
/** @internal */
async function parseResponse(response: {
body: string;
status?: number;
}): Promise<AzureTokenCacheEntry> {
const { status, body: rawBody } = response;
const body: { expires_in?: number; access_token?: string } = (() => {
try {
return JSON.parse(rawBody);
} catch {
throw new MongoCryptAzureKMSRequestError('Malformed JSON body in GET request.');
}
})();
if (status !== 200) {
throw new MongoCryptAzureKMSRequestError('Unable to complete request.', body);
}
if (!body.access_token) {
throw new MongoCryptAzureKMSRequestError(
'Malformed response body - missing field `access_token`.'
);
}
if (!body.expires_in) {
throw new MongoCryptAzureKMSRequestError(
'Malformed response body - missing field `expires_in`.'
);
}
const expiresInMS = Number(body.expires_in) * 1000;
if (Number.isNaN(expiresInMS)) {
throw new MongoCryptAzureKMSRequestError(
'Malformed response body - unable to parse int from `expires_in` field.'
);
}
return {
accessToken: body.access_token,
expiresOnTimestamp: Date.now() + expiresInMS
};
}
/**
* @internal
*
* exposed for CSFLE
* [prose test 18](https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#azure-imds-credentials)
*/
export interface AzureKMSRequestOptions {
headers?: Document;
url?: URL | string;
}
/**
* @internal
* Get the Azure endpoint URL.
*/
export function addAzureParams(url: URL, resource: string, username?: string): URL {
url.searchParams.append('api-version', '2018-02-01');
url.searchParams.append('resource', resource);
if (username) {
url.searchParams.append('client_id', username);
}
return url;
}
/**
* @internal
*
* parses any options provided by prose tests to `fetchAzureKMSToken` and merges them with
* the default values for headers and the request url.
*/
export function prepareRequest(options: AzureKMSRequestOptions): {
headers: Document;
url: URL;
} {
const url = new URL(options.url?.toString() ?? AZURE_BASE_URL);
addAzureParams(url, 'https://vault.azure.net');
const headers = { ...options.headers, 'Content-Type': 'application/json', Metadata: true };
return { headers, url };
}
/**
* @internal
*
* `AzureKMSRequestOptions` allows prose tests to modify the http request sent to the idms
* servers. This is required to simulate different server conditions. No options are expected to
* be set outside of tests.
*
* exposed for CSFLE
* [prose test 18](https://github.com/mongodb/specifications/tree/master/source/client-side-encryption/tests#azure-imds-credentials)
*/
export async function fetchAzureKMSToken(
options: AzureKMSRequestOptions = {},
closeSignal: AbortSignal
): Promise<AzureTokenCacheEntry> {
const { headers, url } = prepareRequest(options);
try {
const response = await get(url, { headers }, closeSignal);
return await parseResponse(response);
} catch (error) {
if (error instanceof MongoNetworkTimeoutError) {
throw new MongoCryptAzureKMSRequestError(`[Azure KMS] ${error.message}`);
}
throw error;
}
}
/**
* @internal
*
* @throws Will reject with a `MongoCryptError` if the http request fails or the http response is malformed.
*/
export async function loadAzureCredentials(
kmsProviders: KMSProviders,
closeSignal: AbortSignal
): Promise<KMSProviders> {
const azure = await tokenCache.getToken(closeSignal);
return { ...kmsProviders, azure };
}