Skip to content
This repository has been archived by the owner on Apr 19, 2023. It is now read-only.

HLS Video with Credentials (signed url) cannot be played on iOS/Safari #323

Open
pedramp20 opened this issue Oct 18, 2021 · 11 comments
Open
Labels
enhancement New feature or request

Comments

@pedramp20
Copy link

pedramp20 commented Oct 18, 2021

Describe the bug
HLS videos streamed from and signed for CloudFront cannot be played on iOS. Safari requires secure cookies for streaming HLS natively, and won’t recognise xhr.beforeRequest overloads, hence it is not possible to play signed urls on iOS using the provided workflow.

To Reproduce
Follow the provided instruction to setup CloudFront signed urls, test it on other platforms and make sure it works then try it on iOS.

Additional context
As explained above, iOS requires secure cookies and the cookies must be signed for the origin and since cloudfront domain is utilised as the origin and token is signed for the cloudfront domain, it is not possible to generate a secure cookie.

A variable needs to be exposed in the cloudformation allowing users to provide a CNAME, then the CNAME should be referenced in the CloudFront distribution Aliases. Consequently, a valid SSL certificate could be signed for the domain and saved in the Certificate Manager if it does not already exist)

@nathanagez @wizage Please correct me if my understanding is wrong?

@nathanagez
Copy link
Collaborator

nathanagez commented Oct 18, 2021

Hi @pedramp20, Thank you for reporting this issue. It looks like it's related to video.js itself #131.

@wizage, is it a big effort to use signed cookies instead of signed urls? From what I understand, signed urls are useful for restricting access to a single file and signed cookies for restricting access to multiple files.

You want to provide access to multiple restricted files, for example, all of the files for a video in HLS format or all of the files in the subscribers' area of website.

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-choosing-signed-urls-cookies.html

@wizage
Copy link
Contributor

wizage commented Oct 18, 2021

So using signed cookies is not currently supported. That being said it isn't that much a lift. The function that is used to create the urls is very similiar to the one you need for cookies.

As far as cookies/urls, the concept is the same. You can easily just add the /* to the end of a request to get a cookie and you now use it for any file that falls under that /. Since we sort videos by id it would be something like / and boom you now have all the hls files authed for that cookie/url.

Now the complicated thing where this gets to the hard part is talking about how cookies work vs urls. Urls are easy they work most everywhere and great for POCing. Cookies are better for production and big sites but take a trade off of you need a domain to work with. Cookies work by using a special header called Set-Cookie that is returned in the response. Set-Cookie needs to be called from the same domain as the request coming from url. I.E. example.com/getCookie and example.com/movie.id/master.hls.

This is why cookies will most likely see a long time before implementation via Amplify Video as it requires working w/ Route53 and other DNS products making the solution not as easy to be a one size fits all.

@wizage wizage added the enhancement New feature or request label Oct 18, 2021
@djsjr
Copy link

djsjr commented Oct 18, 2021

I am developing with Flutter, and I am currently successfully playing protected videos using the flutter video players "headers" parameter instead of signed URLs as explained in my comment here. This seems to work for now. Hopefully it does not cause me issues in the future based on what @wizage is saying.

@wizage
Copy link
Contributor

wizage commented Oct 18, 2021

This does work but is considered a work around for the way cookies work.

Cookies are typically supposed to be set using the Set-Cookie and stick around during the entire duration of the session (or when set to expire). This is just modifying the sent headers with the Cookie.

@pedramp20
Copy link
Author

pedramp20 commented Oct 27, 2021

As discussed, the cdn (CloudFront) and api need to be on the same domain. To achieve that I added the functionality that I suggested to the video plugin, where it allows users to set a custom domain for the CloudFront (#329) @wizage @nathanagez
Then you can utilise APIMapping to map the same domain to your api but the bigger issue is that the frontend server (origin) also needs to be on the same domain and for all of those to be on the same domain, it requires major changes and integration between video plugin and the main cli, otherwise CORS is inevitable. (Please note that CORS is required for subdomains)

To be able to issue a valid cookie on the server side, you need to have a domain that you own with a valid SSL anyway. So to get it working with the current situation, you can create a rest api using cli. Then add these custom resources to set a custom domain to the api.

              "Parameters": {
		"env": {
			"Type": "String"
		},
		"certificateArn": {
			"Type": "String",
			"Default": "CertificateArn"
		},
		"hostedZoneId": {
			"Type": "String",
			"Default": "HostedZoneId"
		},
		"customDomain": {
			"Type": "String",
			"Default": "Custom Domain"
		}
	      },
....

		"ApiGWCustomDomain": {
			"Type": "AWS::ApiGateway::DomainName",
			"Properties": {
				"DomainName": {
					"Fn::Join": [
						"",
						[
							{
								"Ref": "env"
							},
							"-",
							{
								"Ref": "customDomain"
							}
						]
					]
				},
				"CertificateArn": {
					"Ref": "certificateArn"
				},
				"EndpointConfiguration": {
					"Types": [ "EDGE" ]
				},
				"SecurityPolicy": "TLS_1_2"
			}
		},
		"Route53RecordSetGroup": {
			"Type": "AWS::Route53::RecordSet",
			"Properties": {
				"Name": {
					"Fn::Join": [
						"",
						[
							{
								"Ref": "env"
							},
							"-",
							{
								"Ref": "customDomain"
							}
						]
					]
				},
				"Type": "A",
				"HostedZoneId": {
					"Ref": "hostedZoneId"
				},
				"AliasTarget": {
					"DNSName": {
						"Fn::GetAtt": [ "ApiGWCustomDomain", "DistributionDomainName" ]
					},
					"EvaluateTargetHealth": false,
					"HostedZoneId": {
						"Fn::GetAtt": [ "ApiGWCustomDomain", "DistributionHostedZoneId" ]
					}
				}
			}
		},

Then add this mapping to your api CloudFormation template

                    "APIMapping": {
			"Type": "AWS::ApiGateway::BasePathMapping",
			"Properties": {
				"BasePath": "<your path>",
				"DomainName": {
					"Fn::Join": [
						"",
						[
							{
								"Ref": "env"
							},
							"-",
							{
								"Ref": "customDomain"
							}
						]
					]
				},
				"RestApiId": {
					"Ref": "<your api id>"
				},
				"Stage": {
					"Fn::If": [
						"ShouldNotCreateEnvResources",
						"Prod",
						{
							"Ref": "env"
						}
					]
				}
			}
		}

and finally change the RootUrl output of the api cloudformation template. So when you call the api using amplify front-end library, it uses the custom domain not the default api gateway url.

	"Outputs": {
		"RootUrl": {
			"Description": "Root URL of the API gateway",
			"Value": {
				"Fn::Join": [
					"",
					[
						"https://",
            {
              "Ref": "env"
            },
            "-",
            {
              "Ref": "customDomain"
            },
            "/<your path>"
					]
				]
			}
		},

in the lambda function connected to the api, use almost the same code as the default CFTokenGenerator just change sigend url to signed cookies by using the following method

 const signedCookie = signer.getSignedCookie(params);

Please note that to set a cookie with CORS you need to set the withCredentials flag when making the request and the server needs to return the Access-Control-Allow-Credentials: true header. You also need to change the Access-Control-Allow-Origin header to a specific origin and can't use wildcards on a request that uses credentials.

// Enable CORS for all methods
app.use(function (request, res, next) {
  const allowedOrigins = ['https://<your domain>', 'xxxx'];
  const origin = request.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
  }
  res.header('Access-Control-Allow-Headers', '*');
  res.header('Access-Control-Allow-Credentials', 'true');
  res.header('Access-Control-Allow-Methods', 'GET,HEAD,OPTIONS,POST,PUT');
  //intercept the OPTIONS call so we don't double up on calls to the integration
  if ('OPTIONS' === request.method) {
    res.send(200);
  } else {
    next();
  }
});

Last but not least set the cookies in the response header

app.post('/<your path>', async (req, res) => {
  // handle authorisation if the caller can get the cookies

  const cookies = await signPath(req.body);
  Object.keys(cookies).map((cookieName) => {
    res.cookie(cookieName, cookies[cookieName], { httpOnly: true, path: "/", domain: ".<your domain>", sameSite: "None", secure: true });
  })
...
  res.json({ body })
});

Please make sure that you set withCredentials flag when calling the api to store the cookies. Then call cloudfront with the same withCredentials flag and cookies should be attached and hence you should get 200.

@wizage
Copy link
Contributor

wizage commented Oct 28, 2021

This is awesome! I will read through this in more depth after a new feature release coming this month. If I see this all correct this would be a huge addition to our plugin! Thanks a ton and can't wait to review this :)

@pedramp20
Copy link
Author

pedramp20 commented Oct 28, 2021

@wizage you're welcome. Happy to contribute. I believe we can even add the rest api creation to the video plugin, so the full solution is available to everyone via the cli. To do so we need to discuss it further, as I don't want to reimplement the functionalities that are already available in the main cli again and duplicate them here.

P.s. In this branch I have added the api gateway creation to video plugin. The only remaining part is updating amplify meta after resource create/remove. I looked at the CLI code and found the amplify helper but didn't want to duplicate them here. Lets have a quick chat. I'd appreciate your input.

@kylekirkby
Copy link

@wizage @pedramp20 any idea when we can get this added to the core plugin?

@pedramp20
Copy link
Author

@kylekirkby I created the pull request a while back but it seem like the team is busy with AWS reinvent. You can always pull the code and build it locally. Let me know if you need any help. The process of building the plugin locally is in the contribution guide.

@kylekirkby
Copy link

@pedramp20 the process was a bit more involved for us. I'll have to write up some details at some point but we've got signed cookies working now.

@pedramp20
Copy link
Author

@kylekirkby appreciate if you could point out the differences and how you got the withCredentials working using amplify http client?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants