Source: authorize.js

/**
 * @fileoverview This file contains the code to authorize the user to the GitHub API
 * @license Apache-2.0
 * @version 2.0.0
 * 
 * @author Michael Hay <michael.hay@mediumroast.io>
 * @file authorize.js
 * @copyright 2024 Mediumroast, Inc. All rights reserved.
 * 
 * @class GitHubAuth
 * @classdesc This class is used to authorize the user to the GitHub API
 * 
 * @requires axios
 * @requires open
 * @requires octoDevAuth
 * @requires chalk
 * @requires cli-table
 * @requires configparser
 * @requires FilesystemOperators
 * 
 * @exports GitHubAuth
 * 
 * @example
 * import {GitHubAuth} from './api/authorize.js'
 * const github = new GitHubAuth(env, environ, configFile)
 * const githubToken = github.verifyAccessToken()
 * 
 */ 

import open from "open"
import * as octoDevAuth from '@octokit/auth-oauth-device'
import chalk from "chalk"
import Table from 'cli-table'
import FilesystemOperators from '../cli/filesystem.js'


class GitHubAuth {
    /**
     * @constructor
     * @param {Object} env - The environment object
     * @param {Object} environ - The environmentals object
     * @param {String} configFile - The configuration file
     */
    constructor (env, environ, configFile, configExists) {
        this.env = env
        this.clientType = 'github-app'
        this.configFile = configFile
        this.configExists = configExists
        this.filesystem = new FilesystemOperators()
        this.environ = environ
        // Use ternary operator to determine if the config file exists and if it does read it else set it to null
        this.config = configExists ? environ.readConfig(configFile) : null
    }

    verifyGitHubSection () {
        if (!this.config) {
            return false
        }
        return this.config.hasSection('GitHub')
    }

    _getFromConfig (section, option) {
        const hasOption = this.config.hasKey(section, option)
        if (hasOption) {
            return this.config.get(section, option)
        } else {
            return null
        }
    }

    getAccessTokenFromConfig () {
        return this._getFromConfig('GitHub', 'token')
    }

    getAuthTypeFromConfig () {
        return this._getFromConfig('GitHub', 'authType')
    }


    async checkTokenExpiration(token) {
        const response = await fetch('https://api.github.com/user', {
            method: 'GET',
            headers: {
                'Authorization': `token ${token}`,
                'Accept': 'application/vnd.github.v3+json'
            }
        })
    
        if (!response.ok) {
            return [false, {status_code: 500, status_msg: response.statusText}, null]
        }
    
        const data = await response.json()
        return [true, {status_code: 200, status_msg: response.statusText}, data]
    } 

    /**
     * @async
     * @function getAccessTokenDeviceFlow
     * @description Get the access token using the device flow
     * @returns {Object} The access token object
     */
    async getAccessTokenDeviceFlow() {
        // Set the clientId depending on if the config file exists
        const clientId = this.configExists ? this.env.clientId : this.env.GitHub.clientId
        // Construct the oAuth device flow object which starts the browser
        let deviceCode // Provide a place for the device code to be captured
        const deviceauth = octoDevAuth.createOAuthDeviceAuth({
            clientType: this.clientType,
            clientId: clientId,
            onVerification(verifier) {
                deviceCode = verifier.device_code
                // Print the verification artifact to the console
                console.log(
                    chalk.blue.bold(`If your OS supports it, opening your browser, otherwise, navigate to the Authorization website. Then, please copy and paste the Authorization code into your browser.\n`)
                )
                const table = new Table({
                    rows: [
                        [chalk.blue.bold(`Authorization website:`), chalk.bold.red(verifier.verification_uri)],
                        [chalk.blue.bold(`Authorization code:`), chalk.bold.red(verifier.user_code)]
                    ]
                })
                console.log(table.toString())
                open(verifier.verification_uri)
            }
        })

        // Call GitHub to obtain the token
        let accessToken = await deviceauth({type: 'oauth'})
        accessToken.deviceCode = deviceCode
        return accessToken
    }

    /**
     * @async
     * @function verifyAccessToken
     * @description Verify if the access token is valid and if not get a new one depending on this.env.authType
     * @param {Boolean} saveToConfig - Save to the configuration file, default is true
     */
    async verifyAccessToken (saveToConfig=true) {

        if (this.configExists) {
            // Get key variables from the config file
            const hasGitHubSection = this.verifyGitHubSection()
            // If the GitHub section is not available, then the token is not available, return false.
            // This is only to be used when called from a function that intendes to setup the configuration file, but
            // just in case this condition occurs we want to return clearly to the caller.
            if (!hasGitHubSection) {
                return [false, {status_code: 500, status_msg: 'The GitHub section is not available in the configuration file'}, null]
            }
        }

        // Get the access token and authType from the config file since the section is available
        let accessToken
        // If the configuration exists then we can obtain the token and authType from the config file, but if
        // the configuration is not present and the intention is to use PAT this code won't be executed. Therefore,
        // prompting the user for the PAT, verifyin the PAT, and saving the PAT to the config file will be done in the
        // caller. However, if the intention is to use deviceFlow then we can support that here and return the token to the
        // caller which will then save the token and the authType to the config file.
        let authType = 'deviceFlow' 
        if (this.configExists) {
            accessToken = this.getAccessTokenFromConfig()
            authType = this.getAuthTypeFromConfig()
        }
        
        // Check to see if the token is valid but if the config isn't present then we can't check the token
        const validToken = this.configExists ? 
            await this.checkTokenExpiration(accessToken) : 
            [false, {status_code: 500, status_msg: 'The configuration file isn\'t present'}, null]
        if (validToken[0] && this.configExists) {
            return [
                true, 
                {status_code: 200, status_msg: validToken[1].status_msg},
                {token: accessToken, authType: authType}
            ]
        // If the token is not valid, then we need to return to the caller (PAT) or get a new token (deviceFlow)
        } else {
            // Case for a Personal Access Token
            if (authType === 'pat') {
                // Return the error message to the caller
                return [
                    false, 
                    {status_code: 500, status_msg: `The Personal Access Token appears to be invalid and was rejected with an error message [${validToken[1].status_msg}].\n\tPlease obtain a new PAT and update the GitHub token setting in the configuration file [${this.configFile}].`}, 
                    null
                ]
            // Case for the device flow
            } else if (authType === 'deviceFlow') {
                // Get the new access token
                accessToken = await this.getAccessTokenDeviceFlow()
                
                // Update the config if the config file exists and if saveToConfig is true
                if (this.configExists && this.config && this.saveToConfig) {
                    let tmpConfig = this.environ.updateConfigSetting(this.config, 'GitHub', 'token', accessToken.token)
                    tmpConfig = this.environ.updateConfigSetting(tmpConfig[1], 'GitHub', 'authType', authType)
                    tmpConfig = this.environ.updateConfigSetting(tmpConfig[1], 'GitHub', 'deviceCode', accessToken.deviceCode)
                    
                    // Save the config file if needed
                    this.config = tmpConfig[1]
                    if (saveToConfig) {
                        await this.config.write(this.configFile)
                    }
                }

                return [
                    true, 
                    {status_code: 200, status_msg: `The access token has been successfully updated and saved to the configuration file [${this.configFile}]`},
                    {token: accessToken.token, authType: authType, deviceCode: accessToken.deviceCode}
                ]
            }
        }
    }

    decodeJWT (token) {
        if(token !== null || token !== undefined){
         const base64String = token.split('.')[1]
         const decodedValue = JSON.parse(
                                Buffer.from(
                                    base64String,    
                                    'base64')
                                .toString('ascii')
                            )
         return decodedValue
        }
        return null
    }
}

export {GitHubAuth}