AWS SDK for JavaScriptを使ってサーバサイド側でAmazon Cognitoの記憶済みデバイスを使った認証をしてみた

2023.10.27

初めに

Amazon Cognitoではデバイスのトラッキング機能があり所定の操作を行うことでユーザに対してログインに利用したデバイスを記憶させることができます。

デバイスの記憶機能を使用するには、USER_SRP_AUTH 認証フローを使用する必要があります。また、ユーザープールに対して多要素認証 (MFA) を有効にする必要があります。

マネジメントコンソール上の説明では上記のような表記があったり以下のre:postの投稿も簡単な説明を見る限りあくまでMFAを利用した上でということになっていますが、実際に色々見てみる限りはMFAはオプションのようです。

ただいまいち確証が取れなかったので実際にコードを書いて実装してみました。

フロントエンド側で実装する場合はamazon-cognito-identity-jsにラッパーがあるため細かい仕組みの理解が特に必要そうではなかったのですが、探してもサーバサイド側の実装(AdminXXXXを使った実装)をいい感じにしてくれているライブラリ等が見当たりませんでした。

上記のre:postの内容を見ても若干理解が怪しいののでその内容とamazon-cognito-identity-jsのソースコード等を参考にサーバサイド側として実装をしてみました。

ソースコード

コード量が多くなってたのでGitHub上にあげています。
とりあえず確認できるように程でリージョンがベタ書きだったりあまり綺麗ではないですが試される方はその辺はよしなにお願いいたします。

今回はSecure Remote Password (SRP)の理解をしっかりすることが目的ではなくデバイスキーを使った認証を行うことを目的としているため、幾らかの処理はamazon-cognito-identity-js内のヘルパーを引っ張って楽しています。

より深い理解が必要な場合はSRP自体を理解する必要が出てくるものかと思います。

実装

上記のサンプルコードには以下の2つを含間れているます。

  1. デバイスなしの認証+デバイスの登録
  2. デバイス情報を含めた認証

デバイスを利用した認証にはまず最初にデバイスなしの通常の認証を行ってデバイスの登録をする必要があるため処理を2つに分けています。

今回は.envに持たせる形でデバイスキー等を2つの処理間で引き継いでいますが、実際には初回認証時に発行された値をWebStorageに持たせる等の形になります。

注: デバイストラッキング機能を使用するには、USER_SRP_AUTH 認証フローを使用する必要があります。

前述の投稿や幾つかの公式ドキュメントに記載のあるとおり認証はUSER_SRP_AUTHを利用する必要があります。

役割上シーケンス図にすると違いはあるものの大枠のコードとしての処理はサーバ側もクライアント側と差はないので基本的にはクライアントの実装フローそのままで問題ありませんでした(具体的にはAdminの文字を一括で消しても動くレベル)。

それぞれの大きな流れは以下のとおりです。

ユーザID/パスワードのみで認証(デバイスキーなし)

  1. AdminInitiateAuthCommandを実行しチャレンジパラメータを取得する
  2. 1.のパラメータを元に署名を作成しUSER_SRP_AUTH認証フローでAdminRespondToAuthChallengeCommandを実行して認証を完了する
  3. 2.でアクセストークンとデバイス情報が得られるのでConfirmDeviceCommandを実行してデバイスをCognito側に記憶させる
    • この送信時にデバイスに割り当てる40文字のパスワードを発行する(Cognitoへの送信はそれを元に所定の計算を済ませた値)
  4. 2.で得られたDeviceKeyとDeviceGroupKey、3.で発行したデバイスのパスワードをbase64でエンコードしたものを記録する

4.で得られたデバイスの情報はデバイスキーを指定した認証を行います。

デバイスキーを含めて認証

  1. AdminInitiateAuthCommandを実行しチャレンジパラメータを取得する
  2. 1.のパラメータを元に署名を作成しUSER_SRP_AUTH認証フローでAdminRespondToAuthChallengeCommandを実行して追加のチャレンジのためのセッションを作成する(?)
  3. DEVICE_SRP_AUTHチャレンジでAdminRespondToAuthChallengeCommand実行してチャレンジを生成する
  4. 3.の情報をもとに署名を作成しりDEVICE_PASSWORD_VERIFIERチャレンジでAdminRespondToAuthChallengeCommandを実行して認証を完了する。

この認識で良いのか分かりませんが個人的な理解としてSRPという方式で1と2でユーザの認証を済ませて、その上で同様の方式で3と4で追加でデバイスの認証を済ませていると個人的には理解しています。

1.及び2.の処理は先のデバイスキーしていなしの処理とフロー自体は同じですが送信パラメータにデバイスキーを含めるかどうかの違いですので、指定しなければ先ほどのデバイスキーなしの認証フローとなります。

実行例

Cognitoの作成、アプリケーションクライアントの作成、および認証で使うユーザは事前に何らかの方法で作成しておいてください。

事前のユーザ認証・デバイス登録

実行前に.envもしくは環境変数に以下の4つの値を指定します。

USER_POOL_ID=
CLIENT_ID=
# ユーザ情報
userName=
userPassword=

main.jsの実行時の第一引数にuserをして実行します。
Device Parametersの値が本命でデバイス認証実行用に使うのでこの値を実行後控えておきます。

% node main.js user                                                                          
--------User Auth Result--------
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: '....',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  AuthenticationResult: {
    AccessToken: '...',
    ExpiresIn: 3600,
    IdToken: '....',
    NewDeviceMetadata: {
      DeviceGroupKey: '-xxxxx',
      DeviceKey: 'ap-northeast-1_xxxxx'
    },
    RefreshToken: '....',
    TokenType: 'Bearer'
  },
  ChallengeParameters: {}
}
--------Device Parameters--------
{
  DeviceGroupKey: '-xxxx',
  DeviceKey: 'xxxxx',
  DevicePassword: 'xxxxx'
}

デバイス情報を使った認証

先ほど最後に出力された値を元に以下の値を埋めて.envに追加します(書き終わった後に環境変数っぽい規則ではないなと気づきましたがご愛嬌で)。

deviceGroupKey=
deviceKey=
devicePassword=

今度は引数にuserではなくdeviceを指定することで、ユーザID・パスワードに加えてデバイス情報を利用して認証を実行しトークンを取得することができます。

 % node main.js device
--------User Auth Result--------
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  ChallengeName: 'DEVICE_SRP_AUTH',
  ChallengeParameters: {},
  Session: 'xxxxx'
}
--------Device Auth Result--------
{
  '$metadata': {
    httpStatusCode: 200,
    requestId: 'xxxxxx',
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  AuthenticationResult: {
    AccessToken: '....',
    ExpiresIn: 3600,
    IdToken: '....',
    RefreshToken: '....',
    TokenType: 'Bearer'
  },
  ChallengeParameters: {}
}

終わりに

結論的にはMFAとデバイス認証は必須のものとして結びついているわけではなくMFAがなくともデバイスキーを用いての認証は可能でした。

今回はシンプルにパラメータの引き渡しによる認証のみを行っていますが、実際に登録済みのデバイスを取得するような処理と組み合わせてすでに認証されているデバイスを持っている場合には呼び出し元にトークンを返さずにエラーとする等付随的な処理は必要になってくるものかと思います。

Cognitoをフロント側で実装する場合はAmplifyのライブラリもあるので比較的楽に実装できるかと思いますが、バックエンドで利用する場合は今回のように諸々の処理を自前で実装しないといけないので大変になることもあるなという印象を受けました。
今回はその辺りの処理を一部amazon-cognito-identity-jsを利用して省略しましたが、他の言語になると自前で実装が必要なるのでご注意ください。