🎉

Auth0でアクセストークンを取得する際に帰ってくるexpires_inって何者?

2023/08/21に公開

はじめに

シングルページアプリケーション(以降SPAとする)でAuth0を使用すると、以下のようなトークン情報を取得します。

{
	“access_token”: “ABCD”,
	“refresh_token”: “JDKF”,
	“id_token”: “HERE.ISYOUR.TOKEN”,
	“expires_in”: 86400,
	“token_type”: “Bearer”
}

今まで特に疑問を持たなかったのですが、今週ふと上記オブジェクトを眺めていたら「expires_inは何者や??」と思い始めました。
そこで、今回はexpires_inの概要や必要性を説明し、使用例を展開していきます。
もし私と同様に、expires_inが何者か気になった方は是非とも続きを読んでいただけますと幸いです。

expires_inとは: アクセストークンの有効期限

expires_inですが、これはアクセストークンの有効期限を示しています。
実際にAuth0のダッシュボードからApplications→APIsで作成したAPIを選択し、Token SettingのToken Expirationを変更するとそれに合わせて変更されます。
なるほど、expires_inはアクセストークンの有効期限を表すことがわかりました。
確かに、アクセストークンの有効期限が切れたときは、認可サーバーへアクセストークンを取得するリクエストを送る処理などをしたくなります。
そのため、有効期限であるexpires_inを設定するのはとても理にかなっています。
以上のことから、expires_inの話は終わり。と言いたいところですが、ここであることに気づきます。

  • 「Auth0が発行するアクセストークンはJWT形式だから、有効期限の情報があるのではないか」
  • 「アクセストークンから有効期限の値を直接取得すれば良いのではないか」
    実際に、アクセストークンには以下のような有効期限を示す値が格納されています。
{
  ...略
  "exp": 1692441430
}

なら、上記の通りアクセストークンから有効期限を取得し、その値を使用すればアクセストークンの有効期限がわかるので、expires_inは必要ないと考えることができます。
しかし、実際は諸々の理由からexpires_inを設定する必要があります。
以降はその説明を行っていきます。

expires_inを設定する理由①: OAuthが望んでいるから

OAuthの標準仕様を定めたRFC6749の5.1 成功レスポンスを見ると、認可サーバーからアクセストークンを返す時に以下のような形式を指定しています。

access_token 必須 (REQUIRED)
token_type 必須 (REQUIRED)
expires_in 推奨 (RECOMMENDED)
refresh_token 任意 (OPTIONAL)
scope 必須 (REQUIRED) or 任意 (OPTIONAL)

expires_inに注目すると、推奨※となっています。
推奨なので、特別な事情がない限り搭載すべき値となります。
今回はアクセストークンからでも有効期限をとれるはずという理由から、expires_inを付与する必要がないのでは?と考えています。
ただ、expires_inは絶対に存在してはいけないわけではありません。
よって、expires_inを無くす明確な理由がなく、標準仕様を守るためにもexpires_inは設定する必要があります。
※推奨、必須、任意となっている項目の意味については、RFC2119を参照してください。

expires_inを設定する理由②: アクセストークンから有効期限を取得するべきではないから

先程はexpires_inを設定する必要性を説明しましたが、ここではクライアント側でアクセストークンの情報を使うべきではないという方向性から、expires_inの必要性を説明します。
クライアントでアクセストークンを使用するのはなぜいけないのでしょうか?
Auth0の場合アクセストークンは署名が付与されているので、改ざんはできません。
また、署名形式を「RS256」にすれば検証に使用するのはJWKSなどの公開鍵を使用したものになるので、鍵の流出が問題となりません。
これらのことから、クライアントでアクセストークン内の情報を使うことは問題なさそうに見えます。
しかし、実際には様々な問題があります。

問題① 使用できる署名形式が限定される

署名形式を公開鍵暗号を使った「RS256」の場合は公開鍵が流出しても問題ありません。
一方で、署名形式が共通鍵暗号を使った「HS256」の場合、共通鍵が流出してしまうのは致命的です。
鍵が流出してしまうと、第三者が正当な署名を付与した悪意のあるアクセストークンをいくらでも作成できてしまうからです。
このような流出すると問題がある情報をクライアントで扱うのは、あまりに危険です。
そのため、クライアント側でアクセストークンの検証する場合「RS256」のみしか使用できず、Auth0を使用できるケースが限定されてしまいます。

問題② アクセストークンを改ざんされる可能性がある

先程、アクセストークンを署名しているから改ざんされないと言いましたが、フロントの実装に脆弱性がある場合、署名の検証が上手くできず改ざんが可能となってしまいます。
例えば、以下のコードを見てください。

<!doctype html>
<html lang="en">
<body>
  <p id="show-text"></p>
  <script>
    const searchParams = new URLSearchParams(window.location.search);
    let alg = 'RS256';
    const joseHeader = {
      alg: 'RS256'
    }

    window.setInnerHTML = function (elm, html) {
      elm.innerHTML = html;
      Array.from(elm.querySelectorAll("script")).forEach(oldScript => {
        const newScript = document.createElement("script");
        Array.from(oldScript.attributes)
          .forEach(attr => newScript.setAttribute(attr.name, attr.value));
        newScript.appendChild(document.createTextNode(oldScript.innerHTML));
        oldScript.parentNode.replaceChild(newScript, oldScript);
      });
    }

    const textElm = document.getElementById('show-text')
    setInnerHTML(textElm, searchParams.get('hoge'));
    textElm.textContent = searchParams.get('hoge');

  </script>

</body>
</html>

上記はアクセスしたとき、URLに含むhogeパラメータの値を取得して値を表示させるのと、hogeパラメータがスクリプトの場合、それを実行させる処理を行っています。
また、署名の検証には暗号方式の値がJOSEヘッダー内にあるalgの値と一致するかを確認する必要があります。
そのため、今回は疑似的にクライアントが想定している暗号方式を設定したものを変数alg、アクセストークンから取得したJOSEヘッダーを変数joseHeaderに格納しています。
では、上記コードをサーバー上で動かしてみましょう。(今回はNode.jsで動かしています。)
http://127.0.0.1:5173/?hoge=test へアクセスすると画面上には「test」と表示され、algとjoseHeaderの値を確認すると上記コードで設定した値になっています

次に[http://127.0.0.1:5173/?hoge=<script>alg='none';joseHeader.alg = 'none'</script>]でアクセスすると、algとjoseHeaderの値がそれぞれ’none’に変わっています。

これはあまりに危険な状態です。
RFC7518を見ると署名形式でnoneは存在していますが、noneは署名を行っていないことを示します。
つまり、algとjoseHeaderの値がそれぞれ’none’に変わると、本来署名してあるアクセストークンが署名されていないものとみなされ、署名の検証をスキップします。
これでは、改ざんがされたかどうかの判断が出来なくなり、好きなだけ悪意のある値を設定できます。
もし、メールアドレスが書換えられてしまったら、本来自分に送られるメールが悪意のあるユーザーに送られてしまうかもしれません。
今回示したコードは、あまりにもセキュリティ的にお粗末なコードで、こんなコードは書かないと思われるかもしれません。
しかし、クライアント側で検証を行うということは、ありとあらゆる改ざんの可能性を潰す必要があります。
そんな余計なリスクを背負うほど、アクセストークンの情報を使う意義はありません。
以上のことから、アクセストークンの情報を使うべきではなく、アクセストークンの有効期限を確認するときは、標準仕様として存在するexpires_inの値を使うのが良い選択となります。

expires_inの使用例

ここではexpires_inが実際にどう使われているかの一例を示します。
具体的にはAuth0が提供しているauth0-spa-jsを見ています。
auth0-spa-jsにおいて、expires_inフロント側でインメモリに保存したトークンが有効期限内かを判断するのに使用しています。
これによって、リソースサーバーへ問い合わせず、Auth0へ直接トークン取得のリクエストを行えるようになります。
以下具体的な手順です。

  1. getTokenSilentlyメソッドで_getEntryFromCacheを呼び出します。
  2. _getEntryFromCacheでCacheManagerクラスのgetメソッドを呼び出します。
  3. InMemoryCacheクラスのgetメソッドを呼び出して、ログインした時に保存した値を取得します。
  4. 3で取得した値でexpires_inを使用した情報を格納したexpiresAtが現在時刻より値が小さければ、refresh_tokenプロパティのみを持つオブジェクトを返すようにします。
  5. アクセストークンのプロパティがないので、Auth0へアクセストークンを取得するリクエストを行います。
    以上のようにexpires_inはクライアント側で有効期限を扱うために使用されます。
    一々、リソースサーバーでアクセストークンが有効期限内かを確認して、有効期限切れだったらエラーハンドリングを行い、認可サーバーへ問い合わせをすることをしなくて良いので、非常に便利ですね。

補足 auth0-spa-jsでのexipres_inを使用しているコードの提示とちょっと解説

※ここは私の趣味で書いているところがあるので、読む必要は全くありません。
expires_inを実際に使用している部分はCacheManagerクラス内のgetメソッド部分です。

async get(
    cacheKey: CacheKey,
    expiryAdjustmentSeconds = DEFAULT_EXPIRY_ADJUSTMENT_SECONDS
  ): Promise<Partial<CacheEntry> | undefined> {
    let wrappedEntry = await this.cache.get<WrappedCacheEntry>(
      cacheKey.toKey()
    );

    if (!wrappedEntry) {
      const keys = await this.getCacheKeys();

      if (!keys) return;

      const matchedKey = this.matchExistingCacheKey(cacheKey, keys);

      if (matchedKey) {
        wrappedEntry = await this.cache.get<WrappedCacheEntry>(matchedKey);
      }
    }

    // If we still don't have an entry, exit.
    if (!wrappedEntry) {
      return;
    }

    const now = await this.nowProvider();
    const nowSeconds = Math.floor(now / 1000);

    if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
      if (wrappedEntry.body.refresh_token) {
        wrappedEntry.body = {
          refresh_token: wrappedEntry.body.refresh_token
        };

        await this.cache.set(cacheKey.toKey(), wrappedEntry);
        return wrappedEntry.body;
      }

      await this.cache.remove(cacheKey.toKey());
      await this.keyManifest?.remove(cacheKey.toKey());

      return;
    }

    return wrappedEntry.body;
  }

この中で今回注目するのは以下の部分になります。

    const now = await this.nowProvider();
    const nowSeconds = Math.floor(now / 1000);

    if (wrappedEntry.expiresAt - expiryAdjustmentSeconds < nowSeconds) {
      if (wrappedEntry.body.refresh_token) {
        wrappedEntry.body = {
          refresh_token: wrappedEntry.body.refresh_token
        };

        // ...略
        return wrappedEntry.body;
      }
	// ...略

      return;
    }

まず、expires_inを使用して設定した有効期限の値が現在時刻よりも前かを判定しています。
すなわち、有効期限が切れているかを確認しています。
もし、有効期限が切れている場合、リフレッシュトークンを設定していればrefresh_tokenプロパティのみ持つオブジェクトを、それ以外の場合はundefinedを返すようにしています。
これによって、Auth0Clientクラスの_getEntryFromCacheメソッドにある以下の処理が走らなくなり、undefinedを返すようになります。

if (entry && entry.access_token) {
      const { access_token, oauthTokenScope, expires_in } = entry as CacheEntry;
      const cache = await this._getIdTokenFromCache();
      return (
        cache && {
          id_token: cache.id_token,
          access_token,
          ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
          expires_in
        }
      );
    }

_getEntryFromCacheメソッドからundefinedを返すことで、トークン情報を取得するAuth0Clientクラスの_getTokenSilentlyメソッド内にある、Auth0へアクセストークンを取得するリクエストを実行する以下の処理が走ります。

const authResult = this.options.useRefreshTokens
          ? await this._getTokenUsingRefreshToken(getTokenOptions)
          : await this._getTokenFromIFrame(getTokenOptions);

よって、ユーザーが意識せずにアクセストークンを取り直してくれて、一々ログインする必要がなくなります。
ここで、話を戻してCacheManagerクラス内のgetメソッドでexpires_inを使用したexpiresAtを使用しているとありましたが、expiresAtはどのように設定しているかを見ていきましょう。
expiresAtを持っているCacheManagerクラス内のgetメソッドwrappedEntryは以下のコードです。

let wrappedEntry = await this.cache.get<WrappedCacheEntry>(
      cacheKey.toKey()
    );

すなわち、chacheプロパティに値をsetするところを探せば良いことが分かります。
その部分は同一クラスのsetメソッドにあります。

async set(entry: CacheEntry): Promise<void> {
    const cacheKey = new CacheKey({
      clientId: entry.client_id,
      scope: entry.scope,
      audience: entry.audience
    });

    const wrappedEntry = await this.wrapCacheEntry(entry);

    await this.cache.set(cacheKey.toKey(), wrappedEntry);
    await this.keyManifest?.add(cacheKey.toKey());
  }

setメソッドのawait this.cache.set(cacheKey.toKey(), wrappedEntry); でcahcheをsetしています。
では、valueに設定している変数wrappedEntryに代入している関数wrapCacheEntryを確認します。

private async wrapCacheEntry(entry: CacheEntry): Promise<WrappedCacheEntry> {
    const now = await this.nowProvider();
    const expiresInTime = Math.floor(now / 1000) + entry.expires_in;

    return {
      body: entry,
      expiresAt: expiresInTime
    };
  }

とうとうexpires_inが出てきました。
ここでexpires_inを使い、トークンの有効期限を設定するexpiresAtを作成しています。
以上のことから、有効期限を示すexpiresAtは現在時刻のタイムスタンプ(ms)を秒数に足したものを、Auth0から受け取るexpires_inを足し合わせたものとなります。
処理を追うのは大変でしたが、expires_inの使い方自体は一般的な使い方をしていました。

おわりに

今回はアクセストークン取得が成功した時に、Auth0から受け取るexpires_inについて見ていきました。
expires_inそのものの理解はもちろん、自分のなかで曖昧だったなぜアクセストークンの情報をクライアントで解析・使用してはいけないかも理解できました。
まだ、Auth0・OAuthは全然全容が掴めないですね。
ここまで読んで頂きありがとうございました。

Discussion