[アップデート]Amazon Cognito がリフレッシュトークンのローテーションをサポートしました

[アップデート]Amazon Cognito がリフレッシュトークンのローテーションをサポートしました

Amazon Cognito がリフレッシュトークンのローテーションをサポートしました
Clock Icon2025.04.25

お疲れさまです。とーちです。

少し前のアップデートですが、Amazon Cognito がリフレッシュトークンのローテーションをサポートしたというアップデートがありました。Amazon Cognitoにはしばらく触っていなかったのでキャッチアップがてら、こちらのアップデートを試してみました。

https://aws.amazon.com/jp/about-aws/whats-new/2025/04/amazon-cognito-refresh-token-rotation

とりあえずまとめ

  • Amazon Cognito がリフレッシュトークンのローテーションをサポート
  • リフレッシュトークンを使用するたびに新しいリフレッシュトークンが発行される
  • 古いリフレッシュトークンには最大60秒の猶予期間を設定可能
  • Essentials、Plusプランでのみ利用可能な機能
  • アプリケーション側では GetTokensFromRefreshToken API を使用する必要がある

そもそもリフレッシュトークンとは?

ウェブアプリやモバイルアプリでは、ユーザーが一度ログインした後も、継続してサービスを利用できるようにする仕組みが必要です。その仕組みの一つが「トークン」と呼ばれる認証情報です。

主に3種類のトークンがあります。

  1. アクセストークン:短期間(数分〜数時間)有効で、APIやリソースにアクセスする権限を証明するためのトークン
  2. IDトークン:ユーザー情報を含む、身分証明書のようなもので、ユーザー名やメールアドレスが含まれるのでクライアントアプリ側でこれらの情報を表示する等の使い方がある
  3. リフレッシュトークン:長期間(数日〜数ヶ月)有効で、アクセストークンとIDトークンを再取得するために使用

リフレッシュトークンのローテーションとは?

このアップデートで追加されたリフレッシュトークンのローテーションというのは、文字通りリフレッシュトークンを自動で新しいものに置き換える仕組みです。

リフレッシュトークンのローテーションがどのように行われるかのフロー図が以下です。実際に確認してみて分かったのですが、GetTokensFromRefreshToken APIを実行するたびにリフレッシュトークンは更新されていました。このAPIドキュメントを見る限りではリフレッシュトークンを更新しないオプションはなさそうでした。

注意点

注意点としてはローテーションされることでリフレッシュトークンの有効期限が伸びるわけではないということです。

ドキュメントにも以下のように記載があります。

The new refresh token is valid for the remaining duration of the original refresh token.

また、リフレッシュトークンローテーション機能を使う場合は、Amazon Cognitoユーザープールの設定だけでなく、Amazon Cognitoの認証を行うアプリケーションにも修正を加える必要がある点に注意です。

従来は、認証フローとしてALLOW_REFRESH_TOKEN_AUTH を有効にしたうえで、AdminInitiateAuth または InitiateAuth API オペレーションを使用して、リフレッシュトークンを使ったアクセストークン等の更新を行っていました。

ALLOW_REFRESH_TOKEN_AUTH設定画面

リフレッシュトークンローテーション機能を使う場合、ALLOW_REFRESH_TOKEN_AUTH は無効にする必要があります。無効にしないとローテーション機能は有効にできません。

また使用するAPIについても変更する必要があり、GetTokensFromRefreshToken APIを使う必要があります。アプリケーション側でも修正が必要というのはこの点になります。

CognitoにはLite、Essentials、Plusと3つのプランがありますが、リフレッシュトークンローテーション機能を使用できるのは、Essentials、Plusプランのみである点に注意してください。

やってみる

それでは実際にリフレッシュトークンがローテーションされることを確認してみようと思います。

Cognitoユーザープールの作成

Amazon Cognitoユーザープールを作成するところから行います。ユーザープールはマネージメントコンソールから以下の設定で作成しました。アプリケーションタイプを「従来のウェブアプリケーション」で作ってしまっていますが、後で別のアプリケーションタイプで作り直しています。

Cognitoユーザープール作成画面

アプリケーションクライアントの設定

続いてアプリケーションクライアントの項目を選び、「アプリケーションクライアントを作成」ボタンを押して、アプリケーションクライアント設定を新規で作成します。ここで「従来のウェブアプリケーション」を選択するとクライアントシークレットが生成され、この後の認証を行う際に複雑な処理を追加する必要が出てきます。検証目的なので、クライアントシークレットを生成しないアプリケーションタイプである「シングルページアプリケーション」を選択しました。また、今回は開発端末上にWebサーバを立てるので、リターンURLはlocalhostとしています。

アプリケーションクライアント作成画面

作成したアプリケーションクライアントを開き、設定を以下のように変更します。

アプリケーションクライアント設定画面

認証フローでALLOW_USER_PASSWORD_AUTHを有効にしているのは、検証目的のためシンプルな認証フローにしたいからです。また、上記の通りリフレッシュトークンのローテーションを有効にするためにALLOW_REFRESH_TOKEN_AUTH は外しておきます。

テストユーザーの作成

また、ユーザー管理⇒ユーザーからテスト用のユーザーを作成しておきます。

テストユーザー作成画面

ユーザー作成直後は パスワードを強制的に変更 ステータスになっています。この状態だとログイン時に新しいパスワードを入力することが要求され、プログラムもそれを考慮した作りにしないといけないので、開発端末から以下のコマンドで事前にパスワードを変更しておきます。

aws cognito-idp admin-set-user-password \
  --user-pool-id YOUR_USER_POOL_ID \
  --username test@example.com \
  --password YourStrongPassword123! \
  --permanent

テスト用アプリケーションの作成

続いてリフレッシュトークンのローテーションを確認するための簡単なアプリケーションをローカル端末上に作成します。

今回はNode.jsで作成しました。

> node --version
v22.14.0

AWS SDK for JavaScript v3を使うので以下のパッケージをインストールします。

npm install express @aws-sdk/client-cognito-identity-provider dotenv

CognitoユーザープールID等の情報を指定するために .envファイルを作成します。

# .env
COGNITO_REGION=ap-northeast-1  # リージョン
COGNITO_USER_POOL_ID=ap-northeast-1_xxxxxxxx  # ユーザープールID
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxx  # アプリケーションクライアントID

クライアントIDはアプリケーションクライアントの画面に書いてあります。

クライアントID確認画面

実際の認証処理を書くserver.jsファイルを作成します。以下の内容で作成しました。なお、コードは生成AIに書いてもらってます。

const express = require('express');
const { CognitoIdentityProviderClient, InitiateAuthCommand, GetTokensFromRefreshTokenCommand } = require('@aws-sdk/client-cognito-identity-provider');
const path = require('path');
const dotenv = require('dotenv');

// 環境変数の読み込み
dotenv.config();

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));

// Cognito設定
const cognitoConfig = {
  region: process.env.COGNITO_REGION,
  clientId: process.env.COGNITO_CLIENT_ID,
  userPoolId: process.env.COGNITO_USER_POOL_ID
};

// Cognitoクライアント
const cognitoClient = new CognitoIdentityProviderClient({ region: cognitoConfig.region });

// トークン保存用(本番環境では適切なストレージを使用)
const tokenStore = {};

// ログインAPI
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;

  if (!username || !password) {
    return res.status(400).json({ error: 'ユーザー名とパスワードが必要です' });
  }

  try {
    const command = new InitiateAuthCommand({
      AuthFlow: 'USER_PASSWORD_AUTH',
      ClientId: cognitoConfig.clientId,
      AuthParameters: {
        USERNAME: username,
        PASSWORD: password
      }
    });

    const response = await cognitoClient.send(command);
    console.log('認証レスポンス:', JSON.stringify(response, null, 2));

    if (response.AuthenticationResult) {
      // トークンを保存
      tokenStore.accessToken = response.AuthenticationResult.AccessToken;
      tokenStore.idToken = response.AuthenticationResult.IdToken;
      tokenStore.refreshToken = response.AuthenticationResult.RefreshToken;
      tokenStore.expiresIn = response.AuthenticationResult.ExpiresIn || 3600; // デフォルト1時間
      tokenStore.timestamp = Date.now();
      tokenStore.username = username;

      res.json({ 
        success: true,
        expiresIn: tokenStore.expiresIn
      });
    } else if (response.ChallengeName) {
      // 追加の認証チャレンジが必要
      res.status(200).json({
        success: false,
        challengeName: response.ChallengeName,
        session: response.Session,
        message: `追加認証が必要です: ${response.ChallengeName}`
      });
    } else {
      // 予期しないレスポンス
      console.log('予期しない認証レスポンス構造:', response);
      res.status(400).json({ error: '認証レスポンスの形式が予期しないものでした' });
    }
  } catch (error) {
    console.error('Login error:', error);
    res.status(401).json({ error: error.message });
  }
});

// トークン更新API
app.post('/api/refresh', async (req, res) => {
    if (!tokenStore.refreshToken) {
      return res.status(401).json({ error: 'リフレッシュトークンがありません' });
    }

    try {
      const command = new GetTokensFromRefreshTokenCommand({
        RefreshToken: tokenStore.refreshToken,
        ClientId: cognitoConfig.clientId
      });

      const response = await cognitoClient.send(command);
      console.log('トークン更新レスポンス:', JSON.stringify(response, null, 2));

      // AuthenticationResultから値を取得するように修正
      if (response.AuthenticationResult) {
        // トークンを更新
        tokenStore.accessToken = response.AuthenticationResult.AccessToken;
        tokenStore.idToken = response.AuthenticationResult.IdToken;
        tokenStore.expiresIn = response.AuthenticationResult.ExpiresIn || 3600; // デフォルト1時間
        tokenStore.timestamp = Date.now();

        // リフレッシュトークンが返された場合(ローテーション)
        const wasRotated = !!response.AuthenticationResult.RefreshToken;
        if (wasRotated) {
          console.log('リフレッシュトークンがローテーションされました!');
          console.log('古いトークン(末尾5文字):', tokenStore.refreshToken.slice(-5));
          console.log('新しいトークン(末尾5文字):', response.AuthenticationResult.RefreshToken.slice(-5));

          // 新しいリフレッシュトークンを保存
          tokenStore.refreshToken = response.AuthenticationResult.RefreshToken;
          tokenStore.lastRotation = Date.now();
        }

        res.json({
          success: true,
          rotated: wasRotated,
          rotationTime: wasRotated ? new Date().toISOString() : null,
          expiresIn: tokenStore.expiresIn
        });
      } else {
        res.status(400).json({ error: '予期しないレスポンス形式' });
      }
    } catch (error) {
      console.error('Token refresh error:', error);
      res.status(401).json({ error: error.message });
    }
});

// トークン情報API
app.get('/api/token-info', (req, res) => {
  console.log('トークンストア状態:', {
    hasAccessToken: !!tokenStore.accessToken,
    hasIdToken: !!tokenStore.idToken,
    hasRefreshToken: !!tokenStore.refreshToken,
    expiresIn: tokenStore.expiresIn,
    timestamp: tokenStore.timestamp ? new Date(tokenStore.timestamp).toISOString() : null
  });

  if (!tokenStore.accessToken) {
    return res.status(401).json({ error: '認証されていません' });
  }

  // アクセストークンの有効期限を計算
  const now = Date.now();
  const expiresAt = tokenStore.timestamp + (tokenStore.expiresIn * 1000);
  const remainingTime = Math.max(0, Math.floor((expiresAt - now) / 1000));

  res.json({
    authenticated: true,
    accessTokenExpiresIn: remainingTime,
    accessTokenAcquiredAt: new Date(tokenStore.timestamp).toISOString(),
    hasRefreshToken: !!tokenStore.refreshToken,
    lastRotation: tokenStore.lastRotation ? new Date(tokenStore.lastRotation).toISOString() : null,
    // トークンの一部を表示(セキュリティのため全体は表示しない)
    accessTokenPreview: tokenStore.accessToken ? `${tokenStore.accessToken.substring(0, 10)}...` : null,
    idTokenPreview: tokenStore.idToken ? `${tokenStore.idToken.substring(0, 10)}...` : null
  });
});

// メインページ
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// サーバー起動
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`サーバーが http://localhost:${PORT} で起動しました`);
});

処理内容としては、そこまで複雑ではなく/api/login エンドポイントを実行されたときはCognitoAPIの InitiateAuthCommand で認証処理及び各種トークン情報の保存と表示を行い、/api/refresh エンドポイントを実行されたときはGetTokensFromRefreshTokenCommand を実行しトークン更新処理とトークン情報の保存・表示をしているだけです。

フロントエンド用のファイルである、public/index.html を配置します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Cognito リフレッシュトークンローテーションデモ</title>
  <style>
    body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
    .card { border: 1px solid #ddd; border-radius: 5px; padding: 15px; margin-bottom: 20px; }
    button { padding: 8px 12px; margin-right: 10px; margin-bottom: 10px; cursor: pointer; }
    pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow: auto; }
    input { padding: 8px; margin-bottom: 10px; width: 100%; box-sizing: border-box; }
    .success { color: green; }
    .error { color: red; }
    .highlight { background-color: #ffffcc; padding: 2px; }
  </style>
</head>
<body>
  <h1>Cognito リフレッシュトークンローテーションデモ</h1>

  <div class="card" id="login-section">
    <h2>ログイン</h2>
    <div>
      <label for="username">ユーザー名/メールアドレス:</label>
      <input type="text" id="username" />
    </div>
    <div>
      <label for="password">パスワード:</label>
      <input type="password" id="password" />
    </div>
    <button onclick="login()">ログイン</button>
  </div>

  <div class="card" id="token-section" style="display: none;">
    <h2>トークン操作</h2>
    <button onclick="getTokenInfo()">トークン情報を取得</button>
    <button onclick="refreshToken()">トークンを更新</button>
    <button onclick="logout()">ログアウト</button>
    <pre id="token-info">トークン情報がここに表示されます</pre>
  </div>

  <div class="card">
    <h2>リフレッシュトークンローテーションについて</h2>
    <p>
      このデモでは、Amazon Cognito のリフレッシュトークンローテーション機能をテストできます。
      「トークンを更新」ボタンを押すと、リフレッシュトークンを使って新しいアクセストークンを取得します。
      リフレッシュトークンローテーションが有効な場合、一定期間後に新しいリフレッシュトークンも発行されます。
    </p>
    <p>
      <strong>注意:</strong> このデモでは、トークンはサーバーのメモリに保存されています。
      本番環境では、適切なセキュリティ対策を施したストレージを使用してください。
    </p>
  </div>

  <div class="card" id="log-section">
    <h2>操作ログ</h2>
    <button onclick="clearLog()">ログをクリア</button>
    <pre id="operation-log"></pre>
  </div>

  <script>
    // ログイン処理
    async function login() {
      const username = document.getElementById('username').value;
      const password = document.getElementById('password').value;

      if (!username || !password) {
        alert('ユーザー名とパスワードを入力してください');
        return;
      }

      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({ username, password })
        });

        const data = await response.json();

        if (response.ok && data.success) {
          // ログイン成功
          document.getElementById('login-section').style.display = 'none';
          document.getElementById('token-section').style.display = 'block';
          addToLog(`✅ ログイン成功! アクセストークンの有効期間: ${data.expiresIn}`);
          getTokenInfo();
        } else if (data.challengeName) {
          // 認証チャレンジが必要
          addToLog(`❌ 追加認証が必要です: ${data.challengeName}`, true);
          alert(`追加認証が必要です: ${data.challengeName}`);
        } else {
          // ログイン失敗
          addToLog(`❌ ログインエラー: ${data.error || data.message}`, true);
        }
      } catch (error) {
        console.error('Login error:', error);
        addToLog(`❌ ログイン処理中にエラーが発生しました: ${error.message}`, true);
      }
    }

    // トークン情報取得
    async function getTokenInfo() {
      try {
        const response = await fetch('/api/token-info');

        if (response.ok) {
          const data = await response.json();
          document.getElementById('token-info').textContent = JSON.stringify(data, null, 2);
          addToLog(`ℹ️ トークン情報を取得しました`);
        } else {
          // 認証エラーなど
          const error = await response.json();
          document.getElementById('token-info').textContent = `エラー: ${error.error}`;
          addToLog(`❌ トークン情報取得エラー: ${error.error}`, true);

          // 認証切れの場合はログイン画面に戻る
          if (response.status === 401) {
            document.getElementById('login-section').style.display = 'block';
            document.getElementById('token-section').style.display = 'none';
          }
        }
      } catch (error) {
        console.error('Error fetching token info:', error);
        document.getElementById('token-info').textContent = `エラー: ${error.message}`;
        addToLog(`❌ トークン情報取得中にエラーが発生しました: ${error.message}`, true);
      }
    }

    // トークン更新
    async function refreshToken() {
      try {
        const response = await fetch('/api/refresh', {
          method: 'POST'
        });

        const data = await response.json();

        if (response.ok) {
          if (data.rotated) {
            const message = `✅ トークン更新成功!<span class="highlight">リフレッシュトークンがローテーションされました!</span> (${data.rotationTime})`;
            document.getElementById('token-info').innerHTML = message;
            addToLog(message);
          } else {
            document.getElementById('token-info').textContent = `✅ トークン更新成功!リフレッシュトークンはローテーションされていません`;
            addToLog(`✅ トークン更新成功!リフレッシュトークンはローテーションされていません`);
          }

          // 少し待ってからトークン情報を更新
          setTimeout(getTokenInfo, 1000);
        } else {
          document.getElementById('token-info').textContent = `エラー: ${data.error}`;
          addToLog(`❌ トークン更新エラー: ${data.error}`, true);
        }
      } catch (error) {
        console.error('Error refreshing token:', error);
        document.getElementById('token-info').textContent = `エラー: ${error.message}`;
        addToLog(`❌ トークン更新中にエラーが発生しました: ${error.message}`, true);
      }
    }

    // ログアウト
    function logout() {
      document.getElementById('login-section').style.display = 'block';
      document.getElementById('token-section').style.display = 'none';
      document.getElementById('username').value = '';
      document.getElementById('password').value = '';
      document.getElementById('token-info').textContent = 'トークン情報がここに表示されます';
      addToLog(`ℹ️ ログアウトしました`);
    }

    // ログに追加
    function addToLog(message, isError = false) {
      const logElement = document.getElementById('operation-log');
      const timestamp = new Date().toLocaleTimeString();
      const logClass = isError ? 'error' : 'success';

      // HTMLタグをエスケープしない(ハイライト用のspanタグを許可)
      logElement.innerHTML = `<div class="${logClass}">[${timestamp}] ${message}</div>` + logElement.innerHTML;
    }

    // ログをクリア
    function clearLog() {
      document.getElementById('operation-log').innerHTML = '';
    }
  </script>
</body>
</html>

node server.jsでウェブサーバをローカルに起動します。ブラウザでhttp://localhost:3000/にアクセスすると以下のような画面が出ました。

ログイン画面

ログインすると以下のようにトークン情報などが表示されます。

トークン情報表示画面

「トークンを更新」ボタンを押すことでGetTokensFromRefreshToken APIが実行されます。

トークン更新後の画面

リフレッシュトークンローテーションの確認

server.jsを実行しているターミナルにログが出力されています。ログを見ると以下のように新旧リフレッシュトークンの末尾が表示されています。

リフレッシュトークンがローテーションされました!
古いトークン(末尾5文字): BVmlg
新しいトークン(末尾5文字): Ry4Kg
トークンストア状態: {
  hasAccessToken: true,
  hasIdToken: true,
  hasRefreshToken: true,
  expiresIn: 3600,
  timestamp: '2025-04-25T03:12:57.351Z'
}

また、GetTokensFromRefreshToken APIなどのレスポンスに含まれる実際のトークンも確認してみましたが、下記のように変わっていることを確認できました。

初回ログイン時:

{
    "AccessToken": "eyJraWQiOiJCem04cFwvTW1hVHNCMno0RzIyak...[省略]...ohg",
    "ExpiresIn": 3600,
    "IdToken": "eyJraWQiOiJhQ3AwSWxEd1MxT0l6MG5cLzlVNFB...[省略]...sBw",
    "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNI...[省略]...y4Kg",
    "TokenType": "Bearer"
}

GetTokensFromRefreshToken API実行後:

{
  "AccessToken": "eyJraWQiOiJCem04cFwvTW1hVHNCMno0Rz...[省略]...fGg",
  "ExpiresIn": 3600,
  "IdToken": "eyJraWQiOiJhQ3AwSWxEd1MxT0l6MG5cLz...[省略]...yBg",
  "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0...[省略]...mlg",
  "TokenType": "Bearer"
}

GetTokensFromRefreshToken APIの2回目の実行後:

{
  "AccessToken": "eyJraWQiOiJCem04cFwvTW1hVHNCMno0Rz...[省略]...VkA",
  "ExpiresIn": 3600,
  "IdToken": "eyJraWQiOiJhQ3AwSWxEd1MxT0l6MG5cLz...[省略]...Qw",
  "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0...[省略]...jA",
  "TokenType": "Bearer"
}

実行するたびにトークンが変わっているのが確認できますね。

まとめ

以上、Amazon Cognito がリフレッシュトークンのローテーションをサポートしたというアップデートでした。クライアントアプリケーション側にも手を入れる必要はありますが、リフレッシュトークンをローテーションすることで、以下のような課題に対して

  • 長期間有効なトークン:ユーザー体験は良いが、トークンが盗まれた場合のリスクが大きい
  • 短期間有効なトークン:セキュリティは高いが、ユーザーが頻繁に再ログインする必要がある

「リフレッシュトークンを定期的に更新する」という解決策で対応できます。上記のような課題感がある場合はお試し頂ければと思います。

以上、とーちでした。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.