メインコンテンツにスキップ

OpenID Connect における RP 開始ログアウトと SSO ログアウト: Casdoor がユーザーをサインアウトする方法

· 1分読む
Yang Luo
Casdoor Maintainer

OpenID Connect アプリでユーザーをログアウトするには、自前のセッションクッキーを削除するだけでは不十分です。ユーザーはアイデンティティプロバイダーでまだサインインしているため、次の「Sign in」クリック時にパスワードなしでそのまま通過し、そのアイデンティティプロバイダーセッションを共有している他のアプリもサインインしたままになります。適切なログアウトには 3 つの部分があります: アプリのローカルセッションをクリアし、ブラウザをプロバイダーの end_session_endpoint へリダイレクトして(RP 開始ログアウト)プロバイダーセッションを終了させ、バックチャネルログアウトを通じてプロバイダーが他のアプリに通知します。Casdoor では、終了セッションエンドポイントは /api/logout であり、バックチャネルログアウトはアプリケーションごとに設定され、リフレッシュトークンは /api/login/oauth/revoke で失効させることができます。

ログアウトメカニズムの概要​

メカニズムトリガー者クリアされるものCasdoor サポート
ローカルアプリログアウトあなたのアプリあなたのアプリのセッションクッキーと保存されたトークンのみあなたのコード
RP 開始ログアウトあなたのアプリによるブラウザのリダイレクトCasdoor ブラウザセッションと id_token_hint のトークンはい、GET または POST /api/logout、end_session_endpoint としてアナウンスされます
バックチャネルログアウトCasdoor によるサーバー間通信他のアプリのセッション、各アプリが自身のセッションを終了はい、アプリケーションごとの Backchannel logout URL
フロントチャネルログアウトプロバイダーによる非表示 iframe 経由ブラウザ経由での他のアプリのセッションいいえ、Casdoor は frontchannel_logout_supported をアナウンスしません
トークン失効 (RFC 7009)あなたのアプリ、サーバーまたはクライアント1 つのアクセストークンまたはリフレッシュトークン、およびそのリフレッシュトークンを共有するトークンはい、POST /api/login/oauth/revoke、revocation_endpoint としてアナウンスされます
Casdoor SSO ログアウトユーザーのトークンまたはクッキーを使ったあなたのアプリユーザーのすべてのセッションとトークン、または現在のセッションのみはい、/api/sso-logout、Casdoor 固有
SAML シングルログアウトCasdoor または SAML サービスプロバイダーSAML アプリ内のセッションはい、署名付き LogoutRequest

ローカルセッション vs. アイデンティティプロバイダーセッション​

すべての OIDC サインインには少なくとも 2 つのセッションが関与しており、多くの場合は 3 つあります:

  1. あなたのアプリのセッション。 通常、ユーザーとトークンエンドポイントから取得したトークンに対応するクッキーです。
  2. Casdoor セッション。 Casdoor ドメイン上のクッキーです。これが有効である限り、Casdoor はワンクリックのアカウントピッカー後、または自動サインインにより静かに、組織の任意のアプリへユーザーをパスワードなしでサインインさせます。これが シングルサインオン を可能にするものです。
  3. 上流のアイデンティティプロバイダーセッション、ユーザーが Google、GitHub、エンタープライズ IdP などを通じて Casdoor にサインインした場合。

Auth0 は、ログアウトドキュメントで同じ 3 つの層(アプリケーション、Auth0、および ID プロバイダーのセッション層)について説明しており、このモデルは任意の OIDC プロバイダーに適用できます。

「ローカライズされたアプリケーションセッション終了」は最初のもののみを終了します。つまり、アプリがユーザーを忘れるだけで、Casdoor のセッションは存続します。これは単にクッキーをクリアした場合に起こることです。「中央集約型シングルサインアウト」は Casdoor のセッションを終了し、すべてのアプリに対してそれぞれのセッションを終了するように通知します。共有コンピュータを使用している場合など、ほとんどのユーザーが「Sign out」をクリックするときは、後者を期待しています。

3 番目の層については、アプリケーションが Custom OAuth プロバイダーを通じてユーザーをサインインさせ、かつプロバイダーにログアウト URL が設定されている場合、Casdoor はそのログアウト URL を呼び出します。Google や GitHub からユーザーをサインアウトさせるわけではありません。

end-session エンドポイントによる RP 開始ログアウト​

OpenID Connect RP-Initiated Logout 1.0 は、RP(あなたのアプリ)がプロバイダーにセッションの終了を求める方法を定義しています。プロバイダーはディスカバリドキュメントでエンドポイントを end_session_endpoint として公開し、リクエストには次のパラメータが含まれます:

  • id_token_hint: プロバイダーがあなたのアプリに発行した ID トークン。仕様では推奨(RECOMMENDED)とされています。Casdoor はこれを使用して、該当するトークンを検索し有効期限切れにします。
  • post_logout_redirect_uri: その後のブラウザのリダイレクト先。仕様では事前に登録されていることが要求されています。Casdoor はこれをアプリケーションの Redirect URLs と照合し、それ以外へのリダイレクトを拒否します。
  • state: Casdoor がリダイレクトに追加する不透明な値であり、アプリ自身のログアウト往復と偽造されたものを区別できるようにします。
  • client_id: ID トークンを送信できない場合にアプリを識別します。

仕様では logout_hint と ui_locales も定義されていますが、Casdoor はこれらを読み取りません。

/.well-known/openid-configuration にある Casdoor のディスカバリドキュメントは、end_session_endpoint を https://<your-casdoor>/api/logout として告知するため、RP 開始ログアウトをサポートする OIDC ライブラリは設定なしでこれを取得できます。仕様で要求されている通り、このエンドポイントは GET と POST の両方を受け付けます。

Casdoor がリクエストに対して行う処理の概要(すべての分岐については RP-initiated logout reference を参照してください):

  • id_token_hint を使用する場合、Casdoor はトークンを検索し、ユーザーのアプリケーションへバックチャネルログアウトトークンを送信し、トークンを有効期限切れにし(リフレッシュトークンも機能停止)、ブラウザの Casdoor セッションをクリアして、state を付与した上で post_logout_redirect_uri へリダイレクトします。
  • id_token_hint なしの場合、Casdoor は現在のブラウザセッションからサインアウトし、そこで発行されたトークンを有効期限切れにします。ブラウザに残っているセッションがない場合でも、セッションまたは client_id からアプリが判明していればリダイレクトを行います。
  • post_logout_redirect_uri なしの場合、エンドポイントはリダイレクトではなく JSON レスポンスを返します。

例: ブラウザアプリでのサインアウトボタン​

認可コードフローと PKCE を使用してサインインした SPA(シングルページアプリ)は、トークン交換後も ID トークンを保持します。サインアウトハンドラはまずローカル状態をクリアし、その後ブラウザを Casdoor に引き渡します:

const ISSUER = "https://door.example.com";
const CLIENT_ID = "your-client-id";

async function signOut() {
const idToken = sessionStorage.getItem("id_token");

const discovery = await fetch(`${ISSUER}/.well-known/openid-configuration`)
.then((r) => r.json());

// 1. Forget the user locally, whatever happens next
sessionStorage.clear();

// 2. Remember a state value to check when Casdoor sends the browser back
const state = crypto.randomUUID();
sessionStorage.setItem("logout_state", state);

// 3. End the Casdoor session
const url = new URL(discovery.end_session_endpoint);
if (idToken) {
url.searchParams.set("id_token_hint", idToken);
} else {
url.searchParams.set("client_id", CLIENT_ID);
}
url.searchParams.set("post_logout_redirect_uri", "https://app.example.com/signed-out");
url.searchParams.set("state", state);
window.location.assign(url.toString());
}

// On https://app.example.com/signed-out
const returned = new URLSearchParams(window.location.search).get("state");
if (returned !== sessionStorage.getItem("logout_state")) {
console.warn("Unexpected logout redirect");
}
sessionStorage.removeItem("logout_state");

Casdoor でアプリケーションの Redirect URLs に https://app.example.com/signed-out を追加してください。追加しない場合、Casdoor はリダイレクトする代わりにエラーを返します。サーバーレンダリングされるアプリも同様に、ログアルート内でサーバーセッションを破棄してから、end-session URL への 302 レスポンスを返します。Spring Security の場合は、Spring Boot SSO post を参照してください。

バックチャネルログアウト: 他のアプリへの通知​

Casdoor セッションの終了により新しいサイレントサインインは停止しますが、アプリ B は依然として 1 時間前の独自セッションクッキーを持っています。アプリ B に何か伝える必要があります。OpenID Connect Back-Channel Logout 1.0 はサーバー間通信でこれを実現します。プロバイダーは、ユーザー名を含む署名付き JWT であるフォームパラメータ logout_token を含む POST を、各アプリに登録されたバックチャネル URI へ送信します。

Casdoor では、アプリケーションの編集ページで Backchannel logout URL を設定します。ユーザーが /api/logout を通じてサインアウトすると、Casdoor はそのユーザーの有効なトークンを保持し、この URL が設定されているすべてのアプリケーションへログアウトトークンを送信します。トークンはアプリケーションの証明書(ID トークンと同じ鍵)で署名され、以下を含みます:

  • iss および aud: アプリの ID トークンと同じ発行者とクライアント ID
  • sub: ユーザー ID、ID トークンの sub と同じ値
  • iat および jti
  • メンバー http://schemas.openid.net/event/backchannel-logout を含む events
  • SSO logout など一部のログアウト時のみ存在する sid(/api/logout では含まれない)

Casdoor's ID tokens don't carry a sid claim, so key your local sessions by sub and end all of them for that user. The logout token expires two minutes after iat, so a validator that requires exp, as the spec does, accepts it. Casdoor sends each token once, with a 30-second timeout, and doesn't retry. For applications outside the built-in organization, it refuses to call loopback and link-local addresses, so http://localhost won't receive logout tokens during development.

例: バックチャネルログアウト受信側​

この Express ハンドラは jose ライブラリを使用し、仕様の検証手順に従います:

import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";

// Take both values from the discovery document of your Casdoor
const ISSUER = "https://door.example.com";
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks`));
const CLIENT_ID = process.env.CASDOOR_CLIENT_ID;
const EVENT = "http://schemas.openid.net/event/backchannel-logout";

const seenJti = new Map();
const app = express();

app.post(
"/oidc/backchannel-logout",
express.urlencoded({ extended: false }),
async (req, res) => {
res.set("Cache-Control", "no-store");
try {
const { payload } = await jwtVerify(req.body.logout_token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID,
maxTokenAge: "2m",
});

if (!payload.events || typeof payload.events[EVENT] !== "object") {
throw new Error("not a logout token");
}
if (!payload.sub && !payload.sid) {
throw new Error("no sub or sid");
}
if ("nonce" in payload) {
throw new Error("nonce is not allowed");
}
if (seenJti.has(payload.jti)) {
throw new Error("replayed token");
}
seenJti.set(payload.jti, Date.now());

// Your own helper: delete every session row that belongs to this user
await destroySessionsForUser(payload.sub);
res.sendStatus(200);
} catch (err) {
res.status(400).json({ error: "invalid_request", error_description: err.message });
}
}
);

jwtVerify は署名、iss、aud、そして maxTokenAge を用いて iat クレームを確認します。ユーザーごとにセッションを検索するには、サインイン時に ID トークンの sub を各セッションレコードに保存します。seenJti は定期的に削除するか、Redis に保持してください。

リフレッシュトークンの失効​

リフレッシュトークンは、それを生成したセッションよりも長く生存します。アプリがこれを保持している場合、それに触れないログアウトは、数日間新しいアクセストークンを発行できる資格情報を残すことになります。

id_token_hint を使用した end-session 呼び出しは、すでにそのトークン(リフレッシュトークンを含む)を有効期限切れにしています。ブラウザを通じたログアウトが行われない場合(例えば CLI、デスクトップ、モバイルアプリ、またはユーザーが Casdoor セッションを維持したままアプリのみ離脱する場合)には、明示的に失効させてください。Casdoor はディスカバリドキュメントの revocation_endpoint で RFC 7009 を実装しています:

curl -X POST https://door.example.com/api/login/oauth/revoke \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d token="$REFRESH_TOKEN" \
-d token_type_hint=refresh_token

PKCE を使用する公開クライアントは、シークレットなしでリクエストボディに client_id を送信できます。リフレッシュトークンを失効させると、それとともに発行されたアクセストークンも期限切れになります。その後、リフレッシュエンドポイントは invalid_grant を返し、イントロスペクションは "active": false を返します。Casdoor がトークンを失効させる場合には、これを行うすべてのイベントがリストされています。

JWT アクセストークンを発行するすべてのプロバイダーに適用される制限があります。署名と exp のみをチェックする API は、失効したアクセストークンでも期限切れになるまで受け入れ続けます。アクセストークンの有効期間を短く設定するか、ログアウトを即座に検出する必要がある場合は、API で トークン イントロスペクション を呼び出してください。

複数のアプリ間でのシングル サインアウト​

上記の要素を用いて、ユーザーがアプリ A で "Sign out" をクリックした場合のシングル サインアウトの動作は次のようになります。

  1. アプリ A はセッションをクリアし、id_token_hint を付けて /api/logout にリダイレクトします。
  2. Casdoor は、ユーザーの他のアプリのバックチャネル URL にログアウトライトを送信し、トークンを期限切れにし、ブラウザの Casdoor セッションを終了します。
  3. アプリ B と C はログアウトライトを受信し、その sub に対するセッションを削除します。
  4. ブラウザはアプリ A のサインアウト済みページに到達します。次にアプリ B を開くと、Casdoor のサインイン ページが表示されます。

バックチャネル URL がないアプリは、独自のセッションまたはアクセストークンが期限切れになるまでサインイン状態のままです。アプリにレシーバーを追加できない場合は、セッションを短く保つか、各リクエストでトークンを検証するようにしてください。

ノートパソコンを紛失した後の「すべての場所からサインアウト」するには、logoutAll=true を指定して Casdoor の SSO ログアウト エンドポイント を使用します。これはユーザーのすべてのトークンを期限切れにし、すべてのセッションを削除し、OIDC をサポートしていないアプリへ通知プロバイダーを通じて署名付き通知も送信します。管理者は セッション ページ で個々のセッションを終了できます。SAML アプリについては、SAML シングル ログアウト を参照してください。背景情報として、www.casdoor.com の シングル ログアウト および SAML シングル ログアウト も参照してください。

参考資料​