はじめに
本記事は、Kotlin Multiplatform Mobile(KMM)でiOS/Androidのアプリ基盤を共通化しつつ、FAPI 2.0(Financial-grade API)の考え方に沿って銀行APIを安全に扱うための実装ガイドです。モバイルは「公開クライアント」であるため、PKCE+PARやJAR/JARM、DPoPなど、パブリック環境前提の防御が重要になります。
ここでは「共有ドメインモデル」「トークン運用」「セキュアストレージ」「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変更時の影響範囲を最小化できます。
ドメイン境界(例:Account、Balance、Transfer)を不変(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・タイムアウト)を隠蔽し、BalanceとAccountIdだけがアプリ内を流れる設計にします。これでプラットフォーム差分を受けにくくなります。
認可ヘッダー(後述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。PKCEのcode_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の順に段階強化。モニタリングしながら機能フラグで安全に進化させる運用が、金融品質に一歩ずつ近づく王道です。