|
| 1 | +import * as crypto from 'node:crypto'; |
| 2 | +import * as queryString from 'node:querystring'; |
| 3 | + |
| 4 | +export interface AWS4 { |
| 5 | + /** |
| 6 | + * Created these inline types to better assert future usage of this API |
| 7 | + * @param options - options for request |
| 8 | + * @param credentials - AWS credential details, sessionToken should be omitted entirely if its false-y |
| 9 | + */ |
| 10 | + sign( |
| 11 | + this: void, |
| 12 | + options: { |
| 13 | + path: '/'; |
| 14 | + body: string; |
| 15 | + host: string; |
| 16 | + method: 'POST'; |
| 17 | + headers: { |
| 18 | + 'Content-Type': 'application/x-www-form-urlencoded'; |
| 19 | + 'Content-Length': number; |
| 20 | + 'X-MongoDB-Server-Nonce': string; |
| 21 | + 'X-MongoDB-GS2-CB-Flag': 'n'; |
| 22 | + }; |
| 23 | + service: string; |
| 24 | + region: string; |
| 25 | + }, |
| 26 | + credentials: |
| 27 | + | { |
| 28 | + accessKeyId: string; |
| 29 | + secretAccessKey: string; |
| 30 | + sessionToken: string; |
| 31 | + } |
| 32 | + | { |
| 33 | + accessKeyId: string; |
| 34 | + secretAccessKey: string; |
| 35 | + } |
| 36 | + | undefined |
| 37 | + ): { |
| 38 | + headers: { |
| 39 | + Authorization: string; |
| 40 | + 'X-Amz-Date': string; |
| 41 | + }; |
| 42 | + }; |
| 43 | +} |
| 44 | + |
| 45 | +export function aws4Sign( |
| 46 | + this: void, |
| 47 | + options: { |
| 48 | + path: '/'; |
| 49 | + body: string; |
| 50 | + host: string; |
| 51 | + method: 'POST'; |
| 52 | + headers: { |
| 53 | + 'Content-Type': 'application/x-www-form-urlencoded'; |
| 54 | + 'Content-Length': number; |
| 55 | + 'X-MongoDB-Server-Nonce': string; |
| 56 | + 'X-MongoDB-GS2-CB-Flag': 'n'; |
| 57 | + }; |
| 58 | + service: string; |
| 59 | + region: string; |
| 60 | + }, |
| 61 | + credentials: |
| 62 | + | { |
| 63 | + accessKeyId: string; |
| 64 | + secretAccessKey: string; |
| 65 | + sessionToken: string; |
| 66 | + } |
| 67 | + | { |
| 68 | + accessKeyId: string; |
| 69 | + secretAccessKey: string; |
| 70 | + } |
| 71 | + | undefined |
| 72 | +): { |
| 73 | + headers: { |
| 74 | + Authorization: string; |
| 75 | + 'X-Amz-Date': string; |
| 76 | + }; |
| 77 | +} { |
| 78 | + let path: string; |
| 79 | + let query: queryString.ParsedUrlQuery | undefined; |
| 80 | + |
| 81 | + const encode = (str: string) => { |
| 82 | + const encoded = encodeURIComponent(str); |
| 83 | + const replaced = encoded.replace(/[!'()*]/g, function (c) { |
| 84 | + return '%' + c.charCodeAt(0).toString(16).toUpperCase(); |
| 85 | + }); |
| 86 | + return replaced; |
| 87 | + }; |
| 88 | + |
| 89 | + const queryIndex = options.path.indexOf('?'); |
| 90 | + if (queryIndex < 0) { |
| 91 | + path = options.path; |
| 92 | + query = undefined; |
| 93 | + } else { |
| 94 | + path = options.path.slice(0, queryIndex); |
| 95 | + query = queryString.parse(options.path.slice(queryIndex + 1)); |
| 96 | + } |
| 97 | + |
| 98 | + let canonicalQuerystring = ''; |
| 99 | + if (query) { |
| 100 | + const isS3 = options.service === 's3'; |
| 101 | + const useFirstArrayValue = isS3; |
| 102 | + // const decodeSlashesInPath = isS3; |
| 103 | + // const decodePath = isS3; |
| 104 | + // const normalizePath = !isS3; |
| 105 | + const queryStrings: string[] = []; |
| 106 | + const sortedQueryKeys = Object.keys(query).sort(); |
| 107 | + for (const key of sortedQueryKeys) { |
| 108 | + if (!key) { |
| 109 | + continue; |
| 110 | + } |
| 111 | + |
| 112 | + const encodedKey = encode(key); |
| 113 | + let value: string | string[] | undefined = query[key]; |
| 114 | + if (Array.isArray(value)) { |
| 115 | + let values: string[] = value; |
| 116 | + if (useFirstArrayValue) { |
| 117 | + values = [value[0]]; |
| 118 | + } |
| 119 | + |
| 120 | + for (const item of values) { |
| 121 | + const encodedValue = encode(item); |
| 122 | + queryStrings.push(`${encodedKey}=${encodedValue}`); |
| 123 | + } |
| 124 | + } else { |
| 125 | + value = value ?? ''; |
| 126 | + const encodedValue = encode(value); |
| 127 | + queryStrings.push(`${encodedKey}=${encodedValue}`); |
| 128 | + } |
| 129 | + } |
| 130 | + canonicalQuerystring = queryStrings.join('&'); |
| 131 | + } |
| 132 | + |
| 133 | + const convertHeaderValue = (value: string | number) => { |
| 134 | + return value.toString().trim().replace(/\s+/g, ' '); |
| 135 | + }; |
| 136 | + const headers: string[] = [ |
| 137 | + `content-length:${convertHeaderValue(options.headers['Content-Length'])}\n`, |
| 138 | + `content-type:${convertHeaderValue(options.headers['Content-Type'])}\n`, |
| 139 | + `x-mongodb-gs2-cb-flag:${convertHeaderValue(options.headers['X-MongoDB-GS2-CB-Flag'])}\n`, |
| 140 | + `x-mongodb-server-nonce:${convertHeaderValue(options.headers['X-MongoDB-Server-Nonce'])}\n` |
| 141 | + ]; |
| 142 | + const canonicalHeaders = headers.sort().join('\n'); |
| 143 | + |
| 144 | + const signedHeaders = 'content-length;content-type;x-mongodb-gs2-cb-flag;x-mongodb-server-nonce'; |
| 145 | + |
| 146 | + const getHash = (str: string): string => { |
| 147 | + return crypto.createHash('sha256').update(str, 'utf8').digest('hex'); |
| 148 | + }; |
| 149 | + const getHmac = (key: string, str: string): string => { |
| 150 | + return crypto.createHmac('sha256', key).update(str, 'utf8').digest('hex'); |
| 151 | + }; |
| 152 | + const hashedPayload = getHash(options.body || ''); |
| 153 | + |
| 154 | + const canonicalUri = path; |
| 155 | + const canonicalRequest = [ |
| 156 | + options.method, |
| 157 | + canonicalUri, |
| 158 | + canonicalQuerystring, |
| 159 | + canonicalHeaders, |
| 160 | + signedHeaders, |
| 161 | + hashedPayload |
| 162 | + ].join('\n'); |
| 163 | + |
| 164 | + const canonicRequestHash = getHash(canonicalRequest); |
| 165 | + const requestDateTime = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); |
| 166 | + const requestDate = requestDateTime.substring(0, 8); |
| 167 | + const credentialScope = `${requestDate}/${options.region}/${options.service}/aws4_request`; |
| 168 | + |
| 169 | + const stringToSign = [ |
| 170 | + 'AWS4-HMAC-SHA256', |
| 171 | + requestDateTime, |
| 172 | + credentialScope, |
| 173 | + canonicRequestHash |
| 174 | + ].join('\n'); |
| 175 | + |
| 176 | + const getEnvCredentials = () => { |
| 177 | + const env = process.env; |
| 178 | + return { |
| 179 | + accessKeyId: env.AWS_ACCESS_KEY_ID || env.AWS_ACCESS_KEY, |
| 180 | + secretAccessKey: env.AWS_SECRET_ACCESS_KEY || env.AWS_SECRET_KEY, |
| 181 | + sessionToken: env.AWS_SESSION_TOKEN |
| 182 | + }; |
| 183 | + }; |
| 184 | + const creds = credentials || getEnvCredentials(); |
| 185 | + const dateKey = getHmac('AWS4' + creds.secretAccessKey, requestDate); |
| 186 | + const dateRegionKey = getHmac(dateKey, options.region); |
| 187 | + const dateRegionServiceKey = getHmac(dateRegionKey, options.service); |
| 188 | + const signingKey = getHmac(dateRegionServiceKey, 'aws4_request'); |
| 189 | + const signature = getHmac(signingKey, stringToSign); |
| 190 | + |
| 191 | + const authorizationHeader = [ |
| 192 | + 'AWS4-HMAC-SHA256 Credential=' + creds.accessKeyId + '/' + credentialScope, |
| 193 | + 'SignedHeaders=' + signedHeaders, |
| 194 | + 'Signature=' + signature |
| 195 | + ].join(', '); |
| 196 | + |
| 197 | + return { |
| 198 | + headers: { |
| 199 | + Authorization: authorizationHeader, |
| 200 | + 'X-Amz-Date': requestDateTime |
| 201 | + } |
| 202 | + }; |
| 203 | +} |
| 204 | + |
| 205 | +// export const aws4: AWS4 = { |
| 206 | +// sign: aws4Sign |
| 207 | +// }; |
0 commit comments