銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

コンソールからGoogle OAuth2の認証を行う

コンソールから Node.jsを使って Google OAuth2 クライアントID による OAuth 認証のお試しコードを書いてみました。

日々の作業でGit BashやMSYS2を多用していますが、コンソールからGoogle Driveスプレッドシートを参照したり、検索、定型データの追加などが軽くできれば、いちいちブラウザアプリを開いて「よっこらしょ」ってな感じより効率的な場合もあるかと思い、これをベースに「なんか作るか」と、思ったり思わなかったりして、いくつになってもお勉強です。

f:id:takamints:20171130164857j:plain
photo credit: suzyhazelwood DSC04299-02 via photopin (license)

JavaScriptエンジニアのためのNode.js入門
(2016-12-26)
売り上げランキング: 1,632

Google OAuth2 に必要なもの

ここで示している認証では Google Developper ConsoleAPIプロジェクト(=アプリケーション)に作成する「クライアントID」と「クライアントシークレット」を使用します(作成手順は後述します)。

これとは別に「クライアントシークレット」を使わず「API KEY」と「クライアントID」を使う方法もあるようですが、Node.js からできるかどうかわかりませんでした。

そもそも OAuth の認証・認可とは

このアプリケーションは、Google Drive API を使用して、ユーザー自身の Google Drive のファイルにアクセスします(とプロジェクトに設定している)。しかしユーザーファイルを勝手に触るわけにはいきません。 このため、実行時に認証ページを表示して、「このアプリケーションがユーザーファイルにアクセスして良い」と、ユーザーに認可してもらうのです。

ユーザーが認可すれば、認可コードがアプリケーションに通知され、アプリケーションは実際にユーザーデータにアクセスするためのアクセストークを手に入れます。

認可を求めるのは1度だけ

認可を求める認証ページが開くのは初回の認可コードを取得するときだけです。

アクセストークンには有効期限が定められていますが、新たなアクセストークンを取得するためのリフレッシュトークも含まれており、2回目以降は、これを使って新しいアクセストークンを取得できるというわけ。

(掲載しているプログラムでは、有効期限の判定は一切行わず、常にリフレッシュしています)

認可コード取得を自動化

初回の認証シーケンス(認証ページの表示から認可コードの取得まで)は一般的なウェブアプリと同じように自動化しています。 通常のサンプルコード等では「以下のURLをブラウザで表示して、表示された認証コードを入力してね」なコピペな感じですが、それよか格段に楽ですよ。

認可コードを得る部分のフローは。

  1. プロセス内にローカルウェブサーバーを起動。
  2. 認証ページをユーザーのデフォルトブラウザで表示。
  3. 認証時のリダイレクト先を上記ウェブサーバーのURLに設定。
  4. ユーザーが認証を完了させると、認可コードがリダイレクト。

認可コードはHTTP GET REQUEST の QUERY_STRINGに含まれています。 だからウェブサーバーがリクエストを受け付けた時点で認可コードを取得できるというわけです。

プロジェクトとクライアントIDを作成する

以下の手順に従って、Google Developper ConsoleプロジェクトとクライアントIDを作成し、クライアントIDのキーファイルをダウンロードしておく必要があります。

  1. Google Developper Consoleで、新規プロジェクトを作成し、上のドロップダウンから作成したプロジェクトを選択。
  2. 「ライブラリ」タブで「Google Drive API」を使用できるように設定しておきます。検索ボックスに「Drive」と入力すればすぐ見つかる。認証をおこなうだけなら不要かもしれませんが、サンプルプログラムではGoogle Driveのルートにあるファイルの一覧を表示するために必要です。
  3. 「認証情報」タブで「OAuth 2.0 クライアント ID」を新規作成(「アプリケーションの種類」は「ウェブアプリケーション」か「その他」を選択。
  4. 作成したクライアントIDのJSONファイルをダウンロードします。

サンプルコード

以下のコードは、依存モジュールをインストールすれば単体で動作します。

ちょっと(いやかなり)長くなってしまい、かつコメント無しでスミマセン。

最初に定義されてるmain()が本体。ざっくり以下の様なことをやっています。

  1. コマンドラインでクライアントIDのキーファイルを(.jsonを抜いて)指定して実行します。
  2. キーファイルを読み込んでOAuth2のクライアントを作成。
  3. 初回の認証ではブラウザを開き、認可コードを受け取ります。
  4. 2回目以降の認証ではアクセストークンをリフレッシュしています。
  5. 何れにせよアクセストークンが得られたら、ファイルに保存し、
  6. 認可したアカウントのGoogle Driveのルートフォルダのファイル一覧を表示します。

※ 実際に動かすための情報は後述。

google-oauth2.js

"use strict";
const google = require("googleapis");
const OAuth2 = google.auth.OAuth2;
const drive = google.drive({ version: 'v3' });

const listit = require("list-it");
const opn = require('opn');
const server = require("node-http-server");
const fs = require("fs");

var REDIRECT_PORT = 8800;

var clientName = process.argv[2];

function main() {
    var clientFn = clientName + ".json";
    readJsonFile(clientFn).then(function(client) {
        console.log(clientFn + " Loaded:");
        console.log("Client:");
        console.log(JSON.stringify(client, null, "  "));
        return createAuth(client);
    }).then(function(auth) {
        var acctokFn = clientName + "-auth.json";
        return readJsonFile(acctokFn).then(function(acctok) {
            console.log(acctokFn + " loaded:");
            console.log("Access Token:");
            console.log(JSON.stringify(acctok, null, "  "));
            console.log("Refresh Tokens:");
            return refreshAccessToken(auth, acctok).then(function(acctok) {
                console.log(JSON.stringify(acctok, null, "  "));
                return writeJsonFile(acctokFn, acctok);
            }).then(function() {
                return auth;
            });
        }).catch(function(err) {
            console.log("No Access Token:");
            var authUrl = auth.generateAuthUrl({
                scope: "https://www.googleapis.com/auth/drive"
            });
            console.log("Auth URL:" + authUrl);
            return getAuthCode(authUrl, REDIRECT_PORT).then(function(code) {
                console.log("Auth Code:" + code);
                return getAccessToken(auth, code);
            }).then(function(acctok) {
                console.log("Access Token:");
                console.log(JSON.stringify(acctok, null, "  "));
                return writeJsonFile(acctokFn, acctok);
            }).then(function() {
                return auth;
            });
        });
    }).then(function(auth) {
        return getFileList({
            auth: auth,
            q: "parents='root' and trashed=false"
        });
    }).then(function(resp) {
        var list = listit.buffer({ "autoAlign" : true });
        list.d([ "name", "mimeType" ]);
        resp.files.forEach(function(file) {
            list.d([ file.name, file.mimeType ]);
        });
        console.log(list.toString());
    }).then(function() {
        process.exit(0);
    }).catch(function(err) {
        console.log("Error: ", err.message);
        process.exit(-1);
    });
}

function createAuth(client) {
    var credential;
    if("installed" in client) {
        credential = client.installed;
    } else if("web" in client) {
        credential = client.web;
    }
    var auth = new OAuth2(
            credential.client_id,
            credential.client_secret,
            "http://localhost:" + REDIRECT_PORT + "/");
    return auth;
}

function refreshAccessToken(auth, oldTokens) {
    return new Promise(function(resolve, reject) {
        auth.credentials = oldTokens;
        auth.refreshAccessToken(function(err, refreshedTokens) {
            if (err) {
                reject(err);
                return;
            }
            auth.credentials = refreshedTokens;
            resolve(refreshedTokens);
        });
    });
}

function getAccessToken(auth, code) {
    return new Promise(function(resolve, reject) {
        auth.getToken(code, function(err, tokens) {
            if (err) {
                reject(err);
                return;
            }
            auth.credentials = tokens;
            resolve(tokens);
        });
    });
}

function getFileList(params) {
    return new Promise(function(resolve, reject) {
        drive.files.list(params, function(err, resp) {
            if(err) {
                reject(err);
                return;
            }
            resolve(resp);
        });
    });
};

function getAuthCode(url, redirect_port) {
    opn(url);
    return waitHttpRequest(redirect_port, "/").then(function(http) {
        var code = http.request.uri.query.code;
        http.serve(http.request, http.response,
            ["Auth Code:", code].join(" "));
        return code;
    });
}

function waitHttpRequest(port, pathname) {
    return new Promise(function(resolve, reject) {
        var config = new server.Config();
        config.port = port;
        server.onRequest = function(request, response, serve) {
            if(request.uri.pathname == pathname) {
                resolve({
                    request: request,
                    response: response,
                    serve: serve
                });
                return true;
            } else {
                reject(new Error("Illegal access."));
                return false;
            }
        };
        server.deploy(config);
    });
}

function readJsonFile(fn) {
    return new Promise(function(resolve, reject) {
        fs.readFile(fn, function(err, data) {
            if(err) {
                reject(err);
                return;
            }
            resolve(JSON.parse(data));
        });
    });
}

function writeJsonFile(fn, obj) {
    return new Promise(function(resolve, reject) {
        fs.writeFile( fn, JSON.stringify(obj, null, "    "), function(err) {
            if(err) {
                reject(err);
                return;
            }
            resolve();
        });
    });
}

main();

依存モジュールのインストール

依存モジュールは以下4つ。

これらが利用可能な状態でなければ動きません。npmでインストールして下さい。 npm install googleapis list-it opn node-http-server をコピペでいけます。

実行例

コマンドライン引数に、クライアントIDのキーファイルを指定して実行します。 以下実行例で、キーファイルは ./client_id.jsonとして保存されています。

ファイル一覧の桁がエラくズレていますが、実際のコンソールでは問題なしよ。 (デバッグ出力が長くて申し訳ない)

初回の認証実行例

$ node google-oauth2.js ./client_id
./client_id.json Loaded:
Client:
{
  "installed": {
    "client_id": "<クライアントID>",
    "project_id": "<プロジェクト名>",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://accounts.google.com/o/oauth2/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "<クライアントシークレット>",
    "redirect_uris": [
      "urn:ietf:wg:oauth:2.0:oob",
      "http://localhost"
    ]
  }
}
No Access Token:
Auth URL:<認証URL>
Auth Code:<認可コード>
Access Token:
{
  "access_token": "<アクセストークン>",
  "refresh_token": "<リフレッシュトークン>",
  "token_type": "Bearer",
  "expiry_date": 1512041811335
}
name                   mimeType
******                   application/vnd.google-apps.spreadsheet
******                   application/vnd.google-apps.folder
******                   application/vnd.google-apps.script
******                   application/vnd.google-apps.folder
Document           application/vnd.google-apps.folder
Image                  application/vnd.google-apps.folder
Google フォト    application/vnd.google-apps.folder
My Tracks            application/vnd.google-apps.folder

$

2回目以降のリフレッシュトークンを利用した実行例

$ node google-oauth2.js ./client_id
./client_id.json Loaded:
Client:
{
  "installed": {
    "client_id": "<クライアントID>",
    "project_id": "<プロジェクト名>",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://accounts.google.com/o/oauth2/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "<クライアントシークレット>",
    "redirect_uris": [
      "urn:ietf:wg:oauth:2.0:oob",
      "http://localhost"
    ]
  }
}
./client_id-auth.json loaded:
Access Token:
{
  "access_token": "<アクセストークン>",
  "token_type": "Bearer",
  "expiry_date": 1512021213468,
  "refresh_token": "<リフレッシュトークン>"
}
Refresh Tokens:
{
  "access_token": "<アクセストークン>",
  "token_type": "Bearer",
  "expiry_date": 1512040667695,
  "refresh_token": "<リフレッシュトークン>"
}
name                   mimeType
******                   application/vnd.google-apps.spreadsheet
******                   application/vnd.google-apps.folder
******                   application/vnd.google-apps.script
******                   application/vnd.google-apps.folder
Document           application/vnd.google-apps.folder
Image                  application/vnd.google-apps.folder
Google フォト    application/vnd.google-apps.folder
My Tracks            application/vnd.google-apps.folder

$

まとめ

とりあえず認証が通ったなら、あとは各種APIを利用して、いろんなことができるので、おらワクワクすっぞ!

もう少しキチンとライブラリ的にまとめられたら独立したnpmとしてpublishするつもりですが、今の段階ではこの状態でごめんなさい。

参考サイト