diff --git a/packages/s3-request-presigner/README.md b/packages/s3-request-presigner/README.md index 2c2eaf1ac3b3a..14a53648df603 100644 --- a/packages/s3-request-presigner/README.md +++ b/packages/s3-request-presigner/README.md @@ -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: diff --git a/packages/s3-request-presigner/package.json b/packages/s3-request-presigner/package.json index 71d023cd35ebb..7afe61a0af003 100644 --- a/packages/s3-request-presigner/package.json +++ b/packages/s3-request-presigner/package.json @@ -18,7 +18,9 @@ }, "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", @@ -26,7 +28,7 @@ }, "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", diff --git a/packages/s3-request-presigner/src/getSignedUrl.spec.ts b/packages/s3-request-presigner/src/getSignedUrl.spec.ts new file mode 100644 index 0000000000000..5e7da0f4ef638 --- /dev/null +++ b/packages/s3-request-presigner/src/getSignedUrl.spec.ts @@ -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); + }); +}); diff --git a/packages/s3-request-presigner/src/getSignedUrl.ts b/packages/s3-request-presigner/src/getSignedUrl.ts new file mode 100644 index 0000000000000..3d3c22305df04 --- /dev/null +++ b/packages/s3-request-presigner/src/getSignedUrl.ts @@ -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, + command: Command, + options: RequestPresigningArguments = {} +): Promise => { + const s3Presigner = new S3RequestPresigner({ ...client.config }); + const presignInterceptMiddleware: BuildMiddleware = (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); +}; diff --git a/packages/s3-request-presigner/src/index.ts b/packages/s3-request-presigner/src/index.ts index 437ededa5e116..433d1cd63f6d4 100644 --- a/packages/s3-request-presigner/src/index.ts +++ b/packages/s3-request-presigner/src/index.ts @@ -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 makes properties specified in K optional in interface T - * see: https://stackoverflow.com/questions/43159887/make-a-single-property-optional-in-typescript - * */ -type Omit = Pick>; -type PartialBy = Omit & Partial>; - -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 { - 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"; diff --git a/packages/s3-request-presigner/src/index.spec.ts b/packages/s3-request-presigner/src/presigner.spec.ts similarity index 99% rename from packages/s3-request-presigner/src/index.spec.ts rename to packages/s3-request-presigner/src/presigner.spec.ts index 6cf843ba9fb02..b9f0896198b17 100644 --- a/packages/s3-request-presigner/src/index.spec.ts +++ b/packages/s3-request-presigner/src/presigner.spec.ts @@ -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 = { diff --git a/packages/s3-request-presigner/src/presigner.ts b/packages/s3-request-presigner/src/presigner.ts new file mode 100644 index 0000000000000..437ededa5e116 --- /dev/null +++ b/packages/s3-request-presigner/src/presigner.ts @@ -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 makes properties specified in K optional in interface T + * see: https://stackoverflow.com/questions/43159887/make-a-single-property-optional-in-typescript + * */ +type Omit = Pick>; +type PartialBy = Omit & Partial>; + +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 { + unsignableHeaders.add("content-type"); + requestToSign.headers[SHA256_HEADER] = UNSIGNED_PAYLOAD; + return this.signer.presign(requestToSign, { + expiresIn: 900, + unsignableHeaders, + ...options, + }); + } +}