auth/index.js

import Client from '../networking'
import { validate } from '../utils/validate'
import { toCamelCase } from '../utils/camel'
import AuthError from './authError'
import TokenCache from '../token/cache'
import log from '../utils/logger'

import { NativeModules, Platform } from 'react-native'
import Scope from '../token/scope'

const { AzureAuth } = NativeModules

function responseHandler (response, exceptions = {}) {
    if (response.ok && response.json) {
        return toCamelCase(response.json, exceptions)
    } else if(response.ok && response.blob) {
        return response.blob
    }
    throw new AuthError(response)
}

/**
 * Azure AD V2 Auth API
 *
 * @export Auth
 * @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols
 *
 * @class Auth
 */
export default class Auth {
    constructor(options = {}) {
        const bundleIdentifier = AzureAuth.bundleIdentifier
        const defaultRedirectUri = `${bundleIdentifier.toLowerCase()}://${bundleIdentifier.toLowerCase()}/${Platform.OS}/callback`


        this.client = new Client(options)
        const { clientId, redirectUri, persistentCache } = options
        if (!clientId) {
            throw new Error('Missing clientId in parameters')
        }
        this.cache = new TokenCache( {clientId, persistent: persistentCache} )
        this.authorityUrl = this.client.baseUrl
        this.clientId = clientId
        this.redirectUri = redirectUri || defaultRedirectUri
    }

    /**
   * Builds the full authorize endpoint url in the Authorization Server (AS) with given parameters.
   *
   * @param {Object} parameters parameters to send to `/authorize`
   * @param {String} parameters.responseType type of the response to get from `/authorize`.
   * @param {String} parameters.redirectUri where the AS will redirect back after success or failure.
   * @param {String} parameters.state random string to prevent CSRF attacks.
   * @param {String} parameters.scope a space-separated list of scopes that you want the user to consent to.
   * @param {String} parameters.prompt (optional) indicates the type of user interaction that is required.
   *    The only valid values at this time are 'login', 'none', and 'consent'.
   * @returns {String} authorize url with specified parameters to redirect to for AuthZ/AuthN.
   *
   * @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code
   *
   * @memberof Auth
   */
    loginUrl(parameters = {}) {
        const query = validate({
            parameters: {
                responseType: { required: true, toName: 'response_type' },
                scope: { required: true },
                state: { required: true },
                prompt: {}
            },
            validate: false // not declared params are allowed:
        }, parameters)
        return this.client.url('authorize',
            {...query,
                client_id: this.clientId,
                redirect_uri: this.redirectUri
            })
    }

    /**
   * Builds the full logout endpoint url in the Authorization Server (AS) with given parameters.
   * https://login.microsoftonline.com/common/oauth2/logout?post_logout_redirect_uri=[URI]&redirect_uri=[URI]
   *
   * @returns {String} logout url with default parameter
   *
   * @memberof Auth
   */
    logoutUrl() {
        return this.client.url('logout', {
            post_logout_redirect_uri: this.redirectUri,
            redirect_uri: this.redirectUri
        })
    }

    /**
   * Exchanges a code obtained via `/authorize` for the access tokens
   *
   * @param {Object} input input used to obtain tokens from a code
   * @param {String} input.code code returned by `/authorize`.
   * @param {String} input.redirectUri original redirectUri used when calling `/authorize`.
   * @param {String} input.scope A space-separated list of scopes.
   *    The scopes requested in this leg must be equivalent to or a subset of the scopes requested in the first leg
   * @returns {Promise}
   * @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code#request-an-access-token
   *
   * @memberof Auth
   */
    exchange(input = {}) {
        const payload = validate({
            parameters: {
                code: { required: true },
                scope: { required: true },
                code_verifier: { required: true },
            }
        }, input)

        return this.client
            .post('token',
                {...payload,
                    client_id: this.clientId,
                    redirect_uri: this.redirectUri,
                    grant_type: 'authorization_code'})
            .then(responseHandler)
    }

    /**
   * Obtain new tokens (access and id) using the Refresh Token obtained during Auth (requesting `offline_access` scope)
   *
   * @param {Object} parameters refresh token parameters
   * @param {String} parameters.refreshToken user's issued refresh token
   * @param {String} parameters.scope scopes requested for the issued tokens.
   * @param {String} [parameters.redirectUri] the same redirect_uri value that was used to acquire the authorization_code.
   * @returns {Promise}
   * @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code#refresh-the-access-token
   *
   * @memberof Auth
   */
    refreshTokens(parameters = {redirectUri: this.redirectUri}) {
        const payload = validate({
            parameters: {
                refreshToken: { required: true, toName: 'refresh_token' },
                scope: { required: true }
            }
        }, parameters)
        const scope = new Scope(payload.scope)
        return this.client
            .post('token', {
                ...payload,
                client_id: this.clientId,
                grant_type: 'refresh_token',
                redirect_uri: this.redirectUri,
                scope: scope.toString()
            })
            .then((response) => {
                if (response.ok && response.json) {
                    return toCamelCase(response.json)
                } else {
                    // on missing consent Azure also answers with HTTP 400 code
                    // Error: AADSTS65001, response.json.error_codes[0] == 65001
                    log.error('Could not refresh token: ', response)
                    return Promise.reject(null)
                }
            })
    }

    /**
     * Try to obtain token silently without user interaction
     *
     * @param {Object} parameters
     * @param {String} parameters.userId user login name (e.g. from Id token)
     * @param {String} parameters.scope scopes requested for the issued tokens.
     *
     * @memberof Auth
     */
    async acquireTokenSilent(parameters = {}) {
        const input = validate({
            parameters: {
                userId: { required: true},
                scope: { required: true }
            }
        }, parameters)
        const scope = new Scope(input.scope)

        try {
            let accessToken = await this.cache.getAccessToken(input.userId, scope)
            if (accessToken && !accessToken.isExpired()) {
                return accessToken
            }
            let refreshToken = await this.cache.getRefreshToken(input.userId)
            if (refreshToken) {
                const tokenResponse = await this.refreshTokens({refreshToken: refreshToken.refreshToken, scope: scope})
                if (tokenResponse && tokenResponse.refreshToken) {
                    this.cache.saveRefreshToken(tokenResponse)
                }
                if (tokenResponse && tokenResponse.accessToken) {
                    accessToken = await this.cache.saveAccessToken(tokenResponse)
                    return accessToken
                }
            }
        } catch (error) {
            console.error('Error in silent request: ', error)
            //return error
        }

        // Not possible silently acquire token - user interaction is needed
        // Resolve Promise with null - token is not found for given scope
        return null
    }

    /**
   * Return user information using an access token
   *
   * @param {Object} parameters user info parameters
   * @param {String} parameters.token user's access token
   * @param {String} parameters.path - MS Graph API Path
   * @returns {Promise}
   * @see https://developer.microsoft.com/en-us/graph/docs/concepts/overview
   * @see https://developer.microsoft.com/en-us/graph/docs/api-reference/v1.0/api/user_get
   *
   * @memberof Auth
   */
    msGraphRequest(parameters = {}) {
        const baseUrl = 'https://graph.microsoft.com/v1.0/'
        const payload = validate({
            parameters: {
                path: { required: true },
                token: { required: true }
            }
        }, parameters)
        const client = new Client({baseUrl, token: payload.token})
        // remove leading and trailing slashes
        const clearedPath = payload.path.replace(/^\//,'').replace(/\/$/,'')
        return client
            .get(clearedPath) // get info for currently authorized user
            .then(responseHandler)
    }

    /**
     * Clear persystent cache - AsyncStorage - for given client ID and user ID or ALL users
     *
     * @param {String} userId ID of user whose tokens will be cleared/deleted
     *      if ommited - tokens for ALL users and current client will be cleared
     *
     * @memberof Auth
     */
    async clearPersistenCache(userId = null) {
        const tokenKeys = await this.cache.getAllUserTokenKeys(userId)
        tokenKeys.forEach((uTokenKey) => {
            this.cache.removeToken(uTokenKey)
        })
    }

}