Source: Authorization.js

'use strict'

const crypto = require('crypto')
const _ = require('lodash')
const Utils = require('./Utils')
const qs = require('qs')

/**
 * Generate authorization tokens for the requests to Azure Blob Storage
 */
class Authorization {
    /**
     * Constructor
     * 
     * @param {string} verb - HTTP verb used (e.g. GET, PUT...)
     * @param {string} blob - Name of the blob accessed
     * @param {Object} [extra] - Optional, extra paramters
     * @param {string} extra.contentMD5 - Value for the Content-MD5 header (optional)
     * @param {string} extra.contentType - Value for the Content-Type header (optional)
     * @param {Object} extra.qs - Dictionary of values for the querystring to append to the URL (optional)
     */
    constructor(verb, blob, extra) {
        if (!blob || !_.isString(blob) || !blob.startsWith('/')) {
            throw Error('Parameter blob must be a string starting with /')
        }

        // Store parameters
        this._verb = verb
        this._blob = blob

        // Extra parameters (optional)
        this._contentType = undefined
        this._contentMD5 = undefined
        this._qs = {}
        if (extra && _.isObject(extra) && !_.isEmpty(extra)) {
            // Content-MD5 and Content-Type
            if (extra.contentMD5 && _.isString(extra.contentMD5)) {
                this._contentMD5 = extra.contentMD5
            }
            if (extra.contentType && _.isString(extra.contentType)) {
                this._contentType = extra.contentType
            }
            
            // Querystring
            if (extra.qs) {
                this._qs = extra.qs
                this._blob += '?' + qs.stringify(extra.qs)
            }
        }

        // Initialize the object to hold custom headers
        this._customHeaders = {}

        // Key type and version
        this._keyType = 'SharedKeyLite'
        this._apiVersion = '2016-05-31'
        
        // Generate the x-ms-date header
        this._date = (new Date()).toUTCString()

        // Storage account name, key and SAS token will be stored in these variables
        this._storageAccountName = false
        this._storageAccountKey = undefined
        this._storageAccountSasToken = undefined
    }

    /**
     * Return the request date stored into the object, for use with the "x-ms-date" header
     * 
     * @return {string} Date for the "x-ms-date" header
     */
    get date() {
        return this._date
    }

    /**
     * Getter for the list of headers to add to the request
     * 
     * @return {Object} List of headers to add to the request
     */
    get headers() {
        // Return a sorted object
        const builtIn = {
            'x-ms-date': this.date,
            'x-ms-version': this._apiVersion
        }
        const allHeaders = _.assign({}, this._customHeaders, builtIn)

        return Utils.sortObject(allHeaders)
    }

    /**
     * Add a custom "x-ms-*" header to the request.
     * 
     * @param {String} name - Name of the header (must start with "x-ms-*")
     * @param {String} value - Value of the header
     */
    addCustomHeader(name, value) {
        if (!name || !_.isString(name) || name.length < 1 ||
            !value || !_.isString(value) || value.length < 1
        ) {
            throw Error('Parameters name and value must be non-empty strings')
        }

        if (!name.match(/^x-ms-/i)) {
            throw Error('Header name does not start with "x-ms-"')
        }

        this._customHeaders[name.toLowerCase()] = value
    }

    /**
     * Manually set the storage account name and key
     * 
     * @param {string} name - Name for the storage account
     * @param {string} key - Access key for the storage account
     * @param {string} sasToken - SAS token for the storage account
     */
    setStorageAccount(name, key, sasToken) {
        if (!name || !_.isString(name) || name.length < 1) {
            throw Error('Parameter name must be a non-empty string')
        }

        const hasKey = !!(key && _.isString(key) && key.length > 1)
        const hasSasToken = !!(sasToken && _.isString(sasToken) && sasToken.length > 1)
        if ((!hasKey && !hasSasToken) || (hasKey && hasSasToken)) {
            throw Error('One and only one of key and sasToken must be set and a non-empty string')
        }

        this._storageAccountName = name
        this._storageAccountKey = hasKey ? key : undefined
        
        if (hasSasToken) {
            // Ensure the SAS token starts with ?
            if (sasToken.charAt(0) !== '?') {
                sasToken = '?' + sasToken
            }

            this._storageAccountSasToken = hasSasToken ? sasToken : undefined
        }
    }

    /**
     * Generate the signature for the Authorization header.
     *
     * When using SAS tokens, this returns null.
     * 
     * @return {string} Signature for the request
     */
    generateSignature() {
        if (!this._storageAccountName) {
            throw Error('You must set storage account name and key or SAS token')
        }

        // When using SAS tokens, return null
        if (this._storageAccountSasToken) {
            return null
        }

        const components = [
            this._verb, // HTTP verb
            this._contentMD5 || '', // Content-MD5
            this._contentType || '', // Content-Type
            '', // Date header (not used because of x-ms-date)
        ]

        // Build the "Canonicalized Header String" according to the documentation
        const allHeaders = this.headers // Already sorted
        const join = []
        for (const k in allHeaders) {
            if (allHeaders.hasOwnProperty(k)) {
                join.push(k + ':' + allHeaders[k].replace(/(\n|\r)/g, ' '))
            }
        }
        components.push(join.join('\n'))

        // Build the "Canonicalized Resource String"
        let resourceString = '/' + this._storageAccountName + this._blob

        // If using SAS tokens, append them
        if (this._storageAccountSasToken) {
            resourceString += this._storageAccountSasToken + '&api-version=' + this._apiVersion
        }

        components.push(resourceString)

        // String to sign
        const baseString = components.join('\n')

        // Compute the SHA256-HMAC
        const hmac = crypto.createHmac('sha256',
            Buffer.from(this._storageAccountKey || this._storageAccountSasToken, 'base64')
        )
        hmac.update(baseString)
        const signature = hmac.digest('base64')

        return signature 
    }

    /**
     * Generate the Authorization header for the request.
     *
     * When using SAS tokens, this returns null.
     * 
     * @return {string} Value for the Authorization header
     */
    generateAuthorizationHeader() {
        // When using SAS tokens, return null
        if (this._storageAccountSasToken) {
            return null
        }
        
        // Generate the signature
        const signature = this.generateSignature()

        return this._keyType + ' ' + this._storageAccountName + ':' + signature
    }

    /**
     * Return the list of all headers, including Authorization, Content-MD5 and Content-Type, ready for sending the request.
     * 
     * @return {Object} List of all headers
     */
    requestHeaders() {
        // Auth header
        const auth = this.generateAuthorizationHeader()
        const merge = auth ?
            {'Authorization': auth} :
            {}
        
        // Add Content-MD5 and Content-Type if necessary
        if (this._contentMD5) {
            merge['Content-MD5'] = this._contentMD5
        }
        if (this._contentType) {
            merge['Content-Type'] = this._contentType
        }

        return _.merge({}, this.headers, merge)
    }

    /**
     * Return values for the querystring of URLs when uploading a blob. This is necessary when using SAS tokens.
     *
     * @return {Object} Querystring values to append to blob URL when uploading
     */
    querystring() {
        // When using SAS tokens, add them
        if (this._storageAccountSasToken) {
            // Strip the leading ?, then parse using qs
            const qsSas = qs.parse(this._storageAccountSasToken.substring(1))

            // Add api-version
            qsSas['api-version'] = this._apiVersion

            return _.merge({}, this._qs, qsSas)
        }
        
        return this._qs
    }
}

module.exports = Authorization