dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

ExpressとPassport.jsでOAuth2 (4)有効期限切れのアクセストークンをリフレッシュする

Passport.js を使ってみる回その4です。
以前の記事はこちら:


前回時点での課題

前回までで、認証したユーザー情報を MongoDB に保存することができました。
これで一件落着かと思われたのですが、よくよく考えるとこの後認証先のサービス(ここでは Google)の API を利用するとなると
アクセストークンが必要になります。これも保存しておかないといけませんね。

また、アクセストークンには有効期限が定められているものもあります。
その場合は「アクセストークンが期限切れになったら、リフレッシュトークンを使って新しいアクセストークンを取得し、再度 DB に保存する」という処理も入れなければいけません。

調べてみたところ Passport.js ではそのためのライブラリとして passport-oauth2-refresh というものがあるようです。

(あんまスターついてなくて心配。。。)

というわけで今回はまず、アクセストークンとリフレッシュトークンも DB に保存するようにした後、
passport-oauth2-refresh を使ったアクセストークンの更新処理も実装してみます。


ソースコード

PR は https://github.com/zaki-yama/passport-express-oauth2/pull/2


API の実装

本旨とは関係ありませんが、先に今回用意した API のコードを載せておきます。
内部的に Google Tasks API を叩いて Tasklist を取得します。

// routes/api.js
import express from 'express';
import fetch from 'node-fetch';

const router = express.Router();

router.get('/tasklists', (req, res) => {
  console.log('/tasklists', req.user);
  fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${req.user.accessToken}`)
    .then((response) => {
      return response.json();
    })
    .then((data) => {
      console.log(data);
      return res.json(data.items);
    });
});

export default router;

アクセストークンが切れなければこのままでも問題ありませんが、時間が経つと Google Tasks API のレスポンスが 401 になってしまいます。


アクセストークン、リフレッシュトークンを DB に保存する

これは簡単です。
認証時に accessToken, refreshToken は取得できていたので、ユーザーモデルにこれらを追加して一緒に保存してしまいます。

 // models/user.js
 const userSchema = mongoose.Schema({
   _id: String,
   displayName: String,
+  accessToken: String,
+  refreshToken: String,
   image: String,
 });
 // routes/auth.js
-  User.findByIdAndUpdate(profile.id, extractProfile(profile), {
+  const user = {
+    accessToken,
+    refreshToken,
+    ...extractProfile(profile),
+  };
+
+  User.findByIdAndUpdate(profile.id, user, {


passport-oauth2-refresh のインストールと設定

$ yarn add passport-oauth2-refresh

でインストールします。

 // routes/auth.js

 // [START setup]
 import passport from 'passport';
 import passportGoogleOauth2 from 'passport-google-oauth20';
+import refresh from 'passport-oauth2-refresh';

 import User from '../models/user';

 ...
 // along with the user's profile. The function must invoke `cb` with a user
 // object, which will be set at `req.user` in route handlers after
 // authentication.
-passport.use(new GoogleStrategy({
+const strategy = new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   ...
     console.log(err, user);
     return done(err, user);
   });
-}));
+});
+passport.use(strategy);
+refresh.use(strategy);

ここもそんなにやることはないですね。元々 passport.use() にストラテジーを渡していたのと同じことを refresh.use() に対してもやります。


アクセストークンが有効期限切れだったら更新する、という処理を実装

最初に紹介した /routes/api.js にアクセストークンが有効期限切れだったときの処理を入れると、以下のようになります。
これは、 passport-oauth2-refresh の Issue に書かれていたコードを参考にしたものです。
Mongoose でのモデルの save 方法などは↑のコードとは違うようなので、そこは直してます。

 import express from 'express';
 import fetch from 'node-fetch';
+import refresh from 'passport-oauth2-refresh';
 
 const router = express.Router();
 
 router.get('/tasklists', (req, res) => {
-  console.log('/tasklists', req.user);
-  fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${req.user.accessToken}`)
-    .then((response) => {
-      return response.json();
-    })
-    .then((data) => {
-      console.log(data);
-      return res.json(data.items);
-    });
+  const user = req.user;
+  const makeRequest = () => {
+    console.log('/tasklists', user.accessToken);
+    fetch(`https://www.googleapis.com/tasks/v1/users/@me/lists?access_token=${user.accessToken}`)
+      .then((response) => {
+        if (response.status === 401) {
+          console.log('response status 401. Retry');
+          // Access token expired (or is invalid).
+          // Try to fetch a new one.
+          refresh.requestNewAccessToken('google', user.refreshToken, (err, accessToken) => {
+            // TODO: Error handling
+            console.log('new access_token', accessToken);
+
+            // Save the new accessToken
+            user.accessToken = accessToken;
+            user.save().then(() => {
+              makeRequest();
+            });
+          });
+        }
+
+        console.log(response.status);
+        return response.json();
+      })
+      .then((data) => {
+        console.log(data);
+        return res.json(data.items);
+      });
+  };
+
+  return makeRequest();
 });
 
 export default router;

fetch を使った場合、ネットワークエラーでもない限りレスポンスは then() に渡されるので、 response.status の値で 401 が返ってきていないかチェックします。
401 で返ってきた場合はアクセストークン有効期限切れの可能性があるので、

refresh.requestNewAccessToken()

で新しいアクセストークンを取得します。
レスポンスの第二引数には新しいアクセストークンが渡されてくるので、あとはこのアクセストークンでユーザーモデルの内容を上書きして保存( user.save() => { ...)すればいいですね。

ただしリトライ数には上限がないので、Issue のサンプルコードと同じように

  • const retry = 2 などを用意する
  • makeRequest() 時にデクリメントする
  • retry <= 0 の場合は 401 画面を出す

とした方がいいと思います。

また、今回は簡単なサンプルコードのため API が一つしかなく、その中にアクセストークン更新処理&リトライを書いてますが、
これはおそらくすべての API で共通な処理となると思うので、独立した関数にする?など工夫が必要そうです。


TODO

エラーハンドリングを諸々省略してるので、もうちょっと安全にするためにはそういった処理も必要そうです。
なんか動作検証してたら UnhandledPromiseException がちょいちょい出てた気がする(けどリロードしたら出なくなる)んですが、 user.save() に失敗しているとかかな...