OpenID Connectを使った最低限の枠組みを試しに作ってみた。
いろいろ認識間違っているところがある気がしているので 優しく 指摘していただければ……。
動機については以前の記事にて。
OpenID Connect対応の認証サーバ(OP/IdP)を作るために調べてみた
イメージとしては
- サービスサイトの認証ページにアクセス
- 認証プロバイダのログインページにアクセス
- サービスサイトに戻されて認証状態でサービスサイトを利用する
みたいな仕組みの枠組みを作ってみます。
ユーザ情報の検索などは全てダミーデータを使って代替しています。
念のため用語説明:
OP: OpenID Provider 認証する(プロバイダー)側
RP: Relying Party 認証してもらう(クライアント)側
使ったもの
- Node.js v6.10.2
- OP側
- RP側
- express
- express-session
- passport
- panva/node-openid-client
Certifiedなライブラリを使いたかったのでpanva氏のライブラリを使用します。
OP側の実装
プロバイダー側は最低限でよければこんな感じ。
'use strict';
const Provider = require('oidc-provider');
const issuer = 'http://localhost:3030';
const config = {
};
// この部分は実際に使うときはDBから取得する
const clients = [
{
client_id: '0',
client_secret: '0',
scope: 'openid email',
redirect_uris: [
'http://localhost:3000/auth/cb'
]
}
];
const oidc = new Provider(issuer, config);
oidc.initialize({ clients }).then(() => {
console.log(oidc.callback);
console.log(oidc.app);
oidc.app.listen(3030);
});
clients配列の中のオブジェクトには
- client_id
- client_secret
- scope
- redirect_uris
が必須。
scope
に関しては指定できる値が決められている。最初にopenidの指定は必須。
ここの指定が変わることで必要な実装も変わるはずだが、そこまではまだ把握できていない。
redirect_uris
はコールバック先として許可するURIを配列で保持する。RPからredirect_uri
として渡すコールバック先はここに指定したURIと完全に一致しないとエラーになる(末尾の/
の有無も一致させる)。
RP側の実装
ベースはexpress-generator
で作成し、このコミットでの差分がRPとしての実装部分になります。
Express.jsを用いてクライアント側のサービスを書いてみましたが、アプリケーション全体を認証処理でまるっと囲ってあげる感じで実装すれば良さそうです。
ブートストラップ(bin/www
)の修正
var appFactory = require('../app');
// ...
const { Issuer } = require('openid-client');
// OPの探索処理
Issuer.discover('http://localhost:3030').then(issuer => {
// 発見したissuerをapp.jsに渡してclientの取得処理を行う(後述)
const app = appFactory(issuer);
// 既存のbin/wwwの処理
// ...
}).catch(err => {
console.error(err);
process.exit(1);
});
// スコープの関係で、変数serverを利用しているイベントロガー類を削除
// 実サービスに載せるときはちゃんと実装し直す必要がある
Issuer.discover()
の引数にOP提供のURLを指定するだけで、OPの探索処理が実行されるのでちょっと驚いた。すごくお手軽。
コントローラ(app.js
)の修正
まず全体の構成としてexpress-generator
で作成する雛形だと
var app = express();
// ...
module.exports = app;
という形で外部に提供されるが、先述の通り探索した結果のissuer
を受け取りたいので、以下のようにissuer
を引数とする関数を提供するようにする。
module.exports = (issuer) => {
// 引数issuerを用いた初期化処理など
// ...
var app = express();
// ...
return app;
}
その他app.js
への主要な修正は以下の通り。
module.exports = (issuer) => {
const client = new issuer.Client({
client_id: '0',
client_secret: '0'
});
// redirect_uriはOPで定義したredirect_urisに含まれている値でないとエラーになる
const params = {
redirect_uri: 'http://localhost:3000/auth/cb'
};
// 'oidc'と名前をつけて認証ストラテジーを定義する
passport.use('oidc', new Strategy({ client, params }, (tokenset, userinfo, done) => {
console.log('tokenset', tokenset);
console.log('access_token', tokenset.access_token);
console.log('id_token', tokenset.id_token);
console.log('claims', tokenset.claims());
console.log('userinfo', userinfo);
// プロバイダー側で入力したユーザIDがtokenset.claims.subに格納されているので
// 実運用時はこの部分でユーザ検索して存在チェックをする
// ここで比較するのはユーザー名ではなく、認証サーバーで識別されるユーザーID
if (tokenset.claims().sub !== '0') {
return done(null);
}
// 存在するユーザーで認証されている場合はユーザー情報を返す
// 返した情報は`req.user.client_id`に格納される
return done(null, userinfo);
}));
var app = express();
// ...
// express-sessionの定義
app.use(session({
secret: 'keyboard cat',
resave: false,
saveUninitialized: false
}));
// passport利用開始の準備
app.use(passport.initialize());
app.use(passport.session());
// ...
// 認証ページへのルーティング
app.get('/auth', passport.authenticate('oidc'));
// プロバイダーからのコールバック先へのルーティング
app.get('/auth/cb', passport.authenticate('oidc', {
successRedirect: '/', // 認証成功時の遷移先
failureRedirect: '/users' // 認証失敗時の遷移先
}));
// ...
return app;
}
// ユーザ情報をセッションに保存するためにpassportにシリアライザ、デシリアライザを定義する
passport.serializeUser(function(user, done) {
// 実際の処理ではセッション情報にはIDなどキーとなる情報のみ保存する
done(null, user);
});
passport.deserializeUser(function(id, done) {
// 実際の処理ではセッション情報にもつIDなどからユーザ情報を取得する
done(null, {client_id: id});
});
express-sessionの定義部分については「Node.js + Express.js + express-sessionでセッションにデータ格納する方法」もご参照ください。
起動と動作確認
- OPを
node index.js
で起動 - RPを
npm start
で起動-
bin/www
でOPを探索しているので起動時にOPが起動していないとエラーで起動できない
-
- RPの認証ページ
http://localhost:3000/auth
に遷移 - OPの認証ページ
http://localhost:3030
に飛ばされる- ID/PASSをそれぞれ
0
/0
で認証する
- ID/PASSをそれぞれ
- RPのコールバック先
http://localhost:3000/auth/cb
に遷移して(見た目にはわからない)、認証成功していればhttp://localhost:3000/
にリダイレクトされる
まだよくわかっていない部分
- プロバイダー側とクライアント側で
client_id
,client_secret
をどこでマッチングしているのか - パラメータ
scope
の違いによる実装内容、挙動の違い -
client_id
,client_secret
はクライアントサービスごとに定義されているっぽいが、クライアントサービス内で管理しているユーザの扱い