CognitoのUSER_SRP_AUTHをAmplifyなしで認証する
はじめに
はじめまして!エンジニアの@cottpanです。
みなさんCognito使ってますか?マネージドで認証基盤を利用できるのは非常に便利ですよね!
フロントエンドやモバイル開発ではAmplify経由でCognitoを利用しており、認証プロセスで何が行われているのかを意識せずに利用するケースも多いと思います。
今回は認証プロセスへの理解を深めるため、USER_SRP_AUTH フローをコードを交えながら実装していきたいと思います。
USER_SRP_AUTH 認証フローについて
USER_SRP_AUTH フローは、クライアントとサーバー間でパスワード自体を送信することなく、パスワードに基づいた認証を行うためのプロトコルです。RFC2945でも定義されており、大まかには以下のステップで構成されます。
- InitiateAuth: クライアントはユーザー名と SRP の計算に必要な値 (SRP_A) を Cognito に送信します。Cognito は、チャレンジ情報 (SRP_B, SALT など) を返します。
- RespondToAuthChallenge: クライアントは受け取ったチャレンジ情報とユーザーのパスワードを使用して、証明 (Signature) を計算し、Cognito に送信します。Cognito は証明を検証し、成功すれば認証トークン (Access Token, ID Token, Refresh Token) を返します。
以下に認証フローの概要図を示します。
この記事で目指したこと
この記事では、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およびRespondToAuthChallengeAPI を直接呼び出します。
(参考記事)
認証
下準備
認証フローに必要な計算を行うためのヘルパー関数を準備します。
-
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に必要なパラメータを計算します。
-
SRP_Aの生成const getSRPA = () => { // クライアントの公開値 A を計算し、16進数で取得 return authenticationHelper.largeAValue.toString(16); } -
必要であれば
getSecretHash()を呼び出してSECRET_HASHを計算 -
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); }; -
-
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 のレスポンスに含まれるチャレンジに応答します。
-
レスポンスから取得したパラメータ
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 }; } -
USER_ID_FOR_SRPを使用してSECRET_HASHを再計算します -
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 || "", }; }; -
-
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が利用できない環境での実装など、参考になれば嬉しいです。
Discussion