Entitlement
Mobile | Web | TV Platforms | ||||
---|---|---|---|---|---|---|
The Diva player offers support for entitlement check and content protection. This makes easier to integrate the player with commerce systems, user management systems or user account validators. The purpose of the entitlement check integration is to verify whether a certain video content can be played or not, and display a proper message to the user (or a call to action) when it cannot. This happens by passing the user token credential (i.e., an authentication or authorization cookie) to the underlying entitlement system, along with a set of video indicators and IDs that make possible to implement promotions, business checks, or per-content authorizations.
Entitlement Provider​
Calls to the Entitlement BE service is not bundled inside DIVA.
The player needs a Provider that acts as an interface between DIVA and the entitlement service.
The entitlement provider contains request
and getConfiguration
functions.
getConfiguration should return a callback/observable (depending on the platform) so that DIVA can react on hearbeatInterval changes.
Workflow​
- Project provides an instance of EntitlementProvider.
- DIVA receives the instance of entitlementProvider in configuration.
- At each manifest change DIVA calls entitlementProvider request function to perform a tokenization call.
- In case of successfull response, data coming from entitlement will be used for DRM feature.
- In case of error, DIVA will display it with the specific error code.
- If entitlementProvider.getConfiguration() return a heartbeatInterval > 0, DIVA will start calling hearbeat requests through
entitlementProvider.request
function.
Entitlement Provider samples​
- Web & WebTV
- iOS & tvOS
- Android & AndroidTV
- Roku
// Type definition
export interface EntitlementConfiguration {
heartBeatInterval: number;
heartbeatSeekInterval?: number;
}
export type EntitlementProvider = {
getConfiguration: (cb: (cf: EntitlementConfiguration) => void) => void;
request: (request: EntitlementRequest) => Promise<EntitlementSuccessResponse>;
};
export interface EntitlementSuccessResponse {
contentUrl: string;
drmData: {
licenseUrl: string;
token: string;
};
heartBeatInterval: number;
}
export interface EntitlementRequest {
type: EntitlementRequestType;
videoMetadata: VideoMetadataClean;
videoSource: VideoSourceClean;
}
export type EntitlementRequestType = 'processUrl' | 'heartBeat';
export declare const EntitlementRequestType: {
processUrl: EntitlementRequestType;
heartBeat: EntitlementRequestType;
};
// Implementation example
const parameters = {
processingUrlCallPath: "https://<entitlement_service_url>",
secretTxt: "testpassword",
Other: "{SharedSecret: '<123ABC123ABC>', SignatureCheck: 'true', Window: 20, TokenName: 'hdnts'}",
heartBeatCallPath: "https://<entitlement_service_url>",
heartBeatInterval: "10000",
heartbeatSeekInterval: "10000";
}
let configuration: EntitlementConfiguration = {
heartBeatInterval: Number.parseInt(parameters.heartBeatInterval) ?? 0,
heartbeatSeekInterval: Number.parseInt(parameters.heartbeatSeekInterval),
};
let configChangeCb: (cf: EntitlementConfiguration) => void;
const getConfiguration = (cb: (cf: EntitlementConfiguration) => void) => {
configChangeCb = cb;
cb(configuration);
};
const request = (request: EntitlementRequest): Promise<EntitlementSuccessResponse> => {
return new Promise((resolve, reject) => {
const url =
request.type === EntitlementRequestType.processUrl
? parameters.processingUrlCallPath
: parameters.heartBeatCallPath;
if (url) {
const data: any = {
Type: request.type === EntitlementRequestType.processUrl ? 1 : 2,
User: '',
VideoId: request.videoMetadata.videoId,
VideoSource: request.videoSource.uri,
VideoKind: request.videoMetadata.assetState === AssetState.Live ? 'live' : 'replay',
AssetState: request.videoMetadata.assetState === AssetState.Live ? '2' : '3',
PlayerType: 'HTML5',
VideoSourceFormat: request.videoSource.format,
VideoSourceName: request.videoSource.format,
DRMType: request.videoSource.drm.type,
AuthType: !!request.videoMetadata.customAttributes.contentKeyData ? 'Token' : 'Open',
ContentKeyData:
request.videoMetadata.customAttributes[
request.videoSource.format.toLowerCase() === 'hls' ? 'contentKeyDataFairPlay' : 'contentKeyData'
],
VideoOfferType: request.videoMetadata.customAttributes.v_offer_type,
...parameters,
};
try {
// Hack for our very specific Entitlement Service
// Force Signature false for WEB
// eslint-disable-next-line no-eval
data.Other = data.Other ? eval(`(${data.Other})`) : undefined;
data.Other.SignatureCheck = 'false';
data.Other = data.Other ? JSON.stringify(data.Other).replace(/"/gi, "'") : undefined;
} catch (e) { }
fetch(url, {
method: 'POST',
body: JSON.stringify(data),
})
.then(async (response) => {
const resp = await response.json();
if (resp.ResponseCode === 0 && `${resp.Response}`.toLowerCase() === 'ok') {
resolve({
contentUrl: resp.ContentUrl,
drmData: {
licenseUrl: resp.LicenseURL,
token: resp.AuthToken,
},
heartBeatInterval: resp.HeartBeatTime * 1000,
});
} else {
// Here integrator can react on entitlement error
reject(new Error(`${resp.ResponseCode}`.padStart(2, '0')));
}
})
.catch((error) => {
// Here integrator can react on entitlement call error
console.log(error);
reject(new Error('22'));
});
} else {
// If entitlement URL missing or empty, pass through
resolve({
contentUrl: request.videoSource.uri,
drmData: {
licenseUrl: request.videoSource.drm.licenseUrl,
token: request.videoSource.drm.token,
},
heartBeatInterval: configuration.heartBeatInterval,
});
}
});
};
/**
* If the project needs to update the heartbeat interval at runtime
* @param config
*/
export const setEntitlementConfiguration = (config: EntitlementConfiguration): void => {
configuration = config;
configChangeCb?.(config);
};
export const entitlementProvider: EntitlementProvider = {
getConfiguration,
request,
};
import Combine
import DivaCore
import Foundation
final class TestappEntitlementProvider: EntitlementProvider {
private var entitlementCallURL: URL?
private var heartbeatCallURL: URL?
private var secret: String
private var other: String
private let configurationSubject = CurrentValueSubject<Entitlement.Configuration, Never>(.init(heartbeat: .disabled))
private var currentConfiguration: Entitlement.Configuration { configurationSubject.value }
var configuration: AnyPublisher<Entitlement.Configuration, Never> { configurationSubject.eraseToAnyPublisher() }
init(parameters: [String: String]) {
entitlementCallURL = parameters["processingUrlCallPath"].flatMap(URL.init)
heartbeatCallURL = parameters["heartBeatCallPath"].flatMap(URL.init)
secret = parameters["secretTxt"] ?? ""
other = parameters["Other"] ?? ""
let initialHeartbeatInterval = parameters["heartBeatInterval"].flatMap(Double.init) ?? 0
configurationSubject.send(
.init(
heartbeat: initialHeartbeatInterval > 0
? .enabled(milliseconds: initialHeartbeatInterval)
: .disabled
)
)
}
func request(
_ type: Entitlement.RequestType,
videoMetadata: SWVideoMetadataCleanModel,
videoSource: SWVideoSourceCleanModel
) async throws -> Entitlement.EntitlementData? {
let challenge = UUID()
guard let request = try urlRequest(
type,
videoMetadata: videoMetadata,
videoSource: videoSource,
challenge: challenge
) else {
return nil
}
let rawData: Data
let httpResponse: URLResponse
do {
(rawData, httpResponse) = try await URLSession.shared.data(for: request)
} catch {
throw Entitlement.EntitlementError.network.convertToNSError()
}
guard let httpResponse = httpResponse as? HTTPURLResponse, httpResponse.statusCode < 400 else {
throw Entitlement.EntitlementError.network.convertToNSError()
}
guard let json = try? JSONSerialization.jsonObject(with: rawData) as? [String: Any] else {
throw Entitlement.EntitlementError.parsing.convertToNSError()
}
return try Entitlement.EntitlementData(json: json, challenge: challenge, secret: secret)
}
}
private extension TestappEntitlementProvider {
func urlRequest(
_ type: Entitlement.RequestType,
videoMetadata: SWVideoMetadataCleanModel,
videoSource: SWVideoSourceCleanModel,
challenge: UUID
) throws -> URLRequest? {
guard let url = url(for: type) else { return nil }
var body: [String: Any] = [
"Type": type.requestValue,
"User": "",
"VideoId": videoMetadata.videoId,
"VideoSource": videoSource.uri,
"VideoKind": videoMetadata.assetState.rawValue,
"AssetState": videoMetadata.assetState.requestValue,
"Challenge": challenge.uuidString,
"PlayerType": "iOS",
"VideoSourceFormat": videoSource.format,
"VideoSourceName": videoSource.name,
"DRMType": videoSource.drm.type.rawValue,
"AuthType": videoMetadata.authType,
"ContentKeyData": videoMetadata.customAttributes["contentKeyDataFairPlay"] ?? "",
"Other": other,
]
let signatureClean = generateRequestSignature(body: body)
let signatureKey = secret + challenge.uuidString
let hash = Hash.HMACString(input: signatureClean.utf8Data, key: signatureKey.utf8Data, encode: .HEX_UP)
body["Signature"] = hash
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30.0)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let rawData: Data
do {
rawData = try JSONSerialization.data(withJSONObject: body)
} catch {
let entitlementError = Entitlement.EntitlementError.parsing
throw entitlementError.convertToNSError()
}
request.httpBody = rawData
return request
}
func generateRequestSignature(body: [String: Any]) -> String {
let keys = ["Type", "User", "VideoId", "VideoSource", "VideoKind", "AssetState", "PlayerType", "VideoSourceFormat", "VideoSourceName", "DRMType", "AuthType", "ContentKeyData", "Other", "Challenge"]
return keys.reduce("") { partialResult, nextKey in
guard let value = body[nextKey] else { return partialResult }
return partialResult + "\(value)"
}
}
func url(for type: Entitlement.RequestType) -> URL? {
switch type {
case .heartbeat:
return heartbeatCallURL
case .entitlement:
return entitlementCallURL
}
}
}
private extension SWVideoMetadataCleanModel {
var authType: String {
guard
let contentKeyData = customAttributes["contentKeyData"],
!contentKeyData.isEmpty
else { return "Open" }
return "Token"
}
}
private extension Entitlement.RequestType {
var requestValue: Int {
switch self {
case .entitlement: return 1
case .heartbeat: return 2
}
}
}
private extension SWAssetStateModel {
var requestValue: Int {
switch self {
case .live: return 2
case .vod: return 3
}
}
}
private extension Entitlement.EntitlementData {
init(json: [String: Any], challenge: UUID, secret: String) throws {
guard let response = json["Response"] as? String else {
throw Entitlement.EntitlementError.parsing.convertToNSError()
}
switch response.lowercased() {
case "ok":
guard
let contentUrl = json["ContentUrl"] as? String,
let token = json["AuthToken"] as? String,
let licenseUrl = json["LicenseURL"] as? String
else {
throw Entitlement.EntitlementError.parsing.convertToNSError()
}
let heartbeatInterval = (json["HeartBeatTime"] as? Double).flatMap { $0 * 1000 }
let signatureClean = generateResponseSignature(body: json)
let key = challenge.uuidString + secret
let hash = Hash.HMACString(input: signatureClean.utf8Data, key: key.utf8Data, encode: .HEX_UP)
guard json["Signature"] as? String == hash else {
throw Entitlement.EntitlementError.signature.convertToNSError()
}
self.init(
contentUrl: contentUrl,
drm: .init(licenseUrl: licenseUrl, token: token, headers: []),
heartbeatInterval: heartbeatInterval
)
case "ko":
guard let errorCode = json["ResponseCode"] as? Int else {
throw Entitlement.EntitlementError.parsing.convertToNSError()
}
throw Entitlement.EntitlementError.invalid(code: errorCode).convertToNSError()
default:
throw Entitlement.EntitlementError.parsing.convertToNSError()
}
func generateResponseSignature(body: [String: Any]) -> String {
let keys = ["Response", "ResponseCode", "Message", "Action", "ContentUrl", "HeartBeatTime", "ActionParameters", "AuthToken", "LicenseURL"]
return keys.reduce("") { partialResult, nextKey in
guard let value = body[nextKey] else { return partialResult }
return partialResult + "\(value)"
}
}
}
}
private extension Entitlement.EntitlementError {
static let signature: Self = .invalid(code: 21)
static let network: Self = .invalid(code: 22)
static let parsing: Self = .invalid(code: 42)
}
// TODO
Launch Diva Player with subscribed entitlement related handlers and set entitlement data to Diva Player
sub launchDivaPlayer()
m.divaPlayer = getDivaPlayer()
...
observeDivaEntitlement("onEntitlementHandler")
observeDivaEntitlementConfiguration("onEntitlementConfigurationHandler")
...
runDivaPlayer()
end sub
...
// Entitlement request handler
sub onEntitlementHandler(evt as dynamic)
data = evt.getData()
if (data <> invalid)
m.entitlementRequestObject = data
m._entitlementTask = createObject("roSGNode", "EntitlementTask")
m._entitlementTask.request = {
requestType: data.requestType
metadata: data.metadata
videoSource: data.videoSource
parameters: m.divaLaunchParams.parameters
}
m._entitlementTask.observeField("response", "onEntitlementTaskResponseHandler")
m._entitlementTask.control = "RUN"
else
setDivaEntitlement({
error: true
})
end if
end sub
sub onEntitlementTaskResponseHandler(evt as dynamic)
data = evt.getData()
setDivaEntitlement(data)
end sub
sub onEntitlementConfigurationHandler(evt as dynamic)
hbInterval = 0
if (m.divaLaunchParams <> invalid and m.divaLaunchParams.parameters <> invalid and m.divaLaunchParams.parameters.heartBeatInterval <> invalid and m.divaLaunchParams.parameters.heartBeatInterval.toInt() > 0)
hbInterval = m.divaLaunchParams.parameters.heartBeatInterval.toInt()
end if
setDivaEntitlementConfiguration({
heartBeatInterval: hbInterval
})
end sub
New methods in Diva utils file for sending entitlement data to Diva Player
sub setDivaEntitlement(data as dynamic)
divaPlayerUtilsNode = getDivaVideoUtilsNode()
divaPlayerUtilsNode.callFunc("setDDDEntitlement", data)
end sub
sub setDivaEntitlementConfiguration(data as dynamic)
divaPlayerUtilsNode = getDivaVideoUtilsNode()
divaPlayerUtilsNode.callFunc("setDDDEntitlementConfiguration", data)
end sub
Entitlement Task is created for Entitlement service asynchronous communication SceneGraph xml file
<?rokuml version="1.0" encoding="utf-8"?>
<component name="EntitlementTask" extends="Task">
<script type="text/brightscript" uri="EntitlementTask.brs"/>
<interface>
<field id="request" type="assocarray"/>
<field id="response" type="assocarray"/>
</interface>
<children>
</children>
</component>
BrightScript controller file
sub init()
m.top.functionName = "runTask"
m.port = CreateObject("roMessagePort")
end sub
sub runTask()
start()
while true
msg = wait(0, m.port)
msgType = type(msg)
if msgType = "roUrlEvent"
responseCode = msg.GetResponseCode()
responseString = msg.GetString()
if responseCode = 200
...
responseJSON = parseJSON(responseString)
if (responseJSON <> invalid and responseJSON.ResponseCode = 0 and LCase(responseJSON.Response.toStr()) = "ok")
m.top.response = {
error: false
data: {
contentUrl: responseJSON.ContentUrl
drmData: {
licenseUrl: responseJSON.LicenseURL
token: responseJSON.AuthToken
headers: [{
key: "Authorization",
value: responseJSON.AuthToken
}]
}
heartBeatInterval: responseJSON.HeartBeatTime * 1000
}
requestData: m.top.request
}
else
code = ""
if (responseJSON <> invalid)
code = responseJSON.ResponseCode.toStr()
end if
m.top.response = {
error: true
data: {
code: code
}
requestData: m.top.request
}
end if
else
m.top.response = {
error: true
data: responseString
requestData: m.top.request
}
end if
end if
end while
end sub
sub start()
m.urlTransfer = CreateObject("roUrlTransfer")
requestType = m.top.request.requestType
metadata = m.top.request.metadata
videoSource = m.top.request.videoSource
parameters = m.top.request.parameters
location = ""
if (parameters <> invalid)
if (requestType = "processUrl")
location = parameters.processingUrlCallPath
else if (requestType = "heartBeat")
location = parameters.heartBeatCallPath
end if
end if
if (location <> invalid and location.Len() > 0)
...
' setup post data
post = {}
post["User"] = ""
post["VideoId"] = metadata.videoId
post["VideoSource"] = videoSource.uri
post["PlayerType"] = "Roku"
post["VideoSourceFormat"] = videoSource.format
post["VideoSourceName"] = videoSource.format
...
postJSON = FormatJson(post)
m.urlTransfer.asyncPostFromString(postJSON)
else
response = {
contentUrl: m.top.request.videoSource.uri,
}
if (m.top.request.videoSource.drm <> invalid)
response["drmData"] = {
licenseUrl: m.top.request.videoSource.drm.licenseUrl,
token: m.top.request.videoSource.drm.token,
}
end if
if (parameters <> invalid and parameters.heartBeatInterval <> invalid and parameters.heartBeatInterval > 0)
response["heartBeatInterval"] = parameters.heartBeatInterval
else
response["heartBeatInterval"] = 0
end if
m.top.response = {
error: false
data: response
requestData: m.top.request
}
end if
end sub