1
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?

IBM Verifyで実装するPasskey認証 - 既存アプリへの追加実装ガイド(IBM BOB活用)

1
Last updated at Posted at 2026-02-25

IBM Verifyで実装するPasskey認証 - 既存アプリへの追加実装ガイド

1. はじめに

本記事では、IBM VerifyのFIDO2 APIを使用してPasskey認証を実装する方法を、実際のコード例とともに詳しく解説します。

この記事について

本記事は、IBM BOB(AI駆動型開発アシスタント)を活用して執筆・実装されています。IBM BOBは、コード生成、デバッグ、ドキュメント作成などを支援し、開発生産性を大幅に向上させるツールです。

本記事で学べること

  • IBM Verify FIDO2 APIの具体的な使い方
  • Passkey登録・認証フローの実装方法
  • データベース、Redisが必要な理由
  • セキュリティ考慮事項とベストプラクティス

前提知識

  • TypeScript/JavaScriptの基礎知識
  • REST APIの基本的な理解
  • 非同期処理(async/await)の理解

動作環境

  • Node.js 18以上
  • PostgreSQL 14以上
  • Redis 7以上
  • Docker & Docker Compose
  • IBM Verifyテナント

Passkeyとは

従来のパスワード認証には以下の課題があります:

  • パスワードの使い回しによる情報漏洩リスク
  • フィッシング攻撃への脆弱性
  • パスワード管理の煩雑さ

Passkeyはこれらの課題を解決する次世代の認証方式です:

  • パスワード不要: 生体認証(指紋・顔認証)やPINで認証
  • フィッシング耐性: ドメインに紐付けられ、偽サイトでは動作しない
  • 秘密鍵の保護: 秘密鍵はデバイス内に保存され、外部に出ない

WebAuthnとFIDO2の関係

Passkey認証に関連する主要な用語とその役割を整理します。

用語 説明 役割
WebAuthn W3C標準のブラウザAPI ブラウザと認証器(Authenticator)の通信
FIDO2 FIDO Allianceの認証規格 WebAuthnとCTAPを含む包括的な規格
CTAP 認証器とクライアントの通信プロトコル USBキーなどの外部認証器用
Passkey FIDO2認証の実装 ユーザー向けの呼称

IBM Verifyの役割

IBM VerifyはFIDO2準拠のPasskey認証を実現するためのAPIを提供します。アプリケーションはこれらのAPIを呼び出すことで、複雑な暗号処理を自前で実装することなく、セキュアなPasskey認証を実装できます。

IBM Verify APIが提供する機能:

  1. チャレンジ生成API:ランダムな文字列を生成(図中: ステップ3)
  2. 公開鍵の保存API:ユーザーの公開鍵を安全に保管(図中: ステップ9で使用)
  3. 署名検証API:秘密鍵で署名されたか検証(図中: ステップ8-9)
  4. クレデンシャル管理API:登録済みPasskeyの管理(図中: ステップ8-9で参照)

Webアプリケーション側で実装が必要な点:

  1. ユーザー管理:データベースでユーザー情報を管理(図中: ステップ1、ステップ10で使用
  2. セッション管理:JWTトークンでセッションを管理(図中: ステップ10「JWT発行」
  3. チャレンジ管理:Redisで一時的なチャレンジを保存(図中: ステップ4-7のチャレンジ保持
  4. フロントエンド実装:WebAuthn APIの呼び出し(図中: ステップ5-6「WebAuthn API呼び出し」

既存システムへのPasskey認証追加

既存のID/パスワード認証システムにPasskey認証を追加する際の認証フローの変化を示します。従来の認証方式と比較することで、Passkeyがもたらすセキュリティと利便性の向上が明確になります。

Before: 従来のID/パスワード認証

従来のID/パスワード認証の課題:

  • パスワードの記憶・管理が必要
  • フィッシング攻撃のリスク
  • パスワード漏洩による不正アクセスの可能性
  • ユーザー体験の低下(入力の手間)

After: Passkey認証追加後

Passkey認証による改善点:

  • パスワード不要(生体認証やPINで認証)
  • フィッシング耐性(公開鍵暗号方式)
  • 秘密鍵がデバイス内に保存され、サーバーに送信されない
  • シームレスなユーザー体験
  • IBM Verifyによる安全な鍵管理

IBM Verify APIを利用するメリット

IBM VerifyはFIDO2サーバーとしての機能をREST APIとして提供します。アプリケーションはこれらのAPIを呼び出すだけで、以下のメリットが得られます:

  1. FIDO2準拠の実装が容易

    • WebAuthnの複雑な仕様を意識せずに実装可能
    • チャレンジ生成・署名検証などの暗号処理をIBM Verify APIが処理
  2. セキュリティの向上

    • エンタープライズグレードのセキュリティ基盤
    • 公開鍵の安全な保存と管理
    • 定期的なセキュリティアップデート
  3. 開発工数の削減

    • WebAuthnの低レベルAPIを直接扱う必要がない
    • 認証フローの実装が簡素化
    • テストとデバッグが容易
  4. スケーラビリティ

    • クラウドベースのインフラで自動スケール
    • 大規模ユーザーにも対応可能
    • 高可用性の保証
  5. マルチデバイス対応

    • 複数のPasskeyを1ユーザーに紐付け可能
    • デバイス間でのシームレスな認証体験

本記事のサンプルコードは、IBM BOBを使って実装した実際に動作するアプリケーションから抽出したものです。IBM BOBの支援により、効率的かつ高品質な実装を実現しています。

2. Passkey実装に必要なステップ(概要)

全体アーキテクチャ

本システムは、フロントエンド(React)、バックエンド(Express)、データベース(PostgreSQL/Redis)、IBM Verify APIの4つのコンポーネントで構成されます。

実装に必要な4つの要素

  1. FIDO2サーバー(IBM Verify)

    • チャレンジ生成と署名検証を担当
    • 公開鍵の安全な保管
  2. バックエンド(Express API)

    • ユーザー管理とセッション管理
    • IBM Verify APIとの連携
  3. データベース(PostgreSQL)

    • ユーザー情報の永続化
    • Passkey情報の管理
  4. 一時ストレージ(Redis)

    • チャレンジの一時保存(5分間)
    • リプレイ攻撃の防止

実装の流れ(5ステップ)

  1. 環境構築

    • Docker、PostgreSQL、Redisのセットアップ
    • IBM Verifyテナントの準備
  2. IBM Verify連携設定

    • API Clientの作成
    • Relying Partyの設定
    • → IBM Verify管理コンソールでの設定作業
  3. Passkey登録実装

    • バックエンド: IBM Verify APIでチャレンジ取得
    • フロントエンド: WebAuthn APIで認証器と通信
    • バックエンド: IBM Verify APIで登録完了
  4. Passkey認証実装

    • バックエンド: IBM Verify APIでチャレンジ取得
    • フロントエンド: WebAuthn APIで認証器と通信
    • バックエンド: IBM Verify APIで認証完了
  5. セキュリティ強化

    • チャレンジ管理(Redis)
    • カウンター検証
    • JWTトークン管理

3. IBM Verifyのセットアップ

テナントの準備

前提条件:

  • IBM Verifyのテナントアカウント
  • 管理者権限

API Clientの作成

手順:

  1. IBM Verifyテナントにログイン
  2. SecurityAPI accessに移動
  3. Add API clientをクリック
  4. 以下の権限を付与:
    • manageEnrollMFAMethodAnyUser(Passkey登録管理 - 必須)
    • manageEnrollMFAMethod(Passkey登録管理 - 必須)
    • readEnrollMFAMethodAnyUser(Passkey情報取得 - 必須)
    • readEnrollMFAMethod(Passkey情報取得 - 必須)
    • authnAnyUser(認証処理 - 必須)
    • manageAllUserGroups(SCIM API用 - 必須)
  5. Client IDとClient Secretを取得

Relying Partyの作成

手順:

  1. AuthenticationFIDO2 Settingsに移動
  2. Create relying partyをクリック
  3. 以下を設定:

Relying Partyの基本設定項目です。RP IDとOriginは認証時の検証に使用されます。

設定項目 説明
Display Name Your App Name アプリケーション名
Relying party identifier verify-passkey-demo ドメイン名(本番環境では実際のドメイン)
Device Include all device metadata and metadata services デバイス設定
Allowed origins https://verify-passkey-demo アプリケーションのURL
  1. Relying Party UUIDを取得

アプリケーションの環境変数設定

IBM Verifyから取得した情報を、アプリケーション(バックエンド)の環境変数として設定します。

これは設定例です。アプリケーションの実装にあわせて設定方法含めて見直しください。

.env
# IBM Verify Configuration
ISV_TENANT_URL=https://your-tenant.verify.ibm.com
ISV_CLIENT_ID=your-client-id
ISV_CLIENT_SECRET=your-client-secret
ISV_RP_ID=verify-passkey-demo
ISV_RP_ORIGIN=https://verify-passkey-demo

IBM Verifyから取得した値をアプリケーションに設定する環境変数の一覧です。

変数名 説明 IBM Verifyでの取得方法
ISV_TENANT_URL テナントURL テナント管理画面から確認
ISV_CLIENT_ID API ClientのID API Client作成時に取得
ISV_CLIENT_SECRET API Clientのシークレット API Client作成時に取得
ISV_RP_ID Relying PartyのID Relying Party設定で指定した値
ISV_RP_ORIGIN Relying PartyのOrigin Relying Party設定で指定した値

4. データベース設計

なぜデータベースが必要か

IBM Verify APIだけでは不十分な理由:

  • IBM Verify APIはPasskeyの公開鍵の保存・検証機能のみを提供
  • アプリケーション固有のユーザー情報(プロフィール、権限等)の管理機能は提供されない
  • Credential IDとアプリケーションユーザーの紐付けは自前で実装が必要

Passkey機能追加で必要なデータ:

  • Credential IDとユーザーの紐付け
  • 認証カウンター(リプレイ攻撃防止用)
  • 最終使用日時などのメタデータ

既存のusersテーブル(参考)

既存アプリケーションに存在するユーザー管理テーブルです。Passkey機能では、このテーブルの一部フィールドを参照します。

backend/migrations/001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(255) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    display_name VARCHAR(255),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMP WITH TIME ZONE,
    is_active BOOLEAN DEFAULT true
);

CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);

Passkey機能で使用するフィールド:

フィールド Passkey機能での用途
id UUID passkey_credentialsテーブルとの紐付け
username VARCHAR(255) IBM Verify User ID取得時のSCIM検索キー
display_name VARCHAR(255) Passkey登録時のdisplayNameパラメータ

password_hash、email等の他のフィールドは既存のパスワード認証で使用されますが、Passkey機能では使用しません。

passkey_credentialsテーブル(新規追加)

Passkey機能のために新規作成するテーブルです。各ユーザーが登録したPasskey情報を保存します。1ユーザーが複数のPasskeyを登録できます。

backend/migrations/002_create_passkey_credentials.sql
CREATE TABLE IF NOT EXISTS passkey_credentials (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    credential_id TEXT UNIQUE NOT NULL,
    counter BIGINT NOT NULL DEFAULT 0,
    device_type VARCHAR(50),
    backed_up BOOLEAN DEFAULT false,
    transports TEXT[],
    aaguid TEXT,
    credential_name VARCHAR(255),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    last_used_at TIMESTAMP WITH TIME ZONE,
    is_active BOOLEAN DEFAULT true
);

CREATE INDEX idx_passkey_credentials_user_id ON passkey_credentials(user_id);
CREATE INDEX idx_passkey_credentials_credential_id ON passkey_credentials(credential_id);
フィールド 説明
user_id UUID usersテーブルへの外部キー
credential_id TEXT WebAuthn Credential ID(Base64URL)
counter BIGINT 署名カウンター(リプレイ攻撃防止)
credential_name VARCHAR(255) ユーザーが設定したPasskey名
transports TEXT[] サポートされる転送方式

設計のポイント

  1. credential_idをUNIQUE制約: 同じPasskeyの重複登録を防止
  2. ON DELETE CASCADE: ユーザー削除時にPasskeyも自動削除
  3. counterフィールド: リプレイ攻撃防止に必須
  4. インデックス: user_idとcredential_idに高速検索用インデックス

public_keyフィールドは不要です。IBM Verifyが公開鍵を保存し、署名検証も実行するためです。

5. チャレンジ管理(Redis)

なぜRedisが必要か

メモリ内保存の理由:

  • チャレンジは一時的なデータ(5分間のみ有効)
  • 高速なアクセスが必要
  • 自動削除機能(TTL)が必要

TTL(有効期限)の必要性:

  • リプレイ攻撃の防止
  • 古いチャレンジの自動削除
  • メモリの効率的な使用

Redisクライアントの設定

backend/src/config/redis.ts
import { createClient } from 'redis';

const redisClient = createClient({
  socket: {
    host: process.env.REDIS_HOST || 'localhost',
    port: parseInt(process.env.REDIS_PORT || '6379'),
  },
  password: process.env.REDIS_PASSWORD,
});

redisClient.on('error', (err) => {
  console.error('Redis Client Error:', err);
});

export async function connectRedis(): Promise<void> {
  await redisClient.connect();
  console.log('Redis connected successfully');
}

export { redisClient };

チャレンジの保存

// 登録用チャレンジの保存
const challengeKey = `challenge:registration:${userId}`;
await redisClient.setEx(challengeKey, 300, challenge); // 5分間有効

// 認証用チャレンジの保存(ユーザー名なし)
const challengeKey = `challenge:auth:usernameless:${challenge}`;
await redisClient.setEx(challengeKey, 300, challenge);

キー命名規則:

Redisに保存するチャレンジのキー形式と有効期限を定義します。

用途 キー形式 TTL
登録 challenge:registration:{userId} 300秒
認証(ユーザー名あり) challenge:auth:{userId} 300秒
認証(ユーザー名なし) challenge:auth:usernameless:{challenge} 300秒

チャレンジの取得と削除

// チャレンジの取得
const expectedChallenge = await redisClient.get(challengeKey);

if (!expectedChallenge) {
  throw new Error('Challenge expired or not found');
}

// 使用後は即座に削除(リプレイ攻撃防止)
await redisClient.del(challengeKey);

チャレンジは必ず使用後に削除してください。削除しないとリプレイ攻撃のリスクがあります。

6. 既存のセッション管理(参考)

既存アプリのJWT実装

既存アプリケーションでは、ID/パスワード認証後のセッション管理にJWTを使用しています。Passkey機能では、この既存のJWT実装をそのまま活用します。

この章で説明するコードは既存アプリに実装済みのものです。Passkey機能追加のために新規実装する必要はありません。

既存のJWT実装の特徴:

  1. 統一されたセッション管理

    • ID/パスワード認証とPasskey認証で同じトークン形式を使用
    • 認証方式に関わらず、同じ認証ミドルウェアで検証
  2. ステートレス認証

    • サーバー側でセッション状態を保持不要
    • スケーラビリティの向上
  3. セキュリティ

    • 署名により改ざん検知が可能
    • 有効期限の設定が容易

JWTペイロードに含まれる情報:

  • ユーザーID(userId)
  • ユーザー名(username)
  • メールアドレス(email)
  • 発行日時(iat)と有効期限(exp)

既存システムとの統合について

既存システムが別のセッション管理方式(Cookie-based sessionなど)を使用している場合は、そちらに統合することも可能です。本記事ではJWTを例として説明します。

JWTトークンの生成

backend/src/utils/jwt.ts
import jwt, { SignOptions } from 'jsonwebtoken';

export interface JwtPayload {
  userId: string;
  username: string;
  email: string;
}

export function generateToken(payload: JwtPayload): string {
  const options: SignOptions = {
    expiresIn: '24h',
  };
  return jwt.sign(payload, process.env.JWT_SECRET!, options);
}

トークンペイロード例:

{
  "userId": "123e4567-e89b-12d3-a456-426614174000",
  "username": "alice",
  "email": "alice@example.com",
  "iat": 1705315200,
  "exp": 1705401600
}

JWTトークンの検証(既存実装)

既存アプリでは、以下のようにJWTトークンを検証しています。

backend/src/utils/jwt.ts
export function verifyToken(token: string): JwtPayload {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    return decoded;
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new Error('Token expired');
    }
    if (error instanceof jwt.JsonWebTokenError) {
      throw new Error('Invalid token');
    }
    throw error;
  }
}

認証ミドルウェア(既存実装)

既存アプリの認証ミドルウェアです。Passkey認証後のAPI呼び出しでも、このミドルウェアを使用します。

backend/src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import { verifyToken } from '../utils/jwt';

export interface AuthRequest extends Request {
  user?: {
    userId: string;
    username: string;
    email: string;
  };
}

export const authenticate = (
  req: AuthRequest,
  res: Response,
  next: NextFunction
): void => {
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Unauthorized' });
    return;
  }

  const token = authHeader.substring(7);
  
  try {
    const payload = verifyToken(token);
    req.user = payload;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
};

使用例:

// 認証が必要なエンドポイント
router.post('/api/passkey/register/start', authenticate, registerStart);

7. ユーザーとPasskeyの紐付け

なぜ両方で管理が必要か

IBM VerifyがFIDO2準拠の認証処理を担当し、データベースがアプリケーション固有のユーザー管理とビジネスロジックを担当するという役割分担により、セキュリティと柔軟性を両立できます。

IBM Verifyとデータベースの役割分担:

各データをどこで管理するかを明確に分けることで、セキュリティと機能性を両立します。

管理場所 管理内容 理由
IBM Verify 公開鍵、署名検証 FIDO2サーバーとしての役割
データベース Credential ID、ユーザー紐付け アプリケーション固有の情報
データベース カウンター値 リプレイ攻撃防止

データフロー全体図

Passkey登録と認証の全体的なデータフローを示します。各コンポーネント間でどのようにデータがやり取りされるかを理解できます。

8. IBM Verify APIの概要

APIの全体像

IBM VerifyはFIDO2サーバーとしての機能をREST APIで提供します。アプリケーションはこれらのAPIエンドポイントを呼び出すことで、Passkey認証を実装できます。

主要なAPIエンドポイントと用途:

API 用途 エンドポイント
OAuth 2.0 アクセストークン取得 /v1.0/endpoint/default/token
SCIM ユーザー管理 /v2.0/Users
FIDO2登録開始 チャレンジ取得 /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/options
FIDO2登録完了 公開鍵保存 /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result
FIDO2認証開始 チャレンジ取得 /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/options
FIDO2認証完了 署名検証 /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/result

OAuth 2.0認証

トークン取得

HTTPリクエスト例:

POST /v1.0/endpoint/default/token HTTP/1.1
Host: your-tenant.verify.ibm.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=your-client-id
&client_secret=your-client-secret

実装例:

backend/src/services/verifyAuth.ts
private async getAccessToken(): Promise<string> {
  const now = Date.now();
  
  // キャッシュされたトークンが有効ならそれを返す
  if (this.accessToken && this.tokenExpiry > now) {
    return this.accessToken;
  }

  try {
    const response = await axios.post(
      `${verifyConfig.tenantUrl}/v1.0/endpoint/default/token`,
      new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: verifyConfig.clientId,
        client_secret: verifyConfig.clientSecret,
      }),
      {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      }
    );

    this.accessToken = response.data.access_token;
    // 有効期限の1分前に更新
    this.tokenExpiry = now + (response.data.expires_in * 1000) - 60000;

    return this.accessToken!;
  } catch (error) {
    logger.error('Failed to get access token', error);
    throw new Error('Failed to authenticate with IBM Verify');
  }
}

レスポンス例:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid"
}

トークンの使用

backend/src/services/verifyAuth.ts
constructor() {
  this.axiosInstance = axios.create({
    baseURL: verifyConfig.tenantUrl,
    timeout: 30000,
  });

  // リクエストインターセプターでトークンを自動付与
  this.axiosInstance.interceptors.request.use(async (config) => {
    const token = await this.getAccessToken();
    config.headers.Authorization = `Bearer ${token}`;
    return config;
  });
}

API呼び出しの基本パターン

9. Passkey登録フロー

全体の流れ

Passkey登録は4つの主要ステップで完了します。IBM Verify APIとWebAuthn APIを連携させて、安全な認証情報を生成・保存します。

詳細なデータフロー(参考)

以下の図は、各ステップ内で発生するリクエスト/レスポンスの詳細なフローを示しています。

ステップ1: IBM Verify User ID取得(図の1-2)

なぜ必要か

IBM Verify User IDの役割:

  • IBM Verify内部でユーザーを一意に識別
  • Passkey登録時に必須のパラメータ
  • データベースのユーザーIDとは別物

SCIM APIでユーザー検索

HTTPリクエスト例:

GET /v2.0/Users?filter=userName eq "alice" HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}

レスポンス例:

{
  "totalResults": 1,
  "Resources": [
    {
      "id": "755002237F",
      "userName": "alice",
      "name": {
        "givenName": "Alice",
        "familyName": "Smith"
      }
    }
  ]
}

実装例:

backend/src/services/verifyUserManagement.ts
async getUserIdByUserName(userName: string): Promise<string | null> {
  try {
    const response = await this.axiosInstance.get('/v2.0/Users', {
      params: {
        filter: `userName eq "${userName}"`,
      },
    });

    if (response.data.totalResults === 0) {
      return null;
    }

    return response.data.Resources[0].id;
  } catch (error) {
    logger.error('Failed to get user ID from IBM Verify', { userName, error });
    throw new Error('Failed to retrieve user information from IBM Verify');
  }
}

ステップ2: 登録開始(attestation/options)(図の3-4)

何をするAPI?

IBM Verify APIのattestation/optionsエンドポイントの役割:

  • サーバー側でランダムなチャレンジを生成
  • WebAuthn登録に必要なパラメータをレスポンスとして返却
  • アプリケーションはこのレスポンスをブラウザのWebAuthn APIに渡す

リクエスト

HTTPリクエスト例:

POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/options HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "userId": "755002237F",
  "displayName": "Alice",
  "authenticatorSelection": {
    "requireResidentKey": true,
    "userVerification": "preferred"
  },
  "attestation": "none"
}

パラメータ詳細:

各パラメータの意味と設定値を以下の表で説明します。

パラメータ 必須 説明
userId IBM Verify User ID(SCIM APIで取得)
displayName ユーザーの表示名
requireResidentKey trueでパスワードレス認証が可能
userVerification preferredで生体認証を推奨
attestation noneで証明書不要

レスポンス

{
  "challenge": "Ho_xVdyDYEC5ztp_LCfEIwTMwbAaks_wpM497uhH_A8",
  "rp": {
    "id": "verify-passkey-demo",
    "name": "IBM"
  },
  "user": {
    "id": "NzU1MDAyMjM3Rg",
    "name": "alice",
    "displayName": "Alice"
  },
  "pubKeyCredParams": [
    { "type": "public-key", "alg": -7 },
    { "type": "public-key", "alg": -257 }
  ],
  "timeout": 240000,
  "authenticatorSelection": {
    "requireResidentKey": true,
    "userVerification": "preferred"
  },
  "attestation": "none"
}

レスポンスフィールド解説:

WebAuthn APIに渡す主要なフィールドの意味を説明します。

フィールド 説明
challenge ランダムに生成されたチャレンジ(Base64URL)
rp.id Relying Party ID(ドメイン名)
user.id IBM Verify User ID(Base64エンコード)
pubKeyCredParams サポートされる公開鍵アルゴリズム
timeout タイムアウト時間(ミリ秒)

バックエンド実装

backend/src/services/verifyAuth.ts
async initiateRegistration(
  userId: string,
  displayName: string
): Promise<VerifyRegistrationInitResponse> {
  const requestBody = {
    userId: userId,
    displayName: displayName,
    authenticatorSelection: {
      requireResidentKey: true,
      userVerification: "preferred"
    },
    attestation: "none"
  };
  
  const response = await this.axiosInstance.post(
    `/v2.0/factors/fido2/relyingparties/${verifyConfig.rpUuid}/attestation/options`,
    requestBody
  );

  return response.data;
}

コントローラー実装:

backend/src/controllers/passkeyController.ts
export const registerStart = async (
  req: AuthRequest,
  res: Response
): Promise<void> => {
  const userId = req.user?.userId;
  const username = req.user?.username;

  // ユーザー情報取得
  const userResult = await pool.query(
    'SELECT display_name FROM users WHERE id = $1',
    [userId]
  );

  const displayName = userResult.rows[0].display_name || username;

  // IBM Verify User IDを取得
  const ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
  
  if (!ibmVerifyUserId) {
    res.status(404).json({
      error: 'IBM Verify user not found. Please ensure user is registered.',
    });
    return;
  }

  // 登録開始
  const options = await verifyAuthService.initiateRegistration(
    ibmVerifyUserId,
    displayName
  );

  // チャレンジをRedisに保存
  const challengeKey = `challenge:registration:${userId}`;
  await redisClient.setEx(challengeKey, 300, options.challenge);

  res.json({ success: true, options });
};

ステップ3: WebAuthn API呼び出し(図の5-6)

フロントエンド実装

frontend/src/services/webauthn.ts
export async function registerPasskey(deviceName?: string): Promise<void> {
  // 1. サーバーから登録オプションを取得
  const { options } = await api.passkeyRegisterStart();

  // 2. JSON形式をブラウザAPI用に変換
  const publicKeyOptions = convertCreationOptions(options);

  // 3. ブラウザの標準WebAuthn APIを使用して認証器と通信
  const credential = await navigator.credentials.create({
    publicKey: publicKeyOptions,
  });

  if (!credential || credential.type !== 'public-key') {
    throw new Error('認証器からの応答が無効です');
  }

  // 4. ブラウザのCredentialをJSON形式に変換
  const credentialJSON = convertRegistrationCredential(
    credential as PublicKeyCredential
  );

  // 5. サーバーに認証情報を送信して登録完了
  await api.passkeyRegisterComplete({
    credential: credentialJSON,
    deviceName,
  });
}

何が起こる?

  1. ブラウザがデバイスの認証器(Authenticator: Touch ID、Windows Hello等)を呼び出し
  2. ユーザーが生体認証またはPINで本人確認
  3. 認証器が公開鍵・秘密鍵ペアを生成
  4. 秘密鍵はデバイス内のセキュアエンクレーブに保存
  5. 公開鍵と署名データをブラウザに返却

ステップ4: 登録完了(attestation/result)(図の7-10)

何をするAPI?

このAPIの役割:

  • ブラウザが生成した公開鍵を検証
  • チャレンジの署名を検証
  • 公開鍵をIBM Verifyのデータベースに保存

リクエスト

HTTPリクエスト例:

POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "id": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
  "rawId": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
  "response": {
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIi...",
    "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRo..."
  },
  "type": "public-key",
  "nickname": "My iPhone",
  "enabled": true
}

レスポンス

{
  "id": "7046c263-0807-4e4c-a1e6-be992075a87e",
  "type": "fido2",
  "userId": "755002237F",
  "enabled": true,
  "validated": true,
  "created": "2026-02-05T01:07:08.589Z",
  "attributes": {
    "credentialId": "JI_m6aKOfu4I74WVx9igSbUct1SLPkukVa-vUVvxixs",
    "credentialPublicKey": "v2EzYi03Yi0xAWItMlggZ96N9sjv...",
    "counter": 0,
    "nickname": "My iPhone"
  }
}

バックエンド実装

サービス層実装:

backend/src/services/verifyAuth.ts
/**
 * Complete FIDO registration
 * POST /v2.0/factors/fido2/relyingparties/{rpUuid}/attestation/result
 */
async completeRegistration(
  credentialData: any,
  nickname: string = 'My Passkey'
): Promise<VerifyRegistrationCompleteResponse> {
  const requestBody = {
    id: credentialData.id,
    rawId: credentialData.rawId,
    response: {
      clientDataJSON: credentialData.response.clientDataJSON,
      attestationObject: credentialData.response.attestationObject,
    },
    type: credentialData.type,
    nickname,
    enabled: true,
  };
  
  const response = await this.axiosInstance.post<VerifyRegistrationCompleteResponse>(
    `/v2.0/factors/fido2/relyingparties/${verifyConfig.rpUuid}/attestation/result`,
    requestBody
  );

  return response.data;
}

コントローラー実装:

backend/src/controllers/passkeyController.ts
export const registerComplete = async (
  req: AuthRequest,
  res: Response
): Promise<void> => {
  const userId = req.user?.userId;
  const { response, credentialName } = req.body;

  // チャレンジ検証
  const challengeKey = `challenge:registration:${userId}`;
  const expectedChallenge = await redisClient.get(challengeKey);

  if (!expectedChallenge) {
    res.status(400).json({ error: 'Challenge expired or not found' });
    return;
  }

  // IBM Verifyで登録完了
  const result = await verifyAuthService.completeRegistration(
    response,
    credentialName || 'My Passkey'
  );

  // データベースに保存
  await pool.query(
    `INSERT INTO passkey_credentials
     (user_id, credential_id, credential_name, created_at, is_active)
     VALUES ($1, $2, $3, NOW(), true)`,
    [userId, response.id, credentialName]
  );

  // チャレンジ削除
  await redisClient.del(challengeKey);

  res.json({ success: true, credentialId: result.id });
};

登録完了時点で、公開鍵はIBM Verifyに保存され、秘密鍵はデバイス内に保存されています。

10. Passkey認証フロー

全体の流れ

Passkey認証は3つの主要ステップで完了します。保存済みの認証情報を使用して、パスワードレスでユーザーを認証します。

詳細なデータフロー(参考)

以下の図は、各ステップ内で発生するリクエスト/レスポンスの詳細なフローを示しています。

ステップ1: 認証開始(assertion/options)(図の1-3)

リクエスト

HTTPリクエスト例(ユーザー名なし):

POST /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/options HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "userVerification": "required"
}

userIdを省略すると、デバイスに保存されたすべてのPasskeyから選択できます(Discoverable Credentials)。
これにより、ユーザー名入力なしでログインが可能になります(パスワードレス+ユーザー名レス)。

レスポンス

{
  "challenge": "MwZWd5uqlvaUoq-cCSKoQ3wMMShEmKHBgZhEztEBvO4",
  "timeout": 240000,
  "rpId": "verify-passkey-demo",
  "userVerification": "required"
}

バックエンド実装

backend/src/controllers/authController.ts
export const authStart = async (
  req: Request,
  res: Response
): Promise<void> => {
  // ユーザー名なし認証をサポート
  const { username } = req.body;

  let ibmVerifyUserId: string | undefined;

  if (username) {
    // ユーザー名ありの場合、IBM Verify User IDを取得
    ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
  }

  // 認証開始
  const options = await verifyAuthService.initiateAuthentication(ibmVerifyUserId);

  // チャレンジをRedisに保存
  const challengeKey = ibmVerifyUserId
    ? `challenge:auth:${ibmVerifyUserId}`
    : `challenge:auth:usernameless:${options.challenge}`;
  
  await redisClient.setEx(challengeKey, 300, options.challenge);

  res.json({ success: true, options });
};

ステップ2: WebAuthn API呼び出し(図の4-5)

フロントエンド実装

frontend/src/services/webauthn.ts
export async function authenticateWithPasskey(username?: string): Promise<{
  token: string;
  user: any;
}> {
  // 1. サーバーから認証オプションを取得
  const { options, userId } = await api.passkeyAuthStart(username);

  // 2. JSON形式をブラウザAPI用に変換
  const publicKeyOptions = convertRequestOptions(options);

  // 3. ブラウザの標準WebAuthn APIを使用して認証器と通信
  const credential = await navigator.credentials.get({
    publicKey: publicKeyOptions,
  });

  if (!credential || credential.type !== 'public-key') {
    throw new Error('認証器からの応答が無効です');
  }

  // 4. ブラウザのCredentialをJSON形式に変換
  const credentialJSON = convertAuthenticationCredential(
    credential as PublicKeyCredential
  );

  // 5. サーバーに認証情報を送信して認証完了
  const response = await api.passkeyAuthComplete({
    userId,
    credential: credentialJSON,
  });

  return {
    token: response.token,
    user: response.user,
  };
}

ステップ3: 認証完了(assertion/result)(図の6-10)

リクエスト

HTTPリクエスト例:

POST /v2.0/factors/fido2/relyingparties/{rpUuid}/assertion/result HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}
Content-Type: application/json

{
  "id": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0",
  "rawId": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0",
  "response": {
    "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Ii...",
    "authenticatorData": "mCLUwMGr72CgUu7HW2dnQLkUCCexdiPpMdTdyfqnK8wFAAAAAQ",
    "signature": "MEUCIQDcYxZgeH5CYBUFnUBUyId9jqO8eMP9mNhWRSLhvhY09QIgAUyCiQYddVdbFg5zRRQWD3sV8HjGBF9A3aSuo9VfTLQ",
    "userHandle": "NzU1MDAyMjM3Rg"
  },
  "type": "public-key"
}

レスポンス

{
  "id": "5473db9a-6737-44d5-95b2-3f90658d1986",
  "type": "fido2",
  "userId": "755002237F",
  "enabled": true,
  "validated": true,
  "attributes": {
    "counter": 1,
    "credentialId": "FSFhEerKtDp93uOQS3tPjzW_x_0pvJEbDFZi1JWhSM0"
  }
}

バックエンド実装(カウンター検証含む)

backend/src/controllers/authController.ts
export async function passkeyAuthComplete(req: Request, res: Response): Promise<void> {
  const { userId, response: webauthnResponse } = req.body;

  // 1. Credential IDでデータベースから検索
  const credResult = await pool.query(
    'SELECT user_id, counter FROM passkey_credentials WHERE credential_id = $1 AND is_active = true',
    [credential.id]
  );

  if (credResult.rows.length === 0) {
    res.status(400).json({ error: 'Credential not found' });
    return;
  }

  const { user_id, counter: storedCounter } = credResult.rows[0];

  // 2. ユーザー情報取得
  const userResult = await pool.query(
    'SELECT id, username, email FROM users WHERE id = $1 AND is_active = true',
    [user_id]
  );

  if (userResult.rows.length === 0) {
    res.status(404).json({ error: 'User not found' });
    return;
  }

  const user = userResult.rows[0];

  // 3. チャレンジ検証
  const clientDataJSON = Buffer.from(
    credential.response.clientDataJSON,
    'base64url'
  ).toString();
  const clientData = JSON.parse(clientDataJSON);
  const challenge = clientData.challenge;

  const challengeKey = `challenge:auth:usernameless:${challenge}`;
  const expectedChallenge = await redisClient.get(challengeKey);

  if (!expectedChallenge) {
    res.status(400).json({ error: 'Challenge expired or not found' });
    return;
  }

  // 4. IBM Verifyで署名検証
  const verifyResult = await verifyAuthService.completeAuthentication(credential);

  // 5. カウンター検証(リプレイ攻撃防止)
  const authenticatorData = Buffer.from(
    credential.response.authenticatorData,
    'base64url'
  );
  const newCounter = authenticatorData.readUInt32BE(33);

  if (newCounter <= storedCounter) {
    logger.error('Counter did not increase - possible replay attack', {
      stored: storedCounter,
      received: newCounter,
    });
    res.status(401).json({ error: 'Authentication failed' });
    return;
  }

  // 6. カウンター更新
  await pool.query(
    'UPDATE passkey_credentials SET counter = $1, last_used_at = NOW() WHERE credential_id = $2',
    [newCounter, credential.id]
  );

  // 7. チャレンジ削除
  await redisClient.del(challengeKey);

  // 8. JWT発行
  const token = generateToken({
    userId: user.id,
    username: user.username,
    email: user.email,
  });

  res.json({ token, user });
};

カウンター検証は必須です。スキップするとリプレイ攻撃に脆弱になります。

11. セキュリティ考慮事項

チャレンジ管理

TTLの設定理由

// チャレンジは5分間のみ有効
await redisClient.setEx(challengeKey, 300, challenge);

理由:

  • 長すぎるとリプレイ攻撃のリスク増加
  • 短すぎるとユーザー体験が悪化
  • 5分は適切なバランス

使用後削除の重要性

// 認証完了後、必ず削除
await redisClient.del(challengeKey);

理由:

  • 同じチャレンジの再利用を防止
  • リプレイ攻撃の防止
  • メモリの効率的な使用

カウンター検証

実装例:

backend/src/services/verifyAuth.ts
async verifyAuthentication(
  response: AuthenticationResponseJSON,
  credential: PasskeyCredential
): Promise<VerificationResult> {
  // IBM Verifyで署名検証
  const verifyResult = await this.completeAuthentication(response);
  
  // カウンターを抽出
  const authenticatorData = Buffer.from(
    response.response.authenticatorData,
    'base64url'
  );
  const counter = authenticatorData.readUInt32BE(33);

  // カウンターの増加を確認
  if (counter <= credential.counter) {
    logger.error('Counter did not increase - possible replay attack', {
      stored: credential.counter,
      received: counter
    });
    return { verified: false, newCounter: credential.counter };
  }

  return { verified: true, newCounter: counter };
}

カウンターが増加しない場合は、リプレイ攻撃の可能性があります。必ず認証を失敗させてください。

データベースセキュリティ

Passkey機能に関連するデータベースセキュリティの考慮事項:

  • カウンター値の整合性:トランザクション内でカウンター更新を実行し、リプレイ攻撃を防止

SQL インジェクション対策、最小権限の原則、暗号化通信などの一般的なデータベースセキュリティは既存アプリで実装済みのため、ここでは説明を省略します。

12. Passkey管理機能

登録一覧取得

HTTPリクエスト例:

GET /v2.0/factors/fido2/registrations?search=userId="{userId}" HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}

レスポンス例:

{
  "fido2": [
    {
      "id": "registration-id-1",
      "userId": "755002237F",
      "attributes": {
        "credentialId": "credential-id-1",
        "nickname": "My iPhone",
        "counter": 42
      },
      "created": "2024-01-01T00:00:00Z"
    }
  ]
}

実装例:

backend/src/controllers/passkeyController.ts
export const listCredentials = async (
  req: AuthRequest,
  res: Response
): Promise<void> => {
  const userId = req.user?.userId;

  const result = await pool.query(
    `SELECT id, credential_id, credential_name, counter, created_at, last_used_at
     FROM passkey_credentials
     WHERE user_id = $1 AND is_active = true
     ORDER BY created_at DESC`,
    [userId]
  );

  const credentials = result.rows.map((row: any) => ({
    id: row.id,
    credential_id: row.credential_id,
    device_name: row.credential_name,
    counter: row.counter,
    created_at: row.created_at,
    last_used_at: row.last_used_at,
  }));

  res.json(credentials);
};

登録削除

HTTPリクエスト例:

DELETE /v2.0/factors/fido2/registrations/{id} HTTP/1.1
Host: your-tenant.verify.ibm.com
Authorization: Bearer {access_token}

実装例:

backend/src/controllers/passkeyController.ts
export const deleteCredential = async (
  req: AuthRequest,
  res: Response
): Promise<void> => {
  const userId = req.user?.userId;
  const { id } = req.params;

  // 所有権確認
  const checkResult = await pool.query(
    'SELECT user_id, credential_id FROM passkey_credentials WHERE id = $1',
    [id]
  );

  if (checkResult.rows.length === 0) {
    res.status(404).json({ error: 'Credential not found' });
    return;
  }

  if (checkResult.rows[0].user_id !== userId) {
    res.status(403).json({ error: 'Forbidden' });
    return;
  }

  const credentialId = checkResult.rows[0].credential_id;

  // IBM Verifyから削除
  const username = req.user?.username;
  const ibmVerifyUserId = await verifyUserManagementService.getUserIdByUserName(username);
  
  if (ibmVerifyUserId) {
    const registrations = await verifyAuthService.listRegistrations(ibmVerifyUserId);
    const matchingReg = registrations.find((reg: any) =>
      reg.attributes?.credentialId === credentialId
    );
    
    if (matchingReg) {
      await verifyAuthService.deleteRegistration(matchingReg.id);
    }
  }

  // データベースから削除
  await pool.query(
    'DELETE FROM passkey_credentials WHERE id = $1',
    [id]
  );

  res.json({ success: true, message: 'Passkey deleted successfully' });
};

13. まとめ

IBM BOBを使ってIBM VerifyのAPIでPasskey実装を試してみました。本記事では、実際に動作するPasskey認証システムの構築方法を、コードとともに詳しく解説しました。

参考リンク

実装中に問題が発生した場合は、IBM Verifyのドキュメントやコミュニティフォーラムを参照してください。

1
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
1
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?