Closed21

100日チャレンジ day7 (パスキーデバイス間認証システム)

riddle_tecriddle_tec

昨日
https://zenn.dev/gin_nazo/scraps/53c75f4a01f2cc


https://blog.framinal.life/entry/2025/04/14/154104

100日チャレンジに感化されたので、アレンジして自分でもやってみます。

やりたいこと

  • 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
  • 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
  • 生成aiをつかったバイブコーティングになれる
  • 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)

できたもの

https://github.com/lirlia/100day_challenge_backend/tree/main/day7_passkey_auth

riddle_tecriddle_tec

今日は パスキー を使った認証システムを作りたい

riddle_tecriddle_tec

day7としてパスキーを使ったアプリを作りたいんですけど、単にパスキーでログインするだけだと面白くなさそうです。
何かアイデアありますか?パスキー周りだと嬉しい


パスキーを使ったアプリ、面白そうですね! day7 のテーマとして、「パスキーを使ったデバイス間認証・承認システム」はいかがでしょうか?

具体的には、以下のような機能を考えられます。

  1. ユーザー登録と最初のパスキー登録: メールアドレスなどでユーザーを識別し、最初のデバイス(例: PC)でパスキーを登録します。
  2. パスキーログイン: 登録済みのデバイスからパスキーでログインできます。
  3. 新しいデバイスからのログイン試行:
    • ユーザーが新しいデバイス(例: スマートフォン)からログインしようとします。
    • システムは、このデバイスがユーザーアカウントに未登録であることを検知します。
  4. 既存デバイスでの承認:
    • システムは、既に登録済みのデバイス(この例ではPC)に対して、「新しいデバイスからのログイン要求があります。承認しますか?」という通知を送ります(あるいは、PC側でログインした際に未承認リクエストを表示します)。
    • ユーザーはPC上でパスキー認証(生体認証など)を実行して、このログイン要求を承認します。
  5. 新しいデバイスでのログイン完了とパスキー登録:
    • 承認が成功すると、スマートフォンでのログインが許可されます。
    • 同時に、スマートフォンでもパスキーを登録するフローを開始し、次回以降はそのスマートフォンからも直接パスキーでログインできるようにします。

このアイデアの利点は以下です。

  • 単なるログインだけでなく、「デバイス認証」というパスキーの応用例を示せる。
  • パスワードレスの利便性に加え、新しいデバイスからのアクセス制御というセキュリティ面でのメリットも体験できる。
  • 技術的にも少し挑戦的で面白い要素がある(デバイス間の連携や状態管理など)。

この方向性で進めてみませんか? もしよろしければ、この仕様でプロジェクトの初期化から始めます。

riddle_tecriddle_tec

今日はバックエンドの生成になんか手間取ってるな

  • DB 周りが既存のものとバッティングしている
  • 型エラーがちょいちょいおきてる
  • パッケージが新しいものに差し代わってた(LLMのデータが古い?
riddle_tecriddle_tec

うーんうまくうごかない
パスキーは難易度が高いな

riddle_tecriddle_tec

実装者がまだすくない?からなのかちょいちょいまちがっててそれを都度直してる感じ

riddle_tecriddle_tec

ログインに失敗する

❯ npm run dev


> day7-passkey-auth@0.1.0 dev
> next dev --turbopack

   ▲ Next.js 15.3.0 (Turbopack)
   - Local:        http://localhost:3001
   - Network:      http://192.168.10.9:3001
   - Environments: .env

 ✓ Starting...
 ✓ Ready in 1211ms
 ○ Compiling /login ...
 ✓ Compiled /login in 2.8s
 GET /login 200 in 3505ms
 ○ Compiling /api/auth/login/start ...
 ✓ Compiled /api/auth/login/start in 1237ms
Passkey Config Loaded:
  RP_ID: localhost
  RP_ORIGIN: http://localhost:3001
[Login Start] Received request for email: test@example.com
prisma:info Starting a sqlite pool with 17 connections.
prisma:query SELECT `main`.`User`.`id`, `main`.`User`.`email`, `main`.`User`.`createdAt`, `main`.`User`.`updatedAt` FROM `main`.`User` WHERE (`main`.`User`.`email` = ? AND 1=1) LIMIT ? OFFSET ?
prisma:query SELECT `main`.`Passkey`.`id`, `main`.`Passkey`.`userId`, `main`.`Passkey`.`credentialId`, `main`.`Passkey`.`publicKey`, `main`.`Passkey`.`counter`, `main`.`Passkey`.`transports`, `main`.`Passkey`.`deviceName`, `main`.`Passkey`.`createdAt`, `main`.`Passkey`.`lastUsedAt` FROM `main`.`Passkey` WHERE `main`.`Passkey`.`userId` IN (?) LIMIT ? OFFSET ?
[Login Start] Found 1 authenticators for user test@example.com
prisma:query INSERT INTO `main`.`Challenge` (`id`, `challenge`, `userId`, `type`, `expiresAt`, `createdAt`) VALUES (?,?,?,?,?,?) RETURNING `id` AS `id`, `challenge` AS `challenge`, `userId` AS `userId`, `type` AS `type`, `expiresAt` AS `expiresAt`, `createdAt` AS `createdAt`
[Login Start] Stored authentication challenge 8W7Dd6tO0gHmO4zSg-RO2HcA8dAJMbTPvPA8ilAzcGw for user cm9nzxnch0001lyjztqpb6itb
[Login Start] Generated authentication options for: test@example.com {
  rpId: 'localhost',
  challenge: '8W7Dd6tO0gHmO4zSg-RO2HcA8dAJMbTPvPA8ilAzcGw',
  allowCredentials: [
    {
      id: '1-4_wj224-9jQ_pB_0PumEiFLct2VKzvEZgt7MzLnAVNmHZDui0AJTvEdOVvzRUKGX_qiPgDDTC2g0-k-ydm',
      type: 'public-key',
      transports: [Array]
    }
  ],
  timeout: 60000,
  userVerification: 'preferred',
  extensions: undefined
}
 POST /api/auth/login/start 200 in 1502ms
 ○ Compiling /api/auth/login/finish ...
 ✓ Compiled /api/auth/login/finish in 520ms
[Login Finish] Received authentication response: {
  id: '1-4_wj224-9jQ_pB_0PumEiFLct2VKzvEZgt7MzLnAVNmHZDui0AJTvEdOVvzRUKGX_qiPgDDTC2g0-k-ydm',
  rawId: '1-4_wj224-9jQ_pB_0PumEiFLct2VKzvEZgt7MzLnAVNmHZDui0AJTvEdOVvzRUKGX_qiPgDDTC2g0-k-ydm',
  response: {
    authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA',
    clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOFc3RGQ2dE8wZ0htTzR6U2ctUk8ySGNBOGRBSk1iVFB2UEE4aWxBemNHdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzAwMSIsImNyb3NzT3JpZ2luIjpmYWxzZX0',
    signature: 'MEUCICqr5hSmoaUoseQoDEyeCLSRBU1RXugRYoHkbXkGsuOVAiEA_CtjkiidNhqyxl8sLg7Na6JUhLVzTDtfzYGrYyrHK0g',
    userHandle: 'dGVzdEBleGFtcGxlLmNvbQ'
  },
  type: 'public-key',
  clientExtensionResults: {},
  authenticatorAttachment: 'platform'
}
prisma:query SELECT `main`.`Challenge`.`id`, `main`.`Challenge`.`challenge`, `main`.`Challenge`.`userId`, `main`.`Challenge`.`type`, `main`.`Challenge`.`expiresAt`, `main`.`Challenge`.`createdAt` FROM `main`.`Challenge` WHERE (`main`.`Challenge`.`challenge` = ? AND (`main`.`Challenge`.`type` = ? AND `main`.`Challenge`.`expiresAt` > ?)) LIMIT ? OFFSET ?
[Login Finish] Found existing authenticator for credential 1-4_wj224-9jQ_pB_0PumEiFLct2VKzvEZgt7MzLnAVNmHZDui0AJTvEdOVvzRUKGX_qiPgDDTC2g0-k-ydm: {
  id: 'cm9nzxnch0002lyjznna943pf',
  userId: 'cm9nzxnch0001lyjztqpb6itb',
  credentialId: '1-4_wj224-9jQ_pB_0PumEiFLct2VKzvEZgt7MzLnAVNmHZDui0AJTvEdOVvzRUKGX_qiPgDDTC2g0-k-ydm',
  publicKey: Uint8Array(77) [
    165,   1,   2,   3,  38,  32,   1,  33,  88,  32, 224,  12,
    106, 207, 122,  17, 177, 139, 180,  42,  41, 230, 126, 176,
     70, 123,  60,  37,  36, 123, 196,  65,   8, 144, 241, 191,
    241, 218, 226,  13, 109, 231,  34,  88,  32, 228, 237, 239,
      5, 170, 110, 113, 125, 136, 224, 110, 242, 155,  44,  45,
    173, 250,  78, 146,  86,  25, 237,  12, 232, 252, 185, 119,
    217,  92, 110, 144,  47
  ],
  counter: 0n,
  transports: '["internal","hybrid"]',
  deviceName: 'Device 2025-04-19T09:08:49.648Z',
  createdAt: 2025-04-19T09:08:49.649Z,
  lastUsedAt: 2025-04-19T09:08:49.649Z
}
[Login Finish] Using counter for verification: 0 (original BigInt: 0)
prisma:query SELECT `main`.`Passkey`.`id`, `main`.`Passkey`.`userId`, `main`.`Passkey`.`credentialId`, `main`.`Passkey`.`publicKey`, `main`.`Passkey`.`counter`, `main`.`Passkey`.`transports`, `main`.`Passkey`.`deviceName`, `main`.`Passkey`.`createdAt`, `main`.`Passkey`.`lastUsedAt` FROM `main`.`Passkey` WHERE (`main`.`Passkey`.`credentialId` = ? AND `main`.`Passkey`.`userId` = ?) LIMIT ? OFFSET ?
[Login Finish - Existing Device] Verification failed: TypeError: Cannot read properties of undefined (reading 'counter')
    at async POST (app/api/auth/login/finish/route.ts:176:21)
  174 |     let verification: VerifiedAuthenticationResponse;
  175 |     try {
> 176 |       verification = await verifyAuthenticationResponse({
      |                     ^
  177 |         response: authenticationResponse,
  178 |         expectedChallenge: expectedChallenge.challenge,
  179 |         expectedOrigin: RP_ORIGIN as string,
 POST /api/auth/login/finish 400 in 780ms
prisma:query DELETE FROM `main`.`Challenge` WHERE (`main`.`Challenge`.`id` = ? AND 1=1) RETURNING `id` AS `id`, `challenge` AS `challenge`, `userId` AS `userId`, `type` AS `type`, `expiresAt` AS `expiresAt`, `createdAt` AS `createdAt`
riddle_tecriddle_tec

4o 全然 agent ぽく動いてくれない、一回解答したら終わりみたいな

riddle_tecriddle_tec

なんか型の解釈が古いままで新しいものに対応してなくてずっとエラーになっていた。型についてはちゃんと使ってるバージョンのものを確認するように言った方がいい

人間がエラー見て解消した方が早かったな。楽したらあかん。

riddle_tecriddle_tec

うーん別デバイスからのログインのフローがうまくいかないな

riddle_tecriddle_tec

できた

  1. パスキーの登録
  2. パスキーでのログイン
  3. 別デバイスから認証レス(別のデバイスからの許可)でのログイン

riddle_tecriddle_tec

学び

  • LLMの学習時点とライブラリやフレームワークの使い方が変わってる時は人間のアシストが必要
  • パスキーは結構難航した
riddle_tecriddle_tec

今回のDay7「パスキーデバイス間認証システム」の開発では、いくつかのバグや手戻りが発生しましたね。振り返りを行い、原因と今後の改善策を考えましょう。

なぜバグが多く発生したのか?(原因分析)

主な原因は以下の点が考えられます。

  1. WebAuthn と @simplewebauthn ライブラリの複雑性:

    • WebAuthn は比較的新しい技術であり、公開鍵暗号、Base64URLエンコード/デコード、Uint8Array、CBOR形式など、通常のWeb開発ではあまり触れない概念やデータ形式を扱います。
    • @simplewebauthn/server の検証関数 (verifyRegistrationResponse, verifyAuthenticationResponse) は、期待する引数の構造(特に authenticator オブジェクト)やデータの型(string vs Uint8Array)が厳密であり、少しでも間違うと TypeError などの検証エラーが発生しやすかったです。Base64URL文字列をそのまま渡してしまったり、必要な Uint8Array への変換が漏れていたりしました。
    • クライアントから送られてくる clientDataJSON のデコードとJSONパースも、適切な処理をしないとエラー(例: "bad control character")の原因となりました。
  2. チャレンジ管理の実装:

    • 当初、認証チャレンジをサーバーのインメモリ(Mapオブジェクト)で管理していましたが、これはサーバープロセスが再起動すると消えてしまったり、複数のAPIリクエストをまたいで状態を維持するのが困難でした。そのため、「チャレンジが見つからない」というエラーが頻発しました。最終的にデータベース (Challenge モデル) で管理することで解決しました。
    • チャレンジをDBに保存する際、どの情報(チャレンジ文字列、ユーザーID、タイプ、有効期限)をキーにして検索・検証するかの設計も重要でした。
  3. 設計と実装のギャップ:

    • シーケンス図などの初期設計と、実際のコード実装との間にずれが生じることがありました。特に「新規デバイスでのログインフロー」は、当初の想定(通常のログインフローに近い)と実際の実装(WebAuthn認証なしで承認リクエスト作成)が異なっており、何度かシーケンス図を修正する必要がありました。これは、複雑なフローを実装する過程で、より効率的または現実的な方法に気づき、実装が先行した結果とも言えます。

次回はどのようにプロンプトに記載すれば解決するのか?

今回の経験を踏まえ、次回以降、同様の複雑な機能を実装する際には、以下のような点をプロンプトに含めると、バグを減らしスムーズに進められる可能性が高まります。

  1. ライブラリ/API の具体的な使い方を指示する:

    • 関数名と引数構造:@simplewebauthn/serververifyAuthenticationResponse 関数を使用します。引数には response, expectedChallenge, expectedOrigin, expectedRPID, authenticator を渡してください。」のように、使用する関数と主要な引数を明記します。
    • データ形式と変換:authenticator オブジェクトの credentialID プロパティには、データベースから取得したBase64URL形式の credentialIdUint8Array にデコードしてから設定してください。Buffer.from(credentialId, 'base64url') を使うと良いでしょう。」のように、期待されるデータ型と必要な変換処理を具体的に指示します。
    • データソース:authenticatorpublicKeycounter は、データベースの Passkey モデルから取得した値を使用してください。」のように、データの取得元を指定します。
  2. 状態管理の方針を明確にする:

    • 「認証チャレンジは、有効期限 (例: 5分) と共に Prisma の Challenge モデルを使ってデータベースに保存してください。保存する情報は challenge (Base64URL文字列), userId, type ('registration' or 'authentication'), expiresAt です。検証時には challenge 文字列をキーにしてDBから検索してください。」のように、どこに (DBモデル)、何を (データ項目)、どのように (検索キー、有効期限) 管理するかを具体的に指定します。
  3. フローの詳細なステップを記述する:

    • 「新規デバイスのログインAPI (/api/auth/login/newdevice) の実装手順は以下です:
      1. リクエストからメールアドレスを取得します。
      2. メールアドレスで User を検索します。
      3. WebAuthn認証は行いません。
      4. 一意な requestingDeviceId を生成します。
      5. DeviceApprovalRequest レコードを pending 状態でDBに作成します (userId, requestingDeviceId, expiresAt を含む)。
      6. 作成したリクエストの id をクライアントに返却します。」
        のように、特定のフローについて、あいまいさをなくし、具体的な処理ステップを記述します。
  4. データ処理の注意点を伝える:

    • 「クライアントから受け取る attestationObjectclientDataJSON はBase64URLエンコードされています。サーバー側でデコード処理が必要な場合は、適切なライブラリ関数 (例: Buffer.from(..., 'base64url')) を使用してください。」のように、データ形式とその処理方法に関する注意点を加えます。
  5. 段階的な実装とテストを促す:

    • 「まず、ユーザー登録 (/register/start, /register/finish) のAPIとUIを実装し、curl とブラウザで動作確認をしてください。確認できたら、次にログイン処理に進みましょう。」のように、複雑な機能を小さな単位に分割し、各ステップでの確認を指示します。

WebAuthnのような比較的新しく複雑な技術を扱う場合、ライブラリの仕様やデータ形式に関する正確な理解が特に重要になります。プロンプトでこれらの点をより具体的に指示することで、AIアシスタントはより正確なコードを生成しやすくなり、結果としてバグの発生を抑制できると考えられます。

riddle_tecriddle_tec

base.mdc に以下が追加された

+## 実装時の注意点 (汎用)
+
+- **ライブラリ/APIの事前調査:**
+    - 主要な外部ライブラリやAPIを使用する際は、**コード生成前にその使い方(主要な関数、引数、期待されるデータ構造、必要なデータ変換等)を十分に調査・確認**してください。
+    - 不明な点や複数の選択肢がある場合は、安易に推測せず、**確認または提案**を行ってく
ださい。
+
+- **状態管理の明確化:**
+    - リクエストをまたいで状態を保持する必要がある場合(例: 認証トークン、一時データ、
チャレンジ)、**どこに(例: DBの特定モデル、セッションストレージ)、何を(データ項目)、どのように(有効期限、検索キー)管理するか**を明記してください。インメモリでの安易な状態保持は避け、DBや適切なストレージを利用する方針を指示してください。
+
+- **複雑なフローの段階的実装:**
+    - 複数のステップやコンポーネントが連携する複雑な機能を実装する場合、**機能を小さな
単位に分割**し、**各単位の実装と動作確認**を段階的に進めるように指示してください。(例: 「まずAPIエンドポイントを実装し`curl`でテスト、次にUIを接続」)
+
+- **設計と実装の整合性:**
+    - 最初に設計(シーケンス図など)を提示した場合でも、実装中に**より良い方法が見つか
った場合**や**設計との乖離が発生しそうな場合**は、立ち止まって確認や相談をするように促してください。
このスクラップは5ヶ月前にクローズされました