Introducing Edgio Applications v7Find out what's new.
Edgio
Edgio

AWS Request Signing

AWS Request Signing uses the AWS Signature Version 4 algorithm to sign requests to AWS services.
This process involves calculating a signature using the request’s elements and your AWS access keys. The signature ensures that AWS can verify the request as being sent by an authenticated source, enhancing security when accessing AWS services like S3. Integrating this with Edge Functions allows for efficient and secure fetching of assets from S3 buckets.

Dependencies

This guides references the following dependencies that will need to be installed in your project:
Bash
1npm install crypto-js whatwg-url

Router Configuration

In the Edgio router, you can use the edge_function feature to specify the path to the edge function that will handle requests to assets in the S3 bucket.
JavaScriptroutes.js
1// This file was added by edgio init.
2// You should commit this file to source control.
3import {Router, edgioRoutes} from '@edgio/core';
4
5export default new Router()
6 .use(edgioRoutes)
7
8 // Match any GET request to the /assets/* path and handle it with the edge function
9 .get('/assets/:path*', {
10 edge_function: './edge-functions/main.js',
11 });

Credentials and Origin Configuration

AWS request signing requires the following credentials that need to be defined in your local .env file and within the Edgio Console for the environment you are deploying to. This information can be obtained from your AWS account.
Bash.env
1# AWS S3 credentials
2AWS_SECRET_ACCESS_KEY = XXX
3AWS_ACCESS_KEY_ID = XXX
4AWS_HOSTNAME = XXX
5AWS_REGION = XXX
Define your S3 origin configuration in the edgio.config.js file:
JavaScriptedgio.config.js
1// This file was automatically added by edgio init.
2// You should commit this file to source control.
3// Learn more about this file at https://docs.edg.io/guides/edgio_config
4
5require('dotenv').config();
6
7module.exports = {
8 // ... other configuration options ...
9
10 origins: [
11 {
12 // The name of the backend origin
13 name: 's3',
14
15 // Use the following to override the host header sent from the browser when connecting to the origin
16 override_host_header: process.env.AWS_HOSTNAME,
17
18 // The list of origin hosts to which to connect
19 hosts: [
20 {
21 // The domain name or IP address of the origin server
22 location: process.env.AWS_HOSTNAME,
23 },
24 ],
25
26 tls_verify: {
27 use_sni: true,
28 sni_hint_and_strict_san_check: process.env.AWS_HOSTNAME,
29 },
30
31 // Uncomment the following to configure a shield
32 // shields: { us_east: 'DCD' },
33 },
34 ],
35};

Edge Function

The edge function will sign the incoming request using the AwsV4Signer class, and then forward the request to the S3 bucket. The AwsV4Signer class is a third-party library that handles the signing process, and is included in the edge-functions directory of this example.
JavaScriptedge-functions/main.js
1import {AwsV4Signer} from './awsv4';
2import {URL} from 'whatwg-url';
3
4global.URL = URL;
5
6/**
7 * An example edge function which forwards the request to the origin.
8 * See routes.js for how this function is configured to run for requests to "/assets/*".
9 */
10
11export async function handleHttpRequest(request, context) {
12 const s3Url = new URL(
13 request.path, // this is the incoming request path, but may need to be modified depending on your S3 bucket configuration
14 `https://${context.environmentVars.AWS_HOSTNAME}/`
15 );
16
17 const signer = new AwsV4Signer({
18 url: s3Url.href,
19 method: request.method,
20 region: context.environmentVars.S3_REGION,
21 service: 's3',
22 accessKeyId: context.environmentVars.S3_ACCESS_KEY_ID,
23 secretAccessKey: context.environmentVars.S3_SECRET_ACCESS_KEY,
24 signQuery: true,
25 });
26
27 const signedDetails = await signer.sign();
28
29 return fetch(signedDetails.url, {
30 method: signedDetails.method,
31 headers: signedDetails.headers,
32 edgio: {
33 origin: 's3',
34 },
35 });
36}
JavaScriptedge-functions/awsv4.js
1// @ts-check
2
3import HmacSHA256 from 'crypto-js/hmac-sha256';
4import SHA256 from 'crypto-js/sha256';
5
6/**
7 * @license MIT <https://opensource.org/licenses/MIT>
8 * @copyright Michael Hart 2022
9 */
10
11const encoder = new TextEncoder();
12
13/** @type {Object.<string, string>} */
14const HOST_SERVICES = {
15 appstream2: 'appstream',
16 cloudhsmv2: 'cloudhsm',
17 email: 'ses',
18 marketplace: 'aws-marketplace',
19 mobile: 'AWSMobileHubService',
20 pinpoint: 'mobiletargeting',
21 queue: 'sqs',
22 'git-codecommit': 'codecommit',
23 'mturk-requester-sandbox': 'mturk-requester',
24 'personalize-runtime': 'personalize',
25};
26
27// https://github.com/aws/aws-sdk-js/blob/cc29728c1c4178969ebabe3bbe6b6f3159436394/lib/signers/v4.js#L190-L198
28const UNSIGNABLE_HEADERS = new Set([
29 'authorization',
30 'content-type',
31 'content-length',
32 'user-agent',
33 'presigned-expires',
34 'expect',
35 'x-amzn-trace-id',
36 'range',
37 'connection',
38]);
39
40export class AwsClient {
41 /**
42 * @param {{
43 * accessKeyId: string
44 * secretAccessKey: string
45 * sessionToken?: string
46 * service?: string
47 * region?: string
48 * cache?: Map<string,ArrayBuffer>
49 * retries?: number
50 * initRetryMs?: number
51 * }} options
52 */
53 constructor({
54 accessKeyId,
55 secretAccessKey,
56 sessionToken,
57 service,
58 region,
59 cache,
60 retries,
61 initRetryMs,
62 }) {
63 if (accessKeyId == null)
64 throw new TypeError('accessKeyId is a required option');
65 if (secretAccessKey == null)
66 throw new TypeError('secretAccessKey is a required option');
67 this.accessKeyId = accessKeyId;
68 this.secretAccessKey = secretAccessKey;
69 this.sessionToken = sessionToken;
70 this.service = service;
71 this.region = region;
72 this.cache = cache || new Map();
73 this.retries = retries != null ? retries : 10; // Up to 25.6 secs
74 this.initRetryMs = initRetryMs || 50;
75 }
76
77 /**
78 * @typedef {RequestInit & {
79 * aws?: {
80 * accessKeyId?: string
81 * secretAccessKey?: string
82 * sessionToken?: string
83 * service?: string
84 * region?: string
85 * cache?: Map<string,ArrayBuffer>
86 * datetime?: string
87 * signQuery?: boolean
88 * appendSessionToken?: boolean
89 * allHeaders?: boolean
90 * singleEncode?: boolean
91 * }
92 * }} AwsRequestInit
93 *
94 * @param {RequestInfo} input
95 * @param {?AwsRequestInit} [init]
96 * @returns {Promise<Request>}
97 */
98 async sign(input, init) {
99 if (input instanceof Request) {
100 const {method, url, headers, body} = input;
101 init = Object.assign({method, url, headers}, init);
102 if (init.body == null && headers.has('Content-Type')) {
103 init.body =
104 body != null && headers.has('X-Amz-Content-Sha256')
105 ? body
106 : await input.clone().arrayBuffer();
107 }
108 input = url;
109 }
110 const signer = new AwsV4Signer(
111 Object.assign({url: input}, init, this, init && init.aws)
112 );
113 const signed = Object.assign({}, init, await signer.sign());
114 delete signed.aws;
115 try {
116 return new Request(signed.url.toString(), signed);
117 } catch (e) {
118 if (e instanceof TypeError) {
119 // https://bugs.chromium.org/p/chromium/issues/detail?id=1360943
120 return new Request(
121 signed.url.toString(),
122 Object.assign({duplex: 'half'}, signed)
123 );
124 }
125 throw e;
126 }
127 }
128
129 /**
130 * @param {RequestInfo} input
131 * @param {?AwsRequestInit} [init]
132 * @returns {Promise<Response>}
133 */
134 async fetch(input, init) {
135 for (let i = 0; i <= this.retries; i++) {
136 const fetched = fetch(await this.sign(input, init));
137 if (i === this.retries) {
138 return fetched; // No need to await if we're returning anyway
139 }
140 const res = await fetched;
141 if (res.status < 500 && res.status !== 429) {
142 return res;
143 }
144 await new Promise((resolve) =>
145 setTimeout(resolve, Math.random() * this.initRetryMs * Math.pow(2, i))
146 );
147 }
148 throw new Error(
149 'An unknown error occurred, ensure retries is not negative'
150 );
151 }
152}
153
154export class AwsV4Signer {
155 /**
156 * @param {{
157 * method?: string
158 * url: string
159 * headers?: HeadersInit
160 * body?: BodyInit | null
161 * accessKeyId: string
162 * secretAccessKey: string
163 * sessionToken?: string
164 * service?: string
165 * region?: string
166 * cache?: Map<string,ArrayBuffer>
167 * datetime?: string
168 * signQuery?: boolean
169 * appendSessionToken?: boolean
170 * allHeaders?: boolean
171 * singleEncode?: boolean
172 * }} options
173 */
174 constructor({
175 method,
176 url,
177 headers,
178 body,
179 accessKeyId,
180 secretAccessKey,
181 sessionToken,
182 service,
183 region,
184 cache,
185 datetime,
186 signQuery,
187 appendSessionToken,
188 allHeaders,
189 singleEncode,
190 }) {
191 if (url == null) throw new TypeError('url is a required option');
192 if (accessKeyId == null)
193 throw new TypeError('accessKeyId is a required option');
194 if (secretAccessKey == null)
195 throw new TypeError('secretAccessKey is a required option');
196
197 this.method = method || (body ? 'POST' : 'GET');
198 this.url = new URL(url);
199 this.headers = new Headers(headers || {});
200 this.body = body;
201
202 this.accessKeyId = accessKeyId;
203 this.secretAccessKey = secretAccessKey;
204 this.sessionToken = sessionToken;
205
206 let guessedService, guessedRegion;
207 if (!service || !region) {
208 [guessedService, guessedRegion] = guessServiceRegion(
209 this.url,
210 this.headers
211 );
212 }
213 /** @type {string} */
214 this.service = service || guessedService || '';
215 this.region = region || guessedRegion || 'us-east-1';
216
217 this.cache = cache || new Map();
218 this.datetime =
219 datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
220 this.signQuery = signQuery;
221 this.appendSessionToken =
222 appendSessionToken || this.service === 'iotdevicegateway';
223
224 this.headers.delete('Host'); // Can't be set in insecure env anyway
225
226 if (
227 this.service === 's3' &&
228 !this.signQuery &&
229 !this.headers.has('X-Amz-Content-Sha256')
230 ) {
231 this.headers.set('X-Amz-Content-Sha256', 'UNSIGNED-PAYLOAD');
232 }
233
234 const params = this.signQuery ? this.url.searchParams : this.headers;
235
236 params.set('X-Amz-Date', this.datetime);
237 if (this.sessionToken && !this.appendSessionToken) {
238 params.set('X-Amz-Security-Token', this.sessionToken);
239 }
240
241 // headers are always lowercase in keys()
242 this.signableHeaders = ['host', ...this.headers.keys()]
243 .filter((header) => allHeaders || !UNSIGNABLE_HEADERS.has(header))
244 .sort();
245
246 this.signedHeaders = this.signableHeaders.join(';');
247
248 // headers are always trimmed:
249 // https://fetch.spec.whatwg.org/#concept-header-value-normalize
250 this.canonicalHeaders = this.signableHeaders
251 .map(
252 (header) =>
253 header +
254 ':' +
255 (header === 'host'
256 ? this.url.host
257 : (this.headers.get(header) || '').replace(/\s+/g, ' '))
258 )
259 .join('\n');
260
261 this.credentialString = [
262 this.datetime.slice(0, 8),
263 this.region,
264 this.service,
265 'aws4_request',
266 ].join('/');
267
268 if (this.signQuery) {
269 if (this.service === 's3' && !params.has('X-Amz-Expires')) {
270 params.set('X-Amz-Expires', '86400'); // 24 hours
271 }
272 params.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
273 params.set(
274 'X-Amz-Credential',
275 this.accessKeyId + '/' + this.credentialString
276 );
277 params.set('X-Amz-SignedHeaders', this.signedHeaders);
278 }
279
280 if (this.service === 's3') {
281 try {
282 /** @type {string} */
283 this.encodedPath = decodeURIComponent(
284 this.url.pathname.replace(/\+/g, ' ')
285 );
286 } catch (e) {
287 this.encodedPath = this.url.pathname;
288 }
289 } else {
290 this.encodedPath = this.url.pathname.replace(/\/+/g, '/');
291 }
292 if (!singleEncode) {
293 this.encodedPath = encodeURIComponent(this.encodedPath).replace(
294 /%2F/g,
295 '/'
296 );
297 }
298 this.encodedPath = encodeRfc3986(this.encodedPath);
299
300 const seenKeys = new Set();
301 this.encodedSearch = [...this.url.searchParams]
302 .filter(([k]) => {
303 if (!k) return false; // no empty keys
304 if (this.service === 's3') {
305 if (seenKeys.has(k)) return false; // first val only for S3
306 seenKeys.add(k);
307 }
308 return true;
309 })
310 .map((pair) => pair.map((p) => encodeRfc3986(encodeURIComponent(p))))
311 .sort(([k1, v1], [k2, v2]) =>
312 k1 < k2 ? -1 : k1 > k2 ? 1 : v1 < v2 ? -1 : v1 > v2 ? 1 : 0
313 )
314 .map((pair) => pair.join('='))
315 .join('&');
316 }
317
318 /**
319 * @returns {Promise<{
320 * method: string
321 * url: URL
322 * headers: Headers
323 * body?: BodyInit | null
324 * }>}
325 */
326 async sign() {
327 if (this.signQuery) {
328 this.url.searchParams.set('X-Amz-Signature', await this.signature());
329 if (this.sessionToken && this.appendSessionToken) {
330 this.url.searchParams.set('X-Amz-Security-Token', this.sessionToken);
331 }
332 } else {
333 this.headers.set('Authorization', await this.authHeader());
334 }
335
336 return {
337 method: this.method,
338 url: this.url,
339 headers: this.headers,
340 body: this.body,
341 };
342 }
343
344 /**
345 * @returns {Promise<string>}
346 */
347 async authHeader() {
348 return [
349 'AWS4-HMAC-SHA256 Credential=' +
350 this.accessKeyId +
351 '/' +
352 this.credentialString,
353 'SignedHeaders=' + this.signedHeaders,
354 'Signature=' + (await this.signature()),
355 ].join(', ');
356 }
357
358 /**
359 * @returns {Promise<string>}
360 */
361 async signature() {
362 const date = this.datetime.slice(0, 8);
363 const cacheKey = [
364 this.secretAccessKey,
365 date,
366 this.region,
367 this.service,
368 ].join();
369 // let kCredentials = this.cache.get(cacheKey)
370 const kDate = await hmac('AWS4' + this.secretAccessKey, date);
371 const kRegion = await hmac(kDate, this.region);
372 const kService = await hmac(kRegion, this.service);
373 const kCredentials = await hmac(kService, 'aws4_request');
374 return (await hmac(kCredentials, await this.stringToSign())).toString();
375 }
376
377 /**
378 * @returns {Promise<string>}
379 */
380 async stringToSign() {
381 return [
382 'AWS4-HMAC-SHA256',
383 this.datetime,
384 this.credentialString,
385 (await hash(await this.canonicalString())).toString(),
386 ].join('\n');
387 }
388
389 /**
390 * @returns {Promise<string>}
391 */
392 async canonicalString() {
393 return [
394 this.method.toUpperCase(),
395 this.encodedPath,
396 this.encodedSearch,
397 this.canonicalHeaders + '\n',
398 this.signedHeaders,
399 await this.hexBodyHash(),
400 ].join('\n');
401 }
402
403 /**
404 * @returns {Promise<string>}
405 */
406 async hexBodyHash() {
407 let hashHeader =
408 this.headers.get('X-Amz-Content-Sha256') ||
409 (this.service === 's3' && this.signQuery ? 'UNSIGNED-PAYLOAD' : null);
410 if (hashHeader == null) {
411 if (
412 this.body &&
413 typeof this.body !== 'string' &&
414 !('byteLength' in this.body)
415 ) {
416 throw new Error(
417 'body must be a string, ArrayBuffer or ArrayBufferView, unless you include the X-Amz-Content-Sha256 header'
418 );
419 }
420 hashHeader = (await hash(this.body || '')).toString();
421 }
422 return hashHeader;
423 }
424}
425
426/**
427 * @param {string} key
428 * @param {string} payload
429 // * @returns {Promise<ArrayBuffer>}
430 */
431async function hmac(key, payload) {
432 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
433 // const cryptoKey = await crypto.subtle.importKey(
434 // 'raw',
435 // typeof key === 'string' ? encoder.encode(key) : key,
436 // { name: 'HMAC', hash: { name: 'SHA-256' } },
437 // false,
438 // ['sign'],
439 // )
440 // return crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(string))
441 return HmacSHA256(payload, key);
442}
443
444/**
445 * @param {string | ArrayBufferView | ArrayBuffer} content
446 * @returns {Promise<ArrayBuffer>}
447 */
448async function hash(content) {
449 // @ts-ignore // https://github.com/microsoft/TypeScript/issues/38715
450 // return crypto.subtle.digest('SHA-256', typeof content === 'string' ? encoder.encode(content) : content)
451 return SHA256(content).toString();
452}
453
454/**
455 * @param {ArrayBuffer | ArrayLike<number> | SharedArrayBuffer} buffer
456 * @returns {string}
457 */
458function buf2hex(buffer) {
459 return Array.prototype.map
460 .call(new Uint8Array(buffer), (x) => ('0' + x.toString(16)).slice(-2))
461 .join('');
462}
463
464/**
465 * @param {string} urlEncodedStr
466 * @returns {string}
467 */
468function encodeRfc3986(urlEncodedStr) {
469 return urlEncodedStr.replace(
470 /[!'()*]/g,
471 (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()
472 );
473}
474
475/**
476 * @param {URL} url
477 * @param {Headers} headers
478 * @returns {string[]} [service, region]
479 */
480function guessServiceRegion(url, headers) {
481 const {hostname, pathname} = url;
482
483 if (hostname.endsWith('.r2.cloudflarestorage.com')) {
484 return ['s3', 'auto'];
485 }
486 if (hostname.endsWith('.backblazeb2.com')) {
487 const match = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/);
488 return match != null ? ['s3', match[1]] : ['', ''];
489 }
490 const match = hostname
491 .replace('dualstack.', '')
492 .match(/([^.]+)\.(?:([^.]*)\.)?amazonaws\.com(?:\.cn)?$/);
493 let [service, region] = (match || ['', '']).slice(1, 3);
494
495 if (region === 'us-gov') {
496 region = 'us-gov-west-1';
497 } else if (region === 's3' || region === 's3-accelerate') {
498 region = 'us-east-1';
499 service = 's3';
500 } else if (service === 'iot') {
501 if (hostname.startsWith('iot.')) {
502 service = 'execute-api';
503 } else if (hostname.startsWith('data.jobs.iot.')) {
504 service = 'iot-jobs-data';
505 } else {
506 service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata';
507 }
508 } else if (service === 'autoscaling') {
509 const targetPrefix = (headers.get('X-Amz-Target') || '').split('.')[0];
510 if (targetPrefix === 'AnyScaleFrontendService') {
511 service = 'application-autoscaling';
512 } else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') {
513 service = 'autoscaling-plans';
514 }
515 } else if (region == null && service.startsWith('s3-')) {
516 region = service.slice(3).replace(/^fips-|^external-1/, '');
517 service = 's3';
518 } else if (service.endsWith('-fips')) {
519 service = service.slice(0, -5);
520 } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
521 [service, region] = [region, service];
522 }
523
524 return [HOST_SERVICES[service] || service, region];
525}