Skip to content

Commit

Permalink
feat(sign): Add sync sign support
Browse files Browse the repository at this point in the history
  • Loading branch information
megahertz committed Mar 11, 2025
1 parent bcc2e12 commit c48c52f
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 112 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ S3 URL manipulation helper similar to standard URL class
- Support both Node.js and browser environment
- Simple and lightweight
- No dependencies
- Typescript support
- Built-in presigned URL generation
- TypeScript support
- Built-in presigned URL generation (sync and promised versions)

## Installation

Expand Down Expand Up @@ -64,16 +64,17 @@ S3Url {
// Making a http copy
const httpUrl = s3Url.clone({ protocol: 'http:' }).href;

// Generaing presigned URL, AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
// Generaing presigned URL
// env vars can be used instead of passing arguments
const presignedUrl = s3Url.sign({ accessKeyId, secretAccessKey });
const presignedUrl = await s3Url.sign({ accessKeyId, secretAccessKey });
```

## Providers

Currently, the library is tested with the following providers:

- Amazon S3
- Cloudflare R2
- DigitalOcean Spaces
- Stackpath Storage
- Generic provider (Supports URL schema like bucket.region.example.com)
Expand All @@ -91,7 +92,7 @@ s3Parser.addProvider(new S3Provider({

### Adding a custom provider implementation

To add a parser for a custom provider you need to extend S3Provider class.
To add a parser for a custom provider, you need to extend S3Provider class.
You can use [AmazonAwsProvider.js](src/providers/AmazonAwsProvider.js) as
an example.

Expand Down
72 changes: 15 additions & 57 deletions src/S3Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ const S3Url = require('./S3Url');
const {
decodeS3Key,
encodeS3Key,
encodeSpecialUrlChars,
} = require('./utils/encode');
const { bufferToHex, hmacSha256, sha256 } = require('./utils/crypto');
const { buildSignedUrl, buildSignedUrlSync } = require('./utils/sign');

class S3Provider {
constructor({ id, domain, endpoint, title } = {}) {
Expand Down Expand Up @@ -37,53 +36,20 @@ class S3Provider {
.join('/');
}

async buildSignedUrl({
accessKeyId = getEnv('AWS_ACCESS_KEY_ID'),
secretAccessKey = getEnv('AWS_SECRET_ACCESS_KEY'),
expires = 60 * 60 * 24 * 7,
method = 'GET',
s3Url,
timestamp = Date.now(),
}) {
const algo = 'AWS4-HMAC-SHA256';
const url = new URL(this.buildUrl({ s3Url }));
const time = new Date(timestamp)
.toISOString()
.slice(0, 19)
.replace(/\W/g, '') + 'Z';
const date = time.slice(0, 8);
const signRegion = this.getSignRegion(s3Url);
const scope = `${date}/${signRegion}/s3/aws4_request`;

url.searchParams.set('X-Amz-Algorithm', algo);
url.searchParams.set('X-Amz-Credential', `${accessKeyId}/${scope}`);
url.searchParams.set('X-Amz-Date', time);
url.searchParams.set('X-Amz-Expires', expires.toString(10));
url.searchParams.set('X-Amz-SignedHeaders', 'host');
url.searchParams.sort();

url.search = encodeSpecialUrlChars(url.search);
url.pathname = encodeSpecialUrlChars(url.pathname);

const request = [
method.toUpperCase(),
url.pathname,
url.search.slice(1),
`host:${url.host}`,
'',
'host',
'UNSIGNED-PAYLOAD',
].join('\n');

const signString = [algo, time, scope, await sha256(request)].join('\n');

const signPromise = [date, signRegion, 's3', 'aws4_request', signString]
.reduce(
(promise, data) => promise.then((prev) => hmacSha256(data, prev)),
Promise.resolve('AWS4' + secretAccessKey)
);

return `${url.href}&X-Amz-Signature=${bufferToHex(await signPromise)}`;
async buildSignedUrl({ s3Url, ...options }) {
return buildSignedUrl({
...options,
region: this.getSignRegion(s3Url),
url: this.buildUrl({ s3Url }),
});
}

buildSignedUrlSync({ s3Url, ...options }) {
return buildSignedUrlSync({
...options,
region: this.getSignRegion(s3Url),
url: this.buildUrl({ s3Url }),
});
}

buildUrl({ s3Url }) {
Expand Down Expand Up @@ -207,12 +173,4 @@ class S3Provider {
}
}

function getEnv(name) {
if (typeof process !== 'undefined' && process.env) {
return process.env[name];
}

return undefined;
}

module.exports = S3Provider;
23 changes: 10 additions & 13 deletions src/S3Url.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,23 +134,20 @@ class S3Url {
return this;
}

async sign({
accessKeyId,
expires,
method,
secretAccessKey,
} = {}) {
async sign(params = {}) {
if (!this.provider) {
throw new Error('Cannot sign url from invalid S3Url');
}

return this.provider.buildSignedUrl({
accessKeyId,
expires,
method,
s3Url: this,
secretAccessKey,
});
return this.provider.buildSignedUrl({ ...params, s3Url: this });
}

signSync(params = {}) {
if (!this.provider) {
throw new Error('Cannot sign url from invalid S3Url');
}

return this.provider.buildSignedUrlSync({ ...params, s3Url: this });
}

trimSlashes({ begin = false, end = false } = {}) {
Expand Down
17 changes: 4 additions & 13 deletions src/__specs__/S3Provider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { describe, expect, it } = require('humile');
const AmazonAwsProvider = require('../providers/AmazonAwsProvider');
const S3Provider = require('../S3Provider');
const S3Url = require('../S3Url');
const signFixtures = require('../utils/__specs__/sign.fixtures');

describe('S3Provider', () => {
describe('parseUrl', () => {
Expand Down Expand Up @@ -54,21 +55,11 @@ describe('S3Provider', () => {
const s3Url = new S3Url('https://bucket.s3.amazonaws.com/test/file.zip');
const provider = new AmazonAwsProvider();
const signedUrl = await provider.buildSignedUrl({
accessKeyId: 'test',
secretAccessKey: 'test',
...signFixtures.simpleAwsSign.input,
url: '', // make sure it's not used
s3Url,
timestamp: 0,
});
expect(signedUrl).toBe(
'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip'
+ '?X-Amz-Algorithm=AWS4-HMAC-SHA256'
+ '&X-Amz-Credential=test%2F19700101%2Fus-east-1%2Fs3%2Faws4_request'
+ '&X-Amz-Date=19700101T000000Z'
+ '&X-Amz-Expires=604800'
+ '&X-Amz-SignedHeaders=host'
// eslint-disable-next-line max-len
+ '&X-Amz-Signature=cbefd44bf6ccaec9a70b2eff6bcc17d14039c2d204c5e58545986fcf76cf28be'
);
expect(signedUrl).toBe(signFixtures.simpleAwsSign.output);
});
});
});
45 changes: 31 additions & 14 deletions src/__specs__/S3Url.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

const { describe, expect, it } = require('humile');
const { S3Url } = require('..');
const signFixtures = require('../utils/__specs__/sign.fixtures');

describe('S3Url', () => {
describe(S3Url.name, () => {
describe('isValid', () => {
it('true when provider is detected', () => {
const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/');
Expand All @@ -16,18 +17,6 @@ describe('S3Url', () => {
});
});

describe('clone', () => {
it('changes a key and bucket', () => {
const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/');

expect(
s3Url.clone({ key: 'My file.txt', region: 'eu-west-2' }).href
).toBe(
'https://mybucket.s3.eu-west-2.amazonaws.com/My+file.txt'
);
});
});

describe('fileName', () => {
it('returns empty string if a key is empty', () => {
const s3Url = new S3Url('https://bucket.s3.amazonaws.com/');
Expand Down Expand Up @@ -148,7 +137,35 @@ describe('S3Url', () => {
});
});

describe('trimSlashes', () => {
describe(S3Url.prototype.clone.name, () => {
it('changes a key and bucket', () => {
const s3Url = new S3Url('https://mybucket.s3.amazonaws.com/');

expect(
s3Url.clone({ key: 'My file.txt', region: 'eu-west-2' }).href
).toBe(
'https://mybucket.s3.eu-west-2.amazonaws.com/My+file.txt'
);
});
});

describe(S3Url.prototype.sign.name, () => {
it('signs a url', async () => {
const s3Url = new S3Url(signFixtures.simpleAwsSign.input.url);
const signedUrl = await s3Url.sign(signFixtures.simpleAwsSign.input);
expect(signedUrl).toBe(signFixtures.simpleAwsSign.output);
});
});

describe(S3Url.prototype.signSync.name, () => {
it('signs a url', () => {
const s3Url = new S3Url(signFixtures.simpleAwsSign.input.url);
const signedUrl = s3Url.signSync(signFixtures.simpleAwsSign.input);
expect(signedUrl).toBe(signFixtures.simpleAwsSign.output);
});
});

describe(S3Url.prototype.trimSlashes.name, () => {
it('trims end slash', () => {
const s3Url = new S3Url('https://bucket.s3.amazonaws.com/dir/file.zip/');
expect(s3Url.key).toBe('dir/file.zip/');
Expand Down
20 changes: 12 additions & 8 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
interface SignOptions {
accessKeyId?: string,
expires?: number,
method?: 'GET' | 'PUT',
secretAccessKey?: string,
timestamp?: number,
}

export class S3Url {
bucket: string;
bucketPosition: 'hostname' | 'pathname';
Expand Down Expand Up @@ -36,14 +44,8 @@ export class S3Url {
setProtocol(protocol: string): this;
setProvider(provider: S3Provider | string): this;
setRegion(region: string): this;

sign(opts?: {
accessKeyId?: string,
expires?: number,
method?: string,
secretAccessKey?: string,
}): Promise<string>

sign(options?: SignOptions): Promise<string>;
signSync(options?: SignOptions): string;
trimSlashes(options?: { begin?: boolean; end?: boolean }): this;
}

Expand All @@ -66,6 +68,8 @@ export class S3Provider implements ProviderInterface {
title: string
});

buildSignedUrl(options: SignOptions & { s3Url: S3Url }): Promise<string>;
buildSignedUrlSync(options: SignOptions & { s3Url: S3Url }): string;
buildUrl({ s3Url }: { s3Url: S3Url }): string;
getEndpoint({ region }?: { region: string }): string;
matchHostName(hostName: string): boolean;
Expand Down
22 changes: 22 additions & 0 deletions src/utils/__specs__/sign.fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict';

/* eslint-disable max-len */

module.exports = {
simpleAwsSign: {
input: {
accessKeyId: 'test',
secretAccessKey: 'test',
region: 'us-east-1',
timestamp: 0,
url: 'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip',
},
output: 'https://bucket.s3.us-east-1.amazonaws.com/test/file.zip'
+ '?X-Amz-Algorithm=AWS4-HMAC-SHA256'
+ '&X-Amz-Credential=test%2F19700101%2Fus-east-1%2Fs3%2Faws4_request'
+ '&X-Amz-Date=19700101T000000Z'
+ '&X-Amz-Expires=604800'
+ '&X-Amz-SignedHeaders=host'
+ '&X-Amz-Signature=cbefd44bf6ccaec9a70b2eff6bcc17d14039c2d204c5e58545986fcf76cf28be',
},
};
25 changes: 25 additions & 0 deletions src/utils/__specs__/sign.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use strict';

const { describe, expect, it } = require('humile');
const { buildSignedUrl, buildSignedUrlSync } = require('../sign');
const fixtures = require('./sign.fixtures');

describe('sign', () => {
describe(buildSignedUrl.name, () => {
for (const [name, data] of Object.entries(fixtures)) {
it(`matches ${name} test result`, async () => {
const signedUrl = await buildSignedUrl(data.input);
expect(signedUrl).toBe(data.output);
});
}
});

describe(buildSignedUrlSync.name, () => {
for (const [name, data] of Object.entries(fixtures)) {
it(`matches ${name} test result`, async () => {
const signedUrl = buildSignedUrlSync(data.input);
expect(signedUrl).toBe(data.output);
});
}
});
});
18 changes: 17 additions & 1 deletion src/utils/crypto.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

/* eslint-env browser */

module.exports = { bufferToHex, hmacSha256, sha256 };
const sha = require('./sha256');

module.exports = {
bufferToHex,
hmacSha256,
hmacSha256Sync,
sha256,
sha256Sync,
};

const encoder = new TextEncoder();

Expand All @@ -17,12 +25,20 @@ async function hmacSha256(message, secret) {
return window.crypto.subtle.sign('HMAC', cryptoKey, toBuffer(message));
}

function hmacSha256Sync(message, secret) {
return sha.hmac_sha256(message, secret);
}

async function sha256(message) {
return bufferToHex(
await window.crypto.subtle.digest('SHA-256', toBuffer(message))
);
}

function sha256Sync(message) {
return sha.sha256(message);
}

function bufferToHex(buffer) {
const hexArr = Array.prototype.map.call(
new Uint8Array(buffer),
Expand Down
Loading

0 comments on commit c48c52f

Please sign in to comment.