auth/oauth2/Oauth2Provider.js

/**
 * @module flitter-auth/oauth2/Oauth2Provider
 */

const Provider = require('../Provider')
const Axios = require('axios')
const QS = require('qs')

/**
 * Authentication provider for linking in OAuth2 sources.
 * @extends module:flitter-auth/Provider~Provider
 */
class Oauth2Provider extends Provider {
    /**
     * Defines the services required by this provider.
     * @returns {Array<string>}
     */
    static get services() {
        return [...super.services, 'configs']
    }

    /**
     * Get the view for the login page.
     * @returns {string}
     */
    login_view() {
        return 'auth:form_page'
    }

    /**
     * Build the URL where the user should be redirected to sign
     * into the OAuth2 provider.
     * @returns {string}
     * @private
     */
    _get_landing_url() {
        const base = this.config.source_login_page

        let my_url = this.configs.get('app.url')
        if ( !my_url.endsWith('/') ) my_url += '/'

        return base.replace(/%c/g, this.config.source_client_id)
                    .replace(/%r/g, `${my_url}auth/${this.config.name}/login`)
    }

    /**
     * Send the login page view to the response.
     * @param {express/response} res - the response
     * @returns {Promise<*>}
     * @private
     */
    _redirect_prompt(res) {
        return res.page(this.login_view(), {
            title: 'Login',
            message: `Hi, there! Sign-in with ${this.config.source_name} to continue.`,
            button_text: 'Continue',
            button_link: this._get_landing_url(),
        })
    }

    /**
     * Updates the attributes on the provided {module:flitter-auth/model/User~User}
     * instance with the specified data.
     * @param {module:flitter-auth/model/User~User} user - the user to update
     * @param {object} data - the data attributes to update
     * @returns {Promise<*>}
     * @private
     */
    async _update_user_attributes(user, data) {
        const attrs = this.config.user_data.attributes

        Object.keys(attrs).forEach(user_key => {
            const data_key = attrs[user_key]
            if ( !(['uuid', 'uid'].includes(user_key)) && data[data_key] ) {
                user[user_key] = data[data_key]
            }
        })

        user.data_set('oauth2_retrieval_data', data)

        return user
    }

    /**
     * Get the user object that matches the specified data.
     * (Data should contain the configured user ID.)
     * @param {object} data - the user information, including the UID field
     * @returns {Promise<module:flitter-auth/model/User~User>} - the associated user object
     * @private
     */
    async _get_user_object(data){
        const User = this.User
        const uid = data[this.config.user_data.attributes.uid]

        let user = await User.findOne({ uid, provider: this.config.name })
        if ( !user ){
            user = new User({ uid, provider: this.config.name })
        }

        return await this._update_user_attributes(user, data)
    }

    /**
     * Given a bearer token, make a request to configured user endpoint
     * and build a user object from the resultant data.
     * @param {string} bearer - the bearer token
     * @returns {Promise<module:flitter-auth/model/User~User|boolean>} - User instance if possible, or false if it couldn't be retrieved
     * @private
     */
    async _build_user_from_bearer(bearer) {
        const req_conf = {
            headers: {
                'Authorization': `${this.config.user_data.token_prefix ? this.config.user_data.token_prefix : 'Bearer '}${bearer}`,
            }
        }

        const method = this.config.user_data.method
        const endpoint = this.config.user_data.endpoint
        
        try {
            const response = await Axios[method](endpoint, req_conf)

            let user_data = response.data;
            if ( this.config.user_data.data_root ) user_data = user_data[this.config.user_data.data_root]

            const user = await this._get_user_object(user_data)
            if ( user.block_login ) return false
            await user.save()

            return user
        } catch (e) {
            return false
        }
    }

    /**
     * Handles a GET request to the register route.
     * @param {express/request} req - the request
     * @param {express/response} res - the response
     * @param {function} next - the next function in the stack
     * @returns {Promise<*>}
     */
    async handle_register_get(req, res, next) {
        return this.handle_login_get(req, res, next)
    }

    /**
     * Handles a GET request to the login route.
     * @param {express/request} req - the request
     * @param {express/response} res - the response
     * @param {function} next - the next function in the stack
     * @returns {Promise<*>}
     */
    async handle_login_get(req, res, next) {
        const config = this.config

        // TODO allow for POST returns as well
        if ( !req.query || !req.query[config.callback.token_key] ) {
            return this._redirect_prompt(res)
        } else {
            const code = req.query[config.callback.token_key]

            // Make a request to the OAuth2 server to redeem our auth token for a bearer token
            let spec_endpoint = new URL(config.source_token.endpoint)

            try {
                const body = {}

                if ( config.source_token.client_id_key ) {
                    body[config.source_token.client_id_key] = config.source_client_id
                }

                if ( config.source_token.client_secret_key ) {
                    body[config.source_token.client_secret_key] = config.source_client_secret
                }

                if ( config.source_token.grant_type_key ) {
                    body[config.source_token.grant_type_key] = 'authorization_code'
                }

                if ( config.source_token.token_key ) {
                    body[config.source_token.token_key] = code
                }

                const req_conf = {
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'Accept': 'application/json',
                    }
                }

                const response = await Axios.post(spec_endpoint.toString(), QS.stringify(body), req_conf)
                const bearer = response.data[config.source_token.response_token_key]

                const user = await this._build_user_from_bearer(bearer)
                if ( !user ) return res.error(500)

                await this.session(req, user)

                if ( req.session.auth.flow ){
                    const destination = req.session.auth.flow
                    req.session.auth.flow = false
                    return res.redirect(destination)
                }

                return res.redirect(this.configs.get('auth.default_login_route'))
            } catch (e) {
                return this._redirect_prompt(res)
            }
        }
    }
}

module.exports = exports = Oauth2Provider