はじめに
Amazon Cognito で、独自にパスキー認証を実装できます。Managed Login が注目されていますが、Managed Login を利用しなくても Cognito の API を呼び出してパスキー認証を実装できます。
メリット
- カスタマイズ性が高い : 認証フローや Web サイトの見た目を完全にコントロールでき、ビジネスニーズに合わせた細かい調整が可能
デメリット
- 実装の複雑さ : 独自で認証フローを実装するため、時間やリソースが掛かる
今回は、パスキー認証を Cognito API を呼び出して独自の実装を行う方法を紹介します。
Flask アプリケーションのソースコード全文
今回は、Flask を利用した簡易的な Web アプリケーションを作成しました。以下にソースコードを公開しています。本番環境向けには実装していないので、あくまで動作確認の観点でご活用ください。また、パスキーに準拠したライブラリを利用すると、もっとシンプルにアプリケーションが実装できますが、今回は理解を深めるためにライブラリは利用しませんでした。
パスキー登録のフロー
ブラウザ、バックエンドの Flask アプリケーション、Cognito の関係性を整理するために、フロー図を記載します。以下のフロー図が、新たにパスキーを登録するときのフローです。
パスキー登録プロセスの開始
パスキーを新規に登録するプロセスを開始するための 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 のパスキー提供サービスを利用することも可能です。
パスキーをブラウザ側で作成したあと、公開鍵などの情報をバックエンドに送ります。ソースコードの該当箇所はこちらです。
// 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 をそのまま渡す
)
ここまでのながれでパスキーの登録が完了しました。
パスキー認証のフロー
以下のフロー図が、登録されているパスキーを利用して認証を行う時のフローです。
パスキー認証プロセスの開始
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
を呼び出すことで、ブラウザ側では以下のようなポップアップが表示され、パスキー認証を行うことができます。
その後、署名したデータをバックエンド側に送付する処理です。ソースコードの該当箇所はこちらです。
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 などで細かな違いはありそうなので、商用提供時は丁寧に検証するのがよさそう。