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() に失敗しているとかかな...

[Node.js]ExpressとPassport.jsでOAuth2 (3)認証済みユーザー情報をMongoDBに保存する

はじめに

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

前回は express-session を使い、認証したユーザーの情報をセッションに保存するところまでやりました。
そのときの TODO として、セッションの保存先がデフォルトではサーバー側のメモリ(MemoryStore)になっていたのですが、これは production 環境では推奨されないようです(参考(Warning のところ))。

そこで今回はセッションの保存先として MongoDB を使うように設定します。


リポジトリ


MongoDB のインストール

詳しくは
dackdive.hateblo.jp
を見ていただきたいのですが、Mac の場合 Homebrew からインストールできます。

$ brew install mongodb

# 以後、PC の起動時にプロセスを自動起動
$ brew services start mongodb

# 起動確認
$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
...
> 


各種ライブラリのインストール

MongoDB を Node.js および Express で使うために必要なライブラリをインストールします。

mongoose

Node.js から MongoDB を扱うためのライブラリには node-mongodb-native という MongoDB 公式のドライバもありますが、今回は mongoose というライブラリを使います。
mongoose は「object data modeling(ODM)tool」あるいは「O/R マッパーのように使えるライブラリ」などと説明されています。

$ yarn add mongoose
connect-mongo

こちらは Express のセッションストアとして MongoDB を使うために必要なライブラリです。
express-session の compatible session stores にも記載されています。

$ yarn add connect-mongo


コード


User モデルの作成

はじめに、データベースに保存する User データのモデルを定義します。

// models/user.js
import mongoose from 'mongoose';

const userSchema = mongoose.Schema({
  _id: String,
  displayName: String,
  image: String,
});

export default mongoose.model('User', userSchema);

mongoose.Schema() に渡すオブジェクトでデータの型を定義します。
今回、データを一意に識別するための id には Google のユーザー Id を使用するため、 _id: String を指定します。
(何も指定しないと MongoDB の ObjectId 型になるみたいです)

mongoose.model() の第一引数はコレクション名を決めるもので、今回のように User とした場合、コレクション名は自動的に先頭小文字+複数形の users になります。

参考:Compiling your first model

The first argument is the singular name of the collection your model is for. Mongoose automatically looks for the plural version of your model name. Thus, for the example above, the model Tank is for the tanks collection in the database.


MongoDB および connect-mongo の設定

サーバー側のエントリーポイントである app.js に、以下のように追記します。

 import express from 'express';
 import session from 'express-session';
+import mongoose from 'mongoose';
+import connectMongo from 'connect-mongo';
 import path from 'path';
 import passport from 'passport';

 (略)
+
+const DATABASE_URI =
+  process.env.MONGOLAB_URI ||
+  process.env.MONGOHQ_URL ||
+  'mongodb://localhost/myapp';
+
+mongoose.connect(DATABASE_URI, {
+  useMongoClient: true,
+});



+const MongoStore = connectMongo(session);
 app.use(session({
   secret: 'keyboard cat',
   resave: false,
   saveUninitialized: true,
+  store: new MongoStore({ mongooseConnection: mongoose.connection }),
   cookie: {
     maxAge: 1000 * 60 * 60 * 24 * 30,
   },
}));

前半では mongoose.connect() メソッドを使い MongoDB へ接続します。
DATABASE_URI環境変数を使っているのは、将来的に Heroku などの PaaS でも動かすことを想定しています。
Heroku の公式ドキュメント を参考にしています。
localhost/myappmyapp の部分は任意の文字列で、これが DB 名になります。

また、 useMongoClient: true というオプションについては http://mongoosejs.com/docs/connections.html#use-mongo-client
理由はちゃんと理解していませんが、バージョン 4.11.0 以降はこれを指定しないと起動時に warning が出るようです。

(node:47913) DeprecationWarning: `open()` is deprecated in mongoose >= 4.11.0, use `openUri()` instead, or set the `useMongoClient` option if using `connect()` or `createConnection()`. See http://mongoosejs.com/docs/connections.html#use-mongo-client


後半では express-session 用に MongoStore を作成し、 store オプションに指定することでセッションの保存先を MongoDB に変更しています。

なお、mongoose のセットアップを行った際のコネクションを再利用するため、 mongooseConnection オプションを指定しています。
これは connect-mongo の README に記載がありました。
https://github.com/jdesboeufs/connect-mongo#re-use-a-mongoose-connection


認証完了時に User をデータベースに保存する

routes/auth.js を以下のように更新します。

 passport.use(new GoogleStrategy({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   callbackURL: 'http://localhost:8080/auth/google/callback',
   accessType: 'offline',
-}, (accessToken, refreshToken, profile, cb) => {
+}, (accessToken, refreshToken, profile, done) => {
   // Extract the minimal profile information we need from the profile object
   // provided by Google
-  cb(null, extractProfile(profile));
+  User.findByIdAndUpdate(profile.id, extractProfile(profile), {
+    upsert: true,
+    new: true,
+  }, (err, user) => {
+    console.log(err, user);
+    return done(err, user);
+  });
 }));

ここでは mongoose の findByIdAndUpdate() メソッドを使っています。
upsert: true を指定すると、文字通り DB にデータが存在しなかったときに insert してくれます。
また new: true を指定していますが、これは true の場合更新後のオブジェクトを、false の場合更新前のオブジェクトを返すようにするというオプションです。


セッションには Id のみ保存し、デシリアライズ時に User 情報を復元する

最後に、routes/auth.js の serializeUser()deserializeUser() メソッドを以下のように更新します。

 passport.serializeUser((user, done) => {
   console.log('serializeUser', user);
-  done(null, user);
+  done(null, user.id);
 });
-passport.deserializeUser((obj, done) => {
-  console.log('deserializeUser', obj);
-  done(null, obj);
+passport.deserializeUser((id, done) => {
+  console.log('deserializeUser', id);
+  User.findById(id, (err, user) => {
+    if (err || !user) {
+      console.log('Cannot find user', id);
+      return done(err);
+    }
+    console.log('Found user', user);
+    done(null, user);
+  });
 });

今までは User 情報を丸ごとセッションに保存していたのですが、Id だけを保存するように serializeUser() を修正しました。
そうすると deserializeUser() 側の引数も Id になるので、今度はこの Id を使って users コレクションから User オブジェクトを検索します。


リダイレクト処理を入れる

これはおまけですが、 / にアクセスしたときに req.user が存在するかどうか、つまり認証済みかどうかをチェックし
認証が済んでいない場合は Google の認証画面にリダイレクトするようにしておきます。

 app.get('/', (req, res) => {
-  console.log('user', req.user);
+  console.log('/', req.user);
+  if (!req.user) {
+    return res.redirect('/auth/login');
+  }
   res.sendFile(path.join(__dirname, 'public', 'index.html'));
 });


動作確認

http://localhost:8080 にアクセスすると Google の認証画面にリダイレクトされ、許可するとトップに戻ってきます。
このとき、MongoDB の中身はどうなっているかというと

$ mongo
MongoDB shell version v3.4.7
connecting to: mongodb://127.0.0.1:27017
MongoDB server version: 3.4.7
Server has startup warnings:
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] ** WARNING: Access control is not enabled for the database.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten] **          Read and write access to data and configuration is unrestricted.
2017-08-27T16:16:32.068+0900 I CONTROL  [initandlisten]
> show dbs
admin  0.000GB
local  0.000GB
myapp  0.000GB
> use myapp # myapp に切り替え
switched to db myapp

# sessions と users コレクションが作成されている
> show collections
sessions
users

# セッションの中身
> db.sessions.find()
{ "_id" : "kdBCv3ceCC_ufClCCuekOoeHtU-X_vMV", "session" : "{\"cookie\":{\"originalMaxAge\":2592000000,\"expires\":\"2017-10-02T02:50:00.016Z\",\"httpOnly\":true,\"path\":\"/\"},\"passport\":{\"user\":\"112492058384636445846\"}}", "expires" : ISODate("2017-10-02T02:50:00.038Z") }

# ユーザー情報
> db.users.find()
{ "_id" : "112492058384636445846", "__v" : 0, "displayName" : "Shingo Yamazaki", "image" : "https://lh6.googleusercontent.com/-jwkJLbJL4wE/AAAAAAAAAAI/AAAAAAAACI/1MbHlZlCL5w/photo.jpg?sz=50" }
>

というわけで、無事に保存されました。


まとめと TODO

認証済みのユーザー情報をデータベースに保存するため、MongoDB および mongoose を導入しました。
エラーハンドリングなど詰めが甘いところは多々ありますが、基本的な認証のしくみとしては整ったかと思います。

この後実際に GoogleAPI を利用しようとすると、アクセストークンやリフレッシュトークンを使う必要があります。
そのため、おそらくこれらもユーザー情報にひもづけて保存しておく必要がありますね。。。

と、ここまでやってみて気づいたんですが、 GoogleAPI を利用するのであれば専用の Node.js Client がライブラリとして提供されていました。

最初からこっちを使っていればよかったかも。。。
まあ、 Google 以外のサービスでも使える Passport.js の基本的な使い方がわかったから良しとするか。

(追記)

続編書きました。

(追記ここまで)

わからないこと

期限切れになったセッションの削除方法。
セッションの有効期限が切れると再度認証画面にとばされますが、そこで認証を行って戻ってくると sessions コレクションに新しいセッション情報がセットされます。
古くなったセッションは削除してもよさそうですが、方法がわからず。。。


リファレンス

[Node.js]ExpressとPassport.jsでOAuth2 (2)認証済みユーザー情報をセッションに保存する

はじめに

Passport.js を使ってみる回その2です。
前回はこちら:[Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る - dackdive's blog

前回まででなんとか認証先のサービス(今回は Google)で認証した後、ユーザー情報を取得してアプリに戻ってくるところまでできました。
今回は取得したユーザー情報をセッションに保存し、使い回せるようにします。

リポジトリ

該当のコミットは e0ecd4 あたり


express-sesion のインストール

セッションを利用するために express-session が必要になるのでインストールしておきます。

$ yarn add express-session


コード

今回新たに追加するコードです。

// routes/auth.js(抜粋)
 // Configure the Google strategy for use by Passport.js.
 //
 // OAuth 2-based strategies require a `verify` function which receives the
 // credential (`accessToken`) for accessing the Google API on the user's behalf,
 // 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({
   clientID: process.env.CLIENT_ID,
   clientSecret: process.env.CLIENT_SECRET,
   // FIXME: Enable to switch local & production environment.
   callbackURL: 'http://localhost:8080/auth/google/callback',
   accessType: 'offline',
 }, (accessToken, refreshToken, profile, cb) => {
   // Extract the minimal profile information we need from the profile object
   // provided by Google
   cb(null, extractProfile(profile));
 }));

+passport.serializeUser((user, done) => {
+  console.log('serializeUser', user);
+  done(null, user);
+});
+passport.deserializeUser((obj, done) => {
+  console.log('deserializeUser', obj);
+  done(null, obj);
+});
// app.js(抜粋)
 import express from 'express';
+import session from 'express-session';
 import path from 'path';
 import passport from 'passport';
 
 import auth from './routes/auth';

 (略)

+app.use(session({
+  secret: 'keyboard cat',
+  resave: false,
+  saveUninitialized: true,
+  cookie: {
+    maxAge: 1000 * 60 * 60 * 24 * 30,
+  },
+}));

 app.use(passport.initialize());
+app.use(passport.session());

-// Serve static files
-// app.use(express.static(path.join(__dirname, 'public')));
 
+// Application Root
+app.get('/', (req, res) => {
+  console.log('user', req.user);
+  res.sendFile(path.join(__dirname, 'public', 'index.html'));
+});

routes/auth.js には GoogleStrategy の初期化処理の下に serializeUserdeserializeUser という2つのメソッドを定義しました。
これらは、ユーザー情報をセッションに保存するときや取り出すときにそれぞれ実行します。

参考:http://passportjs.org/docs#sessions

In order to support login sessions, Passport will serialize and deserialize user instances to and from the session.

ドキュメントの例では、

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

セッションにはユーザーオブジェクトそのものではなくユーザーの ID だけを保存するようにしています。

また、app.js では上から順に以下を行っています。

express-session ミドルウェアの設定

app.use(session({ ... }) の箇所です。各オプションについてはこちらを見るといいです。
https://expressjs.com/en/resources/middleware/session.html

なお、サイトによっては cookie-parser を有効にしているコードも見られますが、express-session のドキュメントを読むと

Note Since version 1.5.0, the cookie-parser middleware no longer needs to be used for this module to work.

とあるので、最新版では不要のようです。


Passport.js 側のミドルウェアも有効化

app.use(passport.session()); で Passport.js のセッション用ミドルウェアも有効にしています。
express-session の初期化処理は passport.session() だけでなく passport.initialize() よりも前に行わないといけないので注意


アプリのエントリーポイント(/)に対するミドルウェアを定義

express.static() を指定してしまうと / に来たアクセスをミドルウェアで処理できなかったので書き方を変えました。
今回は / でも req.user をログに出力しているだけですが、ここで

if (!req.user) {
  return res.redirect('/auth/login');
}

などとしてやれば「アクセス時、認証済みかどうかを判定し、未認証の場合は認証画面にリダイレクト」が実現できそうです。


Passport.js や Express におけるセッションの扱いについて

一応ここまでで動くようになりましたが、Passport.js および Express でセッションがどう扱われているのかわかってなかったのでメモ。


Passport.js でセッションを使う/使わないという設定はどうなっているの?

http://passportjs.org/docs#disable-sessions

After successful authentication, Passport will establish a persistent login session. This is useful for the common scenario of users accessing a web application via a browser. However, in some cases, session support is not necessary

という記載から、デフォルトで認証したユーザー情報をセッションに保存するような動きになっていると理解しました。
その上で

http://passportjs.org/docs#middleware

If your application uses persistent login sessions, passport.session() middleware must also be used.

Note that enabling session support is entirely optional, though it is recommended for most applications. If enabled, be sure to use express.session() before passport.session() to ensure that the login session is restored in the correct order.

なので、passport.session() および express.session() は有効にする必要があると。


セッション情報はどこに保存されているの?

express-session 側のドキュメントを読むと
https://github.com/expressjs/session#store

The session store instance, defaults to a new MemoryStore instance.

なので、オプションを特に指定しなかったときの保存先は MemoryStore つまりサーバー側のメモリ上(なので再起動のたびにクリアされる)、
オンメモリではなくデータベース上に永続的に保存したい場合は store オプションで指定する必要があるみたいです。


セッション情報はどのように保存されているの?

こちらも express-session 側のドキュメントを読むと
https://github.com/expressjs/session#sessionoptions

Note Session data is not saved in the cookie itself, just the session ID. Session data is stored server-side.

というわけで、セッション情報は基本的にサーバー側に保存され、その ID だけをブラウザの Cookie に保存しているようです。
実際に今回のコードで Cookie を見ると、ログイン後に connect.sid という名前の Cookie が保存されていることがわかります。

f:id:dackdive:20170827165044p:plain

試しにこちらを削除してリロードすると、再度ログイン画面にリダイレクトされます。


まとめと TODO

というわけで前回取得した認証済みユーザー情報をセッションに保存するところまでできました。
しかしながら express-session#session(options) の項を読むとわかるように

Warning The default server-side session storage, MemoryStore, is purposely not designed for a production environment. It will leak memory under most conditions, does not scale past a single process, and is meant for debugging and developing.

セッションの保存先としてサーバーのメモリを使用するのは本番環境では推奨されないようです。
次回はこれを MongoDB に保存する方法を調べます。

(追記)

書きました。

リファレンス

[Node.js]ExpressとPassport.jsでOAuth2 (1)インストールと認証までのフローを作る

はじめに

Express で TwitterGoogleAPI を使った web アプリを作りたいと思い、
Node.js 用のそのような認証・認可のためのライブラリを探したところ Passport.js というのが有名そうだとわかりました。

既に日本語でも多くの記事があるものの、チュートリアルのような段階的に作り上げていく手順としてまとまったドキュメントがなくて最初戸惑ってしまったので
実装の手順をメモしておきます。

なお、今回は連携先に Google を選びました。

※OAuth に対して「認証」という言葉を使うのは適切でなく「認可」と呼ぶべきなのかもしれませんが、(自分自身理解しきれていないこともあり)ここでは特に区別せず「認証」という言葉を使っています。


リポジトリ

該当のコミットは 27d4b253a35d あたり。


参考にしたリポジトリ

Node.js を使用したユーザーの認証  |  Node.js  |  Google Cloud Platform

GCP の Node.js チュートリアルで Passport.js を使用した認証についても触れており、こちらが非常に分かりやすかったです。
リポジトリはこちら。

https://github.com/GoogleCloudPlatform/nodejs-getting-started


手順

Google Cloud Console でプロジェクトを作成し、client id と client secret を発行する

https://console.cloud.google.com
にアクセスして新規プロジェクトを作成し、[API とサービス] > [認証情報] から新規 OAuth 2.0 クライアント ID を作成します。
こちらの公式ドキュメントを参考にすると良いと思います。
OAuth 2.0 の設定 - Cloud Platform Console ヘルプ

アプリケーションの種類は「その他」で問題ありませんでした。
Google+ API を有効化しておく必要があるので注意

最後に表示されるダイアログの クライアント ID と クライアントシークレット を控えておきます。
またはダイアログを閉じた後も、クライアント ID 一覧の右のダウンロードアイコンからダウンロードできます。

f:id:dackdive:20170824031450p:plain


Express アプリケーションのひな形作成

express-generator で作成したものでも何でもいいです。自分は webpack-dev-server を組み込んだテンプレート を用意していたのでそれを使いました。


Passport.js のインストール
$ yarn add passport passport-google-oauth20

で Passport.js 本体と、Google と連携するための Strategy をインストールします。
Passport.js の構成として、本体の他に連携サービスごとにライブラリが用意されており(Strategy と呼ぶ)、必要なものを追加でインストールします。

passport-google-oauthpassport-google-oauth20 があるが、前者は OAuth 1.0 用の Strategy と OAuth 2.0 用の Strategy の両方が使えるものっぽいです(未確認)。


セットアップ

初期化処理などを書いていきます。まず、エントリーポイントとなる app.js は以下のようになります。
(babel-node を使っているため ES Modules 形式になっています)

// app.js
import express from 'express';
import path from 'path';
import passport from 'passport';

import auth from './routes/auth';

const app = express();
const port = process.env.PORT || 8080;

app.use(passport.initialize());

// Routing
app.use('/auth', auth);

// Serve static files
app.use(express.static(path.join(__dirname, 'public')));

app.listen(port, (err) => {
  if (err) {
    console.error(err);
  } else {
    console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`);
  }
});

Passport の初期化として app.use(passport.initialize()); を追加したぐらいでしょうか。

また、ログインやコールバックなどの URL は /auth という URL 以下にまとめるようにしました。
routes/auth.js の中身は以下です。

// routes/auth.js
import express from 'express';

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

const GoogleStrategy = passportGoogleOauth2.Strategy;

function extractProfile(profile) {
  let imageUrl = '';
  if (profile.photos && profile.photos.length) {
    imageUrl = profile.photos[0].value;
  }
  return {
    id: profile.id,
    displayName: profile.displayName,
    image: imageUrl,
  };
}

// Configure the Google strategy for use by Passport.js.
//
// OAuth 2-based strategies require a `verify` function which receives the
// credential (`accessToken`) for accessing the Google API on the user's behalf,
// 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({
  clientID: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  // FIXME: Enable to switch local & production environment.
  callbackURL: 'http://localhost:8080/auth/google/callback',
  accessType: 'offline',
}, (accessToken, refreshToken, profile, cb) => {
  // Extract the minimal profile information we need from the profile object
  // provided by Google
  cb(null, extractProfile(profile));
}));

// TODO: ルーティング

GoogleStrategy というオブジェクトに先ほどの client id や client secret を渡して初期化しています。
今回、これらの情報は .env に記述し、node-foreman などを使って起動時に読み込む想定でこのような書き方になっています。

また、 extractProfile() は参考リポジトリのコードをそのまま利用しました。
認証が完了した後のレスポンスとして渡されてくる 生の profile (→ 正確にはレスポンスを このあたり で一度加工してるぽい)をこの関数で加工して、アプリで使う認証済みユーザー情報としている。

ログイン処理を実装する

Google の認証画面に遷移するための URL を定義します。先ほどの routes/auth.js の下に追記します。

// routes/auth.js
const router = express.Router();

router.get('/login',
  passport.authenticate('google', { scope: ['email', 'profile'] }),
);

export default router;

基本的にはこれだけ。
この状態で http://localhost:8080/auth/login にアクセスすると Google の認証画面にリダイレクトされます。
ただしコールバック URL /auth/google/callback に対応するミドルウェアを定義していないので、このままでは認証を許可してアプリに戻ってきたときにエラーになります。

リダイレクト処理を実装する

routes/auth.js にもう1つミドルウェアを追加します。

router.get(
  // OAuth 2 callback url. Use this url to configure your OAuth client in the
  // Google Developers console
  '/google/callback',

  // Finish OAuth 2 flow using Passport.js
  passport.authenticate('google'),

  // Redirect back to the original page, if any
  (req, res) => {
    console.log('Succssfully logged in:', req.user);
    const redirect = req.session.oauth2return || '/';
    delete req.session.oauth2return;
    res.redirect(redirect);
  },
);

passport.authenticate('google') をもう1回呼び出して OAuth2 のフローを完了させた後、認証後の処理としてはとりあえずログを出力して / にリダイレクトするだけとしています。
もう一度 /auth/login にアクセスすると、今度は

Succssfully logged in: { id: '112....5846',
  displayName: 'Shingo Yamazaki',
  image: 'https://lh6.googleusercontent.com/-jwkJLL4wE/AAAAAAAAAAI/AAAAAAAAACI/1MbHlZlCL5w/photo.jpg?sz=50' }

のようなログが出力されます。というわけで認証したユーザー情報は req.user で取得できることがわかります。
またこの値は先ほどの extractProfile() で処理した結果になっています。


まとめと TODO

というわけでまずは Passport.js を使った基本的な OAuth2 のフローの実装方法がわかりました。
が、今のままでは認証したユーザー情報を持ち回ることができていないので、次回はこの情報をセッションに保存する方法について調べます。

実際に書いてみてわかりましたが、連携するサービスを変えたい場合はほとんど Strategy の差し替えだけで済みそうで非常に便利。

(追記)

続編書きました。


トラブルシューティング

/auth/login にアクセスして認証した後、

Error
    at /Users/yamazaki/workspace/node_modules/passport-google-oauth20/lib/strategy.js:95:21
    at passBackControl (/Users/yamazaki/workspace/node_modules/oauth/lib/oauth2.js:132:9)
    at IncomingMessage.<anonymous> (/Users/yamazaki/workspace/node_modules/oauth/lib/oauth2.js:157:7)
    at emitNone (events.js:91:20)
    at IncomingMessage.emit (events.js:188:7)
    at endReadableNT (_stream_readable.js:975:12)
    at _combinedTickCallback (internal/process/next_tick.js:80:11)
    at process._tickDomainCallback (internal/process/next_tick.js:128:9)

というエラーが出てしばらく悩みましたが、結果的には Google+ API を有効化していないのが原因でした。

webpack-dev-serverをExpressに組み込んで使う(webpack-dev-middleware, webpack-hot-middleware)

はじめに

1年以上前ですがこんな記事を書きました。

このときは webpack-dev-serverスタンドアロンなサーバーとして使う方法しか知らなかったのですが
既存の Express アプリに組み込んで使うこともできます。
そのためには webpack-dev-middleware および HMR のために webpack-hot-middleware を使います。

今回はその設定手順をメモ。


公式ドキュメント

Webpack v1: https://webpack.github.io/docs/webpack-dev-middleware.html
Webpack v2: https://webpack.js.org/guides/development/#webpack-dev-middleware


設定手順

パッケージをインストールする
$ npm install -D webpack-dev-middleware webpack-hot-middleware


webpack.config.js を修正する

以下のように2箇所修正します。(ES2015 で書いてます)

 // webpack.config.dev.babel.js
 import webpack from 'webpack';

 export default {
   entry: [
+    'webpack-hot-middleware/client',
     './index',
   ],
 
   // Configuration for dev server
   devServer: {
     contentBase: path.resolve(path.join(__dirname, 'public')),
     port: 3000,
   },
 
   devtool: 'cheap-module-eval-source-map',
   plugins: [
       ...
+     new webpack.HotModuleReplacementPlugin(),
   ],
 });


Express アプリ内で middleware を読み込む

続いて、Express アプリ(ここでは server.js)内で上述した2つの middleware を使用します。
(サーバーサイドも ES2015 で書いてる想定です)

 // server.js
 import express from 'express';
 import path from 'path';
 
 const app = express();
 const port = process.env.PORT || 8080;
 
+if (process.env.NODE_ENV !== 'production') {
+  const webpack = require('webpack');
+  const webpackDevMiddleware = require('webpack-dev-middleware');
+  const webpackHotMiddleware = require('webpack-hot-middleware');
+  const config = require('./webpack.config.dev.babel').default;
+  const compiler = webpack(config);
+
+  app.use(webpackHotMiddleware(compiler));
+  app.use(webpackDevMiddleware(compiler, {
+    noInfo: true,
+    publicPath: config.output.publicPath,
+  }));
+}
 
 // Serve static files
 app.use(express.static(path.join(__dirname, 'public')));
 
 console.log(`Served: http://localhost:${port}`);
 app.listen(port, (err) => {
   if (err) {
     console.error(err);
   } else {
     console.info(`==> 🌎  Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`);
   }
 });

基本的には webpack の config ファイルを読み込んで一度 webpack() に渡した後、それ(compiler)をそれぞれの middleware に引数として渡してあげればよいです。

また、webpackDevMiddleware の第二引数に渡しているオプションは

  • noInfo:info レベルのログを console に出力するかどうか。true(しない)にしてる人が多い印象
  • publicPath(必須): webpack.config.js の output.publicPath を指定しておけば OK

それ以外のオプションについては README を参照してください。

NOTE:
webpack.config を ES5 で書いてる場合

const config = require('./webpack.config.dev.babel').default;

.default は不要です。

また、これらの middleware は本番環境では無効にしておく必要があるため
process.env.NODE_ENV で切り替えができるようにしておきます。


サーバーを起動する

設定は以上です。後は package.json

"scripts": {
  "start": "babel-node server.js"
}

などと書き、

$ npm start

でサーバーを起動すると webpack-dev-server および HMR が有効になります。


注意事項

ここまでで開発時の設定としては十分なのですが、この webpack.config.js で本番環境用にビルドをすると
entryplugins に HMR が含まれているため実行時にエラーになります。

GET http://localhost:8080/__webpack_hmr 404 (Not Found)

そのため、実際には開発用(dev server 用)と本番環境向けビルド用に config ファイルを分けるか、または条件分岐を行う必要があります。

このあたりはサンプルコードを作ったので参考にしてください。

config ファイルを

  • webpack.config.base.babel.js:開発用でも本番環境用でも共通の設定
  • webpack.config.dev.babel.js:開発用の設定(↑で挙げたような)を記載
  • webpack.config.prod.babel.js:本番環境用の設定(圧縮など)を記載

というように3つに分割し、webpack-merge を使って base を残り2つにマージする形でそれぞれの環境用の config を生成しています。

ElectronでExpressアプリケーションを動かす

ちょいメモ。こんな感じでいけるっぽい。
元のソースコードelectron-quick-start を使った。

'use strict';

const electron = require('electron');
// Module to control application life.
const app = electron.app;
// Module to create native browser window.
const BrowserWindow = electron.BrowserWindow;

const express = require('express');
const webApp = express();

webApp.get('/', function(req, res, next) {
  res.sendFile(__dirname + '/index.html');
});

const port = 3000;
webApp.listen(port, function() {
  console.log('App server started : http://localhost:' + port);
});

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let mainWindow;

function createWindow() {
  // Create the browser window.
  mainWindow = new BrowserWindow({width: 800, height: 600});

  // and load the index.html of the app.
  mainWindow.loadURL('http://127.0.0.1:' + port);

  // Open the DevTools.
  mainWindow.webContents.openDevTools();

  // Emitted when the window is closed.
  mainWindow.on('closed', function() {
    // Dereference the window object, usually you would store windows
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    mainWindow = null;
  });
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
app.on('ready', createWindow);

// Quit when all windows are closed.
app.on('window-all-closed', function() {
  // On OS X it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', function() {
  // On OS X it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (mainWindow === null) {
    createWindow();
  }
});

diff を取ると

 ...
 // Module to create native browser window.
 const BrowserWindow = electron.BrowserWindow;

+const express = require('express');
+const webApp = express();
+
+webApp.get('/', function(req, res, next) {
+  res.sendFile(__dirname + '/index.html');
+});
+
+const port = 3000;
+webApp.listen(port, function() {
+  console.log('App server started : http://localhost:' + port);
+});

 // Keep a global reference of the window object, if you don't, the window will

 ...

 function createWindow() {
   mainWindow = new BrowserWindow({width: 800, height: 600});

   // and load the index.html of the app.
-  mainWindow.loadURL('file://' + __dirname + '/index.html');
+  mainWindow.loadURL('http://127.0.0.1:' + port);

   // Open the DevTools.
   mainWindow.webContents.openDevTools();

同じことをやっている人がいたので参考にしたい。