オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

クラウド/Webサービス

第三回 Relying Party の実装例 ~passport~

OpenID Connectでつくる「アイデンティティ境界」
株式会社オージス総研
テミストラクトソリューション部
氏縄 武尊
2016年3月10日

本連載は、主にアプリケーション開発者を対象として、ネットワーク上の新たな境界として台頭しつつある「アイデンティティ型の境界」を実現するための数ある認証連携方式の中から、2014年2月に標準化された「OpenID Connect」に注目して仕様説明と有用性を解説します。

本記事の内容

Webアプリケーションが提供するコンテンツの多くは、ユーザー認証によって保護され、認証したユーザー向けのコンテンツが提供されます。これらの機能をアプリケーション開発者がスクラッチで実装すると、アプリケーションによってセキュリティレベルが異なる事態を招く可能性や、多大なコストがかかる恐れがあります。

そうした問題を解消するために、開発者はアプリケーションをOpenID ConnectのRelying Party(以下RP)として実装することで、第二回で紹介したようなOpenID Provider(以下OP)から連携される、ユーザーの認証情報ID・属性情報を活用したアプリケーションの実装ができます。

第三回では、Node.jsでWebアプリケーションが実装されていると仮定し、どのような実装を追加すれば、このアプリケーションがRPとして動作して、認証情報やID・属性情報の連携を受けることができるのかを解説致します。

本記事で扱うミドルウェアのご紹介

OpenID FoundationのWebページ では、インターネットに公開されているOPやRPの実装がいくつか紹介されています。

本記事では、Node.jsで実装したアプリケーションにRPの実装を追加します。よって、上記のページでもNode.js向けの実装として紹介がされているpassport-openidconnectを用いて実装を行います。以下に利用する主なミドルウェアの簡易な説明を記します。

Express

ExpressはNode.js上で動作するWebアプリケーションフレームワークであり、導入の容易さや軽量さ、柔軟性から非常に多くのWebアプリケーションで利用されています。

ExpressのWebページ内のGetting startedの手順に従い、簡単にサンプルのWebアプリケーションを起動できます。

Passport

Passportとは、Node.jsのための認証ミドルウェアであり、連携先のIDプロバイダーや認証方式毎に作られたストラテジーと呼ばれるモジュールが公開されており、必要なパラメータ等を記述することによりで認証を行うことが可能になります。

2016年1月現在、300を超えるストラテジーが公開されており、Twitter, Facebook, GoogleのようなIDプロバイダーから認証連携、ID・属性連携を受けるためのストラテジーが存在します。

本記事は、認可コードフローでOpenID Providerと連携するためのストラテジー「passport-openidconnect」を用いた実装方法のご紹介をします。

Expressにpassportを実装する

前提として、Expressを用いて実装されたWebアプリケーション上に、認証で保護したいコンテンツが実装されていると仮定します。 まず、このExpressのアプリケーションにPassportを組み込む実装の解説をします。Passportの組み込みが完了すると、下図のような流れでOPから認証情報とID・属性情報の連携を受けられます。その後、それらの情報を活用したユースケースの紹介として、認証情報を用いたシングルサインオンの実装例ID・属性情報を用いたメール送信の実装例について解説致します。

認証情報とID・属性情報の連携

前提条件

  • 本章の手順は、ExpressのWebページ内のGetting startedの手順の過程で、プロジェクトが生成され、Webサーバとして起動済みであることを前提としています。

  • 以下、Expressのプロジェクトディレクトリを${express_dir}と明記します。

  • 実装するRPは、認可コードフローを前提として実装し、ID Tokenは暗号化されていない状態で連携されることを想定して実装します。

構成・設定値

実装にあたり、OP及びRPの構成に以下の設定値を用います。 OPの設定方法については、第二回 OpenID Providerの実装例をご参照下さい。

Relying Party

属性名 説明 本記事での設定値
ホスト名 RPのFQDN relyingparty.sample.domain
client_id クライアント識別子 sampleRP
client_secret クライアント識別のためのシークレット secret
redirect_uri OPでの認証後にコールバックを受けるエンドポイント https://relyingparty.sample.domain/cb

OpenID Provider

属性名 説明 本記事での設定値
ホスト名 OPのFQDN openidprovider.sample.domain
issuer identifier ID TokenのクレームをセットするIssuerの識別子 https://openidprovider.sample.domain/openam/oauth2
authorizationURL 認証を行うエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/authorize
tokenURL トークンを発行して受け渡すエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/access_token
userInfoURL ユーザーの情報を返すエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/userinfo

ミドルウェアのインストール・初期実装

まず、Expressのプロジェクトルートに移動して実装に必要なモジュールをインストールします。

(jsonwebtokenrsa-pem-from-mod-expに関しましては、後述のID Tokenの検証における実装で利用します。)

$ cd ${express_dir}
$ npm install passport
$ npm install passport-openidconnect
$ npm install rsa-pem-from-mod-exp
$ npm install jsonwebtoken

インストールしたモジュールを利用するために、app.jsに下記のコードを追記します。

// passport, passport-openidconnectモジュールのロード
var passport = require("passport");
var OpenidConnectStrategy = require("passport-openidconnect").Strategy;

var app = express();
// passportを利用することの宣言
app.use(passport.initialize());
// 【非推奨】サーバ証明書に自己署名証明書を用いた環境でお試しされる場合は0を指定
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "1";

以後、Passportを用いた実装は下記の宣言が必要になります。

var passport = require("passport");

以上でミドルウェアのインストール・初期実装は完了です。

実装

ここから、Passportを用いた認証情報とID・属性情報を受け取る実装を、1)Passportのストラテジー設定2)ログインのエンドポイントの作成3)コールバックのエンドポイントの作成の3つの手順に分けて、解説致します。

1) Passportのストラテジー設定

前述の通り、Passportを用いて認証を行うためにはストラテジーの設定を行い、認証に必要な値を設定する必要があります。

passport-openidconnectのストラテジーの設定では、OPのエンドポイントの指定やクライアントに関する情報を記載するだけで、トークンを受け取るために必要なRPとしての設定が完了します。また、アクセストークンやリフレッシュトークンを受け取った際の挙動についても、ストラテジーの設定内に記載します。

passport-openidconnectで設定可能なパラメータは以下の通りです

(passport-openidconnectstatenonce等の認可リクエストには対応していません)。

これらの設定値の一部は第二回 OpenID Providerの実装例の「OPの設定情報取得用エンドポイントの確認」の章で取得したJSONドキュメントに記載しています。

属性名 目的 本記事での設定値
issuerIdentifier ID TokenのクレームをセットするIssuerの識別子 https://openidprovider.sample.domain/openam/oauth2
authorizationURL 認証を行うエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/authorize
tokenURL トークンを発行して受け渡すエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/access_token
userInfoURL ユーザーの属性情報を返すエンドポイント https://openidprovider.sample.domain:443/openam/oauth2/userinfo
client_id クライアント識別子 sampleRP
client_secret クライアント識別のためのシークレット secret
redirect_uri OPでの認証後にコールバックを受けるエンドポイント https://relyingparty.sample.domain/cb
scope 要求するID・属性情報(デフォルトでopenidが入ります) [“profile”, “email”]

実際に上記の設定を行ったサンプルコード(app.js)は以下の通りになります。

var ISSUER_IDENTIFIER = "https://openidprovider.sample.domain:443/openam/oauth2";
var AUTHORIZATION_ENDPOINT = "https://openidprovider.sample.domain:443/openam/oauth2/authorize";
var TOKEN_ENDPOINT = "https://openidprovider.sample.domain:443/openam/oauth2/access_token";
var USERINFO_ENDPOINT = "https://openidprovider.sample.domain:443/openam/oauth2/userinfo";
var CLIENT_ID = "sampleRP";
var CLIENT_SECRET = "password";
var CALLBACK = "https://relyingparty.sample.domain/cb";
var SCOPE = ["profile", "email"];
var ALGORITHM = "RS256"; // 署名アルゴリズムにはRS256を使用

passport.use(new OpenidConnectStrategy(
  {
    // OPの設定
    authorizationURL: AUTHORIZATION_ENDPOINT,
    tokenURL: TOKEN_ENDPOINT,
    userInfoURL: USERINFO_ENDPOINT,

    // RPの設定。
    clientID: CLIENT_ID,
    clientSecret: CLIENT_SECRET,
    callbackURL: CALLBACK
  }, function(iss, sub, profile, jwtClaims, accessToken, refreshToken, params, done) {
    // IDトークンやパラメータの検証。
    return done(null, profile, jwtClaims);
  }
));

// ユーザーの情報を受け取り利用するための設定。
passport.serializeUser(function(user, done){
  done(null, user);
});

passport.deserializeUser(function(obj, done){
  done(null, obj);
});

2) ログインのエンドポイントを作る

Passportでは、エンドポイントのルーティング設定の第二引数にpassport.authenticate("ストラテジー名")を指定することで、app.jsに記載したストラテジーの設定を元に、認証ページへのリダイレクトが行われます。

設定に必要な値は以下の通りです。

属性 本記事での設定値
ログインのエンドポイント /login
ストラテジー openidconnect

実際に設定を行ったサンプルコード(routes/index.js)は以下の通りになります。

router.get("/login", passport.authenticate("openidconnect"));

3) コールバックのエンドポイントを作る

OPで認証が完了すると、再度RPにリダイレクトされ、routes/index.js内に記載したコールバック用のエンドポイント対してリクエストが送られます。このエンドポイントでは、このリクエストを受け取った後、認証情報の連携をOPから受ける処理を実装します。

実際に設定を行ったサンプルコード(routes/index.js)は以下の通りになります。

router.get("/cb", passport.authenticate("openidconnect", {failureRedirect: "/"}),
  function(req, res) {
    res.redirect("/");
  }
);

ID Tokenの確認・検証

1) ID Tokenの確認

ここまでの実装・設定を行うことで、以下のフローで認証が行われ、認証情報が連携されていることが確認できます。

  1. ユーザーはRPのログインのエンドポイント( https://relyingparty.sample.domain/login )にアクセスする。
  2. RPはOPの認可エンドポイント( https://openidprovider.sample.domain:443/openam/oauth2/authorize )に対してリダイレクトをする
  3. ユーザーはOPで認証をする
  4. 認証が成功すると、再度、ユーザーはRPのコールバックのエンドポイント( https://relyingparty.sample.domain/cb のクエリストリングに認可コードを含む )にリダイレクトをする
  5. コールバックのエンドポイントは認可コードをOPのトークンを発行して受け渡すエンドポイント( https://openidprovider.sample.domain:443/openam/oauth2/access_token )に送信し、レスポンスとして認証情報(ID Token)を取得する

app.js内の89行目付近で、取得したトークンやクレームの値を確認できます。参照可能な値は以下の通りです。

属性名 説明
iss レスポンスの発行者の識別子(URL)
sub ログインユーザー名
profile 認証したユーザーの情報
jwtClaims ID Tokenのペイロード部
accessToken アクセストークン
refreshToken リフレッシュトークン
params OPのトークンエンドポイントのレスポンス

実際に設定を行ったサンプルコード(app.js)は以下の通りになります。

  }, function(iss, sub, profile, jwtClaims, accessToken, refreshToken, params, done) {
    console.log(jwtClaims);
    return done(null, profile, jwtClaims);
  }

実際に認証を行った際のログを確認すると、以下のように、認証を行ったOPの情報やユーザーを識別できる値等の認証情報を確認できます。

{
 tokenName: 'id_token',
  azp: 'sampleRP',
  sub: 'TestUser',
  iss: 'https://openidprovider.sample.domain/openam/oauth2',
  iat: 1455251827,
  auth_time: 1455251812,
  exp: 1455252427,
  tokenType: 'JWTToken',
  aud: [ 'sampleRP' ]
}

2) ID Tokenの検証

これまでの実装で、OPから認証情報(ID Token)の受信を行う実装ができました。

連携されたID Tokenは第一回でご説明した通り、JWT(JSON Web Token)形式で構成されており、署名で保護されています。 しかし、ここまでの実装では、署名の検証が行われていないため、ID Tokenが改ざんされている可能性に対処できていません。

ここからは、今回の実装で受け取ったID Tokenの署名やクレームの値が正しいかどうかの検証を行います。検証を行うことで、受け取ったID Tokenの正当性を確認し、以後の実装に利用することができます。

検証が必要なパラメータは、OpenID Connect Coreの3.1.3.7. ID Token Validationで確認できます。

本実装で検証するクレームの値は以下の通りです。

属性名 属性の説明
iss レスポンスの発行者の識別子(URL)
aud ID Tokenの対象となるRPのリスト
azp 認可リクエストを行ったクライアントの識別子
exp このID Tokenの期限
iat 認証を行った時刻

実際に設定を行ったサンプルコード(app.js)は以下の通りになります。

  }, function(iss, sub, profile, jwtClaims, accessToken, refreshToken, params, done) {
    // 署名アルゴリズムの検証
    // ID Tokenのヘッダに含まれる署名アルゴリズムが、定義したALGORITHM値と一致することを検証する
    var headerStr = new Buffer(params.id_token.split(".")[0], "base64").toString();
    var header = JSON.parse(headerStr);
    if (header.alg !== ALGORITHM) {
      done("Missmatch Algorithm", null);
    }

    // issクレームの検証
    // issクレームの値が、定義したISSUER_IDENTIFIERと一致することを検証する
    if (jwtClaims.iss !== ISSUER_IDENTIFIER) {
      done("Missmatch Issuer", null);
    }

    // audクレームの検証
    // audクレームの値が、定義したCLIENT_IDが含まれることを検証する
    if (jwtClaims.aud.indexOf(CLIENT_ID) < 0) {
      done("Missmatch Audience", null);
    }

    // azpクレームの検証
    // audクレームの値が複数ある場合に、azpクレームが、定義したCLIENT_IDと一致することを検証する
    if (jwtClaims.aud.length > 1 &&
        (!params.claims.azp || params.claims.azp !== CLIENT_ID)) {
      done("Missmatch Authorized party", null);
    }

    // expクレームの検証
    // expクレームの値が、現在時刻よりも大きいることを検証する
    var now = Math.round(Date.now()/1000);
    if (jwtClaims.exp <= now) {
      done("Expired token", null);
    }

    // iatクレームの検証
    // iatクレームの値が、現在時刻から一時間以内かどうかを検証する
    var ONE_HOUR = 3600;
    var now = Math.round(Date.now()/1000);
    if (jwtClaims.iat + ONE_HOUR <= now) {
      done("Expired token", null);
    }

    // ID Tokenの署名検証
    // ID Tokenの署名が正しいかどうかを検証する
    var getPem = require('rsa-pem-from-mod-exp');
    var modulus = ""; // 公開鍵のmodulusの値を指定
    var exponent = ""; // 公開鍵のexponentの値を指定
    var pem = getPem(modulus, exponent);
    var token = params.id_token;

    var jwt = require('jsonwebtoken');
    jwt.verify(token, pem, function(err, decoded) {
      if (err) {
        done("Invalid Signature", null);
      } else {
        console.log("OK");
        done(null, profile, jwtClaims);
      }
    });
  }

ID Tokenの検証は以上となります。

発展

では、実際にOPから連携された認証情報やID・属性情報を使った実装を行ってみましょう。

ここでは実際に認証情報を使った例として、1)認証連携でシングルサインオン2)ID・属性情報でメール送信を行う実装をご紹介します。

1) 認証連携でシングルサインオン

OPから受け取った認証情報(ID Token)に含まれるisssubを確認して、その認証情報が、想定しているOPで認証されている想定されたユーザーのものかどうかを確認し、正しければ認証クッキーを発行するといった、簡単なシングルサインオンの実装を行います。

コールバックのエンドポイントで認証情報を利用するにあたり、app.jsの145行目で指定した関数done()の第三引数の値(例ではjwtClaimsとしていしている値)に連携したい認証情報を与えることにより、コールバックのエンドポイントでは、その値をreq.authInfoという名前で受け取ることができます。

実際に設定を行ったサンプルコード(routes/index.js)は以下の通りになります。

router.get("/F", passport.authenticate("openidconnect", {failureRedirect: "/"}),
  function(req, res) {
    if (req.authInfo && checkIssuerAndSub(req.authInfo.iss, req.authInfo.sub)) {
      var user = req.authInfo.iss + "#" + req.authInfo.sub;
      var sessionId = createSession(user);
      res.cookie("express", sessionId,
        { secure: true, httpOnly: true }
      );
      res.redirect("/");
    } else {
      var error = { message: "Forbidden", error: { status: 403, stack: "" } }
      res.render("error", error);
    }
  }
);

function checkIssuerAndSub(iss, sub) {
  // Issuerを確認してbooleanを返す関数を実装する。
}

function createSession(user) {
  // Sessionの生成を実装する。
}

認証が完了した後にアクセスするページはCookieを元に認証済みかどうかを判断してコンテンツを返却します。

routes/index.js

router.get("/", function(req, res, next) {
  var sessionId = req.cookies.express;
  if(checkSession(sessionId)){
    // コンテンツを返却する処理を実装する。
  } else {
    res.redirect("/login");
  }
});

function checkSession(sessionId) {
  // Sessionの確認を実装する。
}

この実装により、認証情報を使った制御によってコンテンツを保護し、OPでの認証の情報を使った簡単なシングルサインオンができることが確認できます。

2) ID・属性情報でメール送信

OPから受け取ったID・属性情報を用いた実装の例として、メールアドレスを利用するケースの実装を行います。

名前やメールアドレス等のユーザー情報を取得する場合には、ストラテジーの設定で記載したuserInfoURLから値を取得する必要があります。以下のサンプルコードで、ストラテジーの実装にscope値を設定し、どのようなID・属性情報を要求するのかを設定を行います。(app.js(88行目))

    // RPの設定。
    clientID: CLIENT_ID,
    clientSecret: CLIENT_SECRET,
    callbackURL: CALLBACK,
    scope: SCOPE
  }, function(iss, sub, profile, jwtClaims, accessToken, refreshToken, params, done) {

上記の通り、scopeの設定を行った上で認証を行うと、profileという変数から以下のようなユーザー情報が確認できます。

{
  sub: 'sample',
  email: 'sampleUser@sample.domain',
  name: 'sample user',
  family_name: 'sample',
  given_name: 'user'
}

連携されたユーザー情報をコールバックのエンドポイントで利用する場合、app.jsの146行目のdone()の第二引数(profile)に連携したいID・属性情報を与えることにより、コールバックのエンドポイントでは、その値をreq.userという名前で受け取ることができます。(routes/index.js(22行目))

router.get("/cb", passport.authenticate("openidconnect", {failureRedirect: "/"}),
  function(req, res) {
    sendMail(req.user._json.email);

上記のsendMailを実装することで、連携されたメールアドレスに対してメールを送信できます。

以上で連携されたユーザーのID・属性情報を利用した実装を紹介しました。

まとめ

WebアプリケーションをRPとして実装することによる利点

Webアプリケーションに「認証」や「ユーザー管理」の機能を実装することに比べて、RPの実装を追加し、OPから「認証連携」、「ID・属性連携」を受けて、これらの機能を実装することの方が、比較的簡単であることがおわかりいただけたかと思います。また、OpenID Connectのフローは、オーソドックスなREST APIへのリクエストであるため、仕様の理解がしやすくなっています。

本記事でご紹介したようなRPの実装が、多くのWebアプリケーションに一般的に行われるようになることで、ビジネスに直結するような機能開発や改善活動が積極的に行われるようになればと、私は考えています。

実装上の注意

今回はpassport-openidconnectを用いて、Expressで開発したアプリケーションにRPの実装を行いました。それにより、認可コードフローでOPとやりとりが可能となり、認証情報やユーザーの情報を受け渡すことができました。

しかし、実装で利用したpassport-openidconnectでは、先述の通りnoncestateのパラメータは利用できず、これらのパラメータを利用するためにはライブラリの拡張が必要となります。また、実装の中でID Tokenの検証の実装についてご紹介しましたが、開発者がRPの実装を行う際には、OpenID Connect Coreに基づき、これらの実装を行うことが求められます。

適切に実装が行えていることを確認するテストツールとして、OpenID Foundationが公開しているOpenID Connect Certification Programが利用可能です。

記事執筆現在では、OPの実装に対する認証プログラムのみが公開されていますが、RPの認証プログラムについても2016年初旬に公開予定となっています。

おわりに

第一回から第三回に渡ってOpenID Connectの実装、及び、通信の内容について触れてきました。OpenID Connectの実際の通信内容を見ることで、認証というプロセスがどのような流れで行われているのかイメージできたのではないでしょうか。

第四回では、OpenID Connectの利用ケースのご紹介として、OpenID Connectを用いた、AWSリソースのアクセス管理と認証機能を提供するサービス(AWS IAM)とOpenAMを連携し、開発者のアカウント管理を実現してみたいと思います。

付録

今回実装したExpressプロジェクト内の2つのソースコードを公開いたします。ご自由にお使いください。

※ソースコードは、実装完了時ものとなりますので、解説の中で記述している行番号とはものと異なる部分がございます。ご注意ください。

参考文献