JavaScript
Node.js
Passport

Node.js+Passport+Google 認証を SPA から使う

はじめに

ウェブアプリでユーザ認証を実装するのに、Node.js+Passport を使ってみました。

これを通常のウェブアプリでなく、SPA(シングルページアプリ)で使いたいと思いました。

Node.js でウェブ API を用意する

クライアント環境のウェブブラウザで、いわゆるフロントエンドアプリが動きます。
サーバアプリでウェブ API が提供され、クライアントアプリからコールされます。
まず、ウェブ API を提供するサーバアプリを Node.js で用意します。

ワークスペースを作る

まず、Node.js+Express のワークスペースを作ります。

app.js
var express = require('express');
var app = express();
var bodyParser = require('body-parser');
app.use(bodyParser.urlencoded({ extended: true }));

var server = app.listen(3000, function(){
    console.log("Node.js is listening to PORT " + server.address().port);
});

認証不要なウェブ API を用意する

まず、認証不要でアクセスできる API を作ります。単純な文字列を返すようにします。

app.js
app.get('/api/insecure', function(req, res){
    res.send("Insecure response.");
});

クライアントアプリからアクセスしてみます。API から返された文字列を表示します。

app.html
<button id='insecure'>Insecure Request</button>
<div id='status'></div>
<div id='output'></div>

<script>
    document.querySelector('#insecure').addEventListener('click', function(){
        fetch('/api/insecure')
        .then(function(res){
            document.querySelector('#status').innerHTML = res.statusText;
            res.text()
            .then(function(text){
                document.querySelector('#output').innerHTML = text;
            });
        });
    });
</script>

ID とパスワードで都度認証する API を用意する

Passport を使う

ワークスペースに Passport を追加します。

$ npm install --save passport
app.js
var passport = require('passport');
app.use(passport.initialize());

ID とパスワードで都度認証する API を用意する

前述の API を ID とパスワードで都度認証するようにします。

app.js
var LocalStrategy = require('passport-local').Strategy;
passport.use(new LocalStrategy(function(username, password, done){
    if (/* username と password を確認する */) {
        return done(null, username);
    }
    else {
        return done(null, false);
    }
}));

app.post('/api/secure/local', passport.authenticate('local', { session: false }), function(req, res){
    res.send("Secure response from " + JSON.stringify(req.user));
});

クライアントアプリからアクセスします。

app.html
<button id='secure_local'>Local Auth Request</button>

<script>
    document.querySelector('#secure_local').addEventListener('click', function(){
        var data = new FormData();
        data.append('username', '◆◆◆◆');
        data.append('password', '◆◆◆◆');
        fetch('/api/secure/local',{
            method: 'POST',
            body: data
        })
        .then(function(res){
            document.querySelector('#status').innerHTML = res.statusText;
            res.text()
            .then(function(text){
                document.querySelector('#output').innerHTML = text;
            });
        });
    });
</script>

ID とパスワードの代わりに Google 認証を使う

ID とパスワードの代わりに Google を使って認証するようにします。

前述の API を Google を使って認証するようにしてみましょう。

app.js
app.get('/api/secure/google', passport.authenticate('google', { session: false,
    scope: ['https://www.googleapis.com/auth/plus.login'] }), function(req, res){
    res.send("Secure response from " + JSON.stringify(req.user));
});
/* 以下略 */

でも、API にアクセスするたびに Google のサイトに飛んで認証するとか、ナンセンスですね。
そこで、JWT を使います。

JWT で認証する

JWT とは

JWT(ジョット)とは、JSON Web Token の略で、電子署名付きの JSON で、改ざんをチェックできるようになっているものです。
以下のように使います。

①クライアントアプリは認証情報(ID とパスワードとか)をサーバに送る
②サーバアプリは送られた認証情報を確認する
(①②は OAUTH を使ってもいい。)
③サーバアプリは ID と有効期限を含む情報を暗号化して JSON(これが JWT)にして返す
④クライアントアプリは ID とパスワードの代わりに③で貰った JSON を送るようにする
⑤サーバアプリは送られた JSON の改竄検証した上で認証する

JWT で都度認証する API を用意する

ワークスペースで JWT を使った認証できるようにします。

$ npm install --save passport-jwt

前述の API を JWT で都度認証するようにします。

app.js
var JwtStrategy = require('passport-jwt').Strategy;
var ExtractJwt = require('passport-jwt').ExtractJwt;
passport.use(new JwtStrategy({
    jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('jwt'),
    secretOrKey: '◆◆◆◆',
    issuer: '◆◆◆◆',
    audience: '◆◆◆◆'
}, function(payload, done){
    if (/* payload.sub を確認する */) {
        return done(null, user, payload);
    }
    else {
        return done();
    }
}));

app.get('/api/secure/jwt', passport.authenticate('jwt', { session: false }), function(req, res){
    res.send("Secure response from " + JSON.stringify(req.user));
});

クライアントアプリからアクセスします。

app.html
<script>
    var accessToken;
    document.querySelector('#secure_jwt').addEventListener('click', function(){
        var headers = {};
        if (accessToken) {
            headers['Authorization'] = 'JWT ' + accessToken;
        }
        fetch('/api/secure/jwt', { headers: headers })
        .then(function(res){
            document.querySelector('#status').innerHTML = res.statusText;
            res.text()
            .then(function(text){
                document.querySelector('#output').innerHTML = text;
            });
        });
    });
</script>

認証に使うトークンを予め貰っておかないといけませんね。

Google 認証してトークンを返す

Google 認証できるようにする

改めて Google 認証できるようにします。

app.js
var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
passport.use(new GoogleStrategy({
    clientID: "◆◆◆◆◆◆◆◆.apps.googleusercontent.com",
    clientSecret: "◆◆◆◆",
    callbackURL: "http://◆◆◆◆/auth/google/callback"
}, function(accessToken, refreshToken, profile, done){
    if (/* profile.id を確認する */) {
        return done(null, user);
    }
    else {
        return done(null, false);
    }
}));

app.get('/auth/google', passport.authenticate('google', {
    scope: ['https://www.googleapis.com/auth/plus.login']
}));

app.get('/auth/google/callback', passport.authenticate('google', { session: false }), function(req, res){
    /* ここでトークンを生成して返す */
});

トークンを生成する

トークンを生成して返すためこうします。

npm install --save jsonwebtoken
app.js
var jwt = require('jsonwebtoken');

function generateToken(userId) {
    const token = jwt.sign({}, '◆◆◆◆', {
        expiresIn: '◆◆◆◆',
        audience: '◆◆◆◆',
        issuer: '◆◆◆◆',
        subject: userId.toString()
    });
    return token;
}

app.get('/auth/google/callback', passport.authenticate('google', { session: false }), function(req, res){
    var token = generateToken(req.user.id);
    res.JSON({ token: token });
});

クライアントアプリからアクセスしてみます。

app.html
<button id='auth_google'>Google Auth Login</button>

<script>
    document.querySelector('#auth_google').addEventListener('click', function(){
        fetch('/auth/google')
        .then(function(res){
        /* 以下略 */
</script>

上手く行きません。Google 認証するにはページ遷移しないといけないようです。

app.html
<a href="/auth/google">Google Auth Login</a>

Google 認証できますが、ページ遷移してしまうと、クライアントアプリ自体がいなくなってしまいます。

別ウィンドウを開いて Google 認証する

そこで、クライアントアプリでは別ウィンドウを開いて Google 認証を済ませるようにします。

app.html
<button id='auth_google'>Google Auth Login</button>

<script>
    document.querySelector('#auth_google').addEventListener('click', function(){
        window.open('/auth/google');
    });
</script>

クライアントアプリがサーバアプリからトークンを受取る

クライアントアプリがサーバアプリからトークンを受取るには工夫が必要です。

まず、クライアントアプリ側の仕掛け。
window オブジェクトにコールバック関数を仕掛けておきます。

app.html
<script>
    document.querySelector('#auth_google').addEventListener('click', function(){
        window.authenticateCallback = function(token){
            accessToken = token;
        }
        window.open('/auth/google');
    });
</script>

次に、サーバアプリ側の仕掛け。
こんなページテンプレートを用意しておきます。

authenticated.html
<script>
    window.opener.authenticateCallback("{{token}}");
    window.close();
</script>

このページが開かれると、事前に仕掛けておいたコールバック関数で、引数に渡したトークンがクライアントアプリに渡ります。

Google 認証からコールバックされトークンを生成したところで、上記のテンプレートをレンダリングします。
今回はレンダリングエンジンに Handlebars を使います。

npm install --save express-handlebars
app.js
var handlebars = require('express-handlebars');
app.engine('html', handlebars());
app.set('view engine', 'handlebars');
app.set('views', __dirname + '/●●●●');

app.get('/auth/google/callback', passport.authenticate('google', { session: false }), function(req, res){
    var token = generateToken(req.user.id);
    res.render('authenticated.html', { token: token });
});

おわりに

自作の SPA で Google 認証できるようにしたいと思い試してみましたが、上手く行きませんでした。
そこで以下の記事を見つけました。

これはいい記事ですが、記事に記載されているコードだけでは、自分のアプリに応用しても動きませんでした。
Github にあるコード全体を読みましたが、自分のスタイルと違っていて、自分のアプリに応用するのはつらかった。
そこで、自分のコードにするため、記事のコードを書換していきました。その経緯を、この記事にしておきました。