-
Notifications
You must be signed in to change notification settings - Fork 594
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(s3-request-presigner): add getSignedUrl() from client and comman…
…ds (#1454) * feat(s3-request-presigner): add getSignedUrl() from client and commands
- Loading branch information
1 parent
f95cce3
commit 88baaad
Showing
7 changed files
with
244 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} | ||
} |