Source: line-login.js

"use strict";

const debug = require("debug")("line-login:module");
const request = require("request");
const jwt = require("jsonwebtoken");
const secure_compare = require("secure-compare");
const crypto = require("crypto");
const api_version = "v2.1";

Promise = require("bluebird");
Promise.promisifyAll(request);

/**
@class
*/
class LineLogin {
    /**
    @constructor
    @param {Object} options
    @param {String} options.channel_id - LINE Channel Id
    @param {String} options.channel_secret - LINE Channel secret
    @param {String} options.callback_url - LINE Callback URL
    @param {String} [options.scope="profile openid"] - Permission to ask user to approve. Supported values are "profile", "openid" and "email". To specify email, you need to request approval to LINE.
    @param {String} [options.prompt] - Used to force the consent screen to be displayed even if the user has already granted all requested permissions. Supported value is "concent".
    @param {string} [options.bot_prompt="normal"] - Displays an option to add a bot as a friend during login. Set value to either normal or aggressive. Supported values are "normal" and "aggressive".
    @param {Boolean} [options.verify_id_token=true] - Used to verify id token in token response. Default is true.
    */
    constructor(options){
        const required_params = ["channel_id", "channel_secret", "callback_url"];
        const optional_params = ["scope", "prompt", "bot_prompt", "session_options", "verify_id_token"];

        // Check if required parameters are all set.
        required_params.map((param) => {
            if (!options[param]){
                throw new Error(`Required parameter ${param} is missing.`);
            }
        })

        // Check if configured parameters are all valid.
        Object.keys(options).map((param) => {
            if (!required_params.includes(param) && !optional_params.includes(param)){
                throw new Error(`${param} is not a valid parameter.`);
            }
        })

        this.channel_id = options.channel_id;
        this.channel_secret = options.channel_secret;
        this.callback_url = options.callback_url;
        this.scope = options.scope || "profile openid";
        this.prompt = options.prompt;
        this.bot_prompt = options.bot_prompt || "normal";
        if (typeof options.verify_id_token === "undefined"){
            this.verify_id_token = true;
        } else {
            this.verify_id_token = options.verify_id_token;
        }
    }

    /**
    Middlware to initiate OAuth2 flow by redirecting user to LINE authorization endpoint.
    Mount this middleware to the path you like to initiate authorization.
    @method
    @return {Function}
    */
    auth(){
        return (req, res, next) => {
            let state = req.session.line_login_state = LineLogin._random();
            let nonce = req.session.line_login_nonce = LineLogin._random();
            let url = this.make_auth_url(state, nonce);
            return res.redirect(url);
        }
    }

    /**
    Middleware to handle callback after authorization.
    Mount this middleware to the path corresponding to the value of Callback URL in LINE Developers Console.
    @method
    @param {Function} s - Callback function on success.
    @param {Function} f - Callback function on failure.
    */
    callback(s, f){
        return (req, res, next) => {
            const code = req.query.code;
            const state = req.query.state;
            const friendship_status_changed = req.query.friendship_status_changed;

            if (!code){
                debug("Authorization failed.");
                return f(new Error("Authorization failed."));
            }
            if (!secure_compare(req.session.line_login_state, state)){
                debug("Authorization failed. State does not match.");
                return f(new Error("Authorization failed. State does not match."));
            }
            debug("Authorization succeeded.");

            this.issue_access_token(code).then((token_response) => {
                if (this.verify_id_token && token_response.id_token){
                    let decoded_id_token;
                    try {
                        decoded_id_token = jwt.verify(
                            token_response.id_token,
                            this.channel_secret,
                            {
                                audience: this.channel_id,
                                issuer: "https://access.line.me",
                                algorithms: ["HS256"]
                            }
                        );
                        if (!secure_compare(decoded_id_token.nonce, req.session.line_login_nonce)){
                            throw new Error("Nonce does not match.");
                        }
                        debug("id token verification succeeded.");
                        token_response.id_token = decoded_id_token;
                    } catch(exception) {
                        debug("id token verification failed.");
                        if (f) return f(req, res, next, new Error("Verification of id token failed."));
                        throw new Error("Verification of id token failed.");
                    }
                }
                s(req, res, next, token_response);
            }).catch((error) => {
                debug(error);
                if (f) return f(req, res, next, error);
                throw error;
            });
        }
    }

    /**
    Method to make authorization URL
    @method
    @param {String} [nonce] - A string used to prevent replay attacks. This value is returned in an ID token.
    @return {String}
    */
    make_auth_url(state, nonce){
        const client_id = encodeURIComponent(this.channel_id);
        const redirect_uri = encodeURIComponent(this.callback_url);
        const scope = encodeURIComponent(this.scope);
        const prompt = encodeURIComponent(this.prompt);
        const bot_prompt = encodeURIComponent(this.bot_prompt);
        let url = `https://access.line.me/oauth2/${api_version}/authorize?response_type=code&client_id=${client_id}&redirect_uri=${redirect_uri}&scope=${scope}&bot_prompt=${bot_prompt}&state=${state}`;
        if (this.prompt) url += `&prompt=${encodeURIComponent(this.prompt)}`;
        if (nonce) url += `&nonce=${encodeURIComponent(nonce)}`;
        return url
    }

    /**
    Method to retrieve access token using authorization code.
    @method
    @param {String} code - Authorization code
    @return {Object}
    */
    issue_access_token(code){
        const url = `https://api.line.me/oauth2/${api_version}/token`;
        const form = {
            grant_type: "authorization_code",
            code: code,
            redirect_uri: this.callback_url,
            client_id: this.channel_id,
            client_secret: this.channel_secret
        }
        return request.postAsync({
            url: url,
            form: form
        }).then((response) => {
            if (response.statusCode == 200){
                return JSON.parse(response.body);
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to verify the access token.
    @method
    @param {String} access_token - Access token
    @return {Object}
    */
    verify_access_token(access_token){
        const url = `https://api.line.me/oauth2/${api_version}/verify?access_token=${encodeURIComponent(access_token)}`;
        return request.getAsync({
            url: url
        }).then((response) => {
            if (response.statusCode == 200){
                return JSON.parse(response.body);
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to get a new access token using a refresh token.
    @method
    @param {String} refresh_token - Refresh token.
    @return {Object}
    */
    refresh_access_token(refresh_token){
        const url = `https://api.line.me/oauth2/${api_version}/token`;
        const form = {
            grant_type: "refresh_token",
            refresh_token: refresh_token,
            client_id: this.channel_id,
            client_secret: this.channel_secret
        }
        return request.postAsync({
            url: url,
            form: form
        }).then((response) => {
            if (response.statusCode == 200){
                return JSON.parse(response.body);
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to invalidate the access token.
    @method
    @param {String} access_token - Access token.
    @return {Null}
    */
    revoke_access_token(access_token){
        const url = `https://api.line.me/oauth2/${api_version}/revoke`;
        const form = {
            access_token: access_token,
            client_id: this.channel_id,
            client_secret: this.channel_secret
        }
        return request.postAsync({
            url: url,
            form: form
        }).then((response) => {
            if (response.statusCode == 200){
                return null;
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to get user's display name, profile image, and status message.
    @method
    @param {String} access_token - Access token.
    @return {Object}
    */
    get_user_profile(access_token){
        const url = `https://api.line.me/v2/profile`;
        const headers = {
            Authorization: "Bearer " + access_token
        }
        return request.getAsync({
            url: url,
            headers: headers
        }).then((response) => {
            if (response.statusCode == 200){
                return JSON.parse(response.body);
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to get the friendship status of the user and the bot linked to your LNIE Login channel.
    @method
    @param {String} access_token - Access token.
    @return {Object}
    */
    get_friendship_status(access_token){
        const url = `https://api.line.me/friendship/v1/status`;
        const headers = {
            Authorization: "Bearer " + access_token
        }
        return request.getAsync({
            url: url,
            headers: headers
        }).then((response) => {
            if (response.statusCode == 200){
                return JSON.parse(response.body);
            }
            return Promise.reject(new Error(response.statusMessage));
        });
    }

    /**
    Method to generate random string.
    @method
    @return {Number}
    */
    static _random(){
        return crypto.randomBytes(20).toString('hex');
    }
}

module.exports = LineLogin;