Auth0でOpenID Connectの理解を深める


以前、Cognitoユーザープールに関する記事を書いてみたことがあり、その際にJSON Web Token(JWT)などについても調べたりしていたのですが、Auth0というサービスを試用してみて、得られた知識を整理しようと思います。
Auth0は、ドキュメントもかなり充実していて、認証/認可について勉強する上でもとても役立ちました。

Quick Start

アカウントを作成して、ドメイン([your_account].auth0.com)を決めると、コンソール画面が表示されます。
Applicationsのサブメニューを選択すると、Default Appというアプリケーションが既に作成されています。

そして、Quick Startのタブを選択すると、アプリケーションのタイプ毎に導入までの手順が示されます。かなり手厚いサポートです。
今回はAngularアプリに認証を追加してみたので、Single Page > AppAngular 2+と選びました。
GitHubにサンプルが提供されてますので、これを使うのが最速でしょう。

この中の01-Loginが最もシンプルな構成になっていますので、まずはこの実装を参考にすると良いと思います。
私は既存のデモアプリがあったので、ガイドを見ながらそちらに実装を追加しました。
ただ、ちょっとわからなかったのが、以下の部分です。

なぜ、この手順が必要なのか不明だったのでスキップしましたが、特に問題はなさそうでした。
後はガイドの従って(GitHubのコードを参照しながら)実装して、ログインまでの流れは機能するように出来ました。

Universal Login

ガイドに記載されている通り、ログイン画面はUniversal Loginで構成しました。
Angularアプリ内にログイン画面を持つのではなく、Auth0が用意してくれるサイトにリダイレクトします。そして、認証が成功するとAngularアプリにリダイレクトされます。(そのためCallback URLsを設定したのでした)

ログイン画面の表示は、設定に応じて変わってきます。
例えば、Connections -> Database -> Username-Password-Authenticationと選んで、Disable Sign Upsを選択すると、

ログイン画面はそれに連動して、サインアップが選択出来なくなります。
また、Hosted Pagesからログイン画面のカスタマイズを実装することも可能です。ベースとなる実装が示されているのですが、以下のような感じでプレースホルダ(@@config@@)があって、

var config = JSON.parse(decodeURIComponent(escape(window.atob('@@config@@'))));

実行時にここにエンコードされたConfigurationが渡されるようです。
主にはAngularアプリから渡されたもの(重要なものだとresponseTypeとか)です。

「表示言語を変えたり出来ないのかな」と思ったんですが、ベース実装にlanguageを指定する箇所があり、試しにそこに'ja'を指定したら日本語表示になりました。
ですけど、上記のようにConfigurationが埋め込まれるわけですから、そこに言語指定するやり方がありそうです。
しかしながら、どうにもうまく出来ませんでした。
OpenIDの仕様を見ると、

ui_localesというパラメータがあって、表示言語を指定できるようなのですが、リダイレクト時のクエリパラメータをいじったりしても反映はされないようでした。

ID Token

Universal Loginからリダイレクトされた際、クエリパラメータにIDトークン(JWTフォーマット)などが渡されてきます。
WebAuthクラスのparseHashメソッドが渡されてきたクエリパラメータを解析を行ってくれますので、その結果をlocalStorageに保存するような実装になっています。

何が渡されてくるかは、Universal Loginへリダイレクトする際のパラメータresponseTypescopeに何を指定するかで変わってきます。
私の実装では、以下の様にしました。

responseType: 'token id_token',
scope: 'openid profile'

このあたりはOpenIDの仕様にも対応しており、

Auth0を利用する側も、このあたりの理解が必要になってくるということだと思います。(勉強になる)

トークンの検証

今回はOpenID Connectの理解を深めるのが目的なので、トークンの検証も行ってみます。
(最後にサンプル実装を示します)

こちらに色々と書いてあります。
IDトークンの電子署名を検証することになりますが、これに目を通す限りでは、今のところHS256かRS256のどちらかで署名されようです。

デフォルトではRS256になるようですが、Application Settings > Show Advanced Settings > OAuth > JsonWebToken Signature Algorithmで変更することもできます。但し、OIDC Conformantのチェックを外さないとHS256は選択できませんでした。
また、HS256にした時に署名に使われる対称鍵がわからず、どうやったら検証できるかわかりませんでした。
このあたりOpenIDの仕様などをしっかり理解すると良いのかもしれませんが、まだまだ理解が追い付いていないようです。

ということで、以降はRS256前提での検証について書きます。

以下は、IDトークンの検証に使えるライブラリの一覧を教えてくれます。

AWS API GatewayのカスタムオーソライザーをNode.jsで書くと想定して、jsonwebtokenを試してみます。

jwt.verifyのシグニチャを見ると、トークン(今回だとAuth0から受け取ったIDトークン)と、secretOrPublicKeyを渡す必要があるとわかります。
検証に使う公開鍵は、Application Settings > Show Advanced Settings > Certificatesからダウンロードできます。
あるいは、https://[your_account].auth0.com/.well-known/jwks.jsonから取得することもできます。
これは、JSON Web Key Set (JWKS)というものだそうです。

これをverifyの第2引数に渡して検証します。
成功するとPayloadが得られます。今回scopeにprofileを指定しているので、nameなどの属性も取得されています。
検証が成功したら、Payloadに含まれているissaudが想定通りかを確認します。

ログインユーザに応じて認可を行いたい場合、ロール属性を付与してそれをIDトークンのPayloadに含めるようにも出来ます。
Rulesに設定します。

ルールは以下のような実装になるのですが、テンプレートが用意されています。(以下もテンプレート実装そのものです)

上記はEメールアドレスのドメインでロールを決定し、それをapp_metadataに設定、さらにIDトークンにそれを含めるようにしています。
このあたりのことは、ここに色々と書いてあります。

提供サンプルについて

上にも書いたように、Angular版のサンプルがGitHubに提供されているのですが、ユースケース別に複数アプリの実装が提供されています。
JWTの検証サンプルとしては、03-Calling-an-APIが参考になるんではないかと思います。

私もローカルで動かしてみたのですが(2018/4/20実施)、何点かハマったことがありました。

このサンプルでは、サーバサイド(リソースサーバ)をexpressで構成してあって、/api/public/api/privateの2つのアクセスポイントがあります。
前者は認証なしでレスポンスを返しますが、後者がAuthorizationヘッダを要求しています。
Angularアプリ側の実装を見ると、Authorizationヘッダにアクセストークンを渡していました。

this.http.get(`${this.API_URL}/private`, {
  headers: new HttpHeaders()
    .set('Authorization', `Bearer ${localStorage.getItem('access_token')}`)
})

サーバサイドにはJWTの検証ロジックが組まれています。

const checkJwt = jwt({
  // Dynamically provide a signing key based on the kid in the header and the singing keys provided by the JWKS endpoint.
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: `https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`
  }),

  // Validate the audience and the issuer.
  audience: process.env.AUTH0_AUDIENCE,
  issuer: `https://${process.env.AUTH0_DOMAIN}/`,
  algorithms: ['RS256']
});

ですが、Auth0から得られるアクセストークンはJWTフォーマットになっていません。
アクセストークンに関するドキュメントはこちらになります。

Auth0 currently generates Access Tokens in two formats: as opaque strings or as JSON Web Tokens (JWTs).

私の動作確認では、アクセストークンは前者(opaque strings)でした。
ですので、これを検証してもNGとなってしまうのです。
原因は、audienceでした。

If the audience is set to [your_account].auth0.com/userinfo, then the Access Token will be an opaque string.
If the audience is set to the unique identifier of a custom API, then the Access Token will be a JSON Web Token (JWT).

このサンプルでは、auth0-variables.tsにaudience設定を記載するのですが、最初のサンプルにならって前者を設定していました。
これを修正することでアクセストークンもJWTフォーマットになりました。

また、express-jwt-authzによる検証を行っています。

Validate a JWTs scope to authorize access to an endpoint.

得られたアクセストークンにはscopeというクレームが含まれているのですが、サーバサイド実装が期待するread:messagesが含まれていません。

"scope": "openid profile"

Angularアプリ側の実装を見ると、

auth0 = new auth0.WebAuth({
  clientID: AUTH_CONFIG.clientID,
  domain: AUTH_CONFIG.domain,
  responseType: 'token id_token',
  audience: AUTH_CONFIG.apiUrl,
  redirectUri: AUTH_CONFIG.callbackURL,
  scope: 'openid profile read:messages'
});

となっており、read:messagesを指定しているので、本来ならこれがアクセストークンに含まれるはず、ってことのようです。
あれこれ調べてみたところ、これはRuleで対応する必要があるようでした。

リクエストされたscopeはcontext.request.query.scopeに設定されているので、そのまま受け入れてしまうなら、

context.accessToken.scope = context.request.query.scope

ということになります。(クライアントからの要求をまんま受け入れるのではダメなんだと思いますが)
これでようやくサーバサイドのアクセストークン検証がパスするようになりました。

IDトークンとアクセストークン

Cognitoユーザプールのサンプルを書いていたとき、IDトークンとアクセストークンの違いがあまりわかっていませんでした。
「OpenID ConnectがOAuth2.0を拡張して認証機能を実装した」「だからIDトークンは認証用」くらいのざっくり理解です。
未だにそのレベルを脱してないんですが、以下を読んで少しだけ理解が進んだように感じてます。

IDトークンのaudクレームには、Auth0アプリケーションのClient IDが設定されています。
一方でアクセストークンのほうは、Universal Loginへリダイレクトした際に指定したaudienceが設定されます。これはAPIsの存在するAPI(デフォルトではAuth0 Management APIのみが作成されている)のIdentifierを指定しないと、Universal Loginへリダイレクト自体に失敗します。

上のドキュメントには、

In the OIDC-conformant pipeline, ID Tokens should never be used as API tokens.

と書かれているんですが、ユーザ属性に応じてAPIの処理を変えたいといった場合は、IDトークンを渡してしまいたくなります。

・・・

以下を読んで頭を整理してみました。

単なる OAuth 2.0 を認証に使うと、車が通れるほどのどでかいセキュリティー・ホールができる

(追記)以下の認識が誤りであるとようやく理解しました。
アクセストークン検証は、上記記事に照らし合わせるとGraphAPIが行いますが、カット&ペーストアタックされると、Site_A用のトークンを送ってきているのがSite_Bだということを認識する手段がありません。
Callback URLs云々と以下には書いてますが、Callback URLsはAuthzが解するものであって、リソースサーバーであるGraphAPIには全く関係のない話です。
全く意味不明のことを書いてしまっていました。

記事中のSite_Bを「自分が提供しているサービス」という視点で読みます。
今回のケースだと、AuthzがAuth0になります。
そして、Site_Aのようなサイトが存在しえるのか、ということがポイントになると思います。

サーバサイドのアクセストークン検証では、

  • JWTの電子署名の検証
  • issクレームの検証
  • audクレームの検証

を行うわけですが、Site_Aによるカット&ペーストアタックが成立する条件は、

  • Site_ASite_Bのドメインでログインを行うようになっている
  • audienceも同じものを使うように構成されている

になるように思うのですが、何か勘違いしてしまっているでしょうか。
しかしながら、Callback URLsSite_A向けにする必要があり、これは許可されません。
今回は、Auth0のSocial Connections(ログインにFacebook等のサービスを利用する)は確認していないのですが、こちらを利用しない限りは、「車が通れるほどのどでかいセキュリティー・ホールができる」ということにはならない、というように理解しています。

カスタムオーソライザーのサンプル実装

しっかり動作確認したわけではないので、あくまでも参考程度とご理解いただければと。

authorizer.js

署名に使われた公開鍵は、node-jwks-rsaを利用して取得するようにしています。
最初はrequestで取得するように書いてたんですが、あれこれ考慮するのが面倒になって途中で切り替えました。

得られるJSON Web Key SetのJSONにはkeyが複数返されるようになっています。
その中の「どのキーか」を指定するため、JWTヘッダのkidクレームを指定する必要があります。そのため、前段処理としてJWTのデコードが必要になります。

まとめ

Quick Startでサンプルを動かすところまではあっという間でしたが、気になる点を追いかけだしたら次から次へと調べることが出てきて、もうお腹いっぱいです。
それでも、Auth0のドキュメントの一部しか見れておらず、まだまだ理解は浅いように感じます。
それと、上になるべく整理して記載したつもりですが、調べたけど記載できてないことも多々あります。
機会があれば、書き足したいと思います。