media_cdn/dualtoken.rb (101 lines of code) (raw):
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# [START mediacdn_dualtoken_sign_token]
require "base64"
require "time"
require "openssl"
require "ed25519"
# Encodes the string with Base64 compatible with Media CDN.
# Media CDN uses URL-safe base64 encoding and strips off the padding at the end.
#
# @param value [String] string to encode
# @return [String] encoded string
def base64_encode value
encoded_str = Base64.urlsafe_encode64 value, padding: false
end
# Header names
# Returns a string that aggregates all header names, separated by ",".
#
# @param headers [Array<Hash<String,String>]] headers
#
# @return [String] the aggregated string
def header_names headers
header_names = []
headers.each do |header|
header_names.append header[:name]
end
header_names.join ","
end
# Header pairs
# Returns a string that aggregates all header name and value pairs,
# with each follows name=value format, separated by ','
#
# @param headers [Array<Hash<String,String>]] headers
#
# @return [String] the aggregated string
def header_pairs headers
header_pairs = []
headers.each do |header|
header_pairs.append "#{header[:name]}=#{header[:value]}"
end
header_pairs.join ","
end
# Gets the signed URL suffix string for the Media CDN short token URL requests.
# One of (`url_prefix`, `full_path`, `path_globs`) must be included in each input.
#
# @param base64_key [String] a secret key as a base64 encoded string.
# @param signature_algorithm [Symbol] an algorithm as `:SHA1`, `:SHA256`, or `:Ed25519`.
# @param start_time [Time] the start time as a Time object.
# @param expiration_time [Time] the expiration time as a Time object.
# If a value is not specified, the expiration time will be set to 5 mins from now.
# @param url_prefix [String] the URL prefix to sign, including protocol.
# For example: http://example.com/path/ for URLs
# under /path or http://example.com/path?param=1
# @param full_path [String] a full path to sign, starting with the first '/'.
# For example: /path/to/content.mp4
# @param path_globs [String] a set of ','- or '!'- delimited strings.
# For example: /tv/*!/film/* to sign paths starting with /tv/ or /film/ in any URL.
# @param session_id [String] a unique identifier for the session.
# @param data [String] an arbitrary data payload to include in the token.
# @param headers [Array<Hash<String,String>>] array of header name and value pairs.
# For example:
# [{'name': 'foo', 'value': 'bar'},
# {'name': 'baz', 'value': 'qux'}]
# @param ip_ranges [String] a list of comma-separated IP ranges.
# Both IPv4 and IPv6 ranges are acceptable.
# For example: '203.0.113.0/24,2001:db8:4a7f:a732/64'
#
# @return [String] The Signed URL appended with the query parameters,
# based on the specified URL prefix and configuration.
#
# @raise [ArgumentError] any of the required arguments are missing.
#
# rubocop:disable Metrics/PerceivedComplexity
def sign_token(
base64_key:,
signature_algorithm:,
start_time: nil,
expiration_time: nil,
full_path: nil,
path_globs: nil,
url_prefix: nil,
session_id: nil,
data: nil,
headers: nil,
ip_ranges: nil
)
decoded_key = Base64.urlsafe_decode64 base64_key
algo = signature_algorithm.downcase
# For most fields, the value we put in the token and the value we must sign
# are the same. The FullPath and Headers use a different string for the
# value to be signed compared to the token. To illustrate this difference,
# we'll keep the token and the value to be signed separate.
tokens = []
to_sign = []
# check for `full_path` or `path_globs` or `url_prefix`
if !full_path.nil?
tokens.append "FullPath"
to_sign.append "FullPath=#{full_path}"
elsif !path_globs.nil?
field = "PathGlobs=#{path_globs.strip}"
tokens.append field
to_sign.append field
elsif !url_prefix.nil?
field = "URLPrefix=#{base64_encode url_prefix}"
tokens.append field
to_sign.append field
else
raise ArgumentError, "User input missing: one of `url_prefix`, `full_path`, " +
"or `path_globs` must be specified."
end
# check & parse optional params
# start_time
unless start_time.nil?
field = "Starts=#{start_time.utc.to_i}"
tokens.append field
to_sign.append field
end
# expiration_time
expiration_time ||= Time.now.utc + 300
field = "Expires=#{expiration_time.to_i}"
tokens.append field
to_sign.append field
# session_id
unless session_id.nil?
field = "SessionID=#{session_id}"
tokens.append field
to_sign.append field
end
# data
unless data.nil?
field = "Data=#{data}"
tokens.append field
to_sign.append field
end
# headers
unless headers.nil?
tokens.append "Headers=#{header_names headers}"
to_sign.append "Headers=#{header_pairs headers}"
end
# ip-range
unless ip_ranges.nil?
field = "IPRanges=#{base64_encode ip_ranges}"
tokens.append field
to_sign.append field
end
# generating token
to_sign_bytes = to_sign.join "~".encode "utf-8"
# Ed25519
case algo
when :ed25519
digest = Ed25519::SigningKey.new(decoded_key).sign(to_sign_bytes)
signature = base64_encode digest
tokens.append "Signature=#{signature}"
# SHA256
when :sha256
digest = OpenSSL::HMAC.hexdigest "SHA256", decoded_key, to_sign_bytes
signature = digest.encode "utf-8"
tokens.append "hmac=#{signature}"
# SHA1
when :sha1
digest = OpenSSL::HMAC.hexdigest "SHA1", decoded_key, to_sign_bytes
signature = digest.encode "utf-8"
tokens.append "hmac=#{signature}"
else
raise ArgumentError, "Input missing error: `signature_algorithm` can only be" +
" one of `:sha1`, `:sha256`, or `:ed25519`."
end
tokens.join "~"
end
# rubocop:enable Metrics/PerceivedComplexity
#
# [END mediacdn_dualtoken_sign_token]