ユースケース — バックチャネルログアウト
そもそも「バックチャネルログアウト」とは?
ユーザは「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 に依存しており、現代のブラウザでは段階的に動かなくなりつつあります。バックチャネル方式が現実的な選択肢です。
このページで触れる仕様
- OpenID Connect Back-Channel Logout 1.0
- RFC 7519 — JWT(logout token の形式)
- RFC 8417 — Security Event Token (SET)(
eventsclaim の形式) - RFC 1918 — プライベート IPv4 範囲(後述の SSRF 防御で使用)
用語の補足
logout_token— OP が署名して各 RP に POST する短寿命の JWT です。終了したセッションの subject (sub) を運びます。アクセストークンとは別物で、RP は検証後に自身のローカルセッションを破棄するだけです。- SET(Security Event Token、RFC 8417) — セキュリティイベント配送向けの JWT 形式です。
eventsclaim にイベント種別キー(ここではhttp://schemas.openid.net/event/backchannel-logout)を入れることで、汎用 SET 受信側が適切なハンドラに振り分けられるよう設計されています。
アーキテクチャ
OP は RP 毎に logout_token に署名して RP の backchannel_logout_uri に POST します。トークンの中身は次のとおりです。
| claim | 意味 |
|---|---|
iss | OP の issuer |
aud | RP の 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 ごとに有効化します。
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 にも同じ名前で存在します。いずれの型付きクライアント定義からも有効化できます。
ライブラリ全体のオプション:
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 を許容できます。
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 配送は有効になりません。
// 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 の内側に置くときは明示的に許可します。
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 を跨いで動作し、一斉通知時にユーザのブラウザが開いている必要もありません。