RM-BLOG

IT系技術職のおっさんがIT技術とかライブとか日常とか雑多に語るブログです。* 本ブログに書かれている内容は個人の意見・感想であり、特定の組織に属するものではありません。/All opinions are my own.*

【GCP】passport-google-oauth2.0を使ってみた

passport-google-oauth2.0を使った認証機能を作ってみたので、そのメモ。
基本的にはやはり自分用の備忘録。w
というか概ねこの記事に載ってるのだが、英語なのと、記事内容と微妙に違う部分があったので、その補足を兼ねて。

前提条件等

  • GCPのアカウントを持っていること
  • アプリケーションを開発する環境にNode.jsがインストールされていること
  • 記事内の画像や文言等は2021年12月現在のものです

GCPの設定

OAuth同意画面

GCPの管理コンソールに入って左上のハンバーガーメニューから「APIとサービス」を選択する
f:id:rmrmrmarmrmrm:20211206115032p:plain

「OAuth同意画面」を選択し、必須項目を埋めていく
f:id:rmrmrmarmrmrm:20211206115050p:plain
f:id:rmrmrmarmrmrm:20211206115103p:plain

  • 「アプリ名」はわかりやすいのを入れればいい。localでのテストなら単に「localtest」とかそんなんでもいい
  • 「ユーザーサポートメール」、「デベロッパーの連絡先情報」は、よくわからんので自分のを入れた
  • アプリのロゴをいれることをおすすめしますとか言われるが必須ではないので拘りがなければ特に無視してよい
  • 「承認済ドメイン」に、実際Google OAuthを利用する対象のドメインをいれる。例えばlocalhostなら「http://localhost:3000」とか。

「保存して次へ」を押してスコープの選択画面へ。
f:id:rmrmrmarmrmrm:20211206115116p:plain

「スコープを追加または削除」を押す。
f:id:rmrmrmarmrmrm:20211206115127p:plain

  • とりあえず一番最初に出てくる「.../auth/userinfo.profile」を選択する。
  • 冒頭の記事だとこれに加えて「email」というのを選んでるが、私が見たときにはこれが見つからなかった。表示される何かの条件があるのかもしれない。実際、後述するが、profileだけだとE-mailアドレスは取れない。

追記:
スコープの画面で「email」で検索すると(なぜか)「Kubernetes API」という括りの中にユーザーのemailアドレスのスコープが存在する。これを追加すればemailアドレスもとれる。
f:id:rmrmrmarmrmrm:20211206193341p:plain

「保存して次へ」を押してテストユーザーの追加画面へ。
f:id:rmrmrmarmrmrm:20211206115141p:plain

  • 自分のメールアドレスを入力する。

保存して終了。

認証情報

次に同じ「APIとサービス」のメニューから「認証情報」を選択し、「+認証情報の作成」ボタンを押して「OAuthクライアントID」を選択する。
f:id:rmrmrmarmrmrm:20211206115152p:plain

OAuthクライアントの情報を入力していく。
f:id:rmrmrmarmrmrm:20211206115202p:plain

  • 「名前」はわかりやすいのを入力すればよい。ただ後述する対象のURIを複数追加できる関係で、完全テスト目的だとしても、単に「テスト」とだけ入力するのはやめといたほうがよい。まあ後で変えられるけど。
  • 「承認済みのJavascript生成元」のURIには実際Google OAuthを利用する対象のドメインをいれる。例えばlocalhostなら「http://localhost:3000」とか。なぜか末尾に「/」をいれられないらしい(いれると怒られる)。
  • 「承認済みのリダイレクトURI」にはGoogle OAuthで認証完了後にリダイレクトされる先のコールバックURLを入力する。プレースホルダーは「http://example.com/」までになっているが、「~/auth/callback」のようなパスで受け止める場合は、「http://example.com/auth/callback」のようにフルパスで全部入力しないとだめ。ここで入力した値は、後にコード上で指定するコールバックURLと一致していないとだめ。冒頭の記事だと「http://localhost:3000/auth/callback」にしていた。

入力して保存するとクライアントIDとクライアントシークレットが表示されるので、メモる。
ここでメモ忘れても後で認証情報のメニューから対象の認証情報を選択すれば画面で見れる。
f:id:rmrmrmarmrmrm:20211206115213p:plain

アプリの設定

パッケージ用意

なにはともあれとりあえずinit(npm initでもyarn initでもどっちでもいい)

yarn init

dotenv、ejs、express、passport、passport-google-oauth20をいれる

yarn add dotenv ejs express passport passport-google-oauth20

コード用意

.envファイルを作成する

GOOGLE_OAUTH20_CLIENT_ID=xxx
GOOGLE_OAUTH20_CLIENT_SECRET=yyy
GOOGLE_OAUTH20_CALLBACK_URL=http://localhost:5000/auth/google/callback
  • この設定ファイルは後述するアプリ内で読み込ませるために利用する。見てわかる通り、先に作成した認証情報のクライアントID、クライアントシークレット等を記述する。
  • コールバックURLはフルパスで指定する(/auth/google/callbackとか相対パスだけ書いてはいけない)ここで指定する値は、先に設定したOAuthクライアントの 承認済リダイレクトURI」の内容と完全一致していなければならない。(一致していないとGoogleの認証画面を呼び出した際にエラーが起きる)

app.jsを以下のような感じで準備

const express = require('express');
const app = express();
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const dotenv = require('dotenv').config();

app.set('view engine' , 'ejs');

passport.serializeUser(function(user, done) {
    done(null, user);
});

passport.deserializeUser(function(user, done) {
    done(null, user);
});

// 1. passportにGoogleStrategyの利用を設定
passport.use(new GoogleStrategy({
                clientID: process.env['GOOGLE_OAUTH20_CLIENT_ID'],
                clientSecret: process.env['GOOGLE_OAUTH20_CLIENT_SECRET'],
                callbackURL: process.env['GOOGLE_OAUTH20_CALLBACK_URL'],
                passReqToCallback: true,
            },function(req, accessToken, refreshToken, profile, done) {
                process.nextTick(function(){
                    return done(null, profile);
                });
            }
));

// 2.認証できてるかチェックする共通関数。認証せずに来た場合は/loginにリダイレクトする
const isAuthenticated = (req, res, next) => {
    if(req.isAuthenticated()) {
        next();
    } else {
        res.redirect('/login');
    }
};

// 3.ログイン処理用のパス(ここが呼び出されるとGoogleの認証画面に飛んでいく)
app.get('/login' , passport.authenticate('google' , {scope: ['email', 'profile']}) , (req,res)=>{});

// 4.Googleで認証OKになると設定したコールバックに応じてこっちに飛んでくる
app.get('/auth/google/callback' , passport.authenticate('google', {failureRedirect: '/'}) , (req,res)=>{
    res.redirect('/portal');
});

// 5.認証が通った場合にリダイレクトされる先の画面、とりあえずportalというパスとした。isAuthenticatedという共通関数を利用しており、このパスリクエストに際しては認証必須とする
app.get('/portal', isAuthenticated, (req,res)=>{
    const data = {user: req.user};
    res.render('portal', data);
});

app.listen(3000, (err)=>{
    if(err){
        throw err;
    }
});
  1. passportにGoogleStrategyを設定する。ここで、先に作った「認証情報」から、クライアントID、クライアントシークレットを設定する。環境変数からもってくるようになっているので、.envファイルの事前準備が必要。passport.useの第二引数の関数function(req, accessToken, refreshToken, profile, done)...は、認証成功した場合に実行される関数。このときaccessTokenやらrefreshTokenやらが見える。
  2. 認証できてるかチェックするための簡素な関数。認証できていなければ/loginという認証用のパスに強制リダイレクトする。expressの各パス定義における第二引数(/portalの例がわかりやすい)にこの関数を指定すると、そのパスは認証必須となる=認証が必要となるページのパスにこの関数を入れ込めばよい。この例では、認証せずに直接/portalを叩くと強制的に/loginに飛ばされる
  3. ログイン処理のパス。第二引数に指定したpassport.authenticate('google' , {scope: ['email', 'profile']})の内容に基づき、すぐにGoogleの認証画面に飛ばされる。第二引数に指定しているscopeの値は、上述したOAuth同意画面の設定時に指定したスコープと一致させておく必要がある。ここで指定したスコープの値と同意画面で許可しているスコープの値が一致しない場合、エラーになる。
  4. Googleで認証OKになった場合に戻ってくるコールバック用のパス。OAuthクライアントの設定画面で「認証済みリダイレクトURI」で指定したコールバックのURLの相対パスにあたる部分。ここでは/portalにリダイレクトさせる。
  5. 認証が通った直後にリダイレクトされる先の画面。ここは何でもいい。好み。res.renderportalというページ名を指定しているので、事前にportal.ejsの準備が必要。

なお、この例ではapp.js内に全部含めてしまっているが、passport.serializeUser ...からapp.get('/portal', isAuthenticated, (req,res)=>{ ...までの分をrouterに分離(const router = express.Router();してrouterとpassportの定義を記述後、最後にmodule.exports = router;)しても問題なく動作する。
その後、アプリのルートディレクトリに「views」というディレクトリを掘って、その下に「portal.ejs」を以下のように準備

<!DOCTYPE HTML>
<html>
    <head>
        <title>
            portal
        </title>
    </head>
    <body>
        <p>
            hello <%= user.displayName %>
        </p>
    </body>
</html>

/potralでページ遷移する前にセットしているオブジェクトconst data = {user: req.user};の内容に基づき、userデータを取り出して表示しているだけの簡素なページ。
なおスコープとして「profile」を許可していると以下のような内容が取り出せる(※2021年12月現在)

{
    "id": "123456789012345678901",
    "displayName": "Tarou Test",
    "name": {
        "familyName": "Test",
        "givenName": "Tarou"
    },
    "photos": [
        {
            "value": "https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=xxxxx"
        }
    ],
    "provider": "google",
    "_raw": "{\n  \"sub\": \"123456789012345678901\",\n  \"name\": \"Tarou Test\",\n  \"given_name\": \"Tarou\",\n  \"family_name\": \"Test\",\n  \"picture\": \"https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\x003xx00-c\",\n  \"locale\": \"ja\"\n}",
    "_json": {
        "sub": "123456789012345678901",
        "name": "Tarou Test",
        "given_name": "Tarou",
        "family_name": "Test",
        "picture": "https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=xxxxx",
        "locale": "ja"
    }
}

追記:
上述の「追記」内容に基づき、emailもスコープに混ぜると、以下のようなオブジェクトになる(※2021年12月現在)

{
    "id": "123456789012345678901",
    "displayName": "Tarou Test",
    "name": {
        "familyName": "Test",
        "givenName": "Tarou"
    },
    "emails": [
        {
            "value": "test.tarou@gmail.com",
            "verified": true
        }
    ],
    "photos": [
        {
            "value": "https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=xxxxx"
        }
    ],
    "provider": "google",
    "_raw": "{\n  \"sub\": \"123456789012345678901\",\n  \"name\": \"Tarou Test\",\n  \"given_name\": \"Tarou\",\n  \"family_name\": \"Test\",\n  \"picture\": \"https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\x003xx00-c\",\n  \"email\": \"test.tarou@gmail.com\",\n  \"email_verified\": true,\n  \"locale\": \"ja\"\n}",
    "_json": {
        "sub": "123456789012345678901",
        "name": "Tarou Test",
        "given_name": "Tarou",
        "family_name": "Test",
        "picture": "https://xxx.googleusercontent.com/a/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx=xxxxx",
        "email": "test.tarou@gmail.com",
        "email_verified": true,
        "locale": "ja"
    }
}

おわりに

他のOAuthプロバイダを使う時と大体似てるので、それがGoogleに変わっただけといえば変わっただけである。
逆に言えばこれを覚えておくと他のOAuthプロバイダを利用するケースにも適用できる汎用性もある(特にpassport-xxx系のライブラリがもうすでに用意されてる場合)

ちなみに余談として話しておくと、個人的に今回は待ってしまったのは以下のような点だった

  • Client Secretを間違って記述してしまっていた。「xxxxroot@abcde」みたいな感じで登録してしまっていた。「xxxx」までが正しくて、「root@abcde」は不要。コマンドラインから選択してコピーする際、「xxxx」の後ろに改行が入ってなかったせいでコマンドラインの次の行の先頭が意図せず入ってしまっていた。蓋を開けてみれば非常に単純なミス。だがこういうのが重要だったりする。Client SecretがミスっててもGoogleの認証画面自体は表示されるので(認証通した後にエラーになる)気付くのが遅れた。
  • 最初、Callback URLを「http://localhost:5000/」でOAuth同意画面のほうに登録していたため、コード側でコールバックURLを「http://localhost:5000/auth/google/callback」で記述していたら、Googleの認証画面が呼び出せなくて(呼び出し時にエラーになる)、エラー内容として「コールバックURLを"http://localhost:5000/"にしろ」というのが表示されたので、コールバックなのにルートパス指定させるのか、変だなと思ってしまっていた。これも単純なミスで、そもそも自分でコールバックURLをそういう風に(http://localhost:5000/で)入力するように定義づけているのが問題だった。GCPの画面上のプレースホルダーが「http://example.com/」で止まっていたので、スキーム・ポート・ドメインだけを入れるものだと勘違いしてしまっていた。

こういう初歩的なところはきちんと見直さないとな、と改めて実感したのだった…