Cognitoの USER_SRP_AUTHフロー や パスワード付きカスタム認証フローで必要な「SRP_A」を計算する (js, ts限定)

## SRP_A ってなに？
Cognitoに用意されている認証フローのうち、下記で必要になるパラメータだよ。
* USER_SRP_AUTHフロー
* パスワード検証付きカスタム認証フロー

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

上記フローでは、広く標準化された鍵交換プロトコルである **Secure Remote Password プロトコル (SRP)** を使っていて、 SRP_A はそれに関連する **「大きな整数」で生成された値** だよ。

## Cognito使ったことあるけどSRP_Aなんて聞いたことない。これっていつ使うの？
Cognitoが用意してくれるログインエンドポイント＋トークンエンドポイントを使う構成や、Amplify UI にお任せする場合には、あまり登場しない部分かもしれないね。

でも、自分で Cognito の各種 API をコールしていく実装なら、必要になるケースも多いんじゃないかな。

特に、サーバ側でCognito認証をしたい人は、[AdminInitiateAuth](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_AdminInitiateAuth.html)を使う時に「何これ...」ってなる人が多いと思う。

フロントエンドでも、自力で [InitiateAuth](https://docs.aws.amazon.com/ja_jp/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html) をコールする時には、同様の壁にぶち当たる人が多いかも。

## 「大きな整数」って何？

> 上記フローでは、広く標準化された鍵交換プロトコルである **Secure Remote Password プロトコル (SRP)** を使っていて、 SRP_A はそれに関連する **「大きな整数」で生成された値** だよ。

この **「大きな整数」** っていうのが本当に大きな整数で、 **JavaScriptにおける巨大な値を扱うためのクラスであるところの [BigInt](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/BigInt) でも扱えきれない規模の値だよ。**
**（これがあるがために自力でSRP_Aを算出するのをやめた）**

具体的にはこれだよ （[RFC3526](https://www.ipa.go.jp/security/rfc/RFC3526JA.html#4)で定義された3072bitの値。16進数表記）

```
FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1
29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD
EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245
E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED
EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D
C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F
83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D
670C354E 4ABC9804 F1746C08 CA18217C 32905E46 2E36CE3B
E39E772C 180E8603 9B2783A2 EC07A28F B5C55DF0 6F4C52C9
DE2BCBF6 95581718 3995497C EA956AE5 15D22618 98FA0510
15728E5A 8AAAC42D AD33170D 04507A33 A85521AB DF1CBA64
ECFB8504 58DBEF0A 8AEA7157 5D060C7D B3970F85 A6E1E4C7
ABF5AE8C DB0933D7 1E8C94E0 4A25619D CEE3D226 1AD2EE6B
F12FFA06 D98A0864 D8760273 3EC86A64 521F2B18 177B200C
BBE11757 7A615D6C 770988C0 BAD946E2 08E24FA0 74E5AB31
43DB5BFC E0FD108E 4B82D120 A93AD2CA FFFFFFFF FFFFFFFF
```

この「大きな整数」をもとに決められた数式で算出する値が SRP_A なんだけど、いくら標準化されたプロトコルとはいえ、自力で計算するのはそれなりに大変だから、今回は `AuthenticationHelper.js` を使ったお手軽な算出方法を説明していくよ。

## AuthenticationHelper.js で SRP_A を算出する

AuthenticationHelper.js は、 `amazon-cognito-identity-js` の中にあるよ。
型定義ファイルも持ってないし、 AuthenticationHelper.js を使うには `@ts-ignore` して強引に引っ張ってくる必要があるよ。(TypeScript前提)

```typescript
// @ts-ignore
import { default as AuthenticationHelperWrapper } from "amazon-cognito-identity-js/lib/AuthenticationHelper.js" /** ビルドエラーを回避すべくあえて拡張子付き */
const AuthenticationHelper = AuthenticationHelperWrapper.default;
```

インポートできたら、あとは Cognitoユーザープールの名前をもとに AuthenticationHelperのインスタンスを生成して、 `largeAValue` を16進数で取得すればOK。

```typescript

// Cognito User Pool の ID を引数に実行
// （AuthenticationHelperのコンストラクタが User Pool Name を要するため）
const calculateSRP_A = async (userPoolId: string) => {
  // User Pool Name は User Pool ID の　アンダースコア後の文字列
  // 例：
  //   User Pool ID   : ap-northeast-1_xxxxxxxxx
  //   User Pool Name : xxxxxxxxx
  const userPoolName = userPoolId.split('_')[1];
  const authenticationHelper = new AuthenticationHelper(userPoolName);
  const SRP_A = authenticationHelper.largeAValue.toString(16);

  // 後で使うので、AuthenticationHelperのインスタンスも返す
  return {SRP_A, authenticationHelper};
}
```

## これでSRP_A取得できたから後は大丈夫だね！　やったね！
認証フロー終わるまでが遠足です（むしろまだ認証フロー始めてすらいない）

## SRP_A算出後の認証フローを解説する
駆け足で行きます。

### SRPを使ったCognitoユーザープールの認証フローの概要
Cognitoユーザープールの認証フローは、ざっくりこんな順番で進むよ。

1. `SRP_A` を `InitiateAuth` に投げる (サーバ側なら `AdminInitiateAuth` )
2. 返ってきた `SRP_B` をもとに、 `PASSWORD_CLAIM_SIGNATURE` を作成する
3. `PASSWORD_CLAIM_SIGNATURE` を `RespondToAuthChallenge` に投げる
4. パスワード正しければ認証OK！ （アクセストークン、IDトークン、更新トークンが手に入る）

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

この流れを、サーバ側を起点にしたUSER_SRP_AUTHフローを例に解説していくね。
（SRP周りはフロントでも同じなので、フロントで InitiateAuth を自分で叩きたい人も概ね同じようにしてやればOKのはず）

### 1. `SRP_A` を `AdminInitiateAuth` に投げる

```typescript
import { createHmac } from "crypto" 

// CognitoクライアントID
const clientId = "xxxxxxxx"
// Cognitoクライアントシークレットキー
const clientSecret = "xxxxxxxx"
// CognitoユーザプールID
const userPoolId = "ap-northeast-1_xxxxxxx"

const initiateAuthResult = await client.send(new AdminInitiateAuthCommand({
  ClientId: clientId,
  UserPoolId: userPoolId,
  AuthFlow: "USER_SRP_AUTH", // 認証フロー名
  AuthParameters: {
    USERNAME: username, // ログインするユーザの名前
    PASSWORD: password, // ログインするユーザのパスワード
    SRP_A, // さっき算出したSRP_A
    SECRET_HASH:
      createHmac('sha256', clientSecret)
        .update(username + clientId)
        .digest('base64'),
  },
}));
```

### 2. 返ってきた `SRP_B` をもとに、 `PASSWORD_CLAIM_SIGNATURE` を作成する
SRP_A算出で使った `AuthenticationHelper` のインスタンスには、署名を作成するためのHKDFを取得できる関数も用意されているから、それを使って `PASSWORD_CLAIM_SIGNATURE` を作成していくよ。

ただ、そのためにはAuthenticationHelperが各所で利用する独自クラス `BigInteger` と `DateHelper` を同じく `amazon-cognito-identity-js` から引っ張ってくる必要があるから、さっきみたいに強引にimportするよ。

> ※ 上記の `BigInteger` は工夫を凝らして前述の「大きな整数」のような BigIntでも扱えきれない数値 を扱えるようにしているクラスだよ。すごいね

```typescript
import { createHmac } from "crypto" 
// @ts-ignore
import { default as BigIntegerWrapper } from "amazon-cognito-identity-js/lib/BigInteger.js"
const BigInteger = BigIntegerWrapper.default;
// @ts-ignore
import { default as DateHelperWrapper } from "amazon-cognito-identity-js/lib/DateHelper.js"
const DateHelper = DateHelperWrapper.default;

// 署名するための鍵を作成
const hkdfResult = {hkdf: undefined as undefined | string};
authenticationHelper.getPasswordAuthenticationKey(
  username,
  password,
  // AdminInitiateAuthの応答から、SRP_B と SALT を BigIntegerでラップして引数に渡す
  new BigInteger(initiateAuthResult.ChallengeParameters?.SRP_B, 16),
  new BigInteger(initiateAuthResult.ChallengeParameters?.SALT, 16),
  (err: unknown, result?: string) => {
    hkdfResult.hkdf = result;
  },
);

// Cognitoユーザプール名（ユーザIDのアンダースコア後から末尾の文字列）
const userPoolName = userPoolId.split('_')[1];

// タイムスタンプ生成
const dateHelper = new DateHelper();
const dateNow = dateHelper.getNowString();

// 署名するメッセージを作成
const msg = Buffer.concat([
  Buffer.from(userPoolName, 'utf-8'),
  Buffer.from(username, 'utf-8'),
  // SECRET_BLOCK は Base64 として送られてくるので、ここで変換する
  Buffer.from(initiateAuthResult.ChallengeParameters?.SECRET_BLOCK as string, 'base64'),
  Buffer.from(dateNow, 'utf-8'),
])

// PASSWORD_CLAIM_SIGNATURE を作成
const signature = createHmac('sha256', hkdfResult.hkdf as string)
  .update(msg)
  .digest('base64');
```

ちなみに `amazon-cognito-identity-js/lib/DateHelper.js` が生成するタイムスタンプ文字列 `dateNow` は特別なオブジェクトではなくて、単なる特定のフォーマットに沿った日付文字列だから、ライブラリ使わずとも自力で算出してもいいからね。

### 3. `PASSWORD_CLAIM_SIGNATURE` を `RespondToAuthChallenge` に投げる

```typescript
import { createHmac } from "crypto" 

const respondAuthResult = await client.send(
  // 今回はサーバ側の実装だから 「Admin」 RespondToAuthChallengeCommand を使う
  new AdminRespondToAuthChallengeCommand({
    ClientId: clientId, // CognitoクライアントID
    UserPoolId: userPoolId, // CognitoユーザープールID
    ChallengeName: initiateAuthResult.ChallengeName as string,
    ChallengeResponses: {
      PASSWORD_CLAIM_SIGNATURE: signature, // さっき算出した PASSWORD_CLAIM_SIGNATURE
      PASSWORD_CLAIM_SECRET_BLOCK: initiateAuthResult
        .ChallengeParameters?.SECRET_BLOCK as string,
      TIMESTAMP: dateNow, // さっき署名したメッセージに使ったタイムスタンプ
      USERNAME: username,
      SECRET_HASH:
        createHmac('sha256', clientSecret)
          .update(username + clientId)
          .digest('base64'),
    },
    Session: initiateAuthResult.Session,
  })
);
```

### 4. パスワード正しければ認証OK！ （アクセストークン、IDトークン、更新トークンが手に入る）

これで無事、SRP_A算出から始まった長い遠足（USER_SRP_AUTHフロー）が完了。

```typescript
const {
  AccessToken, IdToken, RefreshToken, ExpiresIn
} = respondAuthResult.AuthenticationResult;
```

後は手に入った各種トークンを煮るなり焼くなりしようね。

## おまけ 「 `USER_SRP_AUTHフロー` じゃなくて `パスワード検証つきカスタム認証フロー` の実装も教えて」
概ね同じだから要所をかいつまんで説明するね。

（ただ、カスタム認証フローにおける認証チャレンジ（Lambdaトリガー）の実装方法は本題から外れるから割愛するよ）

USER_SRP_AUTHフローとの相違点は次の３点だよ。
1. InitiateAuthで AuthFlowに、`CUSTOM_AUTH` を指定する
2. InitiateAuthで AuthParametersに `CHALLENGE_NAME: "SRP_A"` を指定する
3. パスワード検証のためにコールする１回目の `RespondToAuthChallenge` の後に、カスタム認証チャレンジに応答するための ２回目以降の `RespondToAuthChallenge` を実装する

これをコードで書くと、こんな感じだよ。

```typescript
// 1. `SRP_A` を `AdminInitiateAuth` に投げる
const initiateAuthResult = await client.send(new AdminInitiateAuthCommand({
  ClientId: clientId,
  UserPoolId: userPoolId,
  AuthFlow: "CUSTOM_AUTH", // USER_SRP_AUTHと違う点その１
  AuthParameters: {
    USERNAME: username,
    PASSWORD: password,

    // USER_SRP_AUTHと違う点その２
    // なお一部のドキュメントには、ChallengeName と記載されているが、
    // AuthParameters に指定する段階でのキー名については CHALLENGE_NAME が正しい。
    // もし ChallengeName と書くと、返ってくるエラーメッセージが、
    // 「Incorrect username or password.」
    // であり、分かりにくくてハマるケースあり。（ただしメッセージの分かりにくさは、セキュリティ的には正しい）
    CHALLENGE_NAME: "SRP_A",

    SRP_A,
    SECRET_HASH:
      createHmac('sha256', clientSecret)
        .update(username + clientId)
        .digest('base64'),
  },
}));

// 2. 返ってきた `SRP_B` をもとに、 `PASSWORD_CLAIM_SIGNATURE` を作成する
// ---- USER_SRP_AUTHフローと同じなので割愛 ----

// 3. `PASSWORD_CLAIM_SIGNATURE` を `RespondToAuthChallenge` に投げる
// ---- USER_SRP_AUTHフローと同じなので割愛 ----

// 4. カスタムチャレンジに答える （カスタム認証フロー特有。ANSWERの値は実装による）
const respondCustomChallengeResult = await client.send(new AdminRespondToAuthChallengeCommand({
  ClientId: clientId,
  UserPoolId: userPoolId,

  // 「３」の応答から取得するのが妥当
  ChallengeName: respondAuthResult.ChallengeName as string,

  // カスタムチャレンジに回答する（パスワードは検証済みなのでもう要らない）
  ChallengeResponses: {
    ANSWER: "5", // カスタムチャレンジの回答はANSWERに入れる（値は実装による）
    USERNAME: username,
    SECRET_HASH:
      createHmac('sha256', clientSecret)
        .update(username + clientId)
        .digest('base64'),
  },
  Session: respondPasswordResult.Session,
}));

// 5. 認証チャレンジの結果が正しければ認証OK！ （アクセストークン、IDトークン、更新トークンが手に入る）
// ---- USER_SRP_AUTHフローと同じなので割愛 ----

```

コード中にもコメントで載せたけど、InitiateAuth における `CHALLENGE_NAME: "SRP_A"` には注意だよ！
公式ドキュメントでは、ここは `ChallengeNameを指定する` と書かれている場合があるけど、それを信じて素直に `ChallengeName: "SPA_A"` と書くとエラーになるよ。

おまけにエラーメッセージが `Incorrect username or password.` だから、ぱっと見 「公式ドキュメント通りなのにどこがおかしいんだ...」 って延々と悩む羽目になるよ。

具体的にはこんな感じ

> For CUSTOM_AUTH: USERNAME (required), SECRET_HASH (if app client is configured with client secret), DEVICE_KEY. To start the authentication flow with password verification, include **ChallengeName: SRP_A** and SRP_A: (The SRP_A Value).

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-cognito-identity-provider/interfaces/admininitiateauthcommandinput.html

こっちには正しいことが書いてある（けど、疲れてる時に読むとちょっと混乱するかも）

> カスタムフローで SRP パスワードの検証を開始する場合、アプリは InitiateAuth を CUSTOM_AUTH として Authflow を呼び出します。AuthParameters マップで、アプリケーションからのリクエストは、SRP_A: (SRP A 値) および **CHALLENGE_NAME: SRP_A** を含んでいます。
> 
> CUSTOM_AUTH フローは、**challengeName: SRP_A** および challengeResult: true の初期セッションにより、DefineAuthChallenge の Lambda トリガーを呼び出します。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/user-pool-lambda-define-auth-challenge.html

前者は InitiateAuthをコールするときのパラメータ `CHALLENGE_NAME: SRP_A` の話で、後者は認証チャレンジでトリガーされたLambdaが受け取るパラメータ `challengeName: SRP_A` の話なんだね。

## まとめ
* `SRP_A` は、Cognito で **S**ecure **R**emote **P**assword プロトコル を採用している `USER_SRP_AUTHフロー` や `パスワード検証付きカスタム認証フロー` で、InitiateAuth 時に必要とされる値だよ
* InitiateAuthは応答で `SRP_B` とかを返すから、それをもとに `PASSWORD_CLAIM_SIGNATURE` を 生成して、 `RespondToAuthChallenge` に投げることで、パスワードが合ってるかどうかを検証するよ
* RFCに定義されている標準的なプロトコルだから自力で実装することも可能だけど、色々大変だから、JS界隈なら `amazon-cognito-identity-js/lib/AuthenticationHelper.js` をインポートして必要な値を導出すると楽だよ

## 著者プロフィール

faable01です。かつては創作仲間と小説を書いたり、製菓業界で楽しくやっていたはずが、紆余曲折を経て、サーバーレス技術を触るのが好きなITエンジニアになっていました。AWSのIaC兼サーバレス爆速開発ツール **「SST」** が好きです。個人ブログでもたまに記事を書いています。

https://signote.link

それから、業務日報SaaS **「RevisNote」** を運営しています。リッチテキストでの日報と、短文SNS感のある分報を書けるのが特徴で、組織に所属する人数での従量課金制です。アカウント開設後すぐ使えて、無料プランもあるから、気軽にお試しください。

https://revisnote.com?1710501811980