KMM×FAPI 2.0:銀行APIを安全に叩く共有ドメインモデルとトークン運用

はじめに

本記事は、Kotlin Multiplatform Mobile(KMM)でiOS/Androidのアプリ基盤を共通化しつつ、FAPI 2.0(Financial-grade API)の考え方に沿って銀行APIを安全に扱うための実装ガイドです。モバイルは「公開クライアント」であるため、PKCE+PARJAR/JARMDPoPなど、パブリック環境前提の防御が重要になります。

ここでは「共有ドメインモデル」「トークン運用」「セキュアストレージ」「KtorによるAPI呼び出し」を軸に、KMMならではのexpect/actual分離や、プラットフォームAPI(ASWebAuthenticationSession / Custom Tabs, Keychain / Keystore)との橋渡し方法をコードと意図で示します。

想定読者は、Kotlin/Android経験者でiOS連携やOAuth/FAPIの要点をキャッチアップしたい学生・若手エンジニア。難語には括弧で短く補足します。サーバ側検証(署名/JARM検証など)は原則サーバで行う前提です。

1. アーキテクチャ設計:KMM×FAPI 2.0の考え方

1-1. KMM構成と共有ドメインモデルの狙い

KMMでは shared モジュール(Kotlin/JVM+Native)に「ドメインモデル」「ユースケース」「HTTPクライアント(Ktor)」を置き、UIとプラットフォーム特有の実装(ブラウザ認証起動・セキュアストレージ・通知など)を iOS/Android 側に分離します。これにより、銀行API変更時の影響範囲を最小化できます。

ドメイン境界(例:AccountBalanceTransfer)を不変(immutable)かつ型安全に設計し、JSONとの変換は kotlinx.serialization で吸収します。特に金額や通貨は値オブジェクトに閉じ込め、文字列を自由に渡さない設計が事故を減らします。

認可フロー(PKCE/PAR/JAR・JARM/DPoP)は shared ではインターフェイスだけ定義し、実際のブラウザ起動やKeychain/Keystoreアクセスは expect/actual で切り替えるのがKMMの定石です。

1-2. FAPI 2.0のモバイル適用:Baseline → Advanced

FAPI 2.0は「Baseline(基本)」「Advanced(より厳格)」の層で整理されます。モバイル公開クライアントでは、Authorization Code + PKCE は必須、PAR(Pushed Authorization Requests)でリクエストをフロントチャネルから排除、JAR/JARMで改ざん耐性を強化します。

アクセストークンは短寿命DPoP(Proof-of-Possession)でHTTPリクエスト単位の署名を付与し、盗難トークンの使い回しを抑止します。クライアント証明書(mTLS)は端末配布・鍵保護が難しいため、モバイルではまずDPoPが現実的です。

なお、JAR/JARMの検証は基本サーバ側で行います。モバイルは「正しいエンドポイントへ」「正しいパラメータで」誘導し、検証責務は自バックエンドが担う構成が安全です。

1-3. 脅威モデルと非機能要件

想定脅威は、リプレイ攻撃(トークン/API要求の使い回し)、フィッシング(偽画面誘導)、端末紛失(ストレージ流出)、改ざん(root/jailbreak環境)です。対策として、DPoP/PKCE/PAR、短寿命・スコープ最小化、セキュアストレージ、生体ゲート、デバイス整合性チェックを組み合わせます。

非機能要件は「観測可能性(失敗率・所要時間)」「ロールバック可能性(機能フラグ)」「監査ログ(誰が・何を・いつ)」。PII(個人情報)はログに残さず、匿名化/トークン化で取り扱います。

UI/UX面では、認証失敗原因(キャンセル/生体NG/期限切れ)を分けて案内し、サポート問い合わせを減らすことも金融では重要です。

2. 共有ドメインモデル設計:不変・型安全・直列化

2-1. Money/Account などの値オブジェクト

金額・通貨はStringで持たず、値クラスに閉じ込めます。丸め誤差や通貨桁の違いを吸収し、計算と整形の責務を統一します。kotlinx.serialization でJSONと相互変換できるよう@Serializableで注釈します。

@Serializable
@JvmInline
value class Currency private constructor(val code: String) {
  companion object {
    fun of(code: String): Currency {
      require(code.matches(Regex("[A-Z]{3}"))) { "Invalid currency" }
      return Currency(code)
    }
  }
}

@Serializable
data class Money(
  val amount: String,          // 10進文字列(正確さ優先)
  val currency: Currency
) {
  fun format(): String = "$amount ${currency.code}"
}

@Serializable
data class AccountId(val value: String)

@Serializable
data class Balance(val accountId: AccountId, val amount: Money)

意図:浮動小数ではなく10進文字列として保持(精度優先)。画面整形はformat()に集約し、各所で勝手にフォーマットしないルールを作ります。通貨コードはISO 4217形式(3文字)を検証します。
アカウントIDはtype-safeに。単なるStringの取り回しをなくすと、API引数取り違えのバグが激減します。

2-2. APIレスポンス→モデル変換(Ktor+serialization)

KMMのHTTPクライアントは Ktor が定番です。JSONは ContentNegotiation プラグインでkotlinx.serializationと接続し、共通モデルへデコードします。タイムアウトや再試行は銀行APIのSLAに合わせてチューニングします。

val json = Json { ignoreUnknownKeys = true; isLenient = false }

val http = HttpClient {
  install(ContentNegotiation) { json(json) }
  install(HttpTimeout) { requestTimeoutMillis = 15_000 }
  defaultRequest {
    header("Accept", "application/json")
  }
}

意図:未知フィールドは無視(ignoreUnknownKeys)しつつ、厳格性は維持。サーバの進化に追随しやすくなります。タイムアウト短縮はUXに効く一方、認可・送金などの重処理は専用タイムアウトに切り分けるのが定石です。
例外は「ネットワーク」「HTTP 4xx/5xx」「JSON変換」の3層で分類し、リトライやユーザーメッセージを分けて扱います。

2-3. ユースケース層:共有ロジックの集約

UseCase層にドメイン操作を寄せ、UI層にロジックを持ち込まないのがマルチプラットフォームのコツです。口座残高取得や振込作成などを関数に閉じ、テスト可能にします。

class AccountsRepository(private val client: HttpClient, private val baseUrl: String) {
  suspend fun balance(accountId: AccountId, auth: AuthHeaders): Balance {
    val res: Balance = client.get("$baseUrl/accounts/${accountId.value}/balance") {
      headers { appendAll(auth.headers()) }
    }.body()
    return res
  }
}

意図:HTTP詳細(ヘッダー・ベースURL・タイムアウト)を隠蔽し、BalanceAccountIdだけがアプリ内を流れる設計にします。これでプラットフォーム差分を受けにくくなります。
認可ヘッダー(後述DPoPなど)はAuthHeadersの戦略で差し替え可能にしておきます。

3. トークン運用:PKCE/PAR/JAR・JARM/DPoP をKMMで扱う

3-1. 認可フロー:PKCE+PAR(公開クライアントの最小セット)

モバイルは公開クライアントなので、Authorization Code + PKCEは必須。さらにPAR(認可リクエストを認可サーバへ「プッシュ」)で、クエリに機密を載せないのがFAPI推奨です。ブラウザ起動は Android: Custom Tabs / iOS: ASWebAuthenticationSession を使います。

// shared(expect):ブラウザ認証を開始し、リダイレクトURLを待つ
expect class AuthStarter {
  suspend fun startAuthorization(pushedRequestUri: String): RedirectResult
}

// Android/iOS(actual):各プラットフォームAPIで実装(概念)

意図:sharedは「pushed_request_uri(PAR応答)」を受け取り、プラットフォームに委譲します。リダイレクトで得たcodeをsharedに戻し、トークンエンドポイントにPOST。PKCEcode_verifier/code_challengeは共有側で生成し、ストレージに短期保存します。
JAR(署名付きリクエスト)/JARM(署名付き応答)はサーバ側での検証が肝。アプリは「対応するエンドポイントに誘導」する責務に留めます。

3-2. DPoPでアクセストークンを所有証明に縛る

DPoPは各HTTPリクエストにJWT(ヘッダーDPoP:)を付与し、アクセストークンを「鍵の所有者」に縛る仕組みです。盗難トークンだけでは使えず、Proof JWTも必要になるため、リプレイ抑止に効きます。

interface AuthHeaders {
  fun headers(): Map<String, String>
}

class DPoPAuthHeaders(
  private val accessToken: String,
  private val dpopKey: Jwk,          // 端末内で生成・保持する鍵(公開鍵JWK)
  private val url: String,
  private val method: String,
  private val nonceProvider: suspend () -> String? // サーバ配布nonce
) : AuthHeaders {
  override fun headers(): Map<String, String> {
    val htm = method.uppercase()
    val htu = url
    val iat = (System.currentTimeMillis() / 1000)
    val nonce = runBlocking { nonceProvider() }
    val jwt = signWithJwk(
      header = mapOf("typ" to "dpop+jwt", "alg" to dpopKey.alg, "jwk" to dpopKey.publicPart() ),
      payload = buildMap {
        put("htu", htu); put("htm", htm); put("iat", iat); put("jti", uuid())
        nonce?.let { put("nonce", it) }
      },
      key = dpopKey
    )
    return mapOf("Authorization" to "DPoP $accessToken", "DPoP" to jwt)
  }
}

意図:HTTPメソッド(htm)、完全URL(htu)、発行時刻(iat)と一意ID(jti)を署名。サーバが望む場合はnonceも含めます。鍵(JWK)は端末で生成し、Keychain/Keystoreに保護保存。DPoPに対応しないRSにはBearerでフォールバック可能な設計にします。
KtorではリクエストごとにDPoPAuthHeadersを組み立て、request.pipelineで付与するのが実装がすっきりします。

3-3. トークン寿命・ローテーション・スコープ設計

金融ではアクセストークンは短寿命(例:10分)、リフレッシュはローテーション(使うたび入れ替え)を推奨します。リフレッシュ流出検知時は即失効し、ユーザーに再認可を促す運用を持ちます。

スコープは最小にし、画面ごとに必要なAPIだけ許可。高リスク操作(送金確定など)はstep-upで再認証(再度PKCEコード+DPoP)を要求します。

トークン保存はメモリ優先、永続化が必要な場合のみ暗号化ストレージ+生体ゲートで保護します。バックグラウンドでの自動更新は失敗復帰が複雑になるため、ユーザー操作中に更新する方が安定します。

4. セキュアストレージとプラットフォーム連携

4-1. expect/actual で Keychain/Keystore を隠蔽

トークン/DPoP鍵/PKCE検証用データなどは、KMMのexpect/actualでセキュアストレージを抽象化します。iOSはKeychain + 生体、AndroidはKeystore + BiometricPromptで保護します。

// shared
expect class SecureStore() {
  fun putSecure(key: String, value: ByteArray)
  fun getSecure(key: String): ByteArray?
  fun delete(key: String)
}

// iOS actual(概念):Keychain Servicesで実装
// Android actual(概念):EncryptedSharedPreferences + Keystore鍵保護

意図:保存は最小限(スコープ・期限・識別子)。PIIを置かない方針を徹底します。DPoP鍵は「エクスポート不可」の鍵にし、OSの安全領域(Secure Enclave/StrongBox)に生成するとより堅牢です。
読み出し時には必ず生体/端末認証プロンプトを挟み、置き忘れ・肩越し攻撃への抑止力にします。

4-2. リダイレクトURIとブラウザ起動の安全設計

認可リダイレクトは アプリが所持する https URI(app-claimed) または 専用スキームを使います。パッケージ検証universal linksで「自アプリにだけ届く」ことを担保します。

Androidは Custom Tabs を使い、iOSは ASWebAuthenticationSession でSafariの認証画面を開きます。WebView内認証はセキュリティ上の理由で避け、ブラウザコンテキストでCookie/同一性保証を活かします。

失敗時(キャンセル・期限切れ)のパスは画面遷移とメッセージを標準化し、ユーザー体験と監査の一貫性を確保します。

4-3. ロギングと監査(PII最小化)

監査イベントは「誰が(匿名ID)/何を(操作種別)/いつ(時刻)/どこから(端末・OS)」を記録し、PIIは含めないのが原則です。traceId を付与し、サーバ側SIEMへ集約します。

重要操作には「ユーザーに見せたメッセージID」もログし、問い合わせ時に同じ表現で説明できるようにします。クライアントログはバッファリングして、オンライン時にまとめて送信します。

保存期間・削除手順はドキュメント化し、運用で初期から守れるようにします。

5. テスト/CI/運用:壊れにくい導入手順

5-1. Ktor MockEngineでユースケースを自動テスト

API呼び出しはMockEngineで疑似レスポンスを返し、ユースケース層を高速に検証します。ネットワーク例外やJSON不整合のケースも合わせて用意し、分岐網羅を目指します。

val mock = MockEngine { req ->
  respond(
    content = """{"accountId":{"value":"A1"},"amount":{"amount":"1000","currency":{"code":"JPY"}}}""",
    headers = headersOf("Content-Type", "application/json")
  )
}
val client = HttpClient(mock) { install(ContentNegotiation) { json() } }
// → AccountsRepository(client, baseUrl).balance(...) のテストに利用

意図:実機依存の少ない「共有ロジック」をまず固めます。UIやブラウザ起動は後段の手動/自動回帰でカバーし、故障点を分離します。
トークン期限切れ→更新→再実行といったシーケンスもモックで再現し、リトライの安定性を確認します。

5-2. 機能フラグと段階リリース

認可方式(Bearer⇄DPoP)やJARMの有効化などは機能フラグで切替可能にします。サーバ側と足並みを揃え、段階的に強化していく運用が現実的です。

ストア配信はphased release(段階配信)にして、異常率(認証失敗・キャンセル率・平均所要時間)をモニタリング。悪化時は即ロールバックできるよう、旧方式を温存します。

メトリクスとログは「日単位の俯瞰」「端末/OS/バージョン別の切り出し」を両立し、原因箇所を特定しやすい形で可視化します。

5-3. サンプル:DPoP付きKtor呼び出しの統合

実際の呼び出しでは、URLとメソッドからDPoPAuthHeadersを都度組み立て、Ktorのget/postに渡します。nonceが必要な場合は先にHEAD/401応答から取得して次リクエストで使用します。

suspend fun getBalanceDPoP(accountId: AccountId): Balance {
  val url = "$baseUrl/accounts/${accountId.value}/balance"
  val auth = DPoPAuthHeaders(
    accessToken = tokenStore.access(),
    dpopKey = keyStore.currentJwk(),
    url = url,
    method = "GET",
    nonceProvider = { nonceCache.getOrNull() }
  )
  return http.get(url) { headers { appendAll(auth.headers()) } }.body()
}

意図:リクエスト単位」でDPoPを生成するのが正解です。ヘッダー生成を共通化しすぎてURL/メソッド不一致にならないよう、呼び出し地点で組み立てるのが安全です。
401(nonce要求)ハンドリングはパイプラインで再送実装にしておくと、アプリ全体での再利用性が高まります。

まとめ

KMMで銀行APIを扱うなら、共有ドメインモデルで型安全にFAPI 2.0の防御(PKCE/PAR/JAR・JARM/DPoP)を押さえ、セキュアストレージとプラットフォームAPIで実装の底を固めるのが近道です。HTTPはKtorで統一し、認可や鍵はexpect/actualで橋渡し、検証の重責はサーバに寄せます。

まずはBearerで動かし、PAR→DPoP→JARMの順に段階強化。モニタリングしながら機能フラグで安全に進化させる運用が、金融品質に一歩ずつ近づく王道です。

参考URL

採用情報 長谷川 横バージョン
SHARE
PHP Code Snippets Powered By : XYZScripts.com
お問い合わせ