認可コードフロー + FIDO-UAF
このドキュメントの目的
認可コードフロー(Authorization Code Flow)でFIDO-UAF認証を利用し、モバイルデバイスでの生体認証を実装することが目標です。
学べること
✅ 認可コードフロー + FIDO-UAFの基礎
- CIBAとの違い(SPAがフロントチャネル、デバイスがバックチャネル)
- login_hintによるユーザー事前解決と、それが必要な理由
- スコープ単位で認証強度を要求する step-up の組み方
- 認証ステータスAPIによるポーリング
✅ 実践的な知識
- login_hint付き認可リクエストの実行
- Push通知によるデバイス認証要求
- 認証ステータスのポーリングによる完了検知
- トークン取得までの一連の 流れ
所要時間
⏱️ 約20分
前提条件
- FIDO-UAF登録でデバイス登録完了
- テナントで認可コードフローが有効化されている
- FCM(Firebase Cloud Messaging)の設定完了
想定するケース
public クライアント(SPA・モバイルアプリ)で、ログイン後に追加の認証をさせたい場合を想定しています。
同じことは CIBA のほうがシームレスに実現できますが、CIBA はクライアント認証が必須(CIBA Core §7.1)のため public クライアントでは使えません。confidential クライアントなら CIBA を検討してください。
典型的な使いどころ: スコープの step-up
初回ログインの ためのフローではありません。 ログイン済みのユーザーに、より強い認証を要求するスコープを後から取得させるケースです。
① 通常ログイン(パスワード等) → scope: openid profile email
② ユーザーが送金画面へ
③ 追加の認可リクエスト(login_hint + scope に transfers)
→ FIDO-UAF 生体認証 → transfers を含むアクセストークン
スコープごとに必要な認証方式は認証ポリシーの level_of_authentication_scopes で設定します(後述)。
AuthenticationTransaction のユーザーは login_hint からのみ解決されます。既存のOPセッションは prompt=none の判定と view-data の session_enabled にしか使われず、認証トランザクションのユーザーには反映されません。
RPは①で受け取ったIDトークンの sub を保持しておき、③で login_hint=sub:{sub} として渡します。初回ログインではRPが sub を知らないためこれができず、**パスワード + FIDO-UAF(MFA)**のパターンを使います(後述)。
実装: OAuthFlowEntryService.java(resolveUserFromLoginHint)
フロー全体の流れ(概要)
サインイン画面(SPA)と認証デバイスが別チャネルで並行して進行します。まず画面とAPIの対応を俯瞰してください。
呼び出し元でパスが分かれる点が要注意です。
| 呼び出し元 | パス | 使うID |
|---|---|---|
| サインイン画面(SPA) | /{tenant-id}/v1/authorizations/{id}/… | 認可リクエストのID |
| 認証デバイス | /{tenant-id}/v1/authentications/{transaction-id}/… | 認証トランザクションのID |
デバイスは認証トランザクション取得API(⑤)でしか自分宛のリクエストを知ることができず、そのレスポンスに認可リクエストのIDは含まれません。
以下は同じフローのシーケンス図です。
ステップ詳細
認可リクエスト(SPA)
login_hintパラメータを付与して認可リクエストを送信します。login_hintで指定されたユーザーがAuthenticationTransactionに事前解決されます。
GET {tenant-id}/v1/authorizations?response_type=code&client_id=...&redirect_uri=...&scope=openid profile email&state=...&login_hint=sub:{userId}
login_hintの形式
| 形式 | 説明 | 例 |
|---|---|---|
sub:{userId} | ユーザーIDで指定 | sub:3ec055a8-8000-44a2-8677-e70ebff414e2 |
device:{deviceId} | デバイスIDで指定 | device:7736a252-60b4-45f5-b817-65ea9a540860 |
email:{email} | メールアドレスで指定 | email:user@example.com |
phone:{phone} | 電話番号で指定 | phone:+81-90-1234-5678 |
IdPプロバイダーの指定も可能: sub:{userId},idp:{providerId}
レスポンス
302リダイレクト。Locationヘッダにidパラメータ(authorization_id)が含まれます。
view-data取得(SPA)
認証ポリシーとlogin_hint情報を取得します。
GET {tenant-id}/v1/authorizations/{id}/view-data
レスポンス
{
"client_id": "...",
"client_name": "My App",
"scopes": ["openid", "profile", "email"],
"session_enabled": false,
"login_hint": "sub:3ec055a8-...",
"authentication_policy": {
"available_methods": ["authentication-device-notification", "authentication-device-number-matching", "fido-uaf"],
"step_definitions": [
{ "method": "authentication-device-notification", "order": 1 },
{ "method": "authentication-device-number-matching", "order": 2 },
{ "method": "fido-uaf", "order": 3 }
],
"success_conditions": { ... }
}
}
SPAはlogin_hintの有無とauthentication_policyを確認し、デバイス認証フローを開始するかパスワード認証UIを表示するか判断します。
デバイスへのPush通知送信(SPA)(オプション)
Push通知の送信はオプションです。デバイスがPush通知を受け取れない環境(通知をオフにしている等)でも、デバイスが自発的に認証トランザクションをポーリングすればフローは 成立します。Push は number-matching コードを含みません。
POST {tenant-id}/v1/authorizations/{id}/authentication-device-notification
Content-Type: application/json
{}
| ステータス | 説明 |
|---|---|
| 200 | Push通知送信成功 |
| 400 | ユーザー未解決、デバイス未登録、通知チャネル未設定、Push配信失敗など |
Push(FCM)は CIBA と共有の
authentication-device-notificationinteractor が担い、FCM 設定は1箇所に集約されます。number-matching コードの生成はこれとは分離されています(次節)。
number-matching による push fatigue 対策(Issue #1505)
認可コードフローの FIDO step-up は、攻撃者が正規ユーザーの email 等を把握していれば攻撃者起点でフローを開始でき、繰り返し push を送って**誤承認(push fatigue / MFA fatigue)**を狙えます。これを防ぐため、number-matching(番号一致)を行います。
方式(重要): number-matching コードはサーバーが生成し、コード発行エンドポイントのレスポンス(=サインイン画面/SPA)にのみ返します。push payload にもデバイス向けトランザクションにも含めません。ユーザーは画面に表示された値を**デバイスアプリに手入力(転記)**します。これにより「承認する人はサインイン画面を見ている本人」であることが保証され、push に載せて自動 echo させる方式より push fatigue 耐性が高くなります。
設計上のポイント: コード発行(必須)と push 送信(任意)は別エンドポイントに分離されています。push は FCM 設定を共有する authentication-device-notification が担当し、コード発行はそれに依存しません(push 失敗やポーリング運用でも number-matching は成立)。
SPA idp-server device (bank-app)
├─ POST .../authentication-device-number-matching-challenge
│ └─ コード "4821" を生成・サーバー保存(デバイスには出さない)
│◀─ { number_matching_code: "4821" }
├─ 画面に "4821" を大きく表示
├─ (任意)POST .../authentication-device-notification → push 送信(コードは載せない)
│ ├─ 受信 or ポーリングで「画面の番号を入力」
│ ├─ ユーザーが "4821" を転記して送信
│ ├─ POST .../authentication-device-number-matching
│ │ body: { "number_matching_code": "4821" }
│ ├─ 保存値と一致検証
│ ├─ POST .../fido-uaf-authentication(生体認証)
CIBA では binding_message がリクエストパラメータから供給されデバイスに表示されます(transaction binding)が、認可コードフローではサーバーが number-matching コードを生成しデバイスには出しません(anti push-fatigue)。目的が異なるため別 interactor です。
コードの発行(SPA)
POST {tenant-id}/v1/authorizations/{id}/authentication-device-number-matching-challenge
Content-Type: application/json
{}
レスポンス:
{ "number_matching_code": "4821" }
コードは**数字(0-9)**です(MS Authenticator / Okta の number matching と同様)。長さは要件依存で、authentication-device-number-matching 設定の execution.details.length で調整できます(既定 4桁)。桁数はデバイス向け API のレスポンスには含まれないため、デバイスアプリの入力画面は桁数を固定にしないか、アプリ側の設定として持ってください。
コードの検証(device)
POST {tenant-id}/v1/authentications/{transaction-id}/authentication-device-number-matching
Content-Type: application/json
{ "number_matching_code": "4821" }
{transaction-id} は次節の認証トランザクション取得APIが返す id です。デバイスは認可リクエストの id を知りません(取得APIのレスポンスに含まれません)。デバイス側の呼び出しは FIDO-UAF 認証と同じ /v1/authentications/{transaction-id}/ 配下に揃います。
POST {tenant-id}/v1/authorizations/{id}/authentication-device-number-matching でも同じ検証が実行されます(両者は OAuthFlowEntryService#interactInternal に合流します)。ただしデバイスは認可リクエストの id を取得できないため、デバイス実装では使えません。
| ステータス | 説明 |
|---|---|
| 200 | 一致。次の FIDO-UAF 認証へ |
| 400 | コード不一致 or 未発行 |
400 の error_description で区別できます。
| 状況 | error_description |
|---|---|
| チャレンジ未実行 | number_matching_code has not been issued |
| コード不一致 | number_matching_code does not match |
失敗回数の上限
コード不一致は $.authentication-device-number-matching.failure_count に積算されます。認証ポリシーの failure_conditions / lock_conditions でこの値を参照すると、上限到達時に認証ステータスが failure / locked になります(同梱テンプレート config/templates/use-cases/mfa-fido-uaf/authentication-policy.json は 5回で failure と locked の両方を満たす設定)。
残り試行回数はデバイス向け API から取得できません。 デバイスアプリは「不一致」を伝えるだけにし、上限到達後はサインイン画面側が authentication-status で failure / locked を検知して案内する、という役割分担にしてください。上限を設けない場合、デバイス側パスは認証なしで到達できる(後述)ため、コードの総当たりが可能になります。failure_conditions に上限を必ず入れてください。
入力画面の要否判定(device)
デバイスアプリは、認証トランザクション取得APIのレスポンスに含まれる number_matching_required を見て、番号入力画面を出すかどうかを判定します。
GET {tenant-id}/v1/authentication-devices/{device-id}/authentications?flow=oauth
{
"list": [
{ "id": "...", "flow": "oauth", "number_matching_required": true }
]
}
id は認証トランザクションのIDです。以降のデバイス側の呼び出し(コード検証・FIDO-UAF認証)はこの値を使います。
コードが発行されると number_matching_required が true になります。コード検証に成功した後も true のままです。 検証成功でクリアすると、フローを開始した攻撃者が自分でコード検証を通すことで「もう入力は不要」という状態を被害者のデバイスへ伝えられてしまい、number-matching が塞いでいる push fatigue の経路が再び開くためです。実際にコードが検証済みかどうかは認証結果側で管理されます。