Skip to content

Commit

Permalink
feat(cloudfront-signer): allow url to be optional when using `polic…
Browse files Browse the repository at this point in the history
…y` (#5926)

* feat(cloudfront-signer): `getSignedUrl()`: automatically get url from policy's `Resource`

* docs(cloudfront-sign): align types with behavior

`url` should be optional in InputWithPolicy

* fix(cloudfront-signer): add error catch and solves tsc error

* test(cloudfront-signer): update E2E tests

Co-authored-by: Atir Nayab <[email protected]>

* docs(cloudfront-signer): add examples for policy usage

Co-authored-by: Atir Nayab <[email protected]>

* docs(cloudfront-sign): better types<=>behavior aligning

Co-authored-by: Atir Nayab <[email protected]>

* chore(cloudfront-signer): clean up types, error handling

---------

Co-authored-by: Atir Nayab <[email protected]>
Co-authored-by: George Fu <[email protected]>
  • Loading branch information
3 people authored Mar 22, 2024
1 parent 37d500c commit 92aa194
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 41 deletions.
73 changes: 72 additions & 1 deletion packages/cloudfront-signer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,43 @@ const signedUrl = getSignedUrl({
});
```

### Sign a URL for cookies
### Sign a URL with a Policy

```javascript
import { getSignedUrl } from "@aws-sdk/cloudfront-signer"; // ESM
// const { getSignedUrl } = require("@aws-sdk/cloudfront-signer"); // CJS

const cloudfrontDistributionDomain = "https://d111111abcdef8.cloudfront.net";
const s3ObjectKey = "private-content/private.jpeg";
const url = `${cloudfrontDistributionDomain}/${s3ObjectKey}`;
const privateKey = "CONTENTS-OF-PRIVATE-KEY";
const keyPairId = "PUBLIC-KEY-ID-OF-CLOUDFRONT-KEY-PAIR";
const dateLessThan = "2022-01-01";

const policy = {
Statement: [
{
Resource: url,
Condition: {
DateLessThan: {
"AWS:EpochTime": new Date(dateLessThan).getTime() / 1000, // time in seconds
},
},
},
],
};

const policyString = JSON.stringify(policy);

const cookies = getSignedUrl({
keyPairId,
privateKey,
policy: policyString,
// url is automatically extracted from the policy, however you could still overwrite it if needed
});
```

### Get signed cookies for a resource

```javascript
import { getSignedCookies } from "@aws-sdk/cloudfront-signer"; // ESM
Expand All @@ -47,3 +83,38 @@ const cookies = getSignedCookies({
privateKey,
});
```

### Get signed cookies with a Policy

```javascript
import { getSignedCookies } from "@aws-sdk/cloudfront-signer"; // ESM
// const { getSignedCookies } = require("@aws-sdk/cloudfront-signer"); // CJS

const cloudfrontDistributionDomain = "https://d111111abcdef8.cloudfront.net";
const s3ObjectKey = "private-content/private.jpeg";
const url = `${cloudfrontDistributionDomain}/${s3ObjectKey}`;
const privateKey = "CONTENTS-OF-PRIVATE-KEY";
const keyPairId = "PUBLIC-KEY-ID-OF-CLOUDFRONT-KEY-PAIR";
const dateLessThan = "2022-01-01";

const policy = {
Statement: [
{
Resource: url,
Condition: {
DateLessThan: {
"AWS:EpochTime": new Date(dateLessThan).getTime() / 1000, // time in seconds
},
},
},
],
};

const policyString = JSON.stringify(policy);

const cookies = getSignedCookies({
keyPairId,
privateKey,
policy: policyString,
});
```
19 changes: 14 additions & 5 deletions packages/cloudfront-signer/src/sign.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { parseUrl } from "@smithy/url-parser";
import { createSign, createVerify } from "crypto";
import { mkdtempSync, rmdirSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { resolve } from "path";

import { getSignedCookies, getSignedUrl } from "./index";

Expand Down Expand Up @@ -341,6 +338,19 @@ describe("getSignedUrl", () => {
const signatureQueryParam = denormalizeBase64(signature);
expect(verifySignature(signatureQueryParam, policy)).toBeTruthy();
});
it("should sign a URL automatically extracted from a policy provided by the user", () => {
const policy = JSON.stringify({ Statement: [{ Resource: url }] });
const result = getSignedUrl({
keyPairId,
privateKey,
policy,
passphrase,
});
const signature = createSignature(policy);
expect(result).toBe(`${url}?Policy=${encodeToBase64(policy)}&Key-Pair-Id=${keyPairId}&Signature=${signature}`);
const signatureQueryParam = denormalizeBase64(signature);
expect(verifySignature(signatureQueryParam, policy)).toBeTruthy();
});
});

describe("getSignedCookies", () => {
Expand Down Expand Up @@ -573,10 +583,9 @@ describe("getSignedCookies", () => {
expect(result["CloudFront-Signature"]).toBe(expected["CloudFront-Signature"]);
expect(verifySignature(denormalizeBase64(result["CloudFront-Signature"]), policyStr)).toBeTruthy();
});
it("should sign a URL with a policy provided by the user", () => {
it("should sign cookies with a policy provided by the user without a url", () => {
const policy = '{"foo":"bar"}';
const result = getSignedCookies({
url,
keyPairId,
privateKey,
policy,
Expand Down
144 changes: 109 additions & 35 deletions packages/cloudfront-signer/src/sign.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,68 @@
import { createSign } from "crypto";

/** Input type to getSignedUrl and getSignedCookies. */
/**
* Input type to getSignedUrl and getSignedCookies.
* @public
*/
export type CloudfrontSignInput = CloudfrontSignInputWithParameters | CloudfrontSignInputWithPolicy;

export interface CloudfrontSignInputBase {
/** The URL string to sign. */
url: string;
/**
* @public
*/
export type CloudfrontSignerCredentials = {
/** The ID of the Cloudfront key pair. */
keyPairId: string;
/** The content of the Cloudfront private key. */
privateKey: string | Buffer;
/** The passphrase of RSA-SHA1 key*/
passphrase?: string;
/** The date string for when the signed URL or cookie can no longer be accessed. */
dateLessThan?: string;
/** The IP address string to restrict signed URL access to. */
ipAddress?: string;
/** The date string for when the signed URL or cookie can start to be accessed. */
dateGreaterThan?: string;
}
};

export type CloudfrontSignInputWithParameters = CloudfrontSignInputBase & {
/**
* @public
*/
export type CloudfrontSignInputWithParameters = CloudfrontSignerCredentials & {
/** The URL string to sign. */
url: string;
/** The date string for when the signed URL or cookie can no longer be accessed */
dateLessThan: string;
/** For this type policy should not be provided. */
/** The date string for when the signed URL or cookie can start to be accessed. */
dateGreaterThan?: string;
/** The IP address string to restrict signed URL access to. */
ipAddress?: string;
/**
* [policy] should not be provided when using separate
* dateLessThan, dateGreaterThan, or ipAddress inputs.
*/
policy?: never;
};

export type CloudfrontSignInputWithPolicy = CloudfrontSignInputBase & {
/** The JSON-encoded policy string */
policy: string;
/**
* @public
*/
export type CloudfrontSignInputWithPolicy = CloudfrontSignerCredentials & {
/**
* For this type dateLessThan should not be provided.
* The URL string to sign. Optional when policy is provided.
*
* This will be used as the initial url if calling getSignedUrl
* with a policy.
*
* This will be ignored if calling getSignedCookies with a policy.
*/
url?: string;
/** The JSON-encoded policy string */
policy: string;
/** When using a policy, a separate dateLessThan should not be provided. */
dateLessThan?: never;
/**
* For this type ipAddress should not be provided.
*/
ipAddress?: string;
/**
* For this type dateGreaterThan should not be provided.
*/
/** When using a policy, a separate dateGreaterThan should not be provided. */
dateGreaterThan?: never;
/** When using a policy, a separate ipAddress should not be provided. */
ipAddress?: never;
};

/**
* @public
*/
export interface CloudfrontSignedCookiesOutput {
/** ID of the Cloudfront key pair. */
"CloudFront-Key-Pair-Id": string;
Expand All @@ -57,6 +76,7 @@ export interface CloudfrontSignedCookiesOutput {

/**
* Creates a signed URL string using a canned or custom policy.
* @public
* @returns the input URL with signature attached as query parameters.
*/
export function getSignedUrl({
Expand All @@ -74,6 +94,11 @@ export function getSignedUrl({
privateKey,
passphrase,
});

if (!url && !policy) {
throw new Error("@aws-sdk/cloudfront-signer: Please provide 'url' or 'policy'.");
}

if (policy) {
cloudfrontSignBuilder.setCustomPolicy(policy);
} else {
Expand All @@ -85,10 +110,23 @@ export function getSignedUrl({
});
}

const newURL = new URL(url);
let baseUrl: string | undefined;
if (url) {
baseUrl = url;
} else if (policy) {
const resources = getPolicyResources(policy!);
if (!resources[0]) {
throw new Error(
"@aws-sdk/cloudfront-signer: No URL provided and unable to determine URL from first policy statement resource."
);
}
baseUrl = resources[0].replace("*://", "https://");
}

const newURL = new URL(baseUrl!);
newURL.search = Array.from(newURL.searchParams.entries())
.concat(Object.entries(cloudfrontSignBuilder.createCloudfrontAttribute()))
.filter(([key, value]) => value !== undefined)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");

Expand All @@ -97,6 +135,7 @@ export function getSignedUrl({

/**
* Creates signed cookies using a canned or custom policy.
* @public
* @returns an object with keys/values that can be added to cookies.
*/
export function getSignedCookies({
Expand Down Expand Up @@ -138,6 +177,9 @@ export function getSignedCookies({
return cookies;
}

/**
* @internal
*/
interface Policy {
Statement: Array<{
Resource: string;
Expand All @@ -155,22 +197,45 @@ interface Policy {
}>;
}

/**
* @internal
*/
interface PolicyDates {
dateLessThan: number;
dateGreaterThan?: number;
}

/**
* @internal
*/
interface BuildPolicyInput extends PolicyDates, Pick<CloudfrontSignInput, "ipAddress"> {
resource: string;
}

/**
* @internal
*/
interface CloudfrontAttributes {
Expires?: number;
Policy?: string;
"Key-Pair-Id": string;
Signature: string;
}

/**
* Utility to get the allowed resources of a policy.
* @internal
*
* @param policy - The JSON/JSON-encoded policy
*/
function getPolicyResources(policy: string | Policy) {
const parsedPolicy: Policy = typeof policy === "string" ? JSON.parse(policy) : policy;
return (parsedPolicy?.Statement ?? []).map((s) => s.Resource);
}

/**
* @internal
*/
function getResource(url: URL): string {
switch (url.protocol) {
case "http:":
Expand All @@ -183,22 +248,18 @@ function getResource(url: URL): string {
}
}

/**
* @internal
*/
class CloudfrontSignBuilder {
private keyPairId: string;
private privateKey: string | Buffer;
private passphrase?: string;
private policy: string;
private customPolicy = false;
private dateLessThan?: number | undefined;
constructor({
privateKey,
keyPairId,
passphrase,
}: {
keyPairId: string;
privateKey: string | Buffer;
passphrase?: string;
}) {

constructor({ privateKey, keyPairId, passphrase }: CloudfrontSignerCredentials) {
this.keyPairId = keyPairId;
this.privateKey = privateKey;
this.policy = "";
Expand Down Expand Up @@ -371,3 +432,16 @@ class CloudfrontSignBuilder {
};
}
}

/**
* @deprecated use CloudfrontSignInput, CloudfrontSignInputWithParameters, or CloudfrontSignInputWithPolicy.
*/
export type CloudfrontSignInputBase = {
url: string;
keyPairId: string;
privateKey: string | Buffer;
passphrase?: string;
dateLessThan?: string;
ipAddress?: string;
dateGreaterThan?: string;
};

0 comments on commit 92aa194

Please sign in to comment.