Skip to content

Commit

Permalink
Create signed cookie for CloudFront (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
EarthlingDavey authored Dec 6, 2024
1 parent f6299fc commit ab13639
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 48 deletions.
14 changes: 11 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ jobs:
- name: Run tests
run: |
cp .env.ci .env
docker compose run --rm -e JWT=${JWT} spider sh -c "npm ci && npm run test"
# Add secrets to .env
echo "JWT=$JWT" >> .env
# These secrets are used for testing, and are not used in production.
echo "AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT=$AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT" >> .env
echo "AWS_CLOUDFRONT_PRIVATE_KEY=\"$AWS_CLOUDFRONT_PRIVATE_KEY\"" >> .env
echo "AWS_CLOUDFRONT_PUBLIC_KEY=\"$AWS_CLOUDFRONT_PUBLIC_KEY\"" >> .env
docker compose run --rm spider sh -c "npm ci && npm run test"
env:
JWT: ${{ secrets.JWT }}
# Use mock AWS CloudFront keys, these do not grant permission to anything.
AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT: ${{ secrets.TEST_AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT }}
AWS_CLOUDFRONT_PRIVATE_KEY: ${{ secrets.TEST_AWS_CLOUDFRONT_PRIVATE_KEY }}
AWS_CLOUDFRONT_PUBLIC_KEY: ${{ secrets.TEST_AWS_CLOUDFRONT_PUBLIC_KEY }}
AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT: ${{ secrets.TEST_AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT }}
4 changes: 2 additions & 2 deletions conf/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ server {
proxy_pass http://localhost:2000/bucket-test;
}

location /set-cf-cookie {
location /access-archive {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://localhost:2000/set-cf-cookie;
proxy_pass http://localhost:2000/access-archive;
}

location / {
Expand Down
8 changes: 8 additions & 0 deletions conf/node/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const s3Credentials = process.env.AWS_ACCESS_KEY_ID &&
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
};

/**
* CloudFront
*/

export const cloudFrontKeysObject = process.env.AWS_CLOUDFRONT_PUBLIC_KEYS_OBJECT;
export const cloudFrontPublicKey = process.env.AWS_CLOUDFRONT_PUBLIC_KEY;
export const cloudFrontPrivateKey = process.env.AWS_CLOUDFRONT_PRIVATE_KEY;

/**
* Options
*/
Expand Down
137 changes: 125 additions & 12 deletions conf/node/controllers/cloudfront.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,124 @@
const { getSignedCookies } = require("@aws-sdk/cloudfront-signer");
import crypto from "node:crypto";
import { getSignedCookies } from "@aws-sdk/cloudfront-signer";

// TODO: Implement the getCookies function
import {
cloudFrontKeysObject as keysObject,
cloudFrontPublicKey as publicKey,
cloudFrontPrivateKey as privateKey,
} from "../constants.js";

const getCookies = () => {
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";
/**
* @typedef {Object} CookieSet
* @property {import('@aws-sdk/cloudfront-signer').CloudfrontSignedCookiesOutput} value
* @property {number} dateLessThan - epoch in milliseconds
*/

/**
* @typedef {Object} Cache
* @property {string|null} keyPairId - will only change on server restart
* @property {[key: string]: CookieSet} cookieSets
*/

/** @type {Cache} */
const cache = {
keyPairId: null,
cookieSets: {},
};

/**
* Infer the CloudFront CDN URL from the app host
*
* @param {string} appHost
* @returns {URL} cdnURL - The CloudFront CDN URL
* @throws {Error} If the host is invalid
*/

export const getCdnUrl = (appHost) => {
// Check appHost starts with `app.`
if (!appHost.startsWith("app.")) {
throw new Error("Invalid host");
}

const cdnHost = appHost.replace(/^app\./, "");

// Use regex to replace the initial app. with an empty string.
return new URL(`https://${cdnHost}`);
};

/**
* Get the CloudFront key pair ID from the public key and keys object
*
* @returns {string} keyPairId - The CloudFront key pair ID
*/

export const getKeyPairId = () => {
// Return the cached value if it exists
if (cache.keyPairId) {
return cache.keyPairId;
}

// Get sha256 hash of the public key, and get the first 8 characters.
const publicKeyShort = crypto
.createHash("sha256")
.update(`${publicKey.trim()}\n`, "utf8")
.digest("hex")
.substring(0, 8);

const keyPairId = JSON.parse(keysObject).find(
(key) => key.comment === publicKeyShort && key.id?.length,
)?.id;

if (!keyPairId) {
throw new Error("Key pair ID not found");
}

cache.keyPairId = keyPairId;

return keyPairId;
};

/**
* Return the epoch value for midnight today, or midnight tomorrow if past midday
*
* @returns {number} dateLessThan - The date in seconds
*/

export const getDateLessThan = () => {
const date = new Date();
const hours = date.getHours();

if (hours >= 12) {
return parseInt(new Date(date.setHours(24, 0, 0, 0)).getTime() / 1000);
}

// This should be midnight tomorrow.
date.setDate(date.getDate() + 1);
return parseInt(new Date(date.setHours(0, 0, 0, 0)).getTime() / 1000);
};

/**
* Get signed CloudFront cookies
*
* @param {Object} props
* @param {string} props.resource
* @param {number} props.dateLessThan
* @returns {import('@aws-sdk/cloudfront-signer').CloudfrontSignedCookiesOutput} cookies - The signed CloudFront cookies
*/

export const getCookies = ({ resource, dateLessThan }) => {
// Check if the cache has a value for the resource
const cachedValue =
cache.cookieSets?.[resource]?.dateLessThan === dateLessThan;

// Return the cached value if it exists
if (cachedValue) {
return cachedValue;
}

const policy = {
Statement: [
{
Resource: url,
Resource: resource,
Condition: {
DateLessThan: {
"AWS:EpochTime": new Date(dateLessThan).getTime() / 1000, // time in seconds
Expand All @@ -25,9 +130,17 @@ const getCookies = () => {

const policyString = JSON.stringify(policy);

return getSignedCookies({
keyPairId,
const signedCookies = getSignedCookies({
keyPairId: getKeyPairId(),
privateKey,
policy: policyString,
});

// Set the cache
cache.cookieSets[resource] = {
dateLessThan,
value: signedCookies,
};

return signedCookies;
};
49 changes: 49 additions & 0 deletions conf/node/controllers/cloudfront.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
getCdnUrl,
getKeyPairId,
getCookies,
getDateLessThan,
} from "./cloudfront.js";

describe("getCdnUrl", () => {
it("should return a cdn URL object", () => {
const result = getCdnUrl("app.archive.example.com");
expect(result.host).toBe("archive.example.com");
expect(result.origin).toBe("https://archive.example.com");
});

it("should throw an error for invalid host", () => {
expect(() => getCdnUrl("archive.example.com")).toThrow("Invalid host");
});
});

describe("getKeyPairId", () => {
it("should return an id", () => {
const id = getKeyPairId();
expect(id).toBe("GENERATED_BY_AWS");
});
});

describe("getDateLessThan", () => {
it("should return a date in the future", () => {
const date = getDateLessThan();
expect(date).toBeGreaterThan(Date.now() / 1000);
});
});

describe("getCookies", () => {
it("should return cookies for CloudFront", () => {
const dateLessThan = getDateLessThan();
const resource = "https://archive.example.com/*";

const result = getCookies({
resource,
dateLessThan,
});

expect(result).toBeDefined();
expect(result["CloudFront-Key-Pair-Id"]).toBeDefined();
expect(result["CloudFront-Policy"]).toBeDefined();
expect(result["CloudFront-Signature"]).toBeDefined();
});
});
2 changes: 1 addition & 1 deletion conf/node/controllers/httrack.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { jwt } from "../constants.js";
/**
* A helper function to get the directory for the snapshot.
*
* @param {props} props
* @param {Object} props
* @param {string} props.host
* @param {string} props.agency
* @returns {Object} object
Expand Down
2 changes: 1 addition & 1 deletion conf/node/controllers/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { sync } from "./s3.js";

/**
*
* @param {props} props
* @param {Object} props
* @param {URL} props.url
* @param {string} props.agency
* @param {?number} props.depth
Expand Down
69 changes: 40 additions & 29 deletions conf/node/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import express from "express";
import { corsOptions, jwt, port } from "./constants.js";
import { parseBody } from "./middleware.js";
import { checkAccess as checkS3Access } from "./controllers/s3.js";
import {
getCdnUrl,
getCookies,
getDateLessThan,
} from "./controllers/cloudfront.js";
import { main } from "./controllers/main.js";

const app = express();
Expand Down Expand Up @@ -62,40 +67,46 @@ app.post("/bucket-test", async function (_req, res, next) {
}
});

app.post("/set-cf-cookie", async function (request, response) {
// Get the current domain from the request
const appHost = request.get("X-Forwarded-Host") || request.get("host");

if (!appHost.startsWith("app.")) {
console.error("Invalid host");
response.status(400).send({ status: 400 });
return;
}

// Use regex to replace the initial app. with an empty string.
// e.g. app.archive.intranet.docker -> archive.intranet.docker
const cdnHost = appHost.replace(/^app\./, "");
app.post("/spider", function (req, res) {
// Start the main function - without awiting for the result.
main(req.mirror);
// Handle the response
res.status(200).sendFile(path.join("/usr/share/nginx/html/working.html"));
});

// TODO Generate CloudFront cookies.
app.get("/access-archive", async function (req, res, next) {
try {
// Get the current domain from the request
const appHost = req.headers["x-forwarded-host"] || req.headers["host"];

// Get the CloudFront CDN URL
const cdnUrl = getCdnUrl(appHost);

// Get the CloudFront cookies
const cookies = getCookies({
resource: `${cdnUrl.origin}/*`,
dateLessThan: getDateLessThan(),
});

// Set the cookie on the response
// response.cookie('jwt', jwt, {
// domain: cdnHost,
// secure: true,
// sameSite: 'None',
// httpOnly: true,
// });
// Set the cookies on the response
Object.entries(cookies).forEach(([name, value]) => {
res.cookie(name, value, {
path: "/",
domain: cdnUrl.host,
secure: true,
sameSite: "Lax",
httpOnly: true,
});
});

response.status(200).send({ appHost, cdnHost });
});
// Send a metadata html tag to redirect to the cdnUrl
const html = `<html><head><meta http-equiv="refresh" content="0; url=${cdnUrl.origin}" /></head></html>`;

app.post("/spider", function (request, response) {
// Start the main function - without awiting for the result.
main(request.mirror);
// Handle the response
response
.status(200)
.sendFile(path.join("/usr/share/nginx/html/working.html"));
res.status(200).send(html);
} catch (err) {
next(err);
}
});

app.listen(port);

0 comments on commit ab13639

Please sign in to comment.