外部IdPアカウント連携
概要
認証済みのユーザーが、自分のアカウントに外部IdPのアカウントを追加で紐付けるための idp-server 独自のプロトコル拡張です。紐付けの過程で外部IdPが発行したアクセストークンとリフレッシュトークンを暗号化して保管し、後から外 部APIの呼び出しに使えるようにします。
既存の Federation(外部IdPでログインする機能)とは目的が異なります。
| Federation | アカウント連携 | |
|---|---|---|
| 目的 | 外部IdPでログインする | 外部IdPのトークンを保管する |
| 起点 | 未認証の認可リクエスト | 認証済みユーザーの操作 |
| 紐付け数 | 1ユーザー1件 | provider ごとに 1 件(multiple_accounts: true で複数可。account_alias で識別) |
| トークン | 平文 JSONB | アプリ層で AES-GCM 暗号化 |
両者は独立した系統です。連携を追加しても Federation の動作は変わりません。
シーケンス
RPアプリ idp-server 外部IdP
│ │ │
├─ POST /v1/me/linked-external-accounts/link/{provider} ───────▶│
│ Authorization: Bearer │ Bearer から user を確定 │
│ │ redirect_uri を allow-list 照合
│◀─ 201 { start_url, state } │ 連携セッションを保存 (pending)
│ │ │
├─ location.href = start_url ────▶ │
│ │ 操作者 == セッションの user か
│ │ を照合 (authorized) │
│◀─ 302 外部IdPの認可URL ──────────┤ │
├────────────────────────────────┼─────────────────────────────▶│
│ │ ユーザーがログイン・同意 │
│ │◀─ 302 /v1/linking/callback ───┤
│ │ │
│ ├─ token 交換 (code_verifier) ─▶│
│ │◀─ access_token / refresh_token│
│ ├─ userinfo ──────────────────▶│
│ │ 暗号化してセッションに預ける │
│ │ (parked) │
│◀─ 302 return_to?linking=done&state= │
│ │ │
├─ POST /v1/me/linked-external-accounts/complete ──────────────▶│
│ Authorization: Bearer { state } Bearer と セッションの user │
│ │ を照合して確定 (consumed) │
│◀─ 201 { alias, provider, ... } │ │
認可コードは idp-server の中で交換されて完結し、RPには渡りません。
エンドポイント体系
RPが呼ぶもの(Bearer 必須)
| エンドポイント | 用途 | 必要な scope |
|---|---|---|
POST /{tenant-id}/v1/me/linked-external-accounts/link/{provider} | 連携の開始。{ redirect_uri, scope } を受け取り { start_url, state, expires_in } を返す | linked_account:manage |
POST /{tenant-id}/v1/me/linked-external-accounts/complete | 連携の確定。{ state } を受け取る | linked_account:manage |
GET /{tenant-id}/v1/me/linked-external-accounts | 連携一覧 | linked_account:read または linked_account:manage |
DELETE /{tenant-id}/v1/me/linked-external-accounts/{alias} | 連携の解除。成功時は 204 | linked_account:manage |
scope が足りないときは 403 { "error": "insufficient_scope", "scope": "..." } を返します。
scope は「連携を見る」(read)と「連携を管理する」(manage)の 2 つに分けています。連携の追加と解除は分けません。管理する画面は両方を使い、外す連携を選ぶには一覧も要るので、manage は一覧も含みます。保管トークンの取得(linked_account:read_token)は別の許可で、read / manage のどちらにも含まれず、どちらも含みません。
連携の確定は、連携を始めたクライアントのトークンでしか受け付けません。同じユーザーでも、別のクライアントのトークンでは 403 になります。
{provider} には連携先の設定名(sso_provider)を指定します。
redirect_uri は連携完了後にブラウザを戻す先で、開始時にクライアント設定の allow-list と完全一致で照合されます。
クライアントが呼ぶもの(クライアント認証)
| エンドポイント | 用途 |
|---|---|
POST /{tenant-id}/v1/linked-external-accounts/{alias}/token | 保管している外部IdPのアクセストークンを取り出す |
保管トークンの取得は、下の節で説明します。
ブラウザ遷移(Bearer なし)
| エンドポイント | 認証 | 用途 |
|---|---|---|
GET /{tenant-id}/v1/linking/start?state= | ブラウザセッション | 操作者を検証し、外部IdPの認可URLへ 302 |
GET /{tenant-id}/v1/linking/callback/{provider}?code&state | state + ブラウザ束縛 | 認可コードを交換し、redirect_uri へ 302。パスの {provider} は使わず、state が指す連携セッションの連携先で処理する |
この2本が /me の外にあるのは、どちらもブラウザのトップレベル遷移で Bearer を運べないためです。/me は ProtectedResourceApiFilter によって Bearer 前提で保護されている名前空間なので、そこに未認証エンドポイントを混ぜると、実装者が「フィルタが通っているはず」と誤認する余地が生まれます。
一覧のレスポンスにトークンは含まれません。返るのは alias / provider / federated_username / scope / 各有効期限 / created_at / updated_at です。
解除は呼び出したユーザー自身の連携だけが対 象で、他人の alias を指定しても 404 になります。行を消す前に、保管しているトークンを外部IdPの失効エンドポイント(RFC 7009)で失効させます。行を消すだけでは、写し取られたトークンが外部IdP側で有効なまま残るためです。
失効はベストエフォートです。次の場合でも連携は解除されます。外部側の都合で解除できないと、ユーザーが連携を取り消せなくなるためです。
revocation_endpointが設定されていない- 外部IdPに届かない、または失効を拒否された
連携先の設定を無効化(enabled: false)していても、失効は行います。無効化で止めるのは新しい連携であって、発行済みトークンの後始末ではないためです。
解除は行ロックを取ってから失効させます。保管トークンの取得が同時にリフレッシュしていると、外部IdPがトークンをローテーションした直後に古いトークンだけを失効させてしまうためです。
ユーザーが削除されたときも、同じ手順で全ての連携を失効・削除します。あわせて、そのユーザーが始めた連携セッションも削除し、コールバックまで進んで確定されていないセッション(parked)が預かっているトークンは失効させます。本人がいなくなると、Bearer を要する complete は二度と通らないためです。linked_external_accounts は user_id に外部キーを張っていないため、ユーザー削除のライフサイクル処理(LinkedExternalAccountDeletionExecutor)が後始末を担います。
テナントを削除するときも、削除の前にテナントの全ての連携と parked の連携セッションを失効・削除します(TenantLinkedAccountCleanup)。行はテナントへの外部キーの ON DELETE CASCADE でも消えますが、それでは失効されないためです。失効は連携 1 件ごとに外部IdPを呼ぶので、削除にかかる時間は連携の数に比例します。
確定を分ける理由
外部IdPからのコールバックはブラウザのリダイレクトで届くため、Authorization ヘッダを持ちません。つまりコールバック自身には「これが誰の連携か」を確かめる手段がありません。
そこでコールバックでは連携を確定させず、交換したトークンを暗号化して連携セッションに預けるだけにします。確定するのは、Bearer を持つ complete だけです。
pending ──[start] 操作者を照合──▶ authorized ──[callback] コード交換──▶ parked ──[complete] Bearer 照合──▶ consumed
各遷移は条件付き UPDATE による単回消費です。コールバックではコードを交換する前に状態を取りにいき、競合に負けた側が認可コードに触れないようにしています。認可コードは単回のため、二重に使うと外部IdP側で認可ごと失効させられる場合があります。
同一性の検証は3箇所に要る
連携の乗っ取りには向きが3つあり、止まる場所がそれぞれ違います。3つの検査は守る相手が違うため、どれも他で代替できません。
| # | 攻撃者がすること | 止まる場所 |
|---|---|---|
| 1 | 被害者の state を自分の Bearer で complete する | complete(Bearer と照合) |
| 2 | 自分の start_url を被害者に踏ませる | /linking/start(操作者と照合) |
| 3 | 自分で /linking/start を通り、外部IdPの認可URLだけを被害者に渡す | コールバック(ブラウザ束縛と照合) |
3つとも成立した場合の結果は同じで、被害者の外部アカウントが攻撃者のレコードに紐付きます。攻撃者が欲しいのは被害者のトークンだからです。逆向き(攻撃者の外部アカウントが被害者に紐付く)を成立させるには被害者の state で /linking/start を通す必要があり、2 と同じ検査で落ちます。
違うのは結果ではなくどこで止まるかです。
1 は state を握っていても Bearer が一致しないため complete で落ちます。
2 は complete では落ちません。state を握っているのは攻撃者なので、攻撃者自身の Bearer で確定できてしまいます。/linking/start で操作者を照合することで、被害者が外部IdPに到達する前に止めます。
3 が厄介です。攻撃者は /linking/start を自分で通過できます(攻撃者こそが束縛されたユーザーなので、操作者照合は正常に通ります)。そこで返る 302 の Location には、外部IdPの認可URLが state も code_challenge も込みで完成した形で載っています。それだけを被害者に渡せば、被害者は /linking/start に一切触れずに外部IdPへ行き、コールバックだけが被害者のブラウザで発火します。操作者照合は被害者に対して一度も評価されません。
これを止めるのがブラウザ束縛です。/linking/start は乱数の秘密値を発行し、そのハッシュだけをセッションに保存して、値そのものは __Host-IDP_LINK_BINDING_{tenant-id} Cookie でブラウザに渡します。コールバックはこの Cookie を要求し、認可コードを交換する前に照合します。被害者のブラウザはこの Cookie を持たないため、被害者の認可コードは交換されません。同じ名前の Cookie が 2 つ以上届いたコールバックも、同じく止めます。
この Cookie には __Host- 接頭辞を付け、Secure・Path=/・Domain なしで出します(テナントの session_config には従いません)。接頭辞が無いと、同じ登録可能ドメインの別のサブドメインが同じ名前の Cookie を被害者のブラウザに書けてしまい、束縛が意味を持ちません。Path=/ になるので、テナントは名前で分けます。
SameSite はテナント設定に従いませんコールバックは外部IdPか らのクロスサイトのトップレベル GET です。SameSite=Lax はこの遷移をちょうど許可しますが、Strict だと Cookie が送られず連携が必ず失敗します。そのため束縛の Cookie は Lax 固定で、テナントの cookie_same_site には従いません。
/linking/start は idp-server のブラウザセッションから操作者を解決します。連携は外部サービスの API を使う許可をアカウントにつなぐ操作なので、セッションのログインが新しいことも求めます。
ブラウザセッションとして数えるのは、このブラウザがログインして受け取ったものだけです。セッションの Cookie(IDP_IDENTITY)はテナントのパスに置かれ接頭辞も無いので、別のサブドメインから攻撃者のセッションの Cookie を被害者のブラウザに置けます。そこで idp-server はログインのたびに乱数を __Host-IDP_BROWSER_{tenant-id} Cookie(Secure・HttpOnly・Path=/・Domain なし)で渡し、そのハッシュを OP セッションに持たせます。/linking/start はこの値が OP セッションのものと合うときだけセッションを使い、合わなければセッションが無いのと同じに扱います(下のとおり本人としてログインし直させます)。値はセッション ID ではないので、Path=/ で他のテナントへのリクエストに付いても、それだけでは使えません。
- セッションが無いか、ログインがクライアントの
linking_max_auth_age(既定 300 秒)より古い場合は、RP へ戻さずに idp-server の認証画面へ送ります。認可リクエストは連携を始めたクライアントの名前で、prompt=loginとlogin_hint=sub:<連携を始めたユーザー>を付けるので、本人だけがログインできます。 - クライアントに
linking_acr_valuesがあるときは、ログインの acr がそのどれかに当たることも求めます。当たらなければ、ログインが新しくてもログインし直させ、認可リクエストにacr_valuesとして載せます。認証ポリシーはclient_id・acr_values・scopeで選ばれるので、普段のログインより強いポリシー(例:FIDO だけ)を連携に当てられます。無いときは、普段のログインと同じポリシーが当たります。 - ログインが終わると、認可コードは発行せずに
/linking/startへ戻り、もう一度セッションとログインの新しさを確かめてから外部IdPへ進みます。認証画面の同意は省きます。 - ログインを取り消した場合や、戻ってもセッションが無い場合(セッションを書けない環境)、戻っても acr が
linking_acr_valuesに当たらない場合は、戻り先へlinking=failed&error=login_requiredを返します(ブラウザ遷移の失敗)。 - セッションが別のユーザーのものなら、ログインへは送らずにエラー画面で止めます。
ネイティブアプリの ephemeral なブラウザのように、ブラウザにログインが残らない場合でも、この流れで連携できます。