From 7197390bf7c63666763819e95ab08c33382272bb Mon Sep 17 00:00:00 2001 From: Alex Barstow Date: Wed, 20 Mar 2019 14:02:23 -0400 Subject: [PATCH] feat: Add support for defining custom headers for default license and certificate requests. (#76) --- README.md | 463 +++++++++++++++++++++++++---------------- src/eme.js | 23 +- src/fairplay.js | 32 ++- src/ms-prefixed.js | 12 +- src/playready.js | 14 +- src/utils.js | 14 ++ test/eme.test.js | 125 +++++++++++ test/fairplay.test.js | 108 +++++++++- test/playready.test.js | 78 ++++++- test/utils.test.js | 22 +- 10 files changed, 678 insertions(+), 213 deletions(-) diff --git a/README.md b/README.md index 9a931d8..3c84db7 100644 --- a/README.md +++ b/README.md @@ -18,61 +18,123 @@ Maintenance Status: Stable - [Using](#using) - [Initialization](#initialization) - [FairPlay](#fairplay) + - [Get Certificate/License by URL](#get-certificatelicense-by-url) + - [Get Certificate/Content ID/License by Functions](#get-certificatecontent-idlicense-by-functions) - [PlayReady for IE11 (Windows 8.1+)](#playready-for-ie11-windows-81) + - [Get License by Default](#get-license-by-default) + - [Get Key by URL](#get-key-by-url) + - [Get Key by Function](#get-key-by-function) - [Other DRM Systems](#other-drm-systems) - - [Source Options](#source-options) - - [Plugin Options](#plugin-options) - - [emeOptions](#emeoptions) - - [initializeMediaKeys](#initializemediakeys) - - [Passing methods seems complicated](#passing-methods-seems-complicated) - - [Special Events](#special-events) -- [Getting Started](#getting-started) - - [Running Tests](#running-tests) - - [Tag and Release](#tag-and-release) + - [Get License By URL](#get-license-by-url) + - [Get License By Function](#get-license-by-function) +- [API](#api) + - [Available Options](#available-options) + - [`keySystems`](#keysystems) + - [`emeHeaders`](#emeheaders) + - [Setting Options per Source](#setting-options-per-source) + - [Setting Options for All Sources](#setting-options-for-all-sources) + - [Header Hierarchy and Removal](#header-hierarchy-and-removal) + - [`emeOptions`](#emeoptions) + - [`initializeMediaKeys()`](#initializemediakeys) + - [Events](#events) + - [`licenserequestattempted`](#licenserequestattempted) + - [`keystatuschange`](#keystatuschange) - [License](#license) ## Using -By default, videojs-contrib-eme is not able to decrypt any audio/video. In order to -decrypt audio/video, a user must pass in either relevant license URIs, or methods specific -to a source and its combination of key system and codec. These are provided to the plugin -via either videojs-contrib-eme's plugin options, or source options. +By default, videojs-contrib-eme is not able to decrypt any audio/video. + +In order to decrypt audio/video, a user must pass in either relevant license URIs, or methods specific to a source and its combination of key system and codec. These are provided to the plugin via either videojs-contrib-eme's plugin options or source options. ### Initialization + The videojs-contrib-eme plugin must be initialized before a source is loaded into the player: -```javascript +```js player.eme(); player.src({ src: '', type: 'application/dash+xml', keySystems: { - 'com.widevine.alpha': '' + 'com.widevine.alpha': '' } }); ``` ### FairPlay -For FairPlay, only `keySystems` is used from the options passed into videojs-contrib-eme, -or provided as part of the source object. +For FairPlay, only `keySystems` is used from the options passed into videojs-contrib-eme or provided as part of the source object. + +There are two ways to configure FairPlay. + +#### Get Certificate/License by URL + +In this simpler implementation, you can provide URLs and allow videojs-contrib-eme to make the requests internally via default mechanisms. + +When using this method, there are two required properties of the `keySystems` object: -The required methods to provide are: -* `getCertificate` -* `getContentId` -* `getLicense` -or, if you are using the default FairPlay methods, the only required parameters are: * `certificateUri` * `licenseUri` -Below is an example of videojs-contrib-eme options when only using FairPlay: +And there are two _optional_ properties: + +* `certificateHeaders` +* `licenseHeaders` + +With this configuration, videojs-contrib-eme will behave in the following ways: + +* It will fetch the certificate by making a GET request to your `certificateUri` with an expected response type of `arraybuffer`. Headers can be defined for this request via the `certificateHeaders` object. +* The content ID will be interpreted from the `initData`. +* It will fetch the license by making a POST request to your `licenseUri` with an expected response type of `arraybuffer`. This will have one default header of `Content-type: application/octet-stream`, but this can be overridden (or other headers added) using `licenseHeaders`. + + +Below are examples of FairPlay configurations of this type: + +```js +{ + keySystems: { + 'com.apple.fps.1_0': { + certificateUri: '', + licenseUri: '' + } + } +} +``` + +or ```javascript { keySystems: { - "com.apple.fps.1_0": { + 'com.apple.fps.1_0': { + certificateUri: '', + certificateHeaders: { + 'Some-Header': 'value' + }, + licenseUri: '', + licenseHeaders: { + 'Some-Header': 'value' + } + } + } +} +``` + +#### Get Certificate/Content ID/License by Functions + +You can control the license and certificate request processes by providing the following methods instead of the properties discussed above: + +* `getCertificate()` - Allows asynchronous retrieval of a certificate. +* `getContentId()` - Allows synchronous retrieval of a content ID. +* `getLicense()` - Allows asynchronous retrieval of a license. + +```js +{ + keySystems: { + 'com.apple.fps.1_0': { getCertificate: function(emeOptions, callback) { // request certificate // if err, callback(err) @@ -91,68 +153,53 @@ Below is an example of videojs-contrib-eme options when only using FairPlay: } ``` -Below is an example of videojs-contrib-eme options when only using FairPlay, and using -the default FairPlay methods: +### PlayReady for IE11 (Windows 8.1+) -```javascript -{ - keySystems: { - "com.apple.fps.1_0": { - certificateUri: "", - licenseUri: "" - } - } -} -``` +PlayReady for IE11 (Windows 8.1+) only requires `keySystems` from the options passed into videojs-contrib-eme or provided as part of the source object. -The default methods are defined as follows: -* getCertificate - GET certificateUri with response type of arraybuffer -* getContentId - gets the hostname from the initData URI -* getLicense - POST licenseUri with response type of arraybuffer, header of -'Content-type': 'application/octet-stream', and body of webKitKeyMessage +There are three ways to configure PlayReady for IE11 (Windows 8.1+). -### PlayReady for IE11 (Windows 8.1+) +#### Get License by Default -PlayReady for IE11 (Windows 8.1+) only requires `keySystems` from the options passed -into videojs-contrib-eme, or provided as part of the source object. +If the value of `true` is provided, then a POST request will be made to the `destinationURI` passed by the message from the browser, with the headers and body specified in the message. -There are four choices for options that may be passed: +```js +keySystems: { + 'com.microsoft.playready': true +} +``` -1) If the value of `true` is provided, then a POST request will be made to the -`detinationURI` passed by the message from the browser, with the headers and body -specified in the message. +#### Get Key by URL -Example: -```javascript - keySystems: { - "com.microsoft.playready": true - } +If a URL is provided - either within an object or as a string - then a POST request will be made to the provided URL, with the headers and body specified in the message. Additionally, a `licenseHeaders` object may be provided, if additional headers are required: + +```js +keySystems: { + 'com.microsoft.playready': '' +} ``` -2/3) If a url is provided, either within an object or as a string, then a POST request -will be made to the provided url, with the headers and body specified in the message. +or -Example: -```javascript +```js keySystems: { - "com.microsoft.playready": "" - } - // or - keySystems: { - "com.microsoft.playready": { - "url": "" + 'com.microsoft.playready': { + url: '', + licenseHeaders: { + 'Some-Header': 'value' + } } } ``` -4) If a `getKey` function is provided, then the function will be run with the message -buffer and destinationURI passed by the browser, and will expect a callback with the key. +#### Get Key by Function -Example: -```javascript +If a `getKey` function is provided, then the function will be run with the message buffer and `destinationURI` passed by the browser, and will expect a callback with the key: + +```js { keySystems: { - "com.microsoft.playready": { + 'com.microsoft.playready': { getKey: function(emeOptions, destinationURI, buffer, callback) { // request key // if err, callback(err) @@ -165,23 +212,30 @@ Example: ### Other DRM Systems -For DRM systems that use the W3C EME specification as of 5 July 2016, only `keySystems` -and a way of obtaining the license are required. +For DRM systems that use the W3C EME specification as of 5 July 2016, only `keySystems` and a way of obtaining the license are required. -To obtain a license requires one of a couple different options: -1) You may use a string as the license url, or a url as an entry in the options: -```javascript +Obtaining a license can be done in two ways. + +#### Get License By URL + +For simple use-cases, you may use a string as the license URL or a URL as a property of in the `keySystems` entry: + +```js { keySystems: { - 'org.w3.clearkey': '', + 'org.w3.clearkey': '', 'com.widevine.alpha': { - url: '' + url: '' } } } ``` -2) You may pass a `getLicense` function: -```javascript + +#### Get License By Function + +For more complex integrations, you may pass a `getLicense` function to fully control the license retrieval process: + +```js { keySystems: { 'org.w3.clearkey': { @@ -195,22 +249,16 @@ To obtain a license requires one of a couple different options: } ``` -Although the license acquisition related config is the only required configuration, -`getCertificate` is also supported if your source needs to retrieve a certificate. +Although the license acquisition is the only required configuration, `getCertificate()` is also supported if your source needs to retrieve a certificate, similar to the [FairPlay](#fairplay) implementation above. -The `audioContentType` and `videoContentType` properties for non-FairPlay sources are -used to determine if the system supports that codec, and to create an appropriate -`keySystemAccess` object. If left out, it is possible that the system will create a -`keySystemAccess` object for the given key system, but will not be able to play the -source due to the browser's inability to use that codec. +The `audioContentType` and `videoContentType` properties for non-FairPlay sources are used to determine if the system supports that codec and to create an appropriate `keySystemAccess` object. If left out, it is possible that the system will create a `keySystemAccess` object for the given key system, but will not be able to play the source due to the browser's inability to use that codec. -Below is an example of videojs-contrib-eme options when only using one of these DRM -systems, and custom `getLicense` and `getCertificate` functions: +Below is an example of using one of these DRM systems and custom `getLicense()` and `getCertificate()` functions: -```javascript +```js { keySystems: { - "org.w3.clearkey": { + 'org.w3.clearkey': { audioContentType: 'audio/webm; codecs="vorbis"', videoContentType: 'video/webm; codecs="vp9"', getCertificate: function(emeOptions, callback) { @@ -228,21 +276,43 @@ systems, and custom `getLicense` and `getCertificate` functions: } ``` -### Source Options +## API -Since each source may have a different set of properties and methods, it is best to use -source options instead of plugin options when specifying key systems. To do that, simply -pass the same options as you would as part of the plugin options, but instead pass them -as part of the source object when specifying `player.src(sourceObject)`. +### Available Options -For example: +#### `keySystems` -```javascript +This is the main option through which videojs-contrib-eme can be configured. It maps key systems by name (e.g. `'org.w3.clearkey'`) to an object for configuring that key system. + +#### `emeHeaders` + +This object can be a convenient way to specify default headers for _all_ requests that are made by videojs-contrib-eme. These headers will override any headers that are set by videojs-contrib-eme internally, but can be further overridden by headers specified in `keySystems` objects (e.g., `certificateHeaders` or `licenseHeaders`). + +An `emeHeaders` object should look like this: + +```js +emeHeaders: { + 'Common-Header': 'value' +} +``` + +### Setting Options per Source + +This is the recommended way of setting most options. Each source may have a different set of requirements; so, it is best to define options on a per source basis. + +To do this, attach the options to the source object you pass to `player.src()`: + +```js player.src({ - // normal src and type options + + // normal Video.js src and type options src: '', type: 'video/webm', + // eme options + emeHeaders: { + 'Common-Header': 'value' + }, keySystems: { 'org.w3.clearkey': { audioContentType: 'audio/webm; codecs="vorbis"', @@ -262,81 +332,138 @@ player.src({ }); ``` -### Plugin Options +### Setting Options for All Sources -Plugin options may be provided in one of two ways. Either they are provided in the -standard plugins configuration when setting up video.js itself, or they may be set by -assigning to the options property on the eme object itself: +While [setting options per source](#setting-options-per-source) is recommended, some implementations may want to use plugin-level options. + +These can be set during plugin invocation: + +```js +player.eme({ + + // Set Common-Header on ALL requests for ALL key systems. + emeHeaders: { + 'Common-Header': 'value' + } +}); +``` + +Plugin-level options may also be set after plugin initialization by assigning to the options property on the `eme` object itself: + +```js +player.eme(); + +player.eme.options.emeHeaders = { + 'Common-Header': 'value' +}; +``` + +or + +```js +player.eme(); -```javascript player.eme.options = { - // options you want to pass + emeHeaders: { + 'Common-Header': 'value' + } }; ``` -### emeOptions +### Header Hierarchy and Removal -`emeOptions` are provided for all methods. This is a reference to the source options for -the current source merged with (overwritten by) the latest plugin options. It is available -to make it easier to access options so that you don't have to maintain them yourself. +Headers defined in the `emeHeaders` option or in `licenseHeaders`/`certificateHeaders` objects within `keySystems` can _remove_ headers defined at lower levels without defining a new value. This can be done by setting their value to `null`. -For example. If you need to use a userId for the getCertificate request, you can pass in -plugin options that have: +The hierarchy of header definitions is: -```javascript -{ +``` +licenseHeaders/certificateHeaders > emeHeaders > internal defaults +``` + +In most cases, the header `{'Content-type': 'application/octet-stream'}` is a default and cannot be overridden without writing your own `getLicense()` function. This internal default can be overridden by either of the user-provided options. + +Here's an example: + +```js +player.eme({ + emeHeaders: { + + // Remove the internal default Content-Type + 'Content-Type': null, + 'Custom-Foo': '' + } +}); + +player.src({ + src: '', + type: '', keySystems: { - "org.w3.clearkey": { - getCertificate: function(emeOptions, callback) { - var userId = emeOptions.userId; // 'user-id' - // ... + 'com.apple.fps.1_0': { + certificateUri: '', + certificateHeaders: { + 'Custom-Foo': '' }, - getLicense: function(emeOptions, keyMessage, callback) { - var userId = emeOptions.userId; // 'user-id' - // ... + licenseUri: '', + licenseHeaders: { + 'License-Bar': '' } } - }, - userId: 'user-id' -} + } +}) ``` -Or, if you need a source-specific userId, you can overwrite it via the source options: +This will result in the following headers for the certificate request: -```javascript -// plugin options -{ +``` +Custom-Foo: +``` + +And for the license request: + +``` +Custom-Foo: +License-Bar: +``` + +### `emeOptions` + +All methods in a key system receive `emeOptions` as their first argument. + +The `emeOptions` are an object which merges source-level options with plugin-level options. + +> **NOTE:** In these cases, plugin-level options will **override** the source-level options. This is used by libraries like [VHS](https://github.com/videojs/http-streaming), but could be unintuitive. This is another reason to prefer source-level options in all cases! + +It is available to make it easier to access options in custom key systems methods, so that you don't have to maintain your own references. + +For example, if you needed to use a `userId` for the `getCertificate()` request, you could: + +```js +player.eme(); + +player.src({ keySystems: { - "org.w3.clearkey": { + 'org.w3.clearkey': { getCertificate: function(emeOptions, callback) { - var userId = emeOptions.userId; // 'source-specific-user-id' + var userId = emeOptions.userId; // 'user-id' // ... }, getLicense: function(emeOptions, keyMessage, callback) { - var userId = emeOptions.userId; // 'source-specific-user-id' + var userId = emeOptions.userId; // 'user-id' // ... } } }, userId: 'user-id' -} - -// source options -player.src({ - src: '', - type: 'video/webm', - userId: 'source-specific-user-id' }); ``` -### initializeMediaKeys -Type: `function` +### `initializeMediaKeys()` -`player.eme.initializeMediaKeys()` sets up MediaKeys immediately on demand. This is useful -for setting up the video element for DRM before loading any content. Otherwise the video -element is set up for DRM on `encrypted` events. This is not supported in Safari. +`player.eme.initializeMediaKeys()` sets up MediaKeys immediately on demand. -```javascript +This is useful for setting up the video element for DRM before loading any content. Otherwise, the video element is set up for DRM on `encrypted` events. This is not supported in Safari. + +```js // additional plugin options var emeOptions = { keySystems: { @@ -352,43 +479,34 @@ var emeCallback = function(error) { // do something else }; +var suppressErrorsIfPossible = true; + player.eme.initializeMediaKeys(emeOptions, emeCallback, suppressErrorsIfPossible); ``` -When `suppressErrorsIfPossible` is set to `false` (the default) and an error -occurs, the error handler will be invoked after the callback finishes, and -`error` called on the player. When set to `true` and an error occurs, the -error handler will not be invoked, with the exception of `mskeyerror` errors -in IE11 since they cannot be suppressed asynchronously. - -### Passing methods seems complicated +When `suppressErrorsIfPossible` is set to `false` (the default) and an error occurs, the error handler will be invoked after the callback finishes and `error()` will be called on the player. When set to `true` and an error occurs, the error handler will not be invoked with the exception of `mskeyerror` errors in IE11 since they cannot be suppressed asynchronously. -While simple URLs are supported for many EME implementations, we wanted to provide as much -flexibility as possible. This means that if your server has a different structure, you use -a different format for FairPlay content IDs, or you want to test something in the browser -without making a request, we can support that, since you can control the methods. +### Events -### Special Events +There are some events that are specific to this plugin. -There are some events that are specific to this plugin. Once such event is `licenserequestattempted`. -This event is triggered on the tech on the callback of every license request. +#### `licenserequestattempted` -In order to listen to this event: +This event is triggered on the Video.js playback tech on the callback of every license request made by videojs-contrib-eme. ``` -player.tech().on('licenserequestattempted', function(event) { +player.tech(true).on('licenserequestattempted', function(event) { // Act on event }); ``` -Additionally, when the status of a key changes, an event of type `keystatuschange` will -be triggered on the `tech_`. This helps you handle feedback to the user for situations -like trying to play DRM-protected media on restricted devices. +#### `keystatuschange` -Just like the above, you can listen to the event like so: +When the status of a key changes, an event of type `keystatuschange` will +be triggered on the Video.js playback tech. This helps you handle feedback to the user for situations like trying to play DRM-protected media on restricted devices. ``` -player.tech().on('keystatuschange', function(event) { +player.tech(true).on('keystatuschange', function(event) { // Event data: // keyId // status: usable, output-restricted, etc @@ -396,28 +514,7 @@ player.tech().on('keystatuschange', function(event) { }); ``` -It is triggered directly from the underlying `keystatuseschange` event, so the statuses -should correspond to [those listed in the spec](https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus). - -## Getting Started - -1. Clone this repository! -1. Install dependencies: `npm install` -1. Run a development server: `npm start` - -That's it! Refer to the [video.js plugin standards](https://github.com/videojs/generator-videojs-plugin/docs/standards.md) for more detail. - -### Running Tests - -- In all available and supported browsers: `npm test` -- In a specific browser: `npm run test:chrome`, `npm run test:firefox`, etc. -- While development server is running, navigate to [`http://localhost:9999/test/`](http://localhost:9999/test/) (_note:_ port may vary, check console output) - -### Tag and Release - -1. Make sure everything is committed. -1. `npm version *` where `*` is `major`, `minor`, `patch`, etc. [Read more about versioning.](https://github.com/videojs/generator-videojs-plugin/docs/standards.md#versioning) -1. `npm publish` +This event is triggered directly from the underlying `keystatuseschange` event, so the statuses should correspond to [those listed in the spec](https://www.w3.org/TR/encrypted-media/#dom-mediakeystatus). ## License diff --git a/src/eme.js b/src/eme.js index b3127be..cd22f1f 100644 --- a/src/eme.js +++ b/src/eme.js @@ -1,6 +1,7 @@ import videojs from 'video.js'; import { requestPlayreadyLicense } from './playready'; import window from 'global/window'; +import {mergeAndRemoveNull} from './utils'; export const getSupportedKeySystem = (keySystems) => { // As this happens after the src is set on the video, we rely only on the set src (we @@ -171,8 +172,8 @@ const setMediaKeys = ({ return Promise.all(promises); }; -const defaultPlayreadyGetLicense = (url) => (emeOptions, keyMessage, callback) => { - requestPlayreadyLicense(url, keyMessage, (err, response, responseBody) => { +const defaultPlayreadyGetLicense = (keySystemOptions) => (emeOptions, keyMessage, callback) => { + requestPlayreadyLicense(keySystemOptions, keyMessage, emeOptions, (err, response, responseBody) => { if (err) { callback(err); return; @@ -182,15 +183,19 @@ const defaultPlayreadyGetLicense = (url) => (emeOptions, keyMessage, callback) = }); }; -const defaultGetLicense = (url) => (emeOptions, keyMessage, callback) => { +const defaultGetLicense = (keySystemOptions) => (emeOptions, keyMessage, callback) => { + const headers = mergeAndRemoveNull( + {'Content-type': 'application/octet-stream'}, + emeOptions.emeHeaders, + keySystemOptions.licenseHeaders + ); + videojs.xhr({ - uri: url, + uri: keySystemOptions.url, method: 'POST', responseType: 'arraybuffer', body: keyMessage, - headers: { - 'Content-type': 'application/octet-stream' - } + headers }, (err, response, responseBody) => { if (err) { callback(err); @@ -230,8 +235,8 @@ const standardizeKeySystemOptions = (keySystem, keySystemOptions) => { if (keySystemOptions.url && !keySystemOptions.getLicense) { keySystemOptions.getLicense = keySystem === 'com.microsoft.playready' ? - defaultPlayreadyGetLicense(keySystemOptions.url) : - defaultGetLicense(keySystemOptions.url); + defaultPlayreadyGetLicense(keySystemOptions) : + defaultGetLicense(keySystemOptions); } return keySystemOptions; diff --git a/src/fairplay.js b/src/fairplay.js index 8fac244..42a7ef3 100644 --- a/src/fairplay.js +++ b/src/fairplay.js @@ -6,7 +6,7 @@ */ import videojs from 'video.js'; import window from 'global/window'; -import {stringToUint16Array, uint8ArrayToString, getHostnameFromUri} from './utils'; +import {stringToUint16Array, uint8ArrayToString, getHostnameFromUri, mergeAndRemoveNull} from './utils'; export const FAIRPLAY_KEY_SYSTEM = 'com.apple.fps.1_0'; @@ -99,11 +99,17 @@ const addKey = ({video, contentId, initData, cert, options, getLicense, eventBus }); }; -const defaultGetCertificate = (certificateUri) => { +export const defaultGetCertificate = (fairplayOptions) => { return (emeOptions, callback) => { + const headers = mergeAndRemoveNull( + emeOptions.emeHeaders, + fairplayOptions.certificateHeaders + ); + videojs.xhr({ - uri: certificateUri, - responseType: 'arraybuffer' + uri: fairplayOptions.certificateUri, + responseType: 'arraybuffer', + headers }, (err, response, responseBody) => { if (err) { callback(err); @@ -119,16 +125,20 @@ const defaultGetContentId = (emeOptions, initData) => { return getHostnameFromUri(uint8ArrayToString(initData)); }; -const defaultGetLicense = (licenseUri) => { +export const defaultGetLicense = (fairplayOptions) => { return (emeOptions, contentId, keyMessage, callback) => { + const headers = mergeAndRemoveNull( + {'Content-type': 'application/octet-stream'}, + emeOptions.emeHeaders, + fairplayOptions.licenseHeaders + ); + videojs.xhr({ - uri: licenseUri, + uri: fairplayOptions.licenseUri, method: 'POST', responseType: 'arraybuffer', body: keyMessage, - headers: { - 'Content-type': 'application/octet-stream' - } + headers }, (err, response, responseBody) => { if (err) { callback(err); @@ -143,10 +153,10 @@ const defaultGetLicense = (licenseUri) => { const fairplay = ({video, initData, options, eventBus}) => { const fairplayOptions = options.keySystems[FAIRPLAY_KEY_SYSTEM]; const getCertificate = fairplayOptions.getCertificate || - defaultGetCertificate(fairplayOptions.certificateUri); + defaultGetCertificate(fairplayOptions); const getContentId = fairplayOptions.getContentId || defaultGetContentId; const getLicense = fairplayOptions.getLicense || - defaultGetLicense(fairplayOptions.licenseUri); + defaultGetLicense(fairplayOptions); return new Promise((resolve, reject) => { getCertificate(options, (err, cert) => { diff --git a/src/ms-prefixed.js b/src/ms-prefixed.js index bd736ff..abd2fd9 100644 --- a/src/ms-prefixed.js +++ b/src/ms-prefixed.js @@ -31,18 +31,22 @@ export const addKeyToSession = (options, session, event, eventBus) => { } if (typeof playreadyOptions === 'string') { - playreadyOptions = { url: playreadyOptions }; + playreadyOptions = {url: playreadyOptions}; + } else if (typeof playreadyOptions === 'boolean') { + playreadyOptions = {}; } - const url = playreadyOptions.url || event.destinationURL; + if (!playreadyOptions.url) { + playreadyOptions.url = event.destinationURL; + } - requestPlayreadyLicense(url, event.message.buffer, (err, response) => { + requestPlayreadyLicense(playreadyOptions, event.message.buffer, options, (err, response) => { if (eventBus) { eventBus.trigger('licenserequestattempted'); } if (err) { eventBus.trigger({ - message: 'Unable to request key from url: ' + url, + message: 'Unable to request key from url: ' + playreadyOptions.url, target: session, type: 'mskeyerror' }); diff --git a/src/playready.js b/src/playready.js index c8d83db..c083a5d 100644 --- a/src/playready.js +++ b/src/playready.js @@ -1,5 +1,6 @@ import videojs from 'video.js'; import window from 'global/window'; +import {mergeAndRemoveNull} from './utils'; /** * Parses the EME key message XML to extract HTTP headers and the Challenge element to use @@ -40,11 +41,18 @@ export const getMessageContents = (message) => { }; }; -export const requestPlayreadyLicense = (url, messageBuffer, callback) => { - const { headers, message } = getMessageContents(messageBuffer); +export const requestPlayreadyLicense = (keySystemOptions, messageBuffer, emeOptions, callback) => { + const messageContents = getMessageContents(messageBuffer); + const message = messageContents.message; + + const headers = mergeAndRemoveNull( + messageContents.headers, + emeOptions.emeHeaders, + keySystemOptions.licenseHeaders + ); videojs.xhr({ - uri: url, + uri: keySystemOptions.url, method: 'post', headers, body: message, diff --git a/src/utils.js b/src/utils.js index 1eb37cc..2f97b60 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,5 @@ import document from 'global/document'; +import videojs from 'video.js'; export const stringToUint16Array = (string) => { // 2 bytes for each char @@ -52,3 +53,16 @@ export const arrayBufferFrom = (bufferOrTypedArray) => { return bufferOrTypedArray; }; + +export const mergeAndRemoveNull = (...args) => { + const result = videojs.mergeOptions(...args); + + // Any header whose value is `null` will be removed. + Object.keys(result).forEach(k => { + if (result[k] === null) { + delete result[k]; + } + }); + + return result; +}; diff --git a/test/eme.test.js b/test/eme.test.js index 422042c..c502700 100644 --- a/test/eme.test.js +++ b/test/eme.test.js @@ -763,3 +763,128 @@ QUnit.test('keySession.update promise rejection', function(assert) { }); }); + +QUnit.test('emeHeaders option sets headers on default license xhr request', function(assert) { + const done = assert.async(); + const origXhr = videojs.xhr; + const xhrCalls = []; + const session = new videojs.EventTarget(); + + videojs.xhr = (options) => { + xhrCalls.push(options); + }; + + const keySystemAccess = { + keySystem: 'com.widevine.alpha', + createMediaKeys: () => { + return { + createSession: () => session + }; + } + }; + + standard5July2016({ + keySystemAccess, + video: { + setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) + }, + initDataType: '', + initData: '', + options: { + keySystems: { + 'com.widevine.alpha': 'some-url' + }, + emeHeaders: { + 'Some-Header': 'some-header-value' + } + } + }).catch((e) => {}); + + setTimeout(() => { + session.trigger({ + type: 'message', + message: 'the-message' + }); + + assert.equal(xhrCalls.length, 1, 'made one XHR'); + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'POST', + responseType: 'arraybuffer', + body: 'the-message', + headers: { + 'Content-type': 'application/octet-stream', + 'Some-Header': 'some-header-value' + } + }, 'made request with proper emeHeaders option value'); + + videojs.xhr = origXhr; + + done(); + }); +}); + +QUnit.test('licenseHeaders keySystems property overrides emeHeaders value', function(assert) { + const done = assert.async(); + const origXhr = videojs.xhr; + const xhrCalls = []; + const session = new videojs.EventTarget(); + + videojs.xhr = (options) => { + xhrCalls.push(options); + }; + + const keySystemAccess = { + keySystem: 'com.widevine.alpha', + createMediaKeys: () => { + return { + createSession: () => session + }; + } + }; + + standard5July2016({ + keySystemAccess, + video: { + setMediaKeys: (createdMediaKeys) => Promise.resolve(createdMediaKeys) + }, + initDataType: '', + initData: '', + options: { + keySystems: { + 'com.widevine.alpha': { + url: 'some-url', + licenseHeaders: { + 'Some-Header': 'priority-header-value' + } + } + }, + emeHeaders: { + 'Some-Header': 'lower-priority-header-value' + } + } + }).catch((e) => {}); + + setTimeout(() => { + session.trigger({ + type: 'message', + message: 'the-message' + }); + + assert.equal(xhrCalls.length, 1, 'made one XHR'); + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'POST', + responseType: 'arraybuffer', + body: 'the-message', + headers: { + 'Content-type': 'application/octet-stream', + 'Some-Header': 'priority-header-value' + } + }, 'made request with proper licenseHeaders value'); + + videojs.xhr = origXhr; + + done(); + }); +}); diff --git a/test/fairplay.test.js b/test/fairplay.test.js index 75324ff..9be3efa 100644 --- a/test/fairplay.test.js +++ b/test/fairplay.test.js @@ -1,5 +1,11 @@ import QUnit from 'qunit'; -import {default as fairplay, FAIRPLAY_KEY_SYSTEM} from '../src/fairplay'; +import { + default as fairplay, + FAIRPLAY_KEY_SYSTEM, + defaultGetLicense, + defaultGetCertificate +} from '../src/fairplay'; +import videojs from 'video.js'; import window from 'global/window'; QUnit.module('videojs-contrib-eme fairplay'); @@ -294,3 +300,103 @@ QUnit.test('a webkitkeyerror rejects promise', function(assert) { }); }); + +QUnit.test('emeHeaders sent with license and certificate requests', function(assert) { + const origXhr = videojs.xhr; + const emeOptions = { + emeHeaders: { + 'Some-Header': 'some-header-value' + } + }; + const fairplayOptions = { + licenseUri: 'some-url', + certificateUri: 'some-other-url' + }; + const xhrCalls = []; + + videojs.xhr = (xhrOptions) => { + xhrCalls.push(xhrOptions); + }; + + const getLicense = defaultGetLicense(fairplayOptions); + const getCertificate = defaultGetCertificate(fairplayOptions); + + getLicense(emeOptions); + getCertificate(emeOptions); + + assert.equal(xhrCalls.length, 2, 'made two XHR requests'); + + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'POST', + responseType: 'arraybuffer', + body: undefined, + headers: { + 'Content-type': 'application/octet-stream', + 'Some-Header': 'some-header-value' + } + }, 'made license request with proper emeHeaders value'); + + assert.deepEqual(xhrCalls[1], { + uri: 'some-other-url', + responseType: 'arraybuffer', + headers: { + 'Some-Header': 'some-header-value' + } + }, 'made certificate request with proper emeHeaders value'); + + videojs.xhr = origXhr; +}); + +QUnit.test('licenseHeaders and certificateHeaders properties override emeHeaders value', function(assert) { + const origXhr = videojs.xhr; + const emeOptions = { + emeHeaders: { + 'Some-Header': 'some-header-value' + } + }; + const fairplayOptions = { + licenseUri: 'some-url', + certificateUri: 'some-other-url', + licenseHeaders: { + 'Some-Header': 'higher-priority-license-header' + }, + certificateHeaders: { + 'Some-Header': 'higher-priority-cert-header' + } + }; + const xhrCalls = []; + + videojs.xhr = (xhrOptions) => { + xhrCalls.push(xhrOptions); + }; + + const getLicense = defaultGetLicense(fairplayOptions); + const getCertificate = defaultGetCertificate(fairplayOptions); + + getLicense(emeOptions); + getCertificate(emeOptions); + + assert.equal(xhrCalls.length, 2, 'made two XHR requests'); + + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'POST', + responseType: 'arraybuffer', + body: undefined, + headers: { + 'Content-type': 'application/octet-stream', + 'Some-Header': 'higher-priority-license-header' + } + }, 'made license request with proper licenseHeaders value'); + + assert.deepEqual(xhrCalls[1], { + uri: 'some-other-url', + responseType: 'arraybuffer', + headers: { + 'Some-Header': 'higher-priority-cert-header' + } + }, 'made certificate request with proper certificateHeaders value'); + + videojs.xhr = origXhr; +}); diff --git a/test/playready.test.js b/test/playready.test.js index 4426c13..6793879 100644 --- a/test/playready.test.js +++ b/test/playready.test.js @@ -1,9 +1,13 @@ import QUnit from 'qunit'; -import { getMessageContents } from '../src/playready'; +import { + getMessageContents, + requestPlayreadyLicense +} from '../src/playready'; import { createMessageBuffer, challengeElement } from './playready-message'; +import videojs from 'video.js'; QUnit.module('playready'); @@ -19,3 +23,75 @@ QUnit.test('getMessageContents parses message contents', function(assert) { 'parses headers'); assert.deepEqual(message, challengeElement, 'parses challenge element'); }); + +QUnit.test('emeHeaders sent with license requests', function(assert) { + const origXhr = videojs.xhr; + const emeOptions = { + emeHeaders: { + 'Some-Header': 'some-header-value' + } + }; + const keySystemOptions = { + url: 'some-url', + licenseHeaders: {} + }; + const xhrCalls = []; + + videojs.xhr = (xhrOptions) => { + xhrCalls.push(xhrOptions); + }; + + requestPlayreadyLicense(keySystemOptions, createMessageBuffer(), emeOptions); + + assert.equal(xhrCalls.length, 1, 'made one XHR'); + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'post', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"', + 'Some-Header': 'some-header-value' + }, + body: challengeElement, + responseType: 'arraybuffer' + }, 'license request sent with correct headers'); + + videojs.xhr = origXhr; +}); + +QUnit.test('licenseHeaders property overrides emeHeaders', function(assert) { + const origXhr = videojs.xhr; + const emeOptions = { + emeHeaders: { + 'Some-Header': 'some-header-value' + } + }; + const keySystemOptions = { + url: 'some-url', + licenseHeaders: { + 'Some-Header': 'priority-header-value' + } + }; + const xhrCalls = []; + + videojs.xhr = (xhrOptions) => { + xhrCalls.push(xhrOptions); + }; + + requestPlayreadyLicense(keySystemOptions, createMessageBuffer(), emeOptions); + + assert.equal(xhrCalls.length, 1, 'made one XHR'); + assert.deepEqual(xhrCalls[0], { + uri: 'some-url', + method: 'post', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': '"http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"', + 'Some-Header': 'priority-header-value' + }, + body: challengeElement, + responseType: 'arraybuffer' + }, 'license request sent with correct headers'); + + videojs.xhr = origXhr; +}); diff --git a/test/utils.test.js b/test/utils.test.js index 1c2e843..f6e0297 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -2,7 +2,8 @@ import QUnit from 'qunit'; import { arrayBuffersEqual, - arrayBufferFrom + arrayBufferFrom, + mergeAndRemoveNull } from '../src/utils'; QUnit.module('utils'); @@ -47,3 +48,22 @@ QUnit.test('arrayBufferFrom returns buffer from typed arrays', function(assert) assert.ok(buffer instanceof ArrayBuffer, 'buffer is still an ArrayBuffer'); assert.equal(buffer, uint16Array.buffer, 'buffer is the same buffer'); }); + +QUnit.test('mergeAndRemoveNull removes property if value is null', function(assert) { + const object1 = { + a: 'a', + b: 'b', + c: 'c' + }; + const object2 = { + a: 'A', + b: null + }; + + const resultObj = mergeAndRemoveNull(object1, object2); + + assert.deepEqual(resultObj, { + a: 'A', + c: 'c' + }, 'successfully merged and removed null property'); +});