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
およびRespondToAuthChallenge
API を直接呼び出します。
(参考記事)
認証
下準備
認証フローに必要な計算を行うためのヘルパー関数を準備します。
-
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