EcAuthDocs

EcAuth 技術Q&A

EF Core 9 移行、トランザクション管理など EcAuth 開発で遭遇した技術的な課題と解決策の Q&A 集。

Q1: OpenID ConnectのIDフェデレーションに特化したIdentity Providerにおいて、IdP内にユーザーのデータベースを持たず、client(EC-CUBE)のcustomerテーブルを利用することは可能ですか?

A1: 技術的に困難

OpenID Connectの仕様上、IdP内にユーザーのデータベースを持たずに、クライアント(EC-CUBE)のcustomerテーブルを直接利用することは技術的に困難です。

主な課題

  1. OpenID Connectの基本原則との矛盾

    • IdPは認証時にユーザーの識別情報を管理し、IDトークンを発行する役割
    • クライアント側のデータベースに直接依存すると、IdPとしての独立性が損なわれる
  2. 認証フローでの問題

    • Authorization Code Flowでは、IdPがユーザー認証後にauthorization codeを発行
    • この時点でIdP側にユーザーの識別情報が必要
    • クライアント側のデータベースを参照するタイミングが適切でない
  3. マルチテナント対応の困難さ

    • 要件書によると複数のEC-CUBEサイト(Organization)に対応予定
    • 各クライアントのデータベースを直接参照する設計では、テナント管理が複雑化

推奨される代替アプローチ

  1. 最小限のユーザー情報保持

    EcAuth側: ユーザー識別用の最小限情報(ハッシュ化メールアドレス、UUID等)
    EC-CUBE側: 詳細なユーザー情報(customerテーブル)
  2. JIT (Just-In-Time) プロビジョニング

    • 初回ログイン時にIdP側で最小限のユーザーレコードを作成
    • EC-CUBE側では受け取ったsubjectをキーにcustomerテーブルと紐付け
  3. 外部IdP連携の活用

    • 要件書に記載されている通り、GoogleやLINEなどの外部IdPを主体とする
    • EcAuthはフェデレーションハブとして機能
    • 実際のユーザー認証は外部IdPに委譲
    • EcAuth側では外部IdPのsubjectとの紐付け情報のみを保持

Q2: Authorization Code Flowのどのタイミングで最小限のユーザー情報を生成するのが適切ですか?

A2: Authorization Endpoint(認可エンドポイント)でのユーザー認証完了直後

Authorization Code Flowにおいて、最小限のユーザー情報を生成する適切なタイミングはAuthorization Endpoint(/authorize)でのユーザー認証完了直後です。

詳細なタイミングと処理フロー

1. クライアントからの認可リクエスト受信
2. ユーザーを外部IdP(Google、LINE等)にリダイレクト
3. 外部IdPでの認証完了後、callback受信
4. 【ここで最小限ユーザー情報生成】
   - 外部IdPから取得したsubject、email等を基に
   - EcAuth内部でのユーザー識別子(UUID)を生成
   - ハッシュ化メールアドレス等の最小限情報を保存
5. authorization codeを生成・発行
6. クライアント(EC-CUBE)にリダイレクト

実装例

// 外部IdPからのcallback処理内
public async Task<IActionResult> ExternalCallback(string provider)
{
    var externalUser = await GetExternalUserInfo();
    
    // EcAuth内部でのユーザー情報生成・取得
    var ecAuthUser = await GetOrCreateUserAsync(new UserCreationRequest
    {
        ExternalProvider = provider,
        ExternalSubject = externalUser.Subject,
        EmailHash = HashEmail(externalUser.Email),
        EcAuthSubject = Guid.NewGuid().ToString() // 内部識別子
    });
    
    // authorization code生成
    var authCode = await GenerateAuthorizationCode(ecAuthUser.Subject);
    
    return Redirect($"{redirectUri}?code={authCode}&state={state}");
}

このタイミングが適切な理由

  1. OpenID Connect仕様への準拠

    • authorization code発行時点で、IdP側にユーザーの識別情報が確定している必要
    • Token EndpointでのIDトークン生成時に、確実にsubjectクレームを設定可能
  2. JIT(Just-In-Time)プロビジョニングの実現

    • 初回ログイン時のみユーザー情報生成
    • 2回目以降は既存情報を利用
    • 事前のユーザー登録が不要
  3. エラーハンドリングの観点

    • ユーザー情報生成に失敗した場合、認可フロー全体を適切にエラー終了可能
    • クライアント側に不整合な状態を返すリスクを回避
  4. 外部IdP連携との整合性

    • 要件書の「外部IdPのsubjectとの紐付け管理」要件に対して、外部認証完了直後が最も自然なタイミング

Token Endpointでの処理

public async Task<TokenResponse> TokenAsync(TokenRequest request)
{
    var authCode = await ValidateAuthorizationCode(request.Code);
    var user = await GetUserBySubject(authCode.Subject);
    
    // 既に生成済みのユーザー情報を使用してIDトークン生成
    var idToken = GenerateIdToken(user);
    var accessToken = GenerateAccessToken(user);
    
    return new TokenResponse { IdToken = idToken, AccessToken = accessToken };
}

設計原則まとめ

このアプローチにより、以下を実現できます:

  • OpenID Connectの仕様に準拠したIdP実装
  • 個人情報保護法要件を満たした最小限のユーザー情報保持
  • 外部IdP連携によるセキュリティリスクの最小化
  • マルチテナント環境での適切なユーザー管理

要件書に記載された「個人情報非保持設計」の方針と整合性を保ちながら、技術的に実現可能なアーキテクチャとなっています。


Q3: .NET 8から.NET 9への移行でEntity Framework Coreマイグレーションがタイムアウトエラーになる問題

A3: EF Core 9のトランザクション管理変更が原因

.NET 8で正常に動作していたマイグレーション処理が、.NET 9(EF Core 9)でタイムアウトエラーが発生するようになりました。

問題の詳細

エラーメッセージ例:

Applying migration '20250207152203_InsertOpenIdProviders'.
Microsoft.Data.SqlClient.SqlException (0x80131904): Execution Timeout Expired.
The timeout period elapsed prior to completion of the operation or the server is not responding.

根本原因:

  • EF Core 9では、マイグレーション実行時に自動的にトランザクションが開始されるようになった
  • マイグレーション内で新しいDbContextを作成すると、トランザクションの競合が発生
  • 結果として、デッドロックまたはタイムアウトが発生する

解決方法

1. Program.csでのDbContext設定修正

builder.Services.AddDbContext<EcAuthDbContext>((sp, options) =>
{
    var tenantService = sp.GetRequiredService<ITenantService>();
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:EcAuthDbContext"],
        sqlOptions => sqlOptions.CommandTimeout(180) // タイムアウトを3分に設定
    );
});

2. MockOpenIdProviderでも同様の修正

builder.Services.AddDbContext<IdpDbContext>(options =>
{
    options.UseSqlServer(
        builder.Configuration["ConnectionStrings:MockIdpDbContext"],
        sqlOptions => sqlOptions.CommandTimeout(180) // タイムアウトを3分に設定
    );
});

3. マイグレーションファイルの根本的修正

EF Core 9のトランザクション競合問題を根本的に解決するため、マイグレーション内でのDbContext作成を完全に削除し、migrationBuilder.Sql()を使用した直接SQL実行に変更:

// 修正前(DbContextを使用 - EF Core 9でタイムアウトエラー)
using (var scope = MigrationServiceProviderFactory<EcAuthDbContext>.CreateMigrationServiceProvider(...)
    .BuildServiceProvider().CreateScope())
{
    var _context = scope.ServiceProvider.GetRequiredService<EcAuthDbContext>();
    var Client = _context.Clients.FirstOrDefault(c => c.ClientId == CLIENT_ID);
    _context.OpenIdProviders.Add(new OpenIdProvider { ... });
    _context.SaveChanges();
}

// 修正後(直接SQL実行 - EF Core 9対応)
DotNetEnv.Env.TraversePath().Load();
var GOOGLE_OAUTH2_APP_NAME = DotNetEnv.Env.GetString("GOOGLE_OAUTH2_APP_NAME");
var GOOGLE_OAUTH2_CLIENT_ID = DotNetEnv.Env.GetString("GOOGLE_OAUTH2_CLIENT_ID");
// ... 他の環境変数

migrationBuilder.Sql($@"
    INSERT INTO open_id_provider (
        name, idp_client_id, idp_client_secret, discovery_document_uri, 
        issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, 
        jwks_uri, created_at, updated_at, client_id
    )
    SELECT 
        '{GOOGLE_OAUTH2_APP_NAME}',
        '{GOOGLE_OAUTH2_CLIENT_ID}',
        '{GOOGLE_OAUTH2_CLIENT_SECRET}',
        '{GOOGLE_OAUTH2_DISCOVERY_URL}',
        'https://accounts.google.com',
        'https://accounts.google.com/o/oauth2/v2/auth',
        'https://oauth2.googleapis.com/token',
        'https://openidconnect.googleapis.com/v1/userinfo',
        'https://www.googleapis.com/oauth2/v3/certs',
        SYSDATETIMEOFFSET(),
        SYSDATETIMEOFFSET(),
        c.id
    FROM client c
    WHERE c.client_id = '{DEFAULT_CLIENT_ID}'
");

修正対象ファイル:

  • IdentityProvider/Migrations/20250207152203_InsertOpenIdProviders.cs
  • IdentityProvider/Migrations/20250219155405_InsertFederateOpenIdProvider.cs
  • MockOpenIdProvider/Migrations/20250213152751_InsertMockIdpUser.cs
  • MockOpenIdProvider/Migrations/20250218143255_InsertFederateClient.cs

技術的背景

EF Core 9の変更点:

  • マイグレーション実行時にExecutionStrategyを使用して自動的にトランザクションを管理
  • すべてのマイグレーションが単一のトランザクションで実行される(EF Core 8では各マイグレーションが個別のトランザクション)
  • マイグレーション内で外部トランザクションを作成するとNotSupportedExceptionが発生

参考資料:

今後の対応方針

  1. マイグレーション設計の見直し

    • マイグレーション内でDbContextを作成するパターンは避ける
    • データ挿入処理は別の初期化メカニズム(Program.csでのシード処理等)に移行を検討
  2. EF Core 10での改善待ち

    • 現在の制限はEF Coreチームでも認識されており、将来のバージョンでの改善が期待される
  3. 代替アプローチ

    • migrationBuilder.Sql()を使用した直接SQL実行
    • アプリケーション起動時のデータシード処理
    • ConsoleAppプロジェクトでの専用シードコマンド作成

Q4: パスキーの管理者向け(B2B)とエンドユーザー向け(B2C)を分離する理由は?

A4: ユースケースと要件の違い

B2BパスキーとB2Cパスキーを別のデータモデル(B2BUser / EcAuthUser)で管理する理由を説明します。

B2BとB2Cの違い

観点 B2B(管理画面) B2C(フロント)
ユーザー規模 数人〜数十人 数百〜数万人
認証頻度 低頻度(業務時間) 高頻度(24時間)
デバイス 業務用PC/スマホ 個人デバイス多様
課金モデル 無料 MAU課金
データモデル B2BUser EcAuthUser
セキュリティ要件 厳格(フィッシング対策必須) 利便性重視
ライフサイクル 明示的な登録 JITプロビジョニング

分離の利点

  1. 段階的リリース

    • B2Bを先行リリースしてフィードバック収集
    • 小規模(管理者のみ)で技術検証可能
    • 問題があっても影響範囲が限定的
  2. 課金体系の柔軟性

    • B2B無料、B2C有料の差別化
    • MAUカウントからB2Bユーザーを除外可能
  3. データモデルの最適化

    • B2B: パスキー認証に特化(B2BPasskeyCredential)
    • B2C: 外部IdP連携に特化(ExternalIdpMapping)
  4. セキュリティポリシーの分離

    • B2B: 管理者は厳格なセキュリティ要件
    • B2C: エンドユーザーは利便性重視
  5. 将来の拡張性

    • B2B: 企業SSO(Azure Entra ID等)への拡張
    • B2C: ソーシャルログイン拡張

Q5: パスキー登録時の本人確認をパスワード再入力で行う理由は?

A5: 実装コストとセキュリティのバランス

パスキー登録時の本人確認方法として「パスワード再入力」を採用した理由を説明します。

検討した選択肢

方式 メリット デメリット
メール/SMS OTP 高セキュリティ 追加インフラ必要、コスト増
管理者承認 組織的な統制 運用負荷、即時性欠如
既存セッション信頼 実装が簡単 セッション乗っ取りリスク
パスワード再入力 追加インフラ不要、即時確認 パスワード漏洩時のリスク

パスワード再入力を採用した理由

  1. EC-CUBE既存のパスワード認証を活用

    • 追加のインフラストラクチャ不要
    • プラグインのみで実装可能
  2. ユーザー体験への影響最小限

    • 管理者は既にパスワードを知っている
    • 追加の登録・設定が不要
  3. FIDO Allianceのベストプラクティスに準拠

    • パスキー登録前の再認証は推奨されている
    • 既存の認証手段での確認が一般的
  4. セキュリティ強化オプション

    • 登録完了通知メール(不正登録の早期発見)
    • 2個目以降のパスキー登録は既存パスキーで認証

実装場所

本人確認はEC-CUBEプラグイン側で実装。EcAuth側はパスキー登録APIを提供するのみ。

flowchart LR
    subgraph Plugin["EC-CUBEプラグイン"]
        P1["パスワード再入力UI"]
        P2["パスワード検証"]
        P3["検証成功 → 一時トークン発行(5分有効)"]
        P4["EcAuth API呼び出し時にトークンを送信"]
        P1 --> P2 --> P3 --> P4
    end

    subgraph EcAuth["EcAuth"]
        E1["トークン検証"]
        E2["パスキー登録処理"]
        E1 --> E2
    end

    P4 --> E1

Q6: B2BUserとEcAuthUserを別テーブルにする理由は?

A6: ユースケースと将来拡張性の違い

B2BUserとEcAuthUserを統合せず、別テーブルで管理する理由を説明します。

設計上の理由

  1. 認証フローの違い

    B2BUser(管理者):

    パスキー認証 → 署名検証 → トークン発行

    EcAuthUser(エンドユーザー):

    外部IdP認証 → JITプロビジョニング → トークン発行
  2. ライフサイクルの違い

    観点 B2BUser EcAuthUser
    作成タイミング 明示的な登録 初回ログイン時(JIT)
    EC-CUBE連携 dtb_member dtb_customer
    関連データ B2BPasskeyCredential ExternalIdpMapping
    削除タイミング 管理者による削除 退会時
  3. 将来の拡張性

    B2BUser:

    • 企業SSO(Azure Entra ID, Google Workspace)
    • B2BExternalIdpMapping で企業IdPと紐付け
    • 組織階層管理(マネージャー、スタッフ等)

    EcAuthUser:

    • ソーシャルログイン拡張(Apple, Yahoo Japan等)
    • B2Cパスキー(Phase 2)
    • 複数外部IdPの紐付け

共通点

両者は以下の点で共通の設計を持ちます:

  • 同一のOrganizationに紐付け(マルチテナント対応)
  • 最小限の個人情報保持(UUID Subject)
  • テナントフィルターによるデータ分離
// 両方のエンティティに適用
modelBuilder.Entity<EcAuthUser>()
    .HasQueryFilter(u => u.Organization.TenantName == _tenantService.TenantName);

modelBuilder.Entity<B2BUser>()
    .HasQueryFilter(u => u.Organization.TenantName == _tenantService.TenantName);

統合しない理由

  1. 単一責任の原則: 各エンティティが明確な役割を持つ
  2. クエリの最適化: 不要なカラムやリレーションを含まない
  3. スキーマ変更の影響範囲: B2B変更がB2Cに影響しない
  4. テスト容易性: 各ユースケースを独立してテスト可能

Q7: B2BPasskeyServiceのパフォーマンス改善とマルチテナント対応の詳細は?

A7: EF Coreクエリ最適化とOrganization.Name動的取得

PR #225 のレビュー対応として実施したパフォーマンス改善とマルチテナント対応の技術的詳細を説明します。

パフォーマンス改善: SequenceEqual → == 演算子への変更

問題点: EF Core は SequenceEqual() をSQLに変換できず、全レコードをメモリに読み込んでから比較を実行していました。

修正前のコード:

// VerifyAuthenticationAsync(357-360行目)
var credentials = await _context.B2BPasskeyCredentials
    .Where(c => c.B2BSubject == challenge.Subject)
    .ToListAsync(); // 全レコードをメモリ読み込み

var credential = credentials
    .FirstOrDefault(c => c.CredentialId.SequenceEqual(assertionCredentialIdBytes)); // メモリ内比較

修正後のコード:

// DB側でフィルタリング
var credential = await _context.B2BPasskeyCredentials
    .Where(c => c.B2BSubject == challenge.Subject)
    .FirstOrDefaultAsync(c => c.CredentialId == assertionCredentialIdBytes); // DB側で比較

技術的背景:

  • SequenceEqual() は LINQ to Objects でのみ動作(SQL変換不可)
  • == 演算子は EF Core が SQL に変換可能
  • SQL例: WHERE credential_id = @p0 としてパラメータ化される

パフォーマンス向上:

  • Before: O(n) メモリ読み込み → O(n) メモリ内比較
  • After: O(1) DB側インデックス検索
  • 100個のクレデンシャルでのベンチマーク: 1秒以内に完了

マルチテナント対応: RP名の動的取得

問題点: WebAuthn登録時のRP名(Relying Party Name)が "EcAuth" にハードコードされており、マルチテナント環境でユーザー体験が低下していました。

修正前のコード:

// CreateRegistrationOptionsAsync(164行目)
Rp = new PublicKeyCredentialRpEntity(challenge.RpId!, "EcAuth"), // ハードコード

修正後のコード:

// Client経由でOrganizationを取得
var client = await _context.Clients
    .Include(c => c.Organization)
    .FirstOrDefaultAsync(c => c.ClientId == request.ClientId);

var rpName = client?.Organization?.Name ?? "EcAuth"; // 動的取得 + フォールバック

Rp = new PublicKeyCredentialRpEntity(challenge.RpId!, rpName),

効果:

  • WebAuthn登録ダイアログで各組織名が表示される
  • ユーザーがどの組織に登録しているか明確に認識できる
  • Organization.Name が null の場合も "EcAuth" にフォールバック

defense-in-depth: 期限チェックの多層防御

設計判断: Copilot が「冗長な期限チェック」と指摘しましたが、セキュリティの多層防御(defense-in-depth)として意図的に残しました。

コード:

// VerifyRegistrationAsync(140-143行目)
// VerifyAuthenticationAsync(342-345行目)

// 期限チェック(defense-in-depth)
// 注: GetChallengeBySessionIdAsync で既に期限切れチェック済みだが、
// 多層防御として明示的に検証。将来の実装変更に対する安全性を確保。
if (challenge.ExpiresAt < DateTimeOffset.UtcNow)
{
    return new VerificationResult { Success = false, ErrorMessage = "Challenge has expired." };
}

理由:

  1. 将来の実装変更への対応: GetChallengeBySessionIdAsync の実装が変更されても期限チェックが保証される
  2. 明示的な検証: コードレビュアーや保守担当者が期限チェックの存在を明確に認識できる
  3. セキュリティ最優先: WebAuthn認証ではチャレンジの期限切れ検証が重要

テストによる検証

パフォーマンステスト:

[Fact]
public async Task VerifyAuthenticationAsync_WithManyCredentials_ShouldPerformEfficientQuery()
{
    // 100個のクレデンシャルを作成
    // 1秒以内に処理完了することを検証
    Assert.True(stopwatch.ElapsedMilliseconds < 1000);
}

マルチテナントテスト:

[Fact]
public async Task CreateRegistrationOptionsAsync_DifferentOrganizations_ShouldShowCorrectRpName()
{
    // 2つのOrganizationで異なるRP名が表示されることを検証
    Assert.Equal("テスト組織", capturedOptions1.Rp.Name);
    Assert.Equal("第二組織", capturedOptions2.Rp.Name);
}

参考資料