メインコンテンツまでスキップ

認可コードフロー + 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 で設定します(後述)。

なぜログイン済みでも login_hint が必要なのか

AuthenticationTransaction のユーザーは login_hint からのみ解決されます。既存のOPセッションは prompt=none の判定と view-data の session_enabled にしか使われず、認証トランザクションのユーザーには反映されません。

RPは①で受け取ったIDトークンの sub を保持しておき、③で login_hint=sub:{sub} として渡します。初回ログインではRPが sub を知らないためこれができず、**パスワード + FIDO-UAF(MFA)**のパターンを使います(後述)。

実装: OAuthFlowEntryService.javaresolveUserFromLoginHint


フロー全体の流れ(概要)

サインイン画面(SPA)と認証デバイスが別チャネルで並行して進行します。まず画面とAPIの対応を俯瞰してください。

認可コードフロー + FIDO-UAF の画面と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

{}
ステータス説明
200Push通知送信成功
400ユーザー未解決、デバイス未登録、通知チャネル未設定、Push配信失敗など

Push(FCM)は CIBA と共有の authentication-device-notification interactor が担い、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}/ 配下に揃います。

SPA 側のパスからも到達できます

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.json5回failurelocked の両方を満たす設定)。

残り試行回数はデバイス向け API から取得できません。 デバイスアプリは「不一致」を伝えるだけにし、上限到達後はサインイン画面側が authentication-statusfailure / 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_requiredtrue になります。コード検証に成功した後も true のままです。 検証成功でクリアすると、フローを開始した攻撃者が自分でコード検証を通すことで「もう入力は不要」という状態を被害者のデバイスへ伝えられてしまい、number-matching が塞いでいる push fatigue の経路が再び開くためです。実際にコードが検証済みかどうかは認証結果側で管理されます。


Push通知なしのパターン

Push通知を使用しない場合、デバイスアプリが定期的に認証トランザクションをポーリングして認証リクエストの存在を検知します。

デバイス: GET {tenant-id}/v1/authentication-devices/{device-id}/authentications?flow=oauth
→ 認証トランザクションが見つかれば FIDO-UAF 認証を開始

この場合、SPA側はauthentication-device-notificationのAPIを呼び出さず、直接authentication-statusのポーリングに進みます。


認証ステータスの確認(SPA)

SPAは認証デバイスでの認証完了をポーリングで検知します。

GET {tenant-id}/v1/authorizations/{id}/authentication-status

レスポンス

{
"status": "in_progress",
"interaction_results": {
"authentication-device-notification": {
"operation_type": "CHALLENGE",
"method": "authentication-device-notification",
"call_count": 1,
"success_count": 1,
"failure_count": 0
}
},
"authentication_methods": []
}

ステータス値

status意味
in_progress認証フロー進行中
success認証成功(authorizeに進める)
failure認証失敗
lockedアカウントロック

ポーリングの推奨間隔

3〜5秒間隔でポーリングすることを推奨します。


FIDO-UAF認証(認証デバイス)

Push通知を受信した認証デバイスは、CIBAフローと同じ/authentications/エンドポイントでFIDO-UAF認証を実行します。

認証トランザクションの取得

GET {tenant-id}/v1/authentication-devices/{device-id}/authentications?flow=oauth

認可コードフローの場合、flowパラメータにoauthを指定して検索します(取り得る値: ciba / oauth / fido-uaf-registration / fido-uaf-deregistration)。CIBA と認可コードフローの両方を扱うデバイスアプリは、flow を省略して取得し、レスポンスの flow で分岐することもできます。

レスポンスのフィールドは CIBA + FIDO-UAF と共通です。認可コードフローで特に見るもの:

フィールド用途
id認証トランザクション ID。以降のデバイス側呼び出しに使う
number_matching_required番号入力画面の要否(前述
expires_atトランザクションの有効期限。期限内に FIDO-UAF 認証まで完了する必要がある
contextscopes / acr_values 等)デバイス認証(identity_policy_config.authentication_device_rule.authentication_type)が none の場合は返りません。 「どのスコープの承認か」をデバイス画面に表示したい場合はデバイス認証を有効にしてください

FIDO-UAFチャレンジ

POST {tenant-id}/v1/authentications/{id}/fido-uaf-authentication-challenge
Content-Type: application/json

{
...FIDOサーバーのAPI仕様に沿ったパラメータを指定する
}

FIDO-UAF認証

POST {tenant-id}/v1/authentications/{id}/fido-uaf-authentication
Content-Type: application/json

{
...FIDOサーバーのAPI仕様に沿ったパラメータを指定する
}

認証成功後、AuthenticationTransactionが更新され、SPAのポーリングでstatus: "success"が返ります。

取り消し・拒否(認証デバイス)

ユーザーがデバイス側で認証を拒否した場合は、次のいずれかを呼び出します。いずれもボディなし({})の POST です。

POST {tenant-id}/v1/authentications/{transaction-id}/authentication-device-deny
POST {tenant-id}/v1/authentications/{transaction-id}/authentication-cancel
インタラクション意図認証ステータスへの影響
authentication-device-denyユーザーの明示的な拒否(「これは自分の操作ではない」)failure
authentication-cancel操作の中断(生体認証のキャンセル、画面を閉じた等)failure

どちらも DENY 種別のインタラクションとして記録されるため、認証ポリシーに failure_conditions が定義されていれば、成功が1回記録された時点で authentication-statusfailure になりますfailure_conditions の個別条件に書かれていなくても同じです)。failure_conditions を持たないポリシーでは DENY 種別は評価されず、in_progress のまま有効期限切れを待つことになるので、後述のポリシー例のように必ず定義してください。

いずれも failure になる点は同じなので、使い分けは主に監査目的です(セキュリティイベントは authentication_device_deny_success / authentication_cancel_success と別になります)。デバイスアプリには「拒否」と「中断」の両方の導線を用意し、それぞれを対応するインタラクションに割り当てることを推奨します。

デバイス側APIの認証について

POST {tenant-id}/v1/authentications/{transaction-id}/{interaction-type} は、認証トランザクション ID のみで到達できます(アクセストークンやデバイス認証は要求されません)。これは CIBA の FIDO-UAF 認証と同じです。認可コードフローで追加された number-matching 検証もこの経路に乗るため、セキュリティモデルは次のように組み合わせて成立しています。

  • トランザクション ID は推測困難な UUID で、デバイス向けの取得 API か Push 通知経由でしか得られない
  • number-matching コードはデバイス向けの API・Push には一切載らない(サインイン画面を見ている本人しか知らない)
  • コード不一致の回数は failure_conditions / lock_conditions で上限を設ける(前述
  • FIDO-UAF 認証そのものは登録済み認証器の署名を要求する

トランザクション取得 API(GET .../authentication-devices/{device-id}/authentications)側は、テナント設定によりデバイス認証を要求できます(context の返却制御)。

実行順序について

認証ポリシーの step_definitions.order実行順序を強制しませんrequires_user の判定にのみ使われます)。number-matching 検証を経ずに FIDO-UAF チャレンジを呼んでもサーバーは拒否せず、success_conditions が満たされないために in_progress のまま留まるだけです。デバイスアプリは number_matching_required を見て、コード検証 → FIDO-UAF の順に呼び出すよう実装してください。


認可(SPA)

認証ステータスがsuccessになったら、認可エンドポイントを呼び出します。

POST {tenant-id}/v1/authorizations/{id}/authorize

レスポンス

{
"redirect_uri": "https://app.example.com/callback?code=...&state=..."
}

トークンリクエスト(SPA)

認可コードをトークンに交換します。

POST {tenant-id}/v1/tokens
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=...&redirect_uri=...&client_id=...&client_secret=...

レスポンス

{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"id_token": "..."
}

IDトークンのamrクレームにfido-uafが含まれることを確認できます。


login_hintなしの場合(パスワード + FIDO-UAF MFA)

login_hintを指定しない場合でも、パスワード認証でユーザーを特定した後にFIDO-UAF認証を2nd factorとして実行できます。

認可リクエスト(login_hintなし)
→ パスワード認証(1st factor、ユーザー特定)
→ デバイス通知(2nd factor、Push送信)
→ FIDO-UAF認証
→ authentication-status: success
→ authorize → トークン

この場合の認証ポリシー設定例:

{
"step_definitions": [
{ "method": "password", "order": 1, "requires_user": false },
{ "method": "authentication-device-notification", "order": 2, "requires_user": true },
{ "method": "fido-uaf", "order": 3, "requires_user": true }
],
"success_conditions": {
"any_of": [
[
{ "path": "$.password-authentication.success_count", "type": "integer", "operation": "gte", "value": 1 },
{ "path": "$.fido-uaf-authentication.success_count", "type": "integer", "operation": "gte", "value": 1 }
]
]
}
}

認証ポリシー設定例

login_hint + FIDO-UAF(デバイス認証のみ)

同梱テンプレート config/templates/use-cases/mfa-fido-uaf/authentication-policy.json と同じ構成です。number-matching を使うには、available_methodsチャレンジと検証の両方authentication-device-number-matching-challenge / authentication-device-number-matching)を含め、success_conditions で number-matching と FIDO-UAF の両方を要求します(コード照合だけで認証が完了しないようにするため)。

available_methods はサインイン画面に渡される UIヒントで、インタラクションの実行可否を制限するものではありません(認証ポリシー設定)。ここに書かれていない方式を呼び出してもサーバーは拒否せず、認証の成否は success_conditions / failure_conditions / lock_conditions だけで決まります(実行順序についてと同じ考え方)。

{
"flow": "oauth",
"enabled": true,
"policies": [
{
"description": "device_fido_uaf_authentication",
"priority": 10,
"conditions": {
"acr_values": ["urn:idp:acr:device"]
},
"available_methods": [
"authentication-device-notification",
"authentication-device-number-matching-challenge",
"authentication-device-number-matching",
"authentication-device-deny",
"fido-uaf"
],
"step_definitions": [
{ "method": "authentication-device-number-matching-challenge", "order": 1, "requires_user": false },
{ "method": "authentication-device-number-matching", "order": 2, "requires_user": false },
{ "method": "fido-uaf", "order": 3, "requires_user": true }
],
"success_conditions": {
"any_of": [
[
{ "path": "$.authentication-device-number-matching.success_count", "type": "integer", "operation": "gte", "value": 1 },
{ "path": "$.fido-uaf-authentication.success_count", "type": "integer", "operation": "gte", "value": 1 }
]
]
},
"failure_conditions": {
"any_of": [
[{ "path": "$.authentication-device-deny.success_count", "type": "integer", "operation": "gte", "value": 1 }],
[{ "path": "$.authentication-device-number-matching.failure_count", "type": "integer", "operation": "gte", "value": 5 }]
]
},
"lock_conditions": {
"any_of": [
[{ "path": "$.authentication-device-number-matching.failure_count", "type": "integer", "operation": "gte", "value": 5 }]
]
}
},
{
"description": "password_fallback",
"priority": 1,
"conditions": {},
"available_methods": ["password"],
"success_conditions": {
"any_of": [
[{ "path": "$.password-authentication.success_count", "type": "integer", "operation": "gte", "value": 1 }]
]
}
}
]
}

スコープ単位で認証強度を要求する

level_of_authentication_scopes に「スコープ → それを許可する認証方式」を書きます(config/templates/use-cases/mfa-fido-uaf/authentication-policy.json より)。

"level_of_authentication_scopes": {
"transfers": ["fido-uaf"],
"account": ["password", "email"]
}

値はいずれか1つを満たせばよい方式のリストです。満たしていないスコープは付与されずに落とされます(エラーにはなりません)。パスワード認証だけでは transfers が付かず、FIDO-UAF を実行すると付く、という挙動になります。

実装: LoaDeniedScopeResolver.java


CIBAフローとの比較

項目CIBAフロー認可コードフロー
フロントチャネルサーバーサイドクライアントSPA(ブラウザ)
ユーザー特定login_hint(必須)login_hint(FIDO-UAFのみで認証する場合は必須)
完了検知トークンエンドポイントのポーリングauthentication-status APIのポーリング
トークン取得トークンエンドポイント直接認可コード → トークンエンドポイント

制限事項

login_hintなし + FIDO-UAFのみの認証はサポートしない

認可コードフローにおいて、login_hintを指定せずにFIDO-UAFデバイス認証だけで認証を完了するパターンはサポートしていません

仕組みによる制約

デバイスへのPush通知送信(authentication-device-notification)は、AuthenticationTransactionにユーザーが解決されていることを前提としています。login_hintなしの場合、認可リクエスト時点ではユーザーが未解決のため、通知APIを呼び出しても "User does not exist" エラーとなり、フロー自体が成立しません。

認可リクエスト(login_hintなし)
→ AuthenticationTransaction にユーザー未設定
→ デバイス通知API呼び出し
→ "User does not exist" エラー ← ここで止まる

セキュリティ上の意図: Push通知疲労攻撃の防止

この仕様は、Push通知疲労攻撃(Push Notification Fatigue Attack) を防ぐ意図も含んでいます。

仮にログイン画面でメールアドレス等を入力するだけでPush通知を送信できてしまうと、攻撃者が対象ユーザーのデバイスに大量のPush通知を送りつけ、ユーザーが疲労して誤って認証を承認してしまうリスクがあります。

ユーザーの解決をlogin_hint(信頼されたクライアントからの指定)またはパスワード認証等の事前認証に限定することで、未認証の第三者がPush通知を発生させることを防いでいます。

サポートされるパターン

FIDO-UAFデバイス認証を利用する場合は、以下のいずれかのパターンを使用してください。

パターン説明Push通知の保護
login_hint + FIDO-UAFのみ信頼されたクライアントがlogin_hintでユーザーを事前指定クライアント認証により保護
パスワード + FIDO-UAF(MFA)パスワードで1st factor認証後、FIDO-UAFを2nd factorとして実行パスワード認証が障壁
CIBA + FIDO-UAFサーバーサイドクライアントがCIBAフローで実行クライアント認証(client_secret等)により保護

いずれのパターンも、Push通知の送信前にクライアント認証またはユーザー認証(パスワード等)が必須となるため、未認証の第三者による通知疲労攻撃を防止できます。


まとめ

認可コードフローでのFIDO-UAF認証は、CIBAフローと同じ認証インフラを再利用しながら、SPAベースのユーザー体験を提供します。

  • login_hintによるユーザー事前解決でデバイス通知が可能
  • authentication-status APIによるポーリングで非同期認証の完了を検知
  • 既存のFIDO-UAF認証エンドポイントをそのまま利用(追加のデバイス側実装不要)

関連ドキュメント