OpenID Connectを使用したユーザー認証とAuth0

2020.03.25

はじめに

はじめまして。認証認可を提供するSaaS (IDaaS) であるAuth0社のSolutions Engineerとしてサービス紹介や技術的な支援をしています岩崎です。このたび当ブログのゲストブロガーとしてお招きいただきました。

みなさんログイン画面を作ったことはありますか? 認証連携したことはありますか?
多要素認証を実装したことはありますか? 不審なログインリクエストへの対応をしたことはありますか?
認証処理はあなたの提供するサービスやアプリケーションの売りとなる機能ではないかと思いますが、 万が一実装を失敗するとサービスや会社にとって大きなダメージになってしまう大切な機能です。

今回の記事では、認証処理とID検証のための標準的なプロトコルであるOpenID Connect (OIDC) を使用して ウェブアプリケーションでエンドユーザー認証を処理する方法をご紹介します。 まず、SDKを使わずにウェブアプリケーションをOpenID Connectプロバイダーと統合する方法を説明した後で、Auth0のSDKを使用した場合の方法を説明します。

Auth0アカウントとテナントの作成

ウェブアプリケーション側の認証処理をいずれの方法で行う場合も、今回はログイン画面のUIを含む接続先のIDプロバイダーはAuth0のものを使用していきます(Auth0はテスト用IDプロバイダーとしても便利にお使いいただけます)。

Auth0のアカウントをまだお持ちでない方は、https://auth0.com/jp/にアクセスして右上にあるサインアップをクリックし、GitHubやGoogleなどお好みのアカウントでサインアップします。

Auth0サイトの画面ショット

テナント作成画面になりますので、適当なサブドメインを入力し「Create」を選びます。 地域は近々JPが増えるという話もありますが、現在のところはUSを選択していただくと良いかと思います。

テナント作成画面の画面ショット

テナントは、Auth0でユーザーや各種設定を管理するための論理的に分離された単位で、一つのアカウントで複数のテナントを持つことができます。 テナント作成はすぐに完了し、管理画面が表示されますので、Applicationsセクションに移動します。

アプリケーション一覧画面の画面ショット

右上の「Create Application」から新しいアプリケーションを作成します。アプリケーションの種類は「Regular Web Applications」を選択します。

アプリケーション作成画面の画面ショット

アプリケーションを作成したらアプリケーション設定を行います。「Settings」タブを下にスクロールしていき、「Allowed Callback URLs」にhttp://localhost:3000/callbackを指定し、一番下の「Save Changes」ボタンを押して設定内容を保存します。

コールバック設定画面の画面ショット

OIDCプロバイダーとの統合(SDKなし)

それでは前項で設定したIDプロバイダーと接続するウェブアプリケーションを準備します。 下記のGitHub上のサンプルアプリに認証処理を追加していきましょう。

$ git clone https://github.com/auth0-blog/oidc-book-regular-webapp
$ cd oidc-book-regular-webapp
$ npm install
$ npm start

ブラウザーからhttp://localhost:3000/にアクセスすると、トップページが表示されます。click hereリンクを押した時に認証できるように実装していきます。

トップ画面画面ショット

一旦サーバーを停止したあと、src/server.jsapp.listen以下の行をコメントアウトし、Open ID Connectの設定を行う下記の処理で置き換えます。 一部引用符はバッククォートですのでご注意ください。

const {OIDC_PROVIDER} = process.env;
const discEnd = `https://${OIDC_PROVIDER}/.well-known/openid-configuration`;
request(discEnd).then((res) => {
  oidcProviderInfo = JSON.parse(res); 
  app.listen(3000, () => {
    console.log('Server running on http://localhost:3000');
  });
}).catch((error) => {
  console.error(error);
  console.error('Unable to get OIDC endpoints for ${OIDC_PROVIDER}'); 
  process.exit(1);
});

環境変数 (process.env) を読み込んでいますので、プロジェクトのルートディレクトリー(README.mdがあるのと同じディレクトリー)に、.envという名前のファイルを作成します。2つの環境変数、OIDC_PROVIDERとCLIENT_IDを定義します。前者はAuth0テナントのドメイン、後者はClient IDを指定します。それぞれAuth0の管理画面の「Applications」から確認できます。

OIDC_PROVIDER=tiwasaki-oidc-sample.auth0.com
CLIENT_ID=pt86c9qBiqAWWgsCR9dCjiTWl3rkrwoD

/loginエンドポイントを書き換えます。定数scopeに、ユーザーのどのような情報を取得するのかを指定しています。

app.get('/login', (req, res) => {
  // define constants for the authorization request
  const authorizationEndpoint = oidcProviderInfo['authorization_endpoint']; 
  const responseType = 'id_token';
  const scope = 'openid profile email';
  const clientID = process.env.CLIENT_ID;
  const redirectUri = 'http://localhost:3000/callback'; 
  const responseMode = 'form_post';
  const nonce = crypto.randomBytes(16).toString('hex'); 
  // define a signed cookie containing the nonce value 
  const options = {
    maxAge: 1000 * 60 * 15,
    httpOnly: true, // The cookie only accessible by the web server 
    signed: true // Indicates if the cookie should be signed
  };
  // add cookie to the response and issue a 302 redirecting user
  res
    .cookie(nonceCookie, nonce, options) 
    .redirect(
      authorizationEndpoint + 
      '?response_mode=' + responseMode + 
      '&response_type=' + responseType + 
      '&scope=' + scope +
      '&client_id=' + clientID + 
      '&redirect_uri='+ redirectUri + 
      '&nonce='+ nonce
    ); 
});

これで再度サーバーを起動するとログイン画面の表示ができるようになりますので、次にログイン後の処理を行う/callbackエンドポイントを置き換えます。 ここで注意していただきたいのは、送られてきたキーの正当性を検証する必要があることです。 キーの検証を適切に行っていない場合、攻撃に対して脆弱性のあるサービスになってしまいます。

app.post('/callback', async (req, res) => {
  // take nonce from cookie
  const nonce = req.signedCookies[nonceCookie];
  // delete nonce
  delete req.signedCookies[nonceCookie]; 
  // take ID Token posted by the user
  const {id_token} = req.body; 
  // decode token
  const decodedToken = jwt.decode(id_token, {complete: true}); 
  // get key id
  const kid = decodedToken.header.kid;
  // get public key
  const client = jwksClient({
    jwksUri: oidcProviderInfo['jwks_uri'],
  });
  
  client.getSigningKey(kid, (err, key) => {
    const signingKey = key.publicKey || key.rsaPublicKey;
    // verify signature & decode token
    const verifiedToken = jwt.verify(id_token, signingKey);
    // check audience, nonce, and expiration time
    const {
      nonce: decodedNonce, 
      aud: audience,
      exp: expirationDate, 
      iss: issuer
    } = verifiedToken;
    const currentTime = Math.floor(Date.now() / 1000); 
    const expectedAudience = process.env.CLIENT_ID;
    if (audience !== expectedAudience ||
       decodedNonce !== nonce ||
       expirationDate < currentTime ||
        issuer !== oidcProviderInfo['issuer']) {
      // send an unauthorized http status
      return res.status(401).send();
    }

    req.session.decodedIdToken = verifiedToken; 
    req.session.idToken = id_token;
  
    // send the decoded version of the ID Token
    res.redirect('/profile');
  });
});

もう一度npm startしてサーバーを起動し、http://localhost:3000/からclick hereを押してログインしてみてください。IDプロバイダー(ここではAuth0)のログイン画面が表示されます。

Auth0ログイン画面の画面キャプチャー

ログイン処理が完了すると、ログインしたユーザーのプロフィール情報が表示されます。おめでとうございます。

プロフィール画面の画面ショット

しかしながら、このサンプルをもとにした認証処理を実際のアプリケーションで行うことはお薦めしません

SDKを利用した認証設定

続いてOIDCプロバイダーとの認証の繋ぎこみにSDKを利用した場合の認証処理の実装を行っていきたいと思います。 今度は下記のリポジトリーをcloneし関連パッケージをインストールします。

$ git clone https://github.com/auth0-blog/oidc-book-regular-webapp-auth0
$ cd oidc-book-regular-webapp-auth0
$ npm install

先ほどのリポジトリーと異なるリポジトリーですが、主な違いは読み込んでいる外部ライブラリーの違いです。先ほどはjsonwebtokenのようなパッケージを使用して直接JWTを取り扱っていましたが、今回は認証ミドルウェアーのPassportと、Passport向けに開発された公式Auth0ライブラリーであるpassport-auth0を使用します。 リクエストを処理する部分はどちらもシンプルな(何も記載のない)状態になっています。

では、先ほどと同様に.envファイルを作成し、環境変数の設定を行います。今回は、Client Secretの設定も行います。

OIDC_PROVIDER=tiwasaki-oidc-sample.auth0.com
CLIENT_ID=pt86c9qBiqAWWgsCR9dCjiTWl3rkrwoD
CLIENT_SECRET=UseYour0wnC1ientSecret

Client SecretはAuth0管理画面のアプリケーション設定画面からコピーできます。

Auth0アプリケーション設定画面の画面キャプチャー

次にsrc/server.jsを開き、const app = express();の記載がある行の直下に、下記のように、今回使用する認証ライブラリーPassportとAuth0を組み合わせて使う設定を追加します。

// Configure Passport to use Auth0
const auth0Strategy = new Auth0Strategy(
  {
    domain: process.env.OIDC_PROVIDER,
    clientID: process.env.CLIENT_ID, 
    clientSecret: process.env.CLIENT_SECRET, 
    callbackURL: 'http://localhost:3000/callback'
  },
  (accessToken, refreshToken, extraParams, profile, done) => {
    profile.idToken = extraParams.id_token; 
    return done(null, profile);
  }
);
passport.use(auth0Strategy);

続いてログイン画面です。ログイン画面ではシンプルにscopeのみ指定します。

app.get(
  '/login', 
  passport.authenticate('auth0', {
    scope: 'openid email profile'
  }),
);

コールバック処理を行うページです。検証はSDKが受け持っているため、先ほどのコールバック処理の実装と比較して 非常に簡潔かつ明快な記載で実装できます。

app.get('/callback', (req, res, next) => { 
  passport.authenticate('auth0', (err, user) => {
    if (err) return next(err);
    if (!user) return res.redirect('/login');

    req.logIn(user, function(err) { 
      if (err) return next(err); res.redirect('/profile');
    });
  })(req, res, next);
});

それではnpm startして、http://localhost:3000/から動作を確認してみてください。最初の例と同様に、Auth0のログイン画面が表示され、ユーザー情報が表示されたことと思います。おめでとうございます。

終わりに

さて、今回は2種類の方法でOpenID Connectプロバイダーによる認証処理への繋ぎ込みの実装を行いました。

実は、この2種類の認証処理はフローが異なっています。SDKを使わない実装では「インプリシットフロー」と呼ばれる簡略化されたフロー、SDKを使った実装では「認可コードフロー」と呼ばれる、本来処理が多くなるフローを採用しています。実際のところ、インプリシットフローはもともとOpenID Connectのフローの一つとして公式に含まれていたものでしたが、現在のベストプラクティスでは推奨されないフローとなっています。SDKを使わず自前でOIDCプロトコルを処理する実装を行った場合には、こういったOIDCの最新情報についても随時確認し実装を改修していくことで、安全なアプリケーションであり続ける必要があることにもご留意ください。

ここまでかなり駆け足で説明してきましたが、実は本記事の内容は、Auth0が公開しているTHE OPENID CONNECTハンドブックから抜粋した内容です。ハンドブックでは、各ステップの細かい説明はもちろん、そもそもOpenID Connectが必要となった経緯や、シングルページアプリケーションでの実装についてなど、本記事では説明していない内容を多数含む全83ページのボリュームとなっています。

現在英語版日本語版とも無料ダウンロード可能ですので、よろしければダウンロードいただき、みなさまのサービスにおける今後のID管理についてどうして行くべきかの検討の一助になればと思います。

その上で、Auth0を導入してID管理を委任するのもありだな、と感じられた方は、弊社にお問い合わせいただいたり、クラスメソッド様が毎月実施されているAuth0ハンズオンセミナーへのご参加を検討されてはいかがでしょうか。

The OpenID Connectハンドブック