I'm making this question as detailed as possible so that anyone else who runs into the same issues will have a comprehensive resource for figuring this out and getting it working.
The Goal
The goal is to use signed cookies so that an authenticated user in my application can access any of their files freely, without having to sign URLs.
The S3 and CloudFront Config
I'm pretty sure most of this is correct, but just for the sake of providing a complete picture, I'll include the setup I have.
S3 Config
I have a bucket we'll call my-storage
. It has the following CORS configuration:
<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>HEAD</AllowedMethod>
<AllowedMethod>GET</AllowedMethod>
<MaxAgeSeconds>3000</MaxAgeSeconds>
<AllowedHeader>*</AllowedHeader>
</CORSRule>
</CORSConfiguration>
The bucket policy is:
{
"Version": "2008-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity xxx"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-storage/*"
},
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-storage/*",
"Condition": {
"StringLike": {
"aws:Referer": "http://localhost:8080/*"
}
}
}
]
}
The localhost exception is just so I can build/test my app locally, since HTTP cookies wouldn't work properly due to cross-domain issues.
CloudFront Distribution
I have a CloudFront distribution that uses this bucket as the origin. For the sake of this post, we'll say the CNAME is files.mysite.com
. This is the origin configuration:
The behavior config is a bit much to screenshot and post, but the important details are:
- Allowed HTTP Methods: GET, HEAD, OPTIONS
- Cache and origin request settings: Use a cache policy and origin request policy
- Cache Policy: Managed-CachingOptimized
- Origin Request Policy: Managed-CORS-S3Origin
- Restrict Viewer Access: Yes
- Trusted Signers: Self
Positive Test Results in Insomnia (REST client)
I'm using the Insomnia REST client to test this out, to remove the browser from the equation, and it looks like it works ok.
I make a request to my API to return signed cookies, which I can see in the response header:
date: Tue, 25 Aug 2020 15:09:35 GMT
x-amzn-requestid: xxx
access-control-allow-origin: https://web.mysite.com
set-cookie: CloudFront-Policy=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure
set-cookie: CloudFront-Key-Pair-Id=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure
set-cookie: CloudFront-Signature=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure
x-amz-apigw-id: xxx
vary: Origin
x-powered-by: Express
x-amzn-trace-id: Root=xxx;Sampled=1
access-control-allow-credentials: true
x-cache: Miss from cloudfront
via: 1.1 xxx.cloudfront.net (CloudFront)
x-amz-cf-pop: ORD52-C1
x-amz-cf-id: xxx
And Insomnia stores the cookies in the client. Then I make a GET
request for a file https://files.mysite.com/users/xx/xx.mp3
. I get a 200 response and the binary data of the file, no problem. The headers show me the cookies were sent properly:
> GET /users/9dbb70d7-3d17-4215-8966-49815e461dee/audio/d76bb13d-0e1d-45dc-b7e5-9cb8fb6dee1a/workfile.mp3 HTTP/1.1
> Host: files.mysite.com
> User-Agent: insomnia/2020.3.3
> Cookie: CloudFront-Key-Pair-Id=xxx; CloudFront-Signature=xxx; CloudFront-Policy=xxx
> Origin: https://web.mysite.com
> Accept: */*
Great! So in theory, this should work.
Actual Browser Result
Here's what happens in the web app though. I authenticate, and I see the API request go out to get the signed cookies:
GET https://api.mysite.com/private/get-signed-cookie
{
"Response Headers (1.373 KB)": {
"headers": [
{
"name": "access-control-allow-credentials",
"value": "true"
},
{
"name": "access-control-allow-origin",
"value": "https://web.mysite.com"
},
{
"name": "date",
"value": "Tue, 25 Aug 2020 15:16:28 GMT"
},
{
"name": "set-cookie",
"value": "CloudFront-Policy=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure"
},
{
"name": "set-cookie",
"value": "CloudFront-Key-Pair-Id=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure"
},
{
"name": "set-cookie",
"value": "CloudFront-Signature=xxx; Domain=mysite.com; Path=/users/; HttpOnly; Secure"
},
{
"name": "vary",
"value": "Origin"
},
{
"name": "via",
"value": "1.1 xxx.cloudfront.net (CloudFront)"
},
{
"name": "x-amz-apigw-id",
"value": "xxx"
},
{
"name": "x-amz-cf-id",
"value": "xxx"
},
{
"name": "x-amz-cf-pop",
"value": "ORD52-C1"
},
{
"name": "x-amzn-requestid",
"value": "xxx"
},
{
"name": "x-amzn-trace-id",
"value": "xxx"
},
{
"name": "x-cache",
"value": "Miss from cloudfront"
},
{
"name": "X-Firefox-Spdy",
"value": "h2"
},
{
"name": "x-powered-by",
"value": "Express"
}
]
}
}
At this point, it's worth noting that I cannot see the cookies in Firefox Dev Tools! I can only assume they didn't get stored.
And when the browser tries to access something via the CloudFront distribution:
GET https://files.mysite.com/users/9dbb70d7-3d17-4215-8966-49815e461dee/audio/d76bb13d-0e1d-45dc-b7e5-9cb8fb6dee1a/workfile.mp3
I get a 403 Forbidden
response with this body:
<?xml version="1.0" encoding="UTF-8"?><Error><Code>MissingKey</Code><Message>Missing Key-Pair-Id query parameter or cookie value</Message></Error>
And sure enough, the request headers show no sign of Cookie
being sent:
{
"Request Headers (535 B)": {
"headers": [
{
"name": "Accept",
"value": "audio/webm,audio/ogg,audio/wav,audio/*;q=0.9,application/ogg;q=0.7,video/*;q=0.6,*/*;q=0.5"
},
{
"name": "Accept-Encoding",
"value": "gzip, deflate, br"
},
{
"name": "Accept-Language",
"value": "en-US,en;q=0.5"
},
{
"name": "Connection",
"value": "keep-alive"
},
{
"name": "Host",
"value": "files.mysite.com"
},
{
"name": "Origin",
"value": "https://web.mysite.com"
},
{
"name": "Range",
"value": "bytes=0-"
},
{
"name": "Referer",
"value": "https://web.mysite.com/dashboard"
},
{
"name": "User-Agent",
"value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:79.0) Gecko/20100101 Firefox/79.0"
}
]
}
}
What am I missing here? All the URLs talking to each other have the same domain (the API that issues the cookies, the web client, the CloudFront distribution). The Express API has the right CORS config, I'm pretty sure:
router.use(
cors({
origin(origin, callback) {
if (/\.mysite\.com$/.test(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}),
);
I'm totally stumped. Any help on this would be greatly appreciated!