Skip to content

ユースケース — バックチャネルログアウト ​

そもそも「バックチャネルログアウト」とは? ​

ユーザは「Acme でサインイン」ボタンを介して、同じ OP に紐づく複数の RP にサインインしているのが普通です。あるアプリ(RP A)で ログアウト をクリックしても、他の RP B / RP C はそれぞれローカル cookie を保持したままなので、「アプリ A ではログアウトしたのにアプリ B ではログイン状態のまま」というズレが残ります。

バックチャネルログアウト は、このズレを OP 側から閉じる一斉通知機構です。各 RP は OP に対してサーバサイドのコールバック URL を事前登録しておきます。セッション終了時、OP は subject の grant から導出した対象 RP それぞれに、署名済み logout_token を直接 POST します(ブラウザを経由しない=バックチャネル)。RP はトークンを検証してローカル cookie を破棄します。

対になる仕組みとして フロントチャネルログアウト もありますが、こちらは <iframe> とサードパーティ cookie に依存しており、現代のブラウザでは段階的に動かなくなりつつあります。バックチャネル方式が現実的な選択肢です。

このページで触れる仕様
用語の補足
  • logout_token — OP が署名して各 RP に POST する短寿命の JWT です。終了したセッションの subject (sub) を運びます。アクセストークンとは別物で、RP は検証後に自身のローカルセッションを破棄するだけです。
  • SET(Security Event Token、RFC 8417) — セキュリティイベント配送向けの JWT 形式です。events claim にイベント種別キー(ここでは http://schemas.openid.net/event/backchannel-logout)を入れることで、汎用 SET 受信側が適切なハンドラに振り分けられるよう設計されています。

ソース: examples/42-back-channel-logout

アーキテクチャ ​

1 回のログアウトを全 RP へ届ける
RP A がユーザを end-session エンドポイントへ送ります。OP はセッションを終了し、そのセッションの grant が指すすべての RP へ署名済み logout token を POST し、最後にユーザを RP A へ戻します。RP AユーザがログアウトOPgo-oidc-providerRP B · RP Cほかの RP すべてGET /end_sessionid_token_hint=…セッションを終了grant を走査POST backchannel_logout_urilogout_token · RP ごとに 1 回200 — あるいは無応答302 → post_logout_redirect_uri
破線の応答を薄く描いているのには理由があります。OP はこの応答を待ちませんし、応答しない RP があっても他はログアウトされます。バックチャネルログアウトは分散トランザクションではなく best-effort の通知です。

OP は RP 毎に logout_token に署名して RP の backchannel_logout_uri に POST します。トークンの中身は次のとおりです。

claim意味
issOP の issuer
audRP の client_id
iat、jti発行時刻 + replay nonce
sub終了したセッションの subject。sid は発行されません(後述)
events{"http://schemas.openid.net/event/backchannel-logout": {}}

JOSE ヘッダには typ: logout+jwt が入るので、RP は他の種別のトークンを拒否できます。RP は署名と aud を検証し、ローカルセッションを破棄したうえで 200 を返します。逆方向では、/end_session が id_token_hint として受け付けるのは ID トークンだけで、logout token やアクセストークンは拒否されます。

実装 ​

クライアント別の BackchannelLogoutURI で RP ごとに有効化します。

go
op.WithStaticClients(op.PublicClient{
  ID:                   "rp-a",
  RedirectURIs:         []string{"https://rp-a.example.com/callback"},
  Scopes:               []string{"openid", "profile"},
  BackchannelLogoutURI: "https://rp-a.example.com/oidc/backchannel-logout",
})

BackchannelLogoutURI フィールドは op.ConfidentialClient と op.PrivateKeyJWTClient にも同じ名前で存在します。いずれの型付きクライアント定義からも有効化できます。

ライブラリ全体のオプション:

go
op.New(
  /* ... */
  op.WithBackchannelLogout(op.BackchannelLogout{
    Transport:    myTransport,         // カスタム http.RoundTripper(mTLS、プロキシ)
    Timeout:      5 * time.Second,     // RP ごとのリクエスト
    FanOutBudget: 30 * time.Second,    // detached fan-out 全体。RP ごとの timeout とは別
  }),
)

ゼロ値のフィールドは既定値になります。渡した Transport はすべての配送に使われ、タイムアウトは OP 側の設定を必ず適用し、リダイレクトは拒否し、宛先 URL は毎回 SSRF 拒否リストで検査します。Transport が nil か *http.Transport なら接続時の検査も加わります(独自の dial 関数は OP のものに置き換わります)。プロキシ経由の場合、接続時の検査が見るのはプロキシのアドレスで、宛先ホストは引き渡しの前に 1 度だけ検査されます。それ以外の http.RoundTripper(otelhttp のラッパーなど)は URL だけで判定するため、DNS rebinding は防げません。

loopback 上にスタブ RP を立てるローカルのデモや CI のフィクスチャでは、loopback の backchannel_logout_uri に限って平文の HTTP を許容できます。

go
op.WithAllowInsecureOutboundForDev(op.OutboundBackchannel)

このオプションは、登録時の URL 検証と実行時の SSRF 判定の両方を 127.0.0.1、[::1]、localhost に限って緩和します。issuer が開発用ホストでない場合、op.New はこのオプションを拒否します。開発用ホストとは、loopback の IP リテラル、localhost、RFC 6761 の特殊用途 TLD である .localhost、.test、.invalid、.example 配下の名前のことです。本番用の近道ではありません。公開ホストと loopback 以外のプライベートネットワークは、下記の本番向け方針を別途明示する必要があります。

SSRF 防御 ​

既定でプライベートネットワーク宛先を拒否

配送処理は、host が loopback / link-local / RFC 1918 / IPv6 ULA に解決される backchannel_logout_uri への POST を 拒否 します。これがないと、任意 URL を登録できる RP が OP の内部ネットワークへの SSRF オラクルになります。

接続先アドレスの拒否リストに加えて、登録時にも URL の形を検査します。backchannel_logout_uri は https 必須、fragment 不可、userinfo 不可、host 必須です。https://attacker:[email protected]/... も https://rp.example.com/cb#anchor も invalid_client_metadata で弾かれます。backchannel_logout_session_required=true は非対応で拒否されます。これを設定しても sid 配送は有効になりません。

jsonc
// NG: userinfo と fragment は登録時に拒否。sid も非対応
{
  "backchannel_logout_uri": "https://attacker:[email protected]/logout#sid",
  "backchannel_logout_session_required": false
}

// NG: sid 配送を要求する設定は非対応(フィールドを指定しない)
{}

// OK: public HTTPS の配送先を明示する
{
  "backchannel_logout_uri": "https://rp.example.com/backchannel-logout",
  "backchannel_logout_session_required": false
}

RP をプライベート DNS の内側に置くときは明示的に許可します。

go
op.WithAllowPrivateNetwork(op.OutboundBackchannel)

この緩和は意図的に選び取る必要があります — オプションを明示的に存在させることで、セキュリティ上のトレードオフが設定箇所に可視化されます。

通知先はどう解決されるか(そしてなぜ上限があるか) ​

一斉通知の通知先は session の行ではなく grant から解決されます。coordinator は終了する session の subject を受け取り、その subject が同意している client の集合を grant ストアに問い合わせます。使うのは store.GrantClientLister.ListClientIDsBySubject で、これは ListBySubject とは別の keyset ページング付きのビューです。1 つの subject が同じ client に対して過去分を含む多数の grant 行を持ちうるため、専用のビューを置いています。一斉通知の各段階には意図的に上限が設けてあります。

上限既定値何を抑えるか
重複排除後の通知先DefaultMaxTargets(256)1 回のログアウトで通知する client 数。後段で絞るのではなく grant クエリ自体に上限を効かせる
同時配送数DefaultMaxConcurrentDeliveries(8)同時に送信する POST の本数
detached fan-out 全体30 秒(DefaultFanOutBudget)1 回のログアウトイベント全体の実時間の予算

通知先ページに NextCursor が付いて返った場合、上限を超える client が該当したということです。coordinator は黙って切り捨てず、そのカーソルを載せた上限超過の監査イベントを出します。到達できない RP が 1 つあっても、一斉通知全体は失敗せず、その RP 個別の監査イベントとして現れます。

detached 処理を開始する前に /end_session はスナップショットを subject 単位でまとめるため、1 つの subject に複数のブラウザ session があっても fan-out は 1 回です。grant の問い合わせ結果も client ID 単位で重複排除されるため、対象 client ごとにその subject の Logout Token は最大 1 通だけ送られます。通知先の上限が数えるのも、この重複排除後の集合です。

detached の通知先解決中にクライアントレジストリへの transport 障害が起きた場合は audit.LogoutBackChannelResolveFailed として記録し、正常な「client が存在しない」結果はイベントなしでスキップします。/end_session 自身の id_token_hint / client_id の参照には別の audit.LogoutClientLookupFailed があり、通信路上は一律の拒否、正常な「client が存在しない」結果はイベントなしです。詳細は 監査イベントカタログ を参照してください。

BackchannelLogout.FanOutBudget はイベント全体の予算を変更します。各 RP のリクエストを制限する BackchannelLogout.Timeout とは独立しています。予算が切れると残りの通知先も失敗として記録され、detached 処理は収束します。プロセス終了時は Provider.Shutdown(ctx) が実行中の fan-out を待ちます。

backchannel_logout_session_supported は false

discovery はこれを false として広告します。これは grant ベースの解決から直接導かれる帰結です。OP 側の session 識別子が特定の RP のものであることを OP は証明できないため、sid を Logout Token に載せることはありません。RP は sub を手がかりにローカル session を破棄してください。型付きのクライアント定義には、このためのフィールドがありません。動的登録でメタデータに backchannel_logout_session_required: true を指定したクライアントは拒否されるので、false のままにするか省略してください。

通知先が 0 件になったとき ​

subject に grant 由来の対象 RP がない場合(すべての grant が失効済み、または古いレコードが存在しない client を指している場合)、一斉通知には通知先がありません。本ライブラリはこれを監査イベントとして表面化させます。

イベント意味
audit.BCLNoSessionsForSubject/end_session がセッションを指定したが、grant ベースの audience 解決で対象 RP が 0 件だった。

このイベントは /end_session がセッションを指定し、grant ベースの audience 解決で対象 RP が 0 件になった場合だけ発火します。揮発な SessionStore がスナップショット前に行を追い出すと、ハンドラは detached notice を作らないため、fan-out も audit.BCLNoSessionsForSubject も開始しません。イベント extras の op.SessionDurabilityPosture(SessionDurabilityVolatile または SessionDurabilityDurable)はトリガ / スナップショットの喪失と対象 0 件を解釈する文脈であり、対象 0 件イベント自体が eviction を証明するわけではありません。op.WithSessionDurabilityPosture がなければ、posture はセッションストアの報告に従います(SQL と DynamoDB のアダプタは durable を報告します)。Redis で AOF 永続化を使う場合などは、明示的に宣言して上書きします。

フロントチャネルログアウト(別の機構) ​

OIDC Front-Channel Logout 1.0(ブラウザ側 iframe 一斉通知)は別仕様で、ライブラリは意図的に実装していません。Back-channel が配備可能な選択です — 第三者 cookie に依存せず、origin を跨いで動作し、一斉通知時にユーザのブラウザが開いている必要もありません。