Passport.js を使ってみる回その4です。
以前の記事はこちら:
- [Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る - dackdive's blog
- [Node.js]ExpressとPassport.jsでOAuth2 (2)認証済みユーザー情報をセッションに保存する - dackdive's blog
- ExpressとPassport.jsでOAuth2 (3)認証済みユーザー情報をMongoDBに保存する - dackdive's blog
前回時点での課題
前回までで、認証したユーザー情報を 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()
に失敗しているとかかな...