Cognito でパスキー認証を実装する (Managed Login 非利用)

# はじめに

Amazon Cognito で、独自にパスキー認証を実装できます。Managed Login が注目されていますが、Managed Login を利用しなくても Cognito の API を呼び出してパスキー認証を実装できます。



メリット

- カスタマイズ性が高い : 認証フローや Web サイトの見た目を完全にコントロールでき、ビジネスニーズに合わせた細かい調整が可能

デメリット

- 実装の複雑さ : 独自で認証フローを実装するため、時間やリソースが掛かる



今回は、パスキー認証を Cognito API を呼び出して独自の実装を行う方法を紹介します。



# Flask アプリケーションのソースコード全文

今回は、Flask を利用した簡易的な Web アプリケーションを作成しました。以下にソースコードを公開しています。本番環境向けには実装していないので、あくまで動作確認の観点でご活用ください。また、パスキーに準拠したライブラリを利用すると、もっとシンプルにアプリケーションが実装できますが、今回は理解を深めるためにライブラリは利用しませんでした。

https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py



# パスキー登録のフロー

ブラウザ、バックエンドの Flask アプリケーション、Cognito の関係性を整理するために、フロー図を記載します。以下のフロー図が、新たにパスキーを登録するときのフローです。

![image-20241228090948141.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/6345e004-2975-7d86-433d-d4a08140ca15.png)



## パスキー登録プロセスの開始

パスキーを新規に登録するプロセスを開始するための API が、[StartWebAuthnRegistraion](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_StartWebAuthnRegistration.html) API です。サンプルアプリケーション上では、[こちらのソースコードの場所](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L450-L452)が該当します。[StartWebAuthnRegistraion](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_StartWebAuthnRegistration.html) API を呼び出すために、その人の access_token が必要なことが留意点です。

```python
        # アクセストークンはリクエストのCookieから取得
        access_token = request.cookies.get('access_token')
        if not access_token:
            return jsonify({'error': 'アクセストークンが必要です'}), 401

        # Cognitoのstart-web-authn-registrationを呼び出し
        response = cognito_client.start_web_authn_registration(
            AccessToken=access_token
        )

        # Cognitoのレスポンスをそのままクライアントに返す
        return jsonify({
            'publicKey': response['CredentialCreationOptions']
        })
```



[StartWebAuthnRegistraion](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_StartWebAuthnRegistration.html) API を実行した時のサンプル response を以下に記載します。この response をブラウザが受け取り、パスキー (公開鍵、秘密鍵など) を作成します。公開鍵を作成する際に許可されているアルゴリズムや、チャレンジ文字列、Relying Party の情報などが含まれています。

このインターフェースは、Web の標準技術を策定する W3C (World Wide Web Consortium) の WebAuthn 仕様に基づいています。

```json
{
    "CredentialCreationOptions": {
        "rp": {
            "id": "auth.cognito-nginx01.sugiaws.tokyo",
            "name": "auth.cognito-nginx01.sugiaws.tokyo"
        },
        "user": {
            "id": "YzcwNGNhZDgtMjBjMS03MDdiLTdiZDItYjNmOGNlZDdhMTM1",
            "name": "secret@gmail.com",
            "displayName": "secret@gmail.com"
        },
        "challenge": "45NwCtlNfDCExk9GKS6w3Q",
        "pubKeyCredParams": [
            {
                "type": "public-key",
                "alg": -7
            },
            {
                "type": "public-key",
                "alg": -257
            }
        ],
        "timeout": 60000,
        "excludeCredentials": [],
        "authenticatorSelection": {
            "requireResidentKey": true,
            "residentKey": "required",
            "userVerification": "preferred"
        }
    }
```



## ブラウザ側でパスキーの作成の送信

[StartWebAuthnRegistraion](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_StartWebAuthnRegistration.html) API の結果をブラウザが受け取り、パスキーを作成します。[こちらのソースコードの場所](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L366-L367)が該当します。`navigator.credentials.create(options);` が、パスキーを作成している重要な部分です。これを呼び出すことで、あとはブラウザ側が提供しているパスキー登録のポップアップが表示され、ユーザーにパスキー登録の操作をしてもらうことが可能です。

```javascript
                    // サーバーから credentialCreationOptions を取得
                    const response = await fetch('/customlogin-addpasskey/start', {
                        method: 'POST'
                    });
                    
                    if (!response.ok) {
                        throw new Error('認証エラー - ログインが必要かもしれません');
                    }

                    const data = await response.json();
                    
                    // credentialCreationOptions を準備
                    const options = {
                        publicKey: {
                            ...data.publicKey,
                            challenge: base64URLToBuffer(data.publicKey.challenge),
                            user: {
                                ...data.publicKey.user,
                                id: base64URLToBuffer(data.publicKey.user.id)
                            }
                        }
                    };

                    // パスキーを作成
                    const credential = await navigator.credentials.create(options);
```

　　

　　

　　

実際のブラウザのポップアップはこんな感じです。Chrome に個人の Google アカウントを利用しているので、個人所有の Pixel 6 が選択肢に表示されています。これ以外にも、Windows Hello を利用することも可能ですし、3rd party のパスキー提供サービスを利用することも可能です。

![image-20241228105548692.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/16f944c3-d4a1-2d7b-c03e-938162e1e598.png)

　　　

　　　

　　　

パスキーをブラウザ側で作成したあと、公開鍵などの情報をバックエンドに送ります。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L369-L393)です。

```javascript
                    // WebAuthn の RegistrationResponseJSON 形式に準拠した形に変換
                    const attestationResponse = {
                        id: credential.id,
                        rawId: bufferToBase64URL(credential.rawId),
                        response: {
                            clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
                            authenticatorData: bufferToBase64URL(credential.response.getAuthenticatorData()),
                            transports: credential.response.getTransports(),
                            publicKey: bufferToBase64URL(credential.response.getPublicKey()),
                            publicKeyAlgorithm: credential.response.getPublicKeyAlgorithm(),
                            attestationObject: bufferToBase64URL(credential.response.attestationObject)
                        },
                        authenticatorAttachment: credential.authenticatorAttachment || "",
                        clientExtensionResults: credential.getClientExtensionResults() || {},
                        type: credential.type
                    };

                    // Cognitoに送信
                    const finishResponse = await fetch('/customlogin-addpasskey/finish', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify(attestationResponse)
                    });
```



## パスキーの登録

つぎに、送信された公開鍵などの情報を利用して、Cognito にパスキーを登録します。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L483-L487)です。ブラウザ側から渡された公開鍵などの情報を基に、[CompleteWebAuthnRegistration](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_CompleteWebAuthnRegistration.html) API を呼びパスキーを登録します。

```python
        access_token = request.cookies.get('access_token')
        if not access_token:
            return jsonify({'error': 'アクセストークンが必要です'}), 401
        
        # クライアントから送られてきた RegistrationResponseJSON を取得
        credential = request.get_json()
        
        # Cognitoにクレデンシャルを送信
        response = cognito_client.complete_web_authn_registration(
            AccessToken=access_token,
            Credential=credential  # RegistrationResponseJSON をそのまま渡す
        )
```



ここまでのながれでパスキーの登録が完了しました。



# パスキー認証のフロー

以下のフロー図が、登録されているパスキーを利用して認証を行う時のフローです。

![image-20241228091006660.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/9d5467e2-3444-c4bb-88b9-c9f4db3dbda9.png)



## パスキー認証プロセスの開始

Cognito の [InitiateAuth](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html) API を呼び出してパスキー認証プロセスを開始します。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L612-L619)です。AuthFlow を `USER_AUTH` とすることで、パスワードを必要としない認証フローを開始することを指定しています。

```python
        initial_response = cognito_client.initiate_auth(
            AuthFlow='USER_AUTH',
            ClientId=CLIENT_ID,
            AuthParameters={
                'USERNAME': username,
                'SECRET_HASH': get_secret_hash(username)
            }
        )
```

　　

　　

　　

[InitiateAuth](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html) API の response 例を記載します。色々書かれていますが、`AvailableChallenges` の欄が注目するポイントです。`PASSWORD_SRP`, `PASSWORD`, `WEB_AUTHN` の 3 つが記載されています。これは、利用可能な認証方式が提示されています。この中から好きな 1 個を選択する流れになります。

`WEB_AUTHN` がパスキーなので、これを選択します。

```json
{
    "ChallengeName": "SELECT_CHALLENGE",
    "Session": "AYABeLYTTYEfDcSmbCdY-ZIZ6uIAHQABAAdTZXJ2aWNlABBDb2duaXRvVX..........",
    "ChallengeParameters": {},
    "AvailableChallenges": [
        "PASSWORD_SRP",
        "PASSWORD",
        "WEB_AUTHN"
    ],
    "ResponseMetadata": {
        "RequestId": "34880412-c790-4b23-a5f5-7987a0159d31",
        "HTTPStatusCode": 200,
        "HTTPHeaders": {
            "date": "Sat, 28 Dec 2024 02:27:42 GMT",
            "content-type": "application/x-amz-json-1.1",
            "content-length": "1027",
            "connection": "keep-alive",
            "x-amzn-requestid": "34880412-c790-4b23-a5f5-7987a0159d31"
        },
        "RetryAttempts": 0
    }
}
```

　　

　　

　　

どの認証方式か選択するのが、 [RespondToAuthChallenge](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html) API です。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L626-L637)です。`'ANSWER': 'WEB_AUTHN',` としており、パスキー認証を利用する選択をしています。

```python
        # SELECT_CHALLENGEが返ってきた場合、WEB_AUTHNを選択
        if initial_response.get('ChallengeName') == 'SELECT_CHALLENGE':
            challenge_response = cognito_client.respond_to_auth_challenge(
                ClientId=CLIENT_ID,
                ChallengeName='SELECT_CHALLENGE',
                Session=initial_response['Session'],
                ChallengeResponses={
                    'USERNAME': username,
                    'ANSWER': 'WEB_AUTHN',
                    'SECRET_HASH': get_secret_hash(username)
                }
            )
```

　　

　　

　　

 [RespondToAuthChallenge](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html) API の response 例を記載します。重要な点は `"challenge": "_fF_lTYH78FsTlLMlzQI1Q",` です。Cognito 側が生成するランダムな値となっており、これをユーザー側が持っている秘密鍵で署名をします。これによって、事前に Cognito の登録している公開鍵を利用して、その署名を検証することで、パスキーの認証を行います。

```json
{
    "publicKey": {
        "allowCredentials": [
            {
                "id": "EnWCJwm-gMbQObgICVpPfw",
                "transports": [
                    "internal",
                    "hybrid"
                ],
                "type": "public-key"
            },
            {
                "id": "AICw-3UbUFeOOR4Q7cK8LA",
                "transports": [
                    "internal",
                    "hybrid"
                ],
                "type": "public-key"
            },
            {
                "id": "hn4fWU2hOai7FJdTOn-Cv4wPRG4",
                "transports": [
                    "internal"
                ],
                "type": "public-key"
            }
        ],
        "challenge": "_fF_lTYH78FsTlLMlzQI1Q",
        "rpId": "cognito-nginx01.sugiaws.tokyo",
        "timeout": 180000,
        "userVerification": "preferred"
    },
    "session": "AYABeM2thXLJ9rEt2ZBjkp3-Uv0AHQABAAdTZXJ2aWNlABBD...."
}
```

　　

　　

　　

取得したチャレンジの値などを含めて、ブラウザに response します。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L643-L651)です。

```python
            # CREDENTIAL_REQUEST_OPTIONSを取得して返す
            credential_request_options = json.loads(
                challenge_response['ChallengeParameters']['CREDENTIAL_REQUEST_OPTIONS']
            )

            return jsonify({
                'publicKey': credential_request_options,
                'session': challenge_response['Session']
            })
```



## ブラウザ側で秘密鍵を利用した署名

ブラウザ側で、受け取ったチャレンジなどの値を基に署名処理を行います。重要な部分が `navigator.credentials.get` の箇所です。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L528-L537)です。

```javascript
                    // Step 2: パスキーで認証
                    response.publicKey.challenge = base64URLToBuffer(response.publicKey.challenge);
                    response.publicKey.allowCredentials = response.publicKey.allowCredentials.map(cred => ({
                        ...cred,
                        id: base64URLToBuffer(cred.id)
                    }));

                    const credential = await navigator.credentials.get({
                        publicKey: response.publicKey
                    });
```

　　

　　

　　

`navigator.credentials.get` を呼び出すことで、ブラウザ側では以下のようなポップアップが表示され、パスキー認証を行うことができます。

![image-20241228114802161.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/c9d1d88e-3ef2-373f-753e-bde2739720fc.png)

　　

　　

　　

その後、署名したデータをバックエンド側に送付する処理です。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L554-L564)です。

```javascript
                    const finalResponse = await fetch('/customlogin-passkeylogin/complete', {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json'
                        },
                        body: JSON.stringify({
                            username: email,
                            session: response.session,
                            credential: authenticatorResponse
                        })
                    });
```



## Coginto 側で認証が正しいか検証する

Cognito 側で、ブラウザから送信された認証結果が正しいものか検証をします。検証するために、[RespondToAuthChallenge](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_RespondToAuthChallenge.html) API を呼び出します。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L684-L693)です。

```python
        complete_response = cognito_client.respond_to_auth_challenge(
            ClientId=CLIENT_ID,
            ChallengeName='WEB_AUTHN',
            Session=session,
            ChallengeResponses={
                'USERNAME': username,
                'CREDENTIAL': json.dumps(credential),  # 認証結果を渡す
                'SECRET_HASH': get_secret_hash(username)
            }
        )
```

　　

　　

　　

この結果が正しい場合、今回のサンプルアプリケーションでは Cookie を発行します。IdToken と AccessToken をそのまま Cookie に保存しています。[ソースコードの該当箇所はこちら](https://github.com/Sugi275/cognito-passkey-custom-implement/blob/master/app.py#L702-L718)です。

```python
        auth_result = complete_response.get('AuthenticationResult', {})
        if auth_result:
            response_data.set_cookie(
                'id_token',
                auth_result.get('IdToken', ''),
                httponly=True,
                secure=True,
                samesite='Lax'
            )
            
            response_data.set_cookie(
                'access_token',
                auth_result.get('AccessToken', ''),
                httponly=True,
                secure=True,
                samesite='Lax'
            )
```



# 検証を通じてわかったこと

- Managed Login を利用しなくても、Cognito の API を呼び出すことで、パスキー認証の実装が可能。
- Cognito API でパスキー認証を実装する場合は、Lambda などを利用したカスタム認証フローは不要。以下の GitHub で、パスキー認証を行うため、カスタム認証フローを Lambda で実装したサンプルアプリケーションが公開されているが、最新機能では不要となる。
  - https://github.com/aws-samples/amazon-cognito-passwordless-auth/tree/main
- ユーザーにパスキーを選択してもらう機能は、ブラウザ側に実装されている。Chrome や Firefox などで細かな違いはありそうなので、商用提供時は丁寧に検証するのがよさそう。