🔐

CognitoのUSER_SRP_AUTHをAmplifyなしで認証する

に公開

はじめに

はじめまして!エンジニアの@cottpanです。

みなさんCognito使ってますか?マネージドで認証基盤を利用できるのは非常に便利ですよね!
フロントエンドやモバイル開発ではAmplify経由でCognitoを利用しており、認証プロセスで何が行われているのかを意識せずに利用するケースも多いと思います。
今回は認証プロセスへの理解を深めるため、USER_SRP_AUTH フローをコードを交えながら実装していきたいと思います。

USER_SRP_AUTH 認証フローについて

USER_SRP_AUTH フローは、クライアントとサーバー間でパスワード自体を送信することなく、パスワードに基づいた認証を行うためのプロトコルです。RFC2945でも定義されており、大まかには以下のステップで構成されます。

  1. InitiateAuth: クライアントはユーザー名と SRP の計算に必要な値 (SRP_A) を Cognito に送信します。Cognito は、チャレンジ情報 (SRP_B, SALT など) を返します。
  2. RespondToAuthChallenge: クライアントは受け取ったチャレンジ情報とユーザーのパスワードを使用して、証明 (Signature) を計算し、Cognito に送信します。Cognito は証明を検証し、成功すれば認証トークン (Access Token, ID Token, Refresh Token) を返します。

以下に認証フローの概要図を示します。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html#amazon-cognito-user-pools-authentication-flow-methods-srp

この記事で目指したこと

この記事では、USER_SRP_AUTH 認証フローについて、実際に手を動かしながら理解を深めることを目指します。
途中の計算には JavaScript の BigInt でも扱えない巨大な整数を扱う必要がありますが、今回は amazon-cognito-identity-js 内で提供されている AuthenticationHelper.js を利用します。

  • SRP 計算: 認証フローと、導出に必要なパラメータについて解説します。
  • Web Crypto API の活用: HMAC-SHA256 などの暗号化処理に Web Crypto API を用います。
  • Cognito API の直接利用: @aws-sdk/client-cognito-identity-provider を使用して InitiateAuth および RespondToAuthChallenge API を直接呼び出します。

(参考記事)

https://qiita.com/faable01/items/ceb7678d5e00917eb0c9
https://zenn.dev/milkcocoa0902/articles/cognito-user-srp-auth-challenge

認証

下準備

認証フローに必要な計算を行うためのヘルパー関数を準備します。

  • hmacSHA256: SECRET_HASH の計算や、チャレンジ応答の署名計算に使用する、 HMAC-SHA256 ハッシュを算出するメソッドです。フロントエンドで利用することを想定し、Web Crypto API を利用します。

    // src/utils/crypto.ts
    function hexToUint8Array(hex: string): Uint8Array {
      const pairs = hex.match(/.{1,2}/g) || [];
      return new Uint8Array(pairs.map((byte) => parseInt(byte, 16)));
    }
    
    type InputType = "text" | "hex";
    
    export async function hmacSHA256(
      message: string,
      key: string,
      inputType: InputType = "text",
    ): Promise<string> {
      const encoder = new TextEncoder();
    
      const keyData =
        inputType === "hex" ? hexToUint8Array(key) : encoder.encode(key);
    
      const messageData =
        inputType === "hex" ? hexToUint8Array(message) : encoder.encode(message);
    
      const cryptoKey = await crypto.subtle.importKey(
        "raw",
        keyData,
        { name: "HMAC", hash: "SHA-256" },
        false,
        ["sign"],
      );
    
      const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
      // 結果を Base64 エンコードして返す
      return btoa(String.fromCharCode(...new Uint8Array(signature)));
    }
    
  • getSecretHash: Cognito アプリクライアントにシークレットが設定されている場合に必要となる SECRET_HASH を計算します。

    export async function getSecretHash(username: string): Promise<string> {
      const message = username + cognitoConfig.ClientId;
      // Cognito Client Secret をキーとして HMAC-SHA256 を計算
      // hmacSHA256 は結果を Base64 で返す
      return await hmacSHA256(message, cognitoConfig.ClientSecret, "text");
    }
    
  • AuthenticationHelper インスタンスの生成: SRP 計算のコアロジックを持つヘルパーを準備します。

    const authenticationHelper = new AuthenticationHelper(
      userPoolName,
    ) as CognitoAuthenticationHelper;
    

InitiateAuth

それでは認証フローに入りましょう。最初のリクエストであるInitiateAuthに必要なパラメータを計算します。

  1. SRP_A の生成

    const getSRPA = () => {
      // クライアントの公開値 A を計算し、16進数で取得
      return authenticationHelper.largeAValue.toString(16);
    }
    
  2. 必要であれば getSecretHash() を呼び出して SECRET_HASH を計算

  3. InitiateAuthCommand を使用して Cognito にリクエストを送信

    • AuthFlow: AuthFlowType.USER_SRP_AUTH を指定
    • ClientId: Cognito アプリクライアント ID を指定
    • AuthParameters:
      • USERNAME: ユーザーが入力したユーザー名
      • SRP_A: ステップ 1 で取得した値
      • SECRET_HASH: ステップ 2 で計算した値(アプリクライアントシークレットがある場合)
    export const signInChallenge = async (
      username: string, // ユーザーが入力したユーザー名
      password: string,
    ) => {
      // 1. SRP_A の計算
      const SRP_A = getSRPA();
    
      // 2. SECRET_HASH の計算 (必要な場合)
      const secretHash = await getSecretHash(username);
    
      // 3. InitiateAuthCommand の準備
      const command = new InitiateAuthCommand({
        AuthFlow: AuthFlowType.USER_SRP_AUTH,
        ClientId: cognitoConfig.ClientId,
        AuthParameters: {
          USERNAME: username,
          SRP_A: SRP_A,
          SECRET_HASH: secretHash, // アプリクライアントにシークレットがない場合は不要
        },
      });
    
      // 4. Cognito API 呼び出しとレスポンス取得
      const response = await cognitoClient.send(command);
    };
    
  4. Cognito からのレスポンスには ChallengeName (PASSWORD_VERIFIER) と ChallengeParameters (SRP_B, SALT, SECRET_BLOCK, USER_ID_FOR_SRP など) が含まれます。これらは次のステップで使用します。

    {
        "$metadata": {
            "httpStatusCode": 200,
            "requestId": "81eae1e1...<REDACTED>",
            "attempts": 1,
            "totalRetryDelay": 0
        },
        "ChallengeName": "PASSWORD_VERIFIER",
        "ChallengeParameters": {
            "SALT": "5fa43a6c45...<REDACTED>",
            "SECRET_BLOCK": "AgV4hsFE/TTpfyG/lvAyS8TaMF7t57P5s9n3L39d...<REDACTED>",
            "SRP_B": "618982810b83494bd13...<REDACTED>",
            "USERNAME": "d7b46af8...<REDACTED>",
            "USER_ID_FOR_SRP": "d7b46af8...<REDACTED>"
        }
    }
    

RespondAuth (RespondToAuthChallenge)

認証プロセスの 2 番目のステップです。InitiateAuth のレスポンスに含まれるチャレンジに応答します。

  1. レスポンスから取得したパラメータSRP_B, SALT, SECRET_BLOCK, USER_ID_FOR_SRPとユーザーのパスワードを渡して、署名 (signature) を計算します。署名に利用したタイムスタンプは後ほど利用します。

      async getRespondValue({
        SRP_B,
        SALT,
        username,
        password,
        secretBlock,
      }: {
        SRP_B: string;
        SALT: string;
        username: string;
        password: string;
        secretBlock: string;
      }): Promise<{ signature: string; dateNow: string }> {
        // AuthenticationHelper を使ってパスワード認証キー (HKDF) を計算
        // この関数は内部で SRP の計算 (u = H(A, B), S = (B - k * g^x)^(a + u * x), K = H(S)) を行い、
        // 結果の K (共有鍵) を Base64 文字列として callback で返す
        const hkdfResult = { hkdf: undefined as undefined | string };
        authenticationHelper.getPasswordAuthenticationKey(
          username,
          password,
          new BigInteger(SRP_B, 16),
          new BigInteger(SALT, 16),
          (_err: unknown, result?: string) => {
            hkdfResult.hkdf = result;
          },
        );
    
        const dateHelper = new DateHelper();
        const dateNow = dateHelper.getNowString();
    
        // 署名対象のメッセージを作成
        // (UserPoolName + USERNAME + SECRET_BLOCK + TIMESTAMP)
        const msg = Buffer.concat([
          Buffer.from(this.userPoolName, "utf-8"),
          Buffer.from(username, "utf-8"),
          Buffer.from(secretBlock, "base64"),
          Buffer.from(dateNow, "utf-8"),
        ]);
    
        const msgHex = msg.toString("hex");
        const hkdfHex = Buffer.from(hkdfResult.hkdf as string, "base64").toString(
          "hex",
        );
    
        // HMAC-SHA256 を計算 (キーは HKDF)
        const signature = await hmacSHA256(msgHex, hkdfHex, "hex");
    
        return { signature, dateNow };
      }
    
  2. USER_ID_FOR_SRP を使用して SECRET_HASH を再計算します

  3. RespondToAuthChallengeCommand を使用して Cognito にリクエストを送信します。

    • ClientId: Cognito アプリクライアント ID
    • ChallengeName: InitiateAuth レスポンスの ChallengeName (PASSWORD_VERIFIER)
    • ChallengeResponses:
      • PASSWORD_CLAIM_SIGNATURE: ステップ 1 で計算した署名 (Base64)
      • PASSWORD_CLAIM_SECRET_BLOCK: InitiateAuth レスポンスの SECRET_BLOCK (Base64)
      • TIMESTAMP: ステップ 1 で計算したタイムスタンプ
      • USERNAME: InitiateAuth レスポンスの USER_ID_FOR_SRP
      • SECRET_HASH: ステップ 2 で計算した値 (Base64) (アプリクライアントシークレットがある場合)
    export const signIn = async (
      username: string,
      password: string,
    ): Promise<...> => {
      // ... InitiateAuth の処理 ...
      const response = await cognitoClient.send(command); // InitiateAuth のレスポンス
    
      // 1. チャレンジパラメータの取得
      const challengeParams = response.ChallengeParameters;
      if (!challengeParams) {
        throw new Error("Challenge parameters are missing");
      }
      const userIdForSrp = challengeParams.USER_ID_FOR_SRP || "";
      const srpB = challengeParams.SRP_B || "";
      const salt = challengeParams.SALT || "";
      const secretBlock = challengeParams.SECRET_BLOCK || "";
    
      // 2. 署名とタイムスタンプの計算
      const { signature, dateNow } = await srpAuth.getRespondValue({
        SRP_B: srpB,
        SALT: salt,
        username: userIdForSrp, // InitiateAuthレスポンスの USER_ID_FOR_SRP を使う
        password: password,
        secretBlock: secretBlock,
      });
    
      // 3. SECRET_HASH の再計算 (USER_ID_FOR_SRP を使用)
      const respondSecretHash = await getSecretHash(userIdForSrp);
    
      // 4. RespondToAuthChallengeCommand の準備
      const respondCommand = new RespondToAuthChallengeCommand({
        ClientId: cognitoConfig.ClientId,
        ChallengeName: response.ChallengeName, // 'PASSWORD_VERIFIER'
        ChallengeResponses: {
          PASSWORD_CLAIM_SIGNATURE: signature,
          PASSWORD_CLAIM_SECRET_BLOCK: secretBlock,
          TIMESTAMP: dateNow,
          USERNAME: userIdForSrp, // InitiateAuthレスポンスの USER_ID_FOR_SRP を使う
          SECRET_HASH: respondSecretHash, // アプリクライアントにシークレットがない場合は不要
        },
      });
    
      // 5. Cognito API 呼び出しと最終結果取得
      const respondResponse = await cognitoClient.send(respondCommand);
    
      if (!respondResponse.AuthenticationResult) {
        throw new Error("Authentication failed");
      }
    
      // 認証成功!トークンを返す
      return {
        accessToken: respondResponse.AuthenticationResult.AccessToken || "",
        idToken: respondResponse.AuthenticationResult.IdToken || "",
        refreshToken: respondResponse.AuthenticationResult.RefreshToken || "",
      };
    };
    
    
  4. Cognito は送信された情報を検証します。検証が成功すると、AuthenticationResult に含まれる認証トークン (AccessToken, IdToken, RefreshToken) が返されます。

    {
        "$metadata": {
            "httpStatusCode": 200,
            "requestId": "95376337...<REDACTED>",
            "attempts": 1,
            "totalRetryDelay": 0
        },
        "AuthenticationResult": {
            "AccessToken": "eyJraWQiOiJtdDI4RnV...<REDACTED>",
            "ExpiresIn": 3600,
            "IdToken": "eyJraWQiOiJ4SWM0em9ZZUI3NG...<REDACTED>",
            "NewDeviceMetadata": {
                "DeviceGroupKey": "4cb3b494...<REDACTED>",
                "DeviceKey": "ap-northeast-1_758c6...<REDACTED>"
            },
            "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMi...<REDACTED>",
            "TokenType": "Bearer"
        },
        "ChallengeParameters": {}
    }
    

これで目的としていたトークンが取得できました🎉

まとめ

この記事では、Amplifyを利用せずにCognitoのUSER_SRP_AUTH認証フローを実装する手順を解説しました。

Amplifyを使えば数行で済む処理ですが、実際に手を動かして各ステップを実装することで、認証フローの内部で何が行われているのか、理解が深まりました。

Cognito認証のカスタマイズや、Amplifyが利用できない環境での実装など、参考になれば嬉しいです。

DRESS CODE TECH BLOG

Discussion