Auth0 を使って ID Token と Access Token の違いをざっくり理解する

2022.09.07

みなさんは 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 incorporating errata set 1 に書かれています。

Auth0 の Authorization Code Flow で ID Token と Access Token を取得する

Auth0は、管理画面からアプリケーションを作成することで、ClientId と Client Secret が発行され、認可サーバーにアクセスすることができるようになります。

Application を作成する

Auth0のダッシュボードにアクセスして、 Create Application で新しいアプリケーションを作成します。ここでは、 Single Page Web Applications を使用します。

認証後のコールバックURLを許可する必要があるので、 https://example.com と入れておきます。

Authorization Code Flow で Token を取得する

Auth0 の Application を作成したことで、Auth0 の Authentication API にアクセスできるようになりました。OAuth 2.0 / OpenID Connect における Authorization Code Flow を使って、ID Token と Access Token を取得してみます。

以下の /authorize 認可エンドポイントURLをブラウザからアクセスして、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 に指定した http://example.com にリダイレクトされ、クエリストリングに ?code=xxx という文字列が付与されています。これが Authorization Code です。このコードを使うことでトークンを取得することができます。これをコピーしておきます。

次に、この Authorization Code と 先ほど Application を作成した際に取得した client_id と client_secret、 authorization code をクエリストリングに含めて /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 であることを確認します。

上記の例を見ると audhttps://example.comhttps://.auth0.com/userinfo の2つが入っています。 https://example.com は先ほどの認可エンドポイントのリクエスト時に audience に指定したURLです。 userinfo エンドポイントについては、Auth0側で自動的に設定される、ログインしたユーザーの情報を取得するAPIです。特に指定しなくても自動的に付加されます。

ということで、この Access Token は https://example.comhttps://.auth0.com/userinfo のリソースにアクセスできるトークンであることがわかりました。

Access Token を使用してリソースサーバーにアクセスしてみる

ここまでで、Access Token を取得して、アクセス先のリソースサーバーを確認できましたので、実際にアクセスしてみます。 https://example.com については、ただの例示用のURLでアクセスできないので、Auth0 の userinfo エンドポイント にアクセスしてみます。これは Auth0 のユーザーの情報を返却するAPIです。先ほど取得した Access Token を Authorization ヘッダに含めてリクエストします。

curl --request GET \                                                                                                                                          Tue Sep  6 17:08:14 2022
      --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 を付与することでユーザーというリソースを取得できましたが、Auth0 内部では付与された 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 : JSON Web Key Setのエンドポイントを設定します。

AUTH0_ISSUER : AUTH0のドメインを設定します。例: https://YOUR_TENANT.auth0.com/

AUTH0_AUDIENCE : トークン発行時に指定した、リソースサーバーのURLです。今回は、https://example.com/ となります。

JSON Web Key Setのエンドポイントは、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://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 を触る際に一通りさらっと読んだだけですが、読んだ後と読まない後とでは理解度が段違いだったので、流し読みでもいいので読むことをおすすめしたいです。

参考

RFC 6749

RFC 6750

RFC 9068

OpenID Connect Core 1.0 incorporating errata set 1

ID Token and Access Token: What's the Difference?

node-oidc-provider