3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-28

はじめに

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

メリット

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

デメリット

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

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

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

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

パスキー登録のフロー

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

image-20241228090948141.png

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

パスキーを新規に登録するプロセスを開始するための API が、StartWebAuthnRegistraion API です。サンプルアプリケーション上では、こちらのソースコードの場所が該当します。StartWebAuthnRegistraion API を呼び出すために、その人の access_token が必要なことが留意点です。

        # アクセストークンはリクエストの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 API を実行した時のサンプル response を以下に記載します。この response をブラウザが受け取り、パスキー (公開鍵、秘密鍵など) を作成します。公開鍵を作成する際に許可されているアルゴリズムや、チャレンジ文字列、Relying Party の情報などが含まれています。

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

{
    "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 API の結果をブラウザが受け取り、パスキーを作成します。こちらのソースコードの場所が該当します。navigator.credentials.create(options); が、パスキーを作成している重要な部分です。これを呼び出すことで、あとはブラウザ側が提供しているパスキー登録のポップアップが表示され、ユーザーにパスキー登録の操作をしてもらうことが可能です。

                    // サーバーから 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

   

   

   

パスキーをブラウザ側で作成したあと、公開鍵などの情報をバックエンドに送ります。ソースコードの該当箇所はこちらです。

                    // 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 にパスキーを登録します。ソースコードの該当箇所はこちらです。ブラウザ側から渡された公開鍵などの情報を基に、CompleteWebAuthnRegistration API を呼びパスキーを登録します。

        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

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

Cognito の InitiateAuth API を呼び出してパスキー認証プロセスを開始します。ソースコードの該当箇所はこちらです。AuthFlow を USER_AUTH とすることで、パスワードを必要としない認証フローを開始することを指定しています。

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

  

  

  

InitiateAuth API の response 例を記載します。色々書かれていますが、AvailableChallenges の欄が注目するポイントです。PASSWORD_SRP, PASSWORD, WEB_AUTHN の 3 つが記載されています。これは、利用可能な認証方式が提示されています。この中から好きな 1 個を選択する流れになります。

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

{
    "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 API です。ソースコードの該当箇所はこちらです。'ANSWER': 'WEB_AUTHN', としており、パスキー認証を利用する選択をしています。

        # 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 API の response 例を記載します。重要な点は "challenge": "_fF_lTYH78FsTlLMlzQI1Q", です。Cognito 側が生成するランダムな値となっており、これをユーザー側が持っている秘密鍵で署名をします。これによって、事前に Cognito の登録している公開鍵を利用して、その署名を検証することで、パスキーの認証を行います。

{
    "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 します。ソースコードの該当箇所はこちらです。

            # 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 の箇所です。ソースコードの該当箇所はこちらです。

                    // 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

  

  

  

その後、署名したデータをバックエンド側に送付する処理です。ソースコードの該当箇所はこちらです。

                    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 API を呼び出します。ソースコードの該当箇所はこちらです。

        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 に保存しています。ソースコードの該当箇所はこちらです。

        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 で実装したサンプルアプリケーションが公開されているが、最新機能では不要となる。
  • ユーザーにパスキーを選択してもらう機能は、ブラウザ側に実装されている。Chrome や Firefox などで細かな違いはありそうなので、商用提供時は丁寧に検証するのがよさそう。
3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?