Skip to content

Commit

Permalink
feat(s3-request-presigner): add getSignedUrl() from client and comman…
Browse files Browse the repository at this point in the history
…ds (#1454)

* feat(s3-request-presigner): add getSignedUrl() from client and commands
  • Loading branch information
AllanZhengYP authored Aug 21, 2020
1 parent f95cce3 commit 88baaad
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 47 deletions.
36 changes: 35 additions & 1 deletion packages/s3-request-presigner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,41 @@
[![NPM version](https://img.shields.io/npm/v/@aws-sdk/s3-request-presigner/beta.svg)](https://www.npmjs.com/package/@aws-sdk/s3-request-presigner)
[![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/s3-request-presigner/beta.svg)](https://www.npmjs.com/package/@aws-sdk/s3-request-presigner)

This package provides a presigner based on signature V4 that will attempt to generate signed url for S3.
This package provides a presigner based on signature V4 that will attempt to
generate signed url for S3.

### Get Presigned URL with Client and Command

You can generated presigned url from S3 client and command. Here's the example:

JavaScript Example:

```javascript
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const client = new S3Client(clientParams);
const command = new GetObjectCommand(getObjectParams);
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
```

ES6 Example

```javascript
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const client = new S3Client(clientParams);
const command = new GetObjectCommand(getObjectParams);
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
```

You can get signed URL for other S3 operations too, like `PutObjectCommand`.
`expiresIn` config from the examples above is optional. If not set, it's default
at `900`.

If you already have a request, you can pre-sign the request following the
section bellow.

### Get Presigned URL from an Existing Request

JavaScript Example:

Expand Down
4 changes: 3 additions & 1 deletion packages/s3-request-presigner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@
},
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/protocol-http": "1.0.0-gamma.5",
"@aws-sdk/signature-v4": "1.0.0-gamma.5",
"@aws-sdk/smithy-client": "1.0.0-gamma.5",
"@aws-sdk/types": "1.0.0-gamma.4",
"@aws-sdk/util-create-request": "1.0.0-gamma.5",
"@aws-sdk/util-format-url": "1.0.0-gamma.5",
"tslib": "^1.8.0"
},
"devDependencies": {
"@aws-sdk/hash-node": "1.0.0-gamma.5",
"@aws-sdk/protocol-http": "1.0.0-gamma.5",
"@aws-sdk/client-s3": "1.0.0-gamma.6",
"@types/jest": "^26.0.4",
"@types/node": "^12.0.2",
"jest": "^26.1.0",
Expand Down
100 changes: 100 additions & 0 deletions packages/s3-request-presigner/src/getSignedUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
const mockV4Sign = jest.fn();
const mockV4Presign = jest.fn();
const mockV4 = jest.fn().mockReturnValue({
presign: mockV4Presign,
sign: mockV4Sign,
});
jest.mock("@aws-sdk/signature-v4", () => ({
SignatureV4: mockV4,
}));

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";

const mockPresign = jest.fn();
const mockPresigner = jest.fn().mockReturnValue({
presign: mockPresign,
});
jest.mock("./presigner", () => ({
S3RequestPresigner: mockPresigner,
}));
jest.mock("@aws-sdk/util-format-url", () => ({
formatUrl: (url: any) => url,
}));

import { RequestPresigningArguments } from "@aws-sdk/types/src";

import { getSignedUrl } from "./getSignedUrl";

describe("getSignedUrl", () => {
beforeEach(() => {
mockPresign.mockReset();
});

it("should call S3Presigner.sign", async () => {
const mockPresigned = "a presigned url";
mockPresign.mockReturnValue(mockPresigned);
const client = new S3Client({});
const command = new GetObjectCommand({
Bucket: "Bucket",
Key: "Key",
});
const signed = await getSignedUrl(client, command);
expect(signed).toBe(mockPresigned);
expect(mockPresign).toBeCalled();
expect(mockV4Presign).not.toBeCalled();
expect(mockV4Sign).not.toBeCalled();
// do not add extra middleware to the client or command
expect(client.middlewareStack.remove("presignInterceptMiddleware")).toBe(false);
expect(command.middlewareStack.remove("presignInterceptMiddleware")).toBe(false);
});

it("should presign with signing region and service in context if exists", async () => {
const mockPresigned = "a presigned url";
mockPresign.mockReturnValue(mockPresigned);
const signingRegion = "aws-foo-1";
const signingService = "bar";
const client = new S3Client({});
client.middlewareStack.addRelativeTo(
(next: any, context: any) => (args: any) => {
context["signing_region"] = signingRegion;
context["signing_service"] = signingService;
return next(args);
},
{
relation: "before",
toMiddleware: "presignInterceptMiddleware",
}
);
const command = new GetObjectCommand({
Bucket: "Bucket",
Key: "Key",
});
await getSignedUrl(client, command);
expect(mockPresign).toBeCalled();
expect(mockPresign.mock.calls[0][1]).toMatchObject({
signingRegion,
signingService,
});
});

it("should presign with parameters from presign options if set", async () => {
const mockPresigned = "a presigned url";
mockPresign.mockReturnValue(mockPresigned);
const options: RequestPresigningArguments = {
signingRegion: "aws-foo-1",
signingService: "bar",
expiresIn: 900,
signingDate: new Date(),
signableHeaders: new Set(["head-1", "head-2"]),
unsignableHeaders: new Set(["head-3", "head-4"]),
};
const client = new S3Client({});
const command = new GetObjectCommand({
Bucket: "Bucket",
Key: "Key",
});
await getSignedUrl(client, command, options);
expect(mockPresign).toBeCalled();
expect(mockPresign.mock.calls[0][1]).toMatchObject(options);
});
});
59 changes: 59 additions & 0 deletions packages/s3-request-presigner/src/getSignedUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { HttpRequest } from "@aws-sdk/protocol-http";
import { Client, Command } from "@aws-sdk/smithy-client";
import { BuildMiddleware, MetadataBearer, RequestPresigningArguments } from "@aws-sdk/types";
import { formatUrl } from "@aws-sdk/util-format-url";

import { S3RequestPresigner } from "./presigner";

export const getSignedUrl = async <
InputTypesUnion extends object,
InputType extends InputTypesUnion,
OutputType extends MetadataBearer = MetadataBearer
>(
client: Client<any, InputTypesUnion, MetadataBearer, any>,
command: Command<InputType, OutputType, any, InputTypesUnion, MetadataBearer>,
options: RequestPresigningArguments = {}
): Promise<string> => {
const s3Presigner = new S3RequestPresigner({ ...client.config });
const presignInterceptMiddleware: BuildMiddleware<InputTypesUnion, MetadataBearer> = (next, context) => async (
args
) => {
const { request } = args;
if (!HttpRequest.isInstance(request)) {
throw new Error("Request to be presigned is not an valid HTTP request.");
}
// Retry information headers are not meaningful in presigned URLs
delete request.headers["amz-sdk-invocation-id"];
delete request.headers["amz-sdk-request"];

const presigned = await s3Presigner.presign(request, {
...options,
signingRegion: options.signingRegion ?? context["signing_region"],
signingService: options.signingService ?? context["signing_service"],
});
return {
// Intercept the middleware stack by returning fake response
response: {},
output: {
$metadata: { httpStatusCode: 200 },
presigned,
},
} as any;
};
client.middlewareStack.addRelativeTo(presignInterceptMiddleware, {
name: "presignInterceptMiddleware",
relation: "before",
toMiddleware: "awsAuthMiddleware",
});

let presigned: HttpRequest;
try {
const output = await client.send(command);
//@ts-ignore the output is faked, so it's not actually OutputType
presigned = output.presigned;
} finally {
client.middlewareStack.remove("presignInterceptMiddleware");
}

return formatUrl(presigned);
};
46 changes: 2 additions & 44 deletions packages/s3-request-presigner/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,2 @@
import { SignatureV4, SignatureV4CryptoInit, SignatureV4Init } from "@aws-sdk/signature-v4";
import { RequestPresigner, RequestPresigningArguments } from "@aws-sdk/types";
import { HttpRequest as IHttpRequest } from "@aws-sdk/types";

import { SHA256_HEADER, UNSIGNED_PAYLOAD } from "./constants";

/**
* PartialBy<T, K> makes properties specified in K optional in interface T
* see: https://stackoverflow.com/questions/43159887/make-a-single-property-optional-in-typescript
* */
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

export type S3RequestPresignerOptions = PartialBy<
SignatureV4Init & SignatureV4CryptoInit,
"service" | "uriEscapePath"
> & { signingName?: string };

export class S3RequestPresigner implements RequestPresigner {
private readonly signer: SignatureV4;
constructor(options: S3RequestPresignerOptions) {
const resolvedOptions = {
// Allow `signingName` because we want to support usecase of supply client's resolved config
// directly. Where service equals signingName.
service: options.signingName || options.service || "s3",
uriEscapePath: options.uriEscapePath || false,
...options,
};
this.signer = new SignatureV4(resolvedOptions);
}

public async presign(
requestToSign: IHttpRequest,
{ unsignableHeaders = new Set(), ...options }: RequestPresigningArguments = {}
): Promise<IHttpRequest> {
unsignableHeaders.add("content-type");
requestToSign.headers[SHA256_HEADER] = UNSIGNED_PAYLOAD;
return this.signer.presign(requestToSign, {
expiresIn: 900,
unsignableHeaders,
...options,
});
}
}
export * from "./presigner";
export * from "./getSignedUrl";
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
SIGNED_HEADERS_QUERY_PARAM,
UNSIGNED_PAYLOAD,
} from "./constants";
import { S3RequestPresigner, S3RequestPresignerOptions } from "./index";
import { S3RequestPresigner, S3RequestPresignerOptions } from "./presigner";

describe("s3 presigner", () => {
const s3ResolvedConfig: S3RequestPresignerOptions = {
Expand Down
44 changes: 44 additions & 0 deletions packages/s3-request-presigner/src/presigner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { SignatureV4, SignatureV4CryptoInit, SignatureV4Init } from "@aws-sdk/signature-v4";
import { RequestPresigner, RequestPresigningArguments } from "@aws-sdk/types";
import { HttpRequest as IHttpRequest } from "@aws-sdk/types";

import { SHA256_HEADER, UNSIGNED_PAYLOAD } from "./constants";

/**
* PartialBy<T, K> makes properties specified in K optional in interface T
* see: https://stackoverflow.com/questions/43159887/make-a-single-property-optional-in-typescript
* */
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

export type S3RequestPresignerOptions = PartialBy<
SignatureV4Init & SignatureV4CryptoInit,
"service" | "uriEscapePath"
> & { signingName?: string };

export class S3RequestPresigner implements RequestPresigner {
private readonly signer: SignatureV4;
constructor(options: S3RequestPresignerOptions) {
const resolvedOptions = {
// Allow `signingName` because we want to support usecase of supply client's resolved config
// directly. Where service equals signingName.
service: options.signingName || options.service || "s3",
uriEscapePath: options.uriEscapePath || false,
...options,
};
this.signer = new SignatureV4(resolvedOptions);
}

public async presign(
requestToSign: IHttpRequest,
{ unsignableHeaders = new Set(), ...options }: RequestPresigningArguments = {}
): Promise<IHttpRequest> {
unsignableHeaders.add("content-type");
requestToSign.headers[SHA256_HEADER] = UNSIGNED_PAYLOAD;
return this.signer.presign(requestToSign, {
expiresIn: 900,
unsignableHeaders,
...options,
});
}
}

0 comments on commit 88baaad

Please sign in to comment.