All files / src/client-side-encryption/providers azure.ts

98.07% Statements 51/52
89.47% Branches 17/19
100% Functions 11/11
98.07% Lines 51/52

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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  428x 428x 428x     428x   428x                                         428x 428x     88x 88x     48x       12x 12x             108x             68x         428x             1028x   1028x 1028x 1028x   592x       436x 8x     428x 196x         232x 4x         228x 228x 4x         224x                                         428x 1224x 1224x 1224x     1224x                 428x       1224x 1224x 1224x 1224x                         428x     1224x 1224x 1224x 1028x   1000x 196x   804x                 428x 68x 28x    
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(): Promise<AccessToken> {
    Eif (this.cachedToken == null || this.needsRefresh(this.cachedToken)) {
      this.cachedToken = await this._getToken();
    }
 
    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(): Promise<AzureTokenCacheEntry> {
    return fetchAzureKMSToken();
  }
}
 
/** @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);
  Iif (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 = {}
): Promise<AzureTokenCacheEntry> {
  const { headers, url } = prepareRequest(options);
  try {
    const response = await get(url, { headers });
    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): Promise<KMSProviders> {
  const azure = await tokenCache.getToken();
  return { ...kmsProviders, azure };
}