通常のブラウザ分離を弱めることなく、複数の独立したWebサイトを1つのアカウントシステムのように動作させる必要がありました。ユーザーはあるドメインでサインインし、後から別のドメインを直接開いて、Log In をクリックするだけで、認証情報を再入力せずにそのサイトを使い続けられるようにしたかったのです。
重要な制約は、それらが独立したルートドメインだったことです。あるルートドメインが作成したセッションCookieを、無関係な別のルートドメインにそのまま適用することはできません。よく混同される2つの概念、つまりグローバルなアイデンティティとローカルなブラウザセッションを分けて考えると、設計はかなり理解しやすくなりました。
最終的なモデルはシンプルです。バックエンドは1つの共有アカウントのアイデンティティを認識し、各ドメインはそれぞれホストに紐づくセッションを所有します。そして、短時間だけ有効なワンタイム・ハンドオフによって、認証済みの信頼できるオリジンが、別の信頼できるオリジン上で新しいセッションを作成することを許可します。
グローバルなアイデンティティに共有Cookieは必要ない
ブラウザセッションがローカルでも、アカウント自体はグローバルにできます。
各サイトはホスト限定のセッションCookieを保持します。あるルートドメインが発行したCookieを、Domain 属性によって無関係なルートドメインまで拡張することはできません。この属性でCookieの適用範囲を広げられるのは、発行元ホストが使用を許可されているドメイン階層の内部だけです。
1つのホストに固定したいセッションには、__Host- プレフィックス付きCookieが適しています。概念的には次のようになります。
Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax重要なのは、次の境界です。
- サイトAは、サイトBのホスト限定セッションCookieを読み取れません。
- サイトBは、サイトAのホスト限定セッションCookieを読み取れません。
- それでも両方のセッションは、同じバックエンドのアイデンティティに解決できます。
したがってクロスドメインSSOでは、あるサイトから別のサイトへCookieを移そうとすべきではありません。代わりに、信頼できる送信元ですでにユーザー認証が済んでいることを示す、一時的な証明を渡します。
ワンタイム・ハンドオフのパターン
認証済みの送信元は、小さなハンドオフ処理を公開します。ブラウザがそれを呼び出すのは、サイトをまたいだ認証遷移が必要なときだけです。
バックエンドは暗号学的にランダムなチケットを生成し、生の値をブラウザに一度だけ返します。保存するのはハッシュと、転送を検証するために必要な最小限の情報だけです。
{
tokenHash,
userId,
sourceOrigin,
targetSite,
returnPath,
expiresAt
}チケットは短命で、1つのターゲットに対してのみ有効にするべきです。送信元側の発行リクエストは、すでに認証済みであり、CSRF対策も施されている必要があります。要求されたターゲットと戻り先は、何かを発行する前に検証します。
その後ブラウザは、ターゲットに対してトップレベルのPOSTを実行します。ターゲットはチケットを消費し、共有ユーザーのアイデンティティを解決して、自分自身のローカルセッションを作成し、ブラウザを目的のパスへリダイレクトします。
概念的には次のようになります。
record = consumeOnce({
tokenHash: hash(ticket),
targetSite: currentSite,
sourceOrigin: requestOrigin,
notExpired: true
})
if (!record) deny()
user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)このチケット自体がターゲットのセッションなのではありません。ターゲット側でそのセッションを作成するための、一度限りの認可です。
アトミックな消費でリプレイの隙間を閉じる
より弱い実装では、チケットを読み込み、検証し、セッションを作成してから、最後にチケットを削除することになります。これでは、2つのリクエストが同じ有効なレコードを同時に参照できる時間帯が生まれます。
より安全なのは、検証と無効化を1回のアトミックな消費処理にまとめる設計です。一致するレコードがなければ認証は失敗します。レコードが返された場合、そのチケットはセッションを作成する前の時点で、すでに利用可能な集合から取り除かれています。
これにより、期待するターゲット、期待する送信元オリジン、トークンハッシュ、有効期限という複数の条件を、ターゲット側で同じ処理に結び付けることもできます。
Originの検証はフェイルクローズにする
Originを束縛することで、もう1つ有用な性質が得られます。ある信頼済みの送信元を通じて発行されたチケットは、無関係なオリジンから消費できないようにできます。
Originチェックそのものを消費条件に含めるべきです。そうすれば、誤ったオリジンからのリクエストでは有効なチケットは消費されず、その後に正当な送信元からハンドオフを完了できます。
ただし、重要な注意点が1つあります。Origin ヘッダーが、すべてのブラウザコンテキストで通常のオリジン文字列を必ず含むとは限りません。利用可能な値なしで到着するリクエストもあり、不透明なコンテキストでは Origin: null になる場合もあります。
私のルールでは、アプリケーションに明示的な代替検証手段がない限り、Origin が欠落している、または不透明である場合はOriginチェック失敗として扱います。互換性のためだけに、リテラル値 null を信頼済みオリジンにしてはいけません。
これはフェイルクローズの選択です。正当なフローで利用可能なOriginがないコンテキストをサポートする必要があるなら、一般的なチェックを暗黙に弱めるのではなく、その例外を明示的に設計するべきです。
ハンドオフ用の認証情報をURLに入れない
私は、一時チケットをクエリ文字列に入れるのではなく、トップレベルのフォームPOSTで送る方を選びます。
一般化したクライアント側の例は次のとおりです。
const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;
form.append(input);
document.body.append(form);
form.submit();ターゲットは、想定したPOSTボディからチケットを受け取り、同等の値をURL経由で渡そうとする試みは拒否します。
これで認証情報が無害になるわけではありませんが、通常のナビゲーションURLからは外せます。その結果、URL履歴、コピーされたリンク、リファラー、URL指向のログを通じた偶発的な露出を減らせます。
認証済みのクロスサイトリンクは自動的にハンドオフへ移行できる
現在のサイトが、ユーザーはすでに認証済みだと把握している場合、別の信頼済みサイトへの通常のリンクは、必要に応じてハンドオフを実行できます。
その場合でも、リンクには本物の href を残しておくべきです。SSOは通常のナビゲーションを強化するものであり、置き換えるものではありません。
簡略化したクライアントフローは次のようになります。
if (session.status !== 'authenticated') {
return; // ordinary browser navigation
}
if (!isTrustedTarget(destination)) {
return;
}
event.preventDefault();
const ticket = await requestHandoff({
targetSite,
returnPath: destinationPath
});
postTicketToTarget(ticket);本番版ではさらに、重複送信を防ぎ、信頼済みレジストリに照らしてターゲットを検証し、ハンドオフを作成できなかった場合は通常のナビゲーションにフォールバックする必要があります。
ブラウザ本来の挙動は、そのまま本来の挙動として残すべきです。中クリック、修飾キー付きクリック、ダウンロード、別のブラウジングコンテキスト向けのリンクを、暗黙のうちに認証リクエストへ変換してはいけません。
2つ目のサイトの Log In ボタンには既知の発行元が必要
より難しいのは、ユーザーがサイトBを直接開くケースです。サイトBにはローカルセッションがないため、ブラウザにサイトAの有効なセッションが残っていても、サイトBが訪問者を匿名として扱うのは正しい動作です。
サイトBは、サイトAのホスト限定Cookieを調べることができません。また、任意のサイトCへリダイレクトし、そこでシステム内の他サイトに属するセッションを見つけられると期待することもできません。互いに無関係な各ルートドメインが参照できるのは、自分自身に属するCookieだけです。
つまり匿名ユーザー向けの Log In フローには、既知の発行元が必要です。正規の認証オリジン、または再利用可能なログインセッションを保持していると想定される、明示的に選ばれた信頼済みサイトのどちらかです。
単純なサイトA → サイトBのケースでは、流れは次のようになります。
- ユーザーがサイトAでサインインします。
- 後からユーザーがサイトBを直接開きます。
- サイトBにはセッションがないため、Log In を表示します。
- ユーザーが Log In をクリックします。
- ブラウザは、選択された発行元であるサイトAへトップレベルでナビゲーションします。
- サイトAは、自分自身の既存のホスト限定セッションCookieを読み取れます。
- そのセッションがまだ有効なので、サイトAはすぐにサイトB向けのワンタイム・ハンドオフを発行します。
- ブラウザがチケットをサイトBへPOSTします。
- サイトBが自分自身のローカルセッションを作成し、ユーザーを要求されたページへ戻します。
ユーザーから見ると、サイトAへ一度移動してすぐ戻ってくるだけです。サイトAには自分が参照を許可されているセッションがすでにあるため、パスワードは不要です。
同じくらい重要なのが、この仕組みの制限です。ユーザーがサイトCでしかログインしていない場合、サイトBからサイトAへリダイレクトしても役に立ちません。例外は、サイトAが真の中央認証ブローカーであり、ログイン状態について独立した知識を持っている場合です。共有ユーザーデータベースがあっても、ブラウザセッションまで共有されるわけではありません。
正規の発行元がシステムを単純にする理由
独立したサイトの数が増えるほど、正規の発行元を用意することで、任意のドメイン間でセッションを探索する必要がなくなります。
それがなければ、匿名状態のサイトBは、現在どの別サイトが有用なCookieを持っていそうかを何らかの方法で判断しなければなりません。サイトAはサイトCのCookieを調べられず、サイトCはサイトAのCookieを調べられず、サイトBはどちらも調べられません。
正規の発行元を置けば、匿名ユーザーのログイン試行には常に1つの予測可能な経路を与えられます。
Site B
→ authentication issuer
→ issuer session exists?
yes → create one-time handoff
no → show normal login
→ Site B consumes handoff
→ Site B creates local session発行元がアプリケーションの残りの部分を提供する必要はありません。ここで重要な責務は、再利用可能な認証セッションを所有し、信頼済みサイトに対して厳密にスコープされたハンドオフを発行することです。
小規模なシステムなら、既存サイトの1つがその役割を担えます。より広いアーキテクチャでは、専用の認証オリジンを設けることで、信頼モデルを理解しやすくできます。
すべての訪問者を発行元経由にしない
匿名ユーザーがページを表示するたびに、発行元フローを自動的に実行するべきではありません。
既存セッションがあるか確認するためだけにすべての訪問者を別ドメインへリダイレクトすると、不要なナビゲーション、追加のレイテンシ、障害要因の増加、分析データのノイズ増加、キャッシュ挙動の複雑化につながります。
公開されたSEO重視のページにも適しません。公開URLを要求したクローラーや通常の訪問者には、先に認証の迂回経路を強制するのではなく、その公開ページを直接返すべきです。
私が使っているルールは次のとおりです。
ページ読み込み時に発行元を探りに行かない。クロスドメイン認証は、明示的な Log In 操作、または意図的な認証済みクロスサイトナビゲーションの後にだけ開始する。
これなら公開トラフィックを単純に保ちつつ、選択された発行元にすでに有効なセッションを持つユーザーには、素早い認証を提供できます。
戻り先パスの検証は単なるプレフィックスチェック以上に注意が必要
ハンドオフでは通常、その後ブラウザをどこへ着地させるかを記憶しておく必要があります。この値を、クライアントが自由に制御できる任意のURLとして扱うと、オープンリダイレクトになり得ます。
可能なら、完全な遷移先URLそのものを受け付けない設計が最も安全です。既知の遷移先に短い識別子やサーバー側マッピングを使えば、必要なURL解析と検証の量を減らせます。
相対パスを受け付ける必要がある場合は、慎重に検証し、ターゲットサイト内に限定してください。
次は、意図的に簡略化した例であり、完全なリダイレクト検証ルーチンではありません。
function isSafeReturnPath(value) {
if (!value.startsWith('/')) return false;
if (value.startsWith('//')) return false;
if (value.includes('\\\\')) return false;
const base = 'https://example.invalid';
const parsed = new URL(value, base);
return parsed.origin === base;
}実際の実装ではさらに、デコード時の挙動、不正な入力、制御文字、正規化、アプリケーションのルーティング規則、そしてリダイレクトが生成される前にプロキシやフレームワークが行う変換も考慮する必要があります。
セキュリティ上の不変条件は、パーサーの細部より単純です。ハンドオフが選べるのは、ターゲットアプリケーション内で許可された場所だけであり、任意の外部遷移先ではありません。
正常系より失敗ケースの方が重要
ログインに成功したことから分かるのは、基本経路が動くということだけです。より有用なのは、信頼境界を実際に検証するテストです。
私は、たとえば次のようなケースの挙動を確認しました。
- 未認証の送信元がハンドオフを発行しようとするケース。
- CSRF対策がないケース。
- Originが欠落している、不透明である、または想定外であるケース。
- 信頼されていないターゲット。
- 安全ではない戻り先。
- 誤ったターゲットに提示されたチケット。
- 誤ったオリジンからの消費試行。
- チケットのリプレイ。
- チケットの有効期限切れ。
- ユーザーがすでに存在しないケース。
- 想定したPOSTボディではなくURL経由でチケットが渡されたケース。
特に直接テストする価値がある不変条件は2つあります。拒否されたリクエストが、正当なフローではまだ有効なチケットを誤って破棄してはならないこと。そして消費に成功した瞬間、そのチケットが直ちに再利用不能にならなければならないことです。
プロトコルはサイト間の個別連携なしで拡張できる
サイトが増えても、ハンドオフ設計の本質は変わりません。
参加する各サイトに必要なのは、次の小さな共通契約です。
- 安定した論理識別子。
- 信頼済みの公開オリジン。
- 共有アカウントのアイデンティティへのアクセス。
- 認証済み送信元向けのハンドオフ発行処理。
- ハンドオフ消費処理。
- 信頼済みサイトのレジストリ。
- ホストに紐づくローカルセッション。
- 安全な戻り先ポリシー。
- 匿名ユーザーの Log In フローで使う既知の発行元。
スケールさせるうえで重要なのは、サイトの組み合わせごとに個別の認証ロジックを作らないことです。すでに認証済みのサイトは、別の信頼済みサイトに同じ種類のハンドオフを発行できます。匿名状態のサイトは、ブラウザを既知の発行元へ送れます。
これにより、増え続ける特殊ケースの組み合わせではなく、1つのプロトコルを基盤にしたシステムを維持できます。
ログインの可搬性とログアウトの伝播は別問題
独立したホスト紐付けセッションには、もう1つ重要な区別があります。ログインを持ち運べるようにしても、グローバルログアウトの意味が自動的に決まるわけではありません。
ユーザーがサイトAからサインアウトしても、バックエンドが意図的に失効させない限り、別サイトのローカルセッションは有効なまま残る可能性があります。これはセッション管理ポリシーの問題であって、SSOハンドオフの失敗ではありません。
システムは、ローカルログアウト、グローバルログアウト、または明示的なセッション管理のいずれかを選べます。その判断をハンドオフから切り離しておけば、両方の仕組みを理解しやすくなります。
私が使っているモデル
この問題を「ドメインをまたいでログインCookieを共有すること」と表現するのをやめると、アーキテクチャはずっと単純になりました。
グローバルなアイデンティティは1つだけあり、各ドメインは自分自身のブラウザセッションを所有する。そして、短命なワンタイム・ハンドオフによって、認証済みの信頼できるオリジンが、別のローカルセッションの作成を許可できる。
匿名状態のサイトへ直接入る場合には、もう1つ重要なルールがあります。ブラウザを、ユーザーの既存セッションを実際に参照できる発行元へ送らなければなりません。ユーザーが以前その発行元でサインインしていれば、往復はほとんど見えないほど短くできます。発行元に有効なセッションがない場合は、無関係な別ドメインのセッションが見えるふりをするのではなく、通常の認証へフォールバックする必要があります。
このモデルなら、ブラウザのドメイン境界を維持し、公開訪問者に不要な認証リダイレクトを行わず、それでも意図した体験を提供できます。つまり、発行元でサインインし、後で別の信頼済みサイトを開き、Log In をクリックして、パスワードを再入力することなく新しいローカルセッションを得て戻ってこられます。