webauth/index.js

import Agent from './agent'
import url from 'url'
import AuthError from '../auth/authError'
import Scope from '../token/scope'
import BaseTokenItem from '../token/baseTokenItem'
import { validate } from '../utils/validate'

/**
 * Helper to perform Auth against Azure AD login page
 *
 * It will use `/authorize` endpoint of the Authorization Server (AS)
 * with Code Grant
 *
 * @export
 * @class WebAuth
 * @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols-oauth-code
 */
export default class WebAuth {

    constructor(auth) {
        this.client = auth
        const { clientId } = auth
        this.clientId = clientId
        this.agent = new Agent()
    }

    /**
   * Starts the AuthN/AuthZ transaction against the AS in the in-app browser.
   *
   * In iOS it will use `SFSafariViewController` and in Android `Chrome Custom Tabs`.
   *
   * @param {Object} options parameters to send
   * @param {String} [options.scope] scopes requested for the issued tokens.
   *    OpenID Connect scopes are always added to every request. `openid profile offline_access`
   *    @see https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-scopes
   * @param {String} [options.prompt] (optional) indicates the type of user interaction that is required.
   *    The only valid values are 'login', 'none', 'consent', and 'select_account'.
   *    @see https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
   * @param {Boolean} [options.ephemeralSession] SSO. It only affects iOS with versions 13 and above.
   * @returns {Promise<BaseTokenItem | AccessTokenItem>}
   *
   * @memberof WebAuth
   */
    async authorize(options = {}) {
        const scope = new Scope(options.scope)

        const { clientId, client, agent } = this
        const {nonce, state, verifier} = await agent.generateRequestParams()

        let requestParams = {
            ...options,
            clientId,
            scope: scope.toString(),
            responseType: 'code' + (scope.toString().includes('openid') ? ' id_token': ''),
            response_mode: 'fragment', // 'query' is unsafe and not supported, the hash fragment is also default
            state: state,
            nonce: nonce,
            code_challenge_method: 'plain',
            code_challenge: verifier
        }
        const loginUrl = this.client.loginUrl(requestParams)

        let redirectUrl = await agent.openWeb(loginUrl, options.ephemeralSession)

        if (!redirectUrl || !redirectUrl.startsWith(client.redirectUri)) {
            throw new AuthError({
                json: {
                    error: 'aa.redirect_uri.not_expected',
                    error_description: `Expected ${client.redirectUri} but got ${redirectUrl}`
                },
                status: 0
            })
        }

        // Response is returned in hash, but we want to get parsed object
        // Query can be parsed, therefore lets replace hash sign with '?' mark
        const queryCheck = /\?.*#/; 
        if(queryCheck.test(redirectUrl)){
            // If there is already a query string, replace hash with '&' to append to query
            redirectUrl = redirectUrl.replace('#','&')
        }else{
            redirectUrl = redirectUrl.replace('#','?')
        }
        const urlHashParsed = url.parse(redirectUrl, true).query
        const {
            code,
            state: resultState,
            error
        } = urlHashParsed

        if (error) {
            throw new AuthError({json: urlHashParsed, status: 0})
        }

        if (resultState !== state) {
            throw new AuthError({
                json: {
                    error: 'aa.state.invalid',
                    error_description: 'Invalid state received in redirect url'
                },
                status: 0
            })
        }
        const tokenResponse = await client.exchange({
            code,
            scope: scope.toString(),
            code_verifier: verifier
        })

        if (tokenResponse.refreshToken) {
            this.client.cache.saveRefreshToken(tokenResponse)
        }
        if (tokenResponse.accessToken) {
            let accessToken = await this.client.cache.saveAccessToken(tokenResponse)
            return accessToken
        } else {
            // we have to have at least id_token in respose
            return new BaseTokenItem(tokenResponse, this.clientId)
        }
    }

    
    /**
   *  Removes Azure session
   *
   * @param {Object} options parameters to send
   * @param {Boolean} [options.closeOnLoad] close browser window on 'Loaded' event (works only on iOS)
   * @returns {Promise}
   *
   * @memberof WebAuth
   */
    clearSession(options = {closeOnLoad: true}) {
        const { client, agent } = this
        const parsedOptions = validate({
            parameters: {
                closeOnLoad: { required: true },
            },
            validate: true // not declared params are NOT allowed:
        }, options)

        const logoutUrl = client.logoutUrl()
        return agent.openWeb(logoutUrl, false, parsedOptions.closeOnLoad)
    }
}