Auth0 を使って ID Token と Access Token の違いをざっくり理解する
みなさんは ID Token と Access Token の違いを理解していますか?
Access Token を使用した保護された Web API を実装する機会は何回かありましたが、理解して使っていたかというとそうではありませんでした。プライベートな Web API にアクセスするときは、Access Token を Authorization ヘッダーに Bearer で渡せばいいんでしょ? ID Token?? の認識で、なんとなく使っていたような状態でした。最近、認証・認可や OAuth 2.0 / OpenID Connect を勉強する機会があり、2 つのトークンの違いについてざっくりと理解できましたので、ブログにしてみました。
このブログでは、OAuth 2.0 / OpenID Connect に対応している Auth0 という IDaaS を使って、この 2 つのトークンの違いについて見ていきたいと思います。
前提
今回は検証用に Auth0 の Authentication API を使います。事前に Auth0 の無料アカウントを作成しておきます。ここでいうリソースサーバーとは認可される側のサーバーで、何らかのリソースを返却します。認可サーバーは、トークンを発行するサーバーです。認証認可のフローについては Authorization Code Flow を使用します。
Access Token とは
OAuth 2.0 で定義されている、リソースへのアクセスを 認可 するためのトークンです。保護されているリソース(Web API など)に文字通りアクセスするためのトークンです。基本的には専用の認可サーバー(ここでは Auth0)から発行されます。認可サーバーとリソースサーバーが同じ場合もあります。発行するためには、メールアドレス・パスワードなどを使ったユーザー認証(Auth0 の場合はユニバーサルログイン)を行い、ログインに成功したときに認可サーバーからクライアントに返却されます。クライアントはこのトークンを使用してリソースサーバーにアクセスできます。
Access Token については RFC 6749 で認可サーバーにおけるトークンの発行方法を定義し RFC 9068 でトークンの JWT の仕様が定義されています。基本的には JWT を用いると思いますが、RFC 6749 ではとくにフォーマットの定義はされていなく、ただの文字列でも OK ということになっているみたいです。
ID Token とは
OpenID Connect で定義されている、ユーザーが 認証 されたことを証明するトークンです。よくある間違いとしては ID Token を Authorization Header の Bearer トークンとして設定してリソースサーバーにアクセスしようとする場合です。私も過去にそのような間違いを犯しました。ID Token はユーザーが認証されたことを証明するトークンなので、認証後のその先のリソースサーバーにアクセスするための認可には使用できません。
ID Token では Access Token と違いエンコード方式が定義されていて JWT を利用する必要があります。アプリケーション側でこの JWT をデコードすることで、認証されたユーザーの情報を取得できます。デコードされた内容にはユーザーに関連する情報(メールアドレス、誕生日など)や認証に関連する情報が含まれています。この JWT のペイロードに含めるべき内容は OpenID Connect Core 1.0 に定義されています。
Auth0 の Authorization Code Flow で ID Token と Access Token を取得する
Auth0 は管理画面からアプリケーションを作成することで ClientId と ClientSecret が発行され、認可サーバーにアクセスできます。
Application を作成する
Auth0 のダッシュボードにアクセスして Create Application で新しいアプリケーションを作成します。ここでは Single Page Web Applications
を使用します。
認証後のコールバック URL を許可する必要があるので https://example.com と入れておきます。
Authorization Code Flow で Token を取得する
Auth0 の Application を作成したことで Authentication API にアクセスできます。OAuth 2.0 / OpenID Connect における Authorization Code Flow を使って ID Token と Access Token を取得します。
以下の /authorize
認可エンドポイントをブラウザからアクセスし Auth0 のユニバーサルログインの認証画面を表示します。ID Token を取得するためには scope=openid
を含める必要があります。また、Access Token を取得するために audience
をアクセス先の URL に含める必要があります。
今回はわかりやすいように scope=openid profile email
としてユーザーのメールアドレスとプロフィール情報を ID Token に含めます。
https://YOUR_TENANT.auth0.com/authorize?audience=https://example.com&scope=openid profile email&response_type=code&client_id=CLIENT_ID&redirect_uri=https://example.com
ブラウザに認証画面が表示されました。ユーザーはまだ作成されていないため Google で続けるをクリックして Google のアカウントを使ってサインアップとログインを行います。
ログインに成功すると、先ほど redirect_uri
に指定した https://example.com にリダイレクトされクエリ文字列に ?code=xxx
という文字列が付与されています。これが Authorization Code
です。このコードを使うことでトークンを取得できます。これをコピーしておきます。
Authorization Code と先ほど Application を作成した際に取得した ClientId と ClientSecret をクエリ文字列に含めて /token
にアクセスします。これで ID Token と Access Token を取得できます。
curl --request POST \ --url 'https://YOUR_TENANT.auth0.com/oauth/token' \ --header 'content-type: application/x-www-form-urlencoded' \ --data 'grant_type=authorization_code&client_id=CLIENT_ID&client_secret=CLIENT_SECRET&code=AUTHORIZATION_CODE&redirect_uri=https://example.com'
ID Token と Access Token が返却されました。
{ "access_token": "eyJhbGciOiJSUz...", "id_token": "eyJhbGciOiJSU...", "scope": "openid profile email", "expires_in": 86400, "token_type": "Bearer" }
ID Token と Access Token をデコードして中身を確認する
ID Token や Access Token はデコードすることで、ユーザーのプロフィール情報や認証・認可における識別子を取得できます。jwt.io を使うことで簡単にデコードして試せるので、やってみます。
ID Token をデコードする
jwt.io にアクセスして、先ほど取得した ID Token を貼り付けます。中身のペイロードを確認できます。
JWT のペイロード部分の中身は以下のようになっていました。ユーザーの姓名、写真、メールアドレスなどのユーザー情報から、有効期限や認証の識別子などを確認できます。
{ "given_name": "直哉", "family_name": "佐藤", "nickname": "sato.naoya", "name": "佐藤直哉", "picture": "https://lh3.googleusercontent.com/xxx", "locale": "ja", "updated_at": "2022-09-06T05:15:45.044Z", "email": "sato.naoya@classmethod.jp", "email_verified": true, "iss": "https://xxx.us.auth0.com/", "sub": "google-oauth2|000000000000000", "aud": "l8ThBc76...", "iat": 1662441672, "exp": 1662477672, "sid": "Nb1Oa53..." }
Web アプリケーションを実装するときに、ログインしたユーザーの情報を画面に表示するのはよくあると思います。ID Token があればアプリケーション側でデコードするだけでログインしているユーザーの情報を取得できるということになります。
この ID Token のペイロードの中身の属性については、すべて OpenID Connect Core 1.0 #ID Token に仕様として定義されています。ユーザーのプロフィール情報についても OpenID Connect Core 1.0 #Claims に定義されています。
これでユーザーの認証に成功したのでアプリケーションから保護された Web API にリクエストできるというわけではありません。ID Token にはアクセス先のリソースサーバーの情報は含まれていないため、リソースサーバー側でトークンを検証する術がないからです。ユーザー認証後のその先のリソースサーバーにアクセスするためには Access Token を使用する必要があります。
Access Token をデコードする
次に、先ほどと同じように jwt.io にアクセスして Access Token を貼り付けて中身を確認します。
{ "iss": "https://xxx.us.auth0.com/", "sub": "google-oauth2|000000000000", "aud": [ "https://example.com/", "https://xxx.us.auth0.com/userinfo" ], "iat": 1662446236, "exp": 1662532636, "azp": "l8ThBc76jdA...", "scope": "openid profile email" }
ID Token と中身が似ています。Access Token の JWT におけるペイロードの仕様は RFC 9068 で定義されています。
リソースサーバー側では JWT の Access Token をデコードして、署名・検証することではじめてリクエストが認可されリソースにアクセスできます。
Access Token には aud
属性にアクセスする先のリソースサーバーの一覧があります。リソースサーバー側ではこの値と iss
JWKs エンドポイントから取得した公開鍵を使用して署名して正当な Access Token であることを確認します。
上記の例を見ると aud
に https://example.com と https://.auth0.com/userinfo の 2 つの URL が入っています。https://example.com は先ほどの認可エンドポイントのリクエスト時 audience
に指定した URL です。userinfo
エンドポイントについては Auth0 で自動的に設定されるログインしたユーザーの情報を取得する API です。とくに audience
に指定しなくても自動的に付加されます。
ということで、この Access Token は https://example.com と https://.auth0.com/userinfo にアクセスできるトークンであることがわかりました。
Access Token を使用してリソースサーバーにアクセスしてみる
ここまでで Access Token を取得してアクセス先のリソースサーバーを確認できましたので実際にアクセスしてみます。https://example.com についてはただの例示用の URL でアクセスできないので、Auth0 の userinfo エンドポイント にアクセスしてみます。これは Auth0 にログインしているユーザー情報を返却する API です。先ほど取得した Access Token を Authorization ヘッダーに含めてリクエストします。
curl --request GET \ --url 'https://YOUR_TENANT.auth0.com/userinfo' \ --header 'Authorization: Bearer ACCESS_TOKEN' \ --header 'Content-Type: application/json'
リクエストすると、ユーザー ID が返却されました。Google の OAuth でログインしたのでプレフィックスに google-oauth2
が含まれています。
{"sub":"google-oauth2|000000000000000000"}
↑の結果はアクセストークンの発行時のスコープを scope=openid
のみにしていたので、ユーザー ID しか返却されませんでした。ユーザーのメールアドレスやプロフィール情報を返却させたい場合は Access Token 発行時のスコープに scope=openid profile email
などを付与すれば、追加の情報が返却されるような仕組みになっています。
Access Token を署名・検証してみる
userinfo
エンドポイントに Access Token を付与することでユーザーというリソースを取得できましたが、内部ではトークンを署名・検証するステップが存在します。簡易的に Node.js を使って先ほど発行された Access Token を署名・検証してみようと思います。
JWT をデコード、検証、署名するための便利なライブラリがあるので npm 経由でインストールします。
jsonwebtoken
: JWT を署名・検証・デコードするライブラリです。
jwks-rsa
: JWKs エンドポイントから署名用の公開鍵を取得するためのライブラリです。
mkdir jwt-vertification-sample cd jwt-vertification-sample npm init -y npm install --save jsonwebtoken jwks-rsa
事前に環境変数を以下の内容で設定します。
ACCESS_TOKEN
: 先ほど取得したアクセストークンを設定します。
AUTH0_JWKS_URL
: JWKs エンドポイントを設定します。
AUTH0_ISSUER
: AUTH0 のドメインを設定します。例: https://YOUR_TENANT.auth0.com/
AUTH0_AUDIENCE
: トークン発行時に指定したリソースサーバーの URL です。今回は https://example.com/ となります。
JWKs エンドポイントは Auth0 で作成した Application の Endpoints というタブから取得できます。
以下のように署名・検証用のコードを書きます。
const jwt = require('jsonwebtoken'); const jwksClient = require('jwks-rsa'); const accessToken = process.env.ACCESS_TOKEN; const jwksUri = process.env.AUTH0_JWKS_URL; const issuer = process.env.AUTH_ISSUER; try { // JWTをデコードして、公開鍵用のkidを取得 const decoded = jwt.decode(accessToken, { complete: true }); console.log({ decoded }); if (!decoded || typeof decoded === 'string') { throw new Error('JWTのデコードに失敗しました'); } // JWT署名用の公開鍵の取得 const client = jwksClient({ jwksUri }); const key = await client.getSigningKey(decoded.header.kid); const publicKey = key.getPublicKey(); console.log({ publicKey }); // 署名(取得した公開鍵で署名の確認)、検証(audienceとissuerを検証) const verifyToken = jwt.verify(accessToken, publicKey, { audience: 'https://example.com', issuer, }); // 復号化されたアクセストークンを取得 console.log({ verifyToken }); } catch(e) { if (e instanceof jwt.JsonWebTokenError) { throw new Error('不正なトークンです'); } if (e instanceof jwt.TokenExpiredError) { throw new Error('トークンの有効期限が切れています'); } throw e; }
実行すると署名と検証に成功し verifyToken が返却されました。
node index.js { decoded: { header: { alg: 'RS256', typ: 'JWT', kid: 'bJXemUts2zI1ZaRmpgUZl' }, payload: { iss: 'https://xxx.us.auth0.com/', sub: 'google-oauth2|0000000000', aud: [Array], iat: 1662446236, exp: 1662532636, azp: 'l8ThBc76j...', scope: 'openid profile email' }, signature: 'rBfxmPCaMNl24y...' } } { publicKey: '-----BEGIN PUBLIC KEY-----\n' + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0BxM5BRI6mXtUG2VJbqb\n' + 'aarA7LcC8h1g2ACyF0G8OdVkhrYCoDrxW/Lhu3pvZkfnwtTFku2E2eqi7qMQF4Hl\n' + '6QTm4yfiELmioIPgyrjwXyUIoVt7MQ+433McI8iri3VCTw1PHmrAsHZgs63gJr61\n' + '3czzUTTxkP8PB9o39FqEAi4MglPVUL6IERPN6ZK1VtztsMKpo9Df/eA+/qXtO3Fw\n' + 'N/UlX4CQ2+pxyaHv2lQYg6qWCOVCXyIFW8VVg4Bta30yhDHgRr1117sXAQaImsC+\n' + 'l7XU5mzNf4eovNC9STozUsWzrDM27/c03ileEan0OdPX9vAieI/ZUMHvb7HcUBYm\n' + 'zQIDAQAB\n' + '-----END PUBLIC KEY-----\n' } { verifyToken: { iss: 'https://xxx.us.auth0.com/', sub: 'google-oauth2|0000000000', aud: [ 'https://example.com/', 'https://xxx.us.auth0.com/userinfo' ], iat: 1662446236, exp: 1662532636, azp: 'l8ThBc76jdA...', scope: 'openid profile email' } }
試しに audience を別の URL(https://www.google.com) にしてみます。以下のようにエラーが起きました。
/Users/sato.naoya/jwt-sample/index.js:34 throw new Error('不正なトークンです'); ^ Error: 不正なトークンです at /Users/sato.naoya/jwt-sample/index.js:34:19
リソースサーバーでは上記の様に Authorization ヘッダーに付与されている Access Token を署名・検証して、正当なトークンなのかを確認する必要があります。
まとめ
Auth0 を使って ID Token, Access Token の発行からアクセス、署名・検証までをひととおり試してみました。認可エンドポイントやトークンエンドポイント、各トークンの仕様については、RFC や OpenID Connect Core 1.0 にすべて記載されています。私は Auth0 を触る際にひととおりさらっと読んだだけですが、読んだ後と読まない後では理解度が全然違ったので、流し読みでもいいので読むことをオススメします。