Skip to content

セッションとログアウト ​

セッション とは、「このブラウザは時刻 T、保証レベル A で、ユーザ X として認証された」という OP 側の状態です。小さな暗号化 cookie が、ブラウザをその状態に紐付けます。ログアウト はその状態を破棄することを意味します — そして任意で、その状態に依存していた RP に「ユーザは去った」ことを通知します。

このページで触れる仕様

30 秒のメンタルモデル

  • セッションは OP 側 に生きていて、cookie の中ではありません。
  • cookie はセッション行を指す暗号化されたポインタにすぎません。
  • 「ログアウト」とはその行を消すこと。任意で RP に通知を fan-out できます。
  • ブラウザ側の cookie だけ消しても、OP 上の行は残ります。行だけ消すと cookie は何も指さなくなり、次のリクエストでセッションが見つからず、ユーザは再認証になります。

本ライブラリでのセッション表現 ​

OP は store.SessionStore に次の行を保持します。

フィールド内容
ID不透明な OP ブラウザセッション識別子。cookie / セッション検索と監査相関に使います。sid claim としては発行されず、本 OP が送る Back-Channel Logout Token は sub だけを持ちます。
SubjectOP 内部の安定したユーザ ID。ID トークンの sub になります(pairwise subject を有効にした場合はクライアントごとに射影されます)。
AuthTimeユーザがいつ認証したか。このセッションから発行されるトークンの auth_time にコピーされます。
ACRAuthentication Context Class Reference — セッションが満たす保証レベル。
AMRAuthentication Methods References(RFC 8176) — pwd、otp、mfa、hwk、…
ChooserGroupIDマルチアカウントチューザのグループ識別子。同じブラウザに属する複数セッションが共有します。
ExpiresAt、CreatedAt、UpdatedAtライフサイクルのタイムスタンプ。

この行を指す cookie が __Host-oidc_session です(internal/cookie/profile.go で定義)。暗号化された payload には、chooser group の ID、現在のセッション ID、cookie を書いた時刻が入ります。セッションの存続期間は保存された行に対して強制されるため、cookie を再送しても延長できません。本ライブラリは合計 5 つの cookie を使います。

Cookie用途仕様
__Host-oidc_session永続セッションへのポインタ。不透明な payload を AES-256-GCM で AEAD 暗号化。__Host- 接頭辞で同一 origin に強制。SameSite=Lax、最大 14 日。
__Host-oidc_interaction進行中のインタラクション(ログインフォーム、MFA チャレンジ)の状態。同じ AEAD。1 時間 TTL。
__Host-oidc_csrfインタラクションフォームの double-submit CSRF トークン。HMAC のみ(AEAD なし)。SameSite=Strict。ブラウザセッション cookie。
__Host-oidc_logout_csrf/end_session の確認画面を GET ページから POST まで運ぶ double-submit CSRF トークン。HMAC のみ(AEAD なし)。SameSite=Strict。5 分 TTL。
__Host-oidc_localeインタラクション画面をまたいでユーザが選んだ UI ロケールを覚えておく。平文(暗号化なし)。1 年 TTL。SameSite=Lax。
__Host- 接頭辞とは

RFC 6265bis により、cookie 名が __Host- で始まるとき、ブラウザは Secure、Path=/、そして Domain 属性 なし を同時に満たさないと cookie を受理しません — つまり、OP の origin に厳密に縛られます。サブドメイン侵害でも偽造できず、兄弟ドメインからも読めません。本ライブラリが平文 HTTP では起動しないのは、まさに __Host- 接頭辞がそれでは成立しないからです。

セッション行は store.SessionStore を経由します — 組み込み側がライブラリの不変条件を破ることなく、揮発性のバックエンド(Redis など)に振り向けてもよいサブストアです。トレードオフは 設計判断 #10 を参照。

/auth でのセッションの再利用 ​

ブラウザが有効なセッションを持って /auth に来たとき、OP はリクエストごとに、そのセッションで処理してよいかを判断します。

リクエスト挙動
prompt=loginセッションは再利用せず、ユーザは最初から再認証する。
セッションの auth_time からの経過時間より短い max_age同じく、完全な再認証。
セッションの acr が満たさない acr_values または必須指定(essential)の acrstep-up を実行する。発行されるトークンはセッションの acr・amr・auth_time を保持し、step-up の認証要素は mfa.* イベントとして監査される。
LoginFlow のルールまたは Decider が別の step を要求セッションの subject からチェーンを実行するため、Primary は繰り返さない。
上のいずれにも該当しないセッションが要求を処理し、トークンはその auth_time・acr・amr を持つ。

prompt=none では、UI が必要になる行はエラーを返します。アカウントチューザで古い、または保証の弱いセッションを選ぶと、リクエストを失敗させず再認証を求めます。

ログアウトの分類 ​

ログアウトに関わる OIDC 仕様は 3 つあります。本ライブラリは 2 つを実装します。

RP-Initiated Logout 1.0 ​

RP はブラウザを次の URL にリダイレクトします。

GET /end_session?id_token_hint=<id_token>&post_logout_redirect_uri=<uri>&state=<opaque>

OP は次のことを行います。

  1. id_token_hint を検証する。署名、aud が登録済みクライアントを指すこと、typ が無いか JWT であることを確認し、logout token やアクセストークンは拒否します。期限切れの ID トークンは受理します。古いタブからのログアウトが典型的だからです。
  2. ヒントの sub がセッション cookie の subject と一致し、かつログアウトが他のアカウントを終了させない場合を除き、確認画面を表示する。ヒントが別の subject のものである場合や、ヒントが無い場合は、必ず確認画面を挟みます。一致するヒントでも、同じブラウザにサインインしている他のアカウントまで終了することになるときは確認画面を挟みます。
  3. logout_scope を解決し、選択されたセッション行を削除。
  4. post_logout_redirect_uri がそのクライアントに登録されていれば、state を反射させてブラウザをリダイレクト。
logout_scope破棄されるセッション
省略(既定)ブラウザの chooser group にあるすべてのセッション。
currentアクティブなセッションだけ。兄弟セッションが残れば cookie をそのセッションへ再バインドする。
空値、未知の値、重複なし。状態を変更する前に HTTP 400 で拒否する。

セッション cookie の subject と一致しない id_token_hint では確認画面を省略できません。セッションストアの transport 障害がセッションの解決または破棄中に起きた場合、/end_session は静的な HTTP 503 ページを返します。post_logout_redirect_uri へはリダイレクトせず、cookie とセッション行をそのまま残します。セッションが存在しない、または期限切れの場合は別の安全に継続できるケースです。

/end_session が返す確認画面とブラウザ向けエラー HTML には、X-Frame-Options: DENY と frame-ancestors 'none' を含む Content-Security-Policy の両方が付くため、どちらの画面も埋め込めません。

ネイティブ / public クライアントでは、redirect_uri と同じ RFC 8252 の loopback の任意ポートルールを post_logout_redirect_uri にも適用します。固定の loopback URI を登録しておき、ログアウト時には別の一時ポートで戻る形を許容します。変動できるのはポートだけで、host / scheme / path は登録形状と一致している必要があります。

主眼は このブラウザの OP セッションを終わらせる ことです。ログアウトを起点にした RP はそれを把握していますが、他の RP は、OP が Back-Channel Logout も動かしていない限り把握できません。

Back-Channel Logout 1.0 ​

/end_session がセッションを終了すると、OP はその subject の grant から導出した対象 RP それぞれに、署名された logout_token JWT を POST します。RP は JWT を検証し、自身のローカルセッションを無効化します。

これは server-to-server で動きます。ブラウザは関与しないので、ユーザがタブを閉じていても、別のブラウザに移っていても動作します。公開されているセッションログアウトの入口は /end_session です。

外向き HTTP リクエストは、JWKS / sector_identifier_uri 取得と同じ SSRF 拒否リストでガードされます — 組み込み側が op.WithAllowPrivateNetwork(op.OutboundBackchannel) でオプトインしない限り、private network には到達しません。リダイレクトは追従しません。BackchannelLogout.Transport で独自の http.RoundTripper(社内 CA や共通の計装など)に差し替えても、拒否リストは外れません。失敗は audit.LogoutBackChannelFailed、成功は audit.LogoutBackChannelDelivered で記録されます。session-bearing logout notice の grant 由来 RP audience が空の場合は audit.BCLNoSessionsForSubject が出ます — 「RP の対象を解決できなかった」と「配送に失敗した」を区別するのに有用です。

detached fan-out には独立した 2 つの時間制限があります。op.WithBackchannelLogout に渡す BackchannelLogout.Timeout は 1 RP への HTTP 配送(既定 5 秒)を制限し、BackchannelLogout.FanOutBudget は detached fan-out 全体(既定 30 秒)を制限します。後者は RP ごとの timeout ではなく、イベント全体の予算です。実行中の fan-out は Provider.Shutdown(ctx) でも drain されます。

揮発セッション下では Back-Channel Logout は best-effort

揮発な store.SessionStore が /end_session の解決と snapshot 取得より前に行を追い出すと、ブラウザからのログアウトの契機を失い detached fan-out は起動しません。snapshot を取得できた場合、RP audience は SessionStore の行を辿らず grant store から解決されます。session-bearing notice で grant 由来 RP 対象が 0 件の場合だけ、対象 0 件として監査されます。op.SessionDurabilityPosture ノブは trigger / snapshot の喪失と対象 0 件をダッシュボードで解釈する文脈を提供します。詳細は 設計判断 #10 を参照。

Front-Channel Logout 1.0 — 非実装 ​

Front-Channel Logout は、OP が RP ごとの frontchannel_logout_uri をひとつずつ <iframe> で並べた HTML を返し、各 iframe がサードパーティ文脈で自身の cookie を読みに行って消す、という仕組みです。この仕組みは「サードパーティの iframe が埋め込み文脈から自身の cookie を読める」という前提に依存しており、主要ブラウザは段階的にこの能力を取り去ってきました。

  • Safari ITP(2017 年〜)
  • Firefox ETP(2019 年〜)
  • Chrome の SameSite=Lax 既定化(2020 年〜)
  • third-party cookie の段階的廃止(2024〜2025 年)

本ライブラリは Front-Channel Logout を 実装しません。discovery 文書でも frontchannel_logout_supported は広告しません。fan-out 型のログアウトが必要な組み込み側は、ブラウザの cookie ポリシーから独立した server-to-server の Back-Channel Logout 1.0 を使います。完全な背景は 設計判断 #5 を参照。

ログアウト画面をブランド化する ​

/end_session は 2 つのページを自分で描画します。ユーザの意思を示す証拠がないままリクエストが届いたときに出す確認ページと、post_logout_redirect_uri を伴わないリクエストのあとに出すサインアウト完了ページです。既定では、どちらも固定のテンプレートで、変えられるのは文言だけです(logout.* キーを使います。i18nを参照)。op.WithLogoutUI を使うと、両方を自前の html/template に置き換えられます。

go
confirm := template.Must(template.New("confirm").Parse(`<!doctype html>
<html lang="{{.Locale}}"><head><meta charset="utf-8"><title>{{.Title}}</title>
<link rel="stylesheet" href="/static/logout.css"></head>
<body><main>
  <h1>{{.Title}}</h1>
  <p>{{.Body}}</p>
  <form method="{{.SubmitMethod}}" action="{{.SubmitAction}}">
    {{range .Fields}}<input type="hidden" name="{{.Name}}" value="{{.Value}}">
    {{end}}<button type="submit">{{.Button}}</button>
  </form>
</main></body></html>`))

signedOut := template.Must(template.New("signed-out").Parse(`<!doctype html>
<html lang="{{.Locale}}"><head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body><main><h1>{{.Title}}</h1><p>{{.Body}}</p></main></body></html>`))

op.New(
  /* 必須オプション */
  op.WithLogoutUI(op.LogoutUI{
    ConfirmTemplate:       confirm,
    SignedOutTemplate:     signedOut,
    ContentSecurityPolicy: "default-src 'none'; style-src 'self'; img-src 'self' data:; sandbox allow-forms allow-same-origin",
  }),
)
  • テンプレートは 2 つとも必須で、オプションは 1 回しか呼べません。サインアウトの途中で見た目が変わることはありません。
  • テンプレートに渡るデータ。 確認テンプレートは interaction.LogoutConfirmTemplateData で実行されます。フィールドは Locale、Title、Body、Button、SubmitMethod(常に "POST")、SubmitAction、ClientName(リクエスト元クライアントの表示名。特定できなければ空)、Fields です。サインアウト完了テンプレートには interaction.LogoutSignedOutTemplateData が渡され、フィールドは Locale、Title、Body です。Title、Body、Button は、リクエストのロケールで解決済みです。
  • Fields は必ず range で出力します。 各要素(Name、Value)は、form がそのまま送り返す hidden フィールドで、double-submit の CSRF トークンもその 1 つです。.Fields への range を書き忘れたテンプレートは、確認のたびに 400 で失敗してしまいます。そのため op.New は、サンプルの値でテンプレートを 1 回実行し、出力に欠けた値があれば拒否します。
  • CSP はヘッダ全体を置き換えます。 ContentSecurityPolicy が空なら既定の default-src 'none'; frame-ancestors 'none'; base-uri 'none'; sandbox allow-forms allow-same-origin が残ります。宣言した値は sandbox を含めてすべてを置き換えるので、sandbox allow-forms allow-same-origin を残すかどうかは宣言する側の判断です。allow-forms がないと、確認の form は送信できません。値は op.New のときに interaction.NormalizeCSP を通り、組み込み側が緩めてはならないディレクティブは拒否されます。描画時にもう一度通り、そこで失敗した場合は厳格な既定が残ります。
  • 失敗時は閉じる側に倒れます。 テンプレートはまずバッファに描画されます。実行に失敗すると、OP は固定のエラーページを 500 で返し、途中までの出力は送りません。
  • 変わらないもの。 Referrer-Policy は same-origin のままです。確認の POST が同一 origin の Origin に依存するためです。エラーページはテンプレート化されません。
  • どの op.WithUI のモードでも有効です。 SPA モードでもログアウトはサーバが描画し、op.SPAUI にログアウト用の route はありません。

SPA でのセッション ​

SPA は、自分でトークンを持つ relying party です。OP のセッション cookie __Host-oidc_session は OP の origin のものであり、SPA はこれを読みません。ここから 2 つの問いが出ます。SPA がどうやってサインイン状態を保つか、そしてログアウトがどこまで届くかです。

サインイン状態を保つ。 更新の方法は 2 つあり、挙動が異なります。

方法仕組み制約
リフレッシュトークンSPA が grant_type=refresh_token で /token を呼びます。cookie も OP のページも関与しません。連鎖の存続期間とローテーションの規則が適用されます。リフレッシュトークンを参照してください。リフレッシュが失敗したら、サインアウトされたものとして扱います。
隠し iframe での prompt=noneUI を出してはならない認可リクエストを SPA が送ります。OP のセッションが生きていれば code を返し、そうでなければ login_required を返します。iframe の中から OP のセッション cookie をブラウザが送る必要があります。cookie は SameSite=Lax なので、SPA と OP が same-site のときだけ動きます。cross-site の iframe では cookie が送られず、常に login_required になります。

リフレッシュトークンを推奨します。サードパーティ cookie の方針に左右されず、OP のセッションを参照することもありません。

ログアウトが届く範囲。 /end_session は、そのブラウザの OP セッションを終了し、ユーザの grant 配下で発行されたアクセストークンとリフレッシュトークンを失効させ(下の節を参照)、backchannel_logout_uri を登録した relying party ごとに Back-Channel Logout トークンを送ります。このトークンは sub だけを持ち、sid はありません。受け取ったサーバは、そのユーザについて持つすべてのセッションを終了できますが、特定の 1 つを選ぶことはできません。front-channel ログアウトはないので、別のタブで開いている SPA には何も届きません。SPA は、次のリフレッシュが失敗したときや、短命なアクセストークンを API が拒否したときに、それを知ります。サーバ側の要素を持たない SPA には、back-channel の POST を受ける場所もありません。受信は、その SPA を配信しているバックエンドに置いてください。SPA からサインアウトするには、自分のトークンを破棄してから、id_token_hint と登録済みの post_logout_redirect_uri を付けて、ブラウザを /end_session に遷移させます。

End-session カスケード ​

/end_session は cookie を消すだけではありません。破棄するセッションに含まれる subject ごとに、本ライブラリはその subject が持つすべての grant を走査し、grant の配下で発行された認証情報(JWT と opaque のアクセストークン、リフレッシュトークン)を失効させます。grant の記録自体は残るため、ユーザの同意は覚えられたままです。JWT アクセストークンは OP が応答するエンドポイント(/userinfo、/introspect)で inactive になります。opaque アクセストークンは introspection を経由するすべての RS で inactive になります。

セッション終了は連鎖するが、全トークンには届かない
上段はセッションモデル。OP オリジンに紐づく 5 つの cookie のうち 1 つが SessionStore の行 ID を運び、OP 側の状態はその行が持ちます。下段は end-session のカスケード。行を削除し、各 grant を走査してその配下のアクセストークンとリフレッシュトークンを失効させ、登録済みの back-channel logout URI すべてに logout_token を POST します。注記として、JWT アクセストークンを拒否するのは OP だけで、opaque なトークンは各リソースサーバが introspect して初めて無効になります。セッションモデルブラウザの cookie__Host-oidc_* × 5OP オリジンに紐づき、RP には渡らない行 IDSessionStore1 セッション 1 行で OP 側の状態を持つID · Subject · AuthTime · ACR · AMRend-session カスケード/end_sessionRP 起点のログアウトSessionStore行を削除するGrantsgrant ごとに失効処理を実行する各 grant 配下のトークンアクセス / リフレッシュを失効Back-Channel Logoutlogout_token → 各 RPどこまで届くかJWT: OP は拒否するopaque: RS の introspect 待ち到達範囲は各 RS 次第
最初の 2 つのステップは同期的で、OP の制御下にあります。トークンの失効と back-channel logout はそうではありません。すでに配られた JWT は有効期限まで形式上は正当なままですし、バックチャネル通知が効くのは URI を登録して応答する RP に限られます。

cookie は単なるポインタで、セッションの実体は SessionStore の行です。ブラウザ側で cookie を消しても行は残るため、OP が行そのものを削除する必要があります。

組み込まれた storeカスケード挙動
Grants を組み込み済み(同梱アダプタを使えば既定)カスケード発火。JWT AT は /userinfo と /introspect で拒否、opaque AT は全 RS で拒否、リフレッシュトークンは更新できなくなる。
Grants が nilカスケードは行われない。AT は自然に exp まで生存。
JWT の失効戦略が NoneJWT 側は行われない。opaque AT とリフレッシュトークンは引き続き失効する。

理由と JWT / opaque のカスケード到達範囲の非対称性は 設計判断 #17 を参照。

揮発ストアと永続ストアの選択 ​

セッションのサブストアは、トランザクショナルストア(認可コード、リフレッシュトークン、client)から意図的に分離されています。組み込み側は典型的には次のいずれかを選びます。

方針SessionStore のバックエンドトレードオフ
Hot/cold 分離(高トラフィック向けに推奨)Redis(揮発)セッション書き込みのレイテンシが低い。追い出しとログアウトが競合すると BCL は best-effort。
すべて永続トランザクショナルストアと同じ SQL クラスタBCL 配送は完全配送が前提。セッション書き込みはトークン書き込みとレイテンシを共有。

op.WithSessionDurabilityPosture(...) で組み込み側のスタンスを宣言することで、監査トレイル(audit.BCLNoSessionsForSubject)を正しく解釈できます。完全な実装例は Hot / Cold 分離の使い方 を参照。

graceful shutdown では、まず HTTP トラフィックの受付を止め、その後 provider の detached 処理を drain します。

go
srv.Shutdown(ctx)      // 受付を停止
provider.Shutdown(ctx) // detached fan-out を drain

セッションライフサイクルの監査イベント ​

イベント発火タイミング
audit.SessionCreated新規セッション発行。
audit.SessionDestroyedセッション行が削除された(ログアウト、追い出し、GC)。
audit.SessionAlreadyAbsentログアウト対象のセッションがすでに存在しない、または期限切れだった。
audit.SessionDestroyFailedセッション削除がストア障害になった。リクエストは静的 503 となり、状態は保持される。
audit.LogoutClientLookupFailedログアウト対象クライアントの解決中に client registry の参照が障害になった。通信路上は一律の拒否で、正常な「client が存在しない」結果では発火しない。
audit.LogoutRPInitiated予約語 — OP は RP が要求したことだけを示すイベントを発火しない。
audit.LogoutTokenRevokeFailedlogout token またはログアウトカスケードの失効処理がストア障害になった。
audit.LogoutBackChannelDeliveredRP が logout_token の POST に 2xx を返した。
audit.LogoutBackChannelFailedRP が non-2xx を返した、ネットワークエラー、または拒否リストで URL が遮断された。
audit.LogoutBackChannelResolveFailedgrant ベースの配送先解決に失敗した。
audit.LogoutBackChannelOverflow上限付き配送先解決が上限を超えた。
audit.BCLNoSessionsForSubjectsession-bearing /end_session notice で grant 由来の RP 対象が 0 件だった。

audit.LogoutRPInitiated は予約されたカタログ語彙です。OP は個別の「RP が要求した」イベントではなく、結果としてのセッション/配送イベントを記録します。

次に読む ​