以前、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へリダイレクトする際のパラメータresponseType
やscope
に何を指定するかで変わってきます。
私の実装では、以下の様にしました。
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に含まれているiss
やaud
が想定通りかを確認します。
ログインユーザに応じて認可を行いたい場合、ロール属性を付与してそれを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_A
がSite_B
のドメインでログインを行うようになっている - audienceも同じものを使うように構成されている
になるように思うのですが、何か勘違いしてしまっているでしょうか。
しかしながら、Callback URLs
をSite_A
向けにする必要があり、これは許可されません。
今回は、Auth0のSocial Connections
(ログインにFacebook等のサービスを利用する)は確認していないのですが、こちらを利用しない限りは、「車が通れるほどのどでかいセキュリティー・ホールができる」ということにはならない、というように理解しています。
カスタムオーソライザーのサンプル実装
しっかり動作確認したわけではないので、あくまでも参考程度とご理解いただければと。
署名に使われた公開鍵は、node-jwks-rsaを利用して取得するようにしています。
最初はrequestで取得するように書いてたんですが、あれこれ考慮するのが面倒になって途中で切り替えました。
得られるJSON Web Key SetのJSONにはkeyが複数返されるようになっています。
その中の「どのキーか」を指定するため、JWTヘッダのkid
クレームを指定する必要があります。そのため、前段処理としてJWTのデコードが必要になります。
まとめ
Quick Startでサンプルを動かすところまではあっという間でしたが、気になる点を追いかけだしたら次から次へと調べることが出てきて、もうお腹いっぱいです。
それでも、Auth0のドキュメントの一部しか見れておらず、まだまだ理解は浅いように感じます。
それと、上になるべく整理して記載したつもりですが、調べたけど記載できてないことも多々あります。
機会があれば、書き足したいと思います。