セッション管理から共有ストアを消す — 暗号化 cookie に状態を持たせる

課題:短命なデータのために共有ストアを運用する手間

株式会社ユーザベースでSpeedaのソフトウェアエンジニアをしている塚田です。 今回は新しい画面の開発でOIDCでログイン機能を実装したときの学びについて書きます。

我々のアプリは複数台のレプリカ構成になっています。 この場合、各レプリカがバラバラに状態を持つとログイン状態が一致しないので、 インメモリでセッションを持つ選択肢はありません(レプリカ間で不整合になります)。 各レプリカで共有できるストアが要る、というのが従来の発想です。実際これまでもセッションは共有の KVS に持たせてきましたし、その運用が特別つらかったわけでもありません。

ただ、そこに載るのは寿命の短いデータです。 その短命なデータのためだけに、わざわざ KVS を一つ立てるのは大げさで面倒だと感じました。

前提:セッション管理は「サーバに状態」か「cookie に状態」か

HTTP はリクエストをまたいで状態を覚えません。 でも認証は複数リクエストにまたがる手続きなので、どこかに状態を持つ必要があります。 持ち方は2つです。

  • サーバに状態を置き、ブラウザには引換券(セッション ID)を渡す
    • 引換券から状態を引くための共有ストアが必要になります
  • 状態そのものを暗号化して cookie に入れ、ブラウザに持たせる
    • サーバは何も覚えなくてよくなります

OIDC フローのおさらい(どこで状態が生まれるか)

本題ではないので簡単に触れます。ポイントは、ログイン開始とコールバックの間(リダイレクトを挟む)にまたがって覚えておく必要のある一時状態があることです。ここが「本来サーバ側に置く状態」の正体になります。

ログイン開始時のコードはこうなります。

// ① ログイン開始
const codeVerifier  = randomPKCECodeVerifier()
const codeChallenge = pkceChallenge(codeVerifier) 
const state = randomState()
const nonce = randomNonce()

const authUrl = buildAuthorizationUrl({
  redirect_uri, scope,
  code_challenge: codeChallenge,
  code_challenge_method: "S256",
  state, nonce,
})

// 一時状態を暗号化 cookie に封入(短命 / 例: 10分)
sealCookie("oidc_tx", { state, nonce, codeVerifier, returnTo })
redirect(authUrl)

この oidc_tx に入れる各値には、それぞれ理由があります。

  • state: CSRF 対策です。戻ってきた state と照合し「自分が始めたログインへの応答」だと確認します
  • nonce: リプレイ対策です。ID トークン内の nonce と照合します
  • codeVerifier: PKCE です。開始時はハッシュ(challenge)だけ IdP に渡し、交換時に元の値を送って検証させます。これは暗号化が必須です
  • returnTo: ログイン後の戻り先(UX)です

コールバックでは、一時状態を取り戻してトークン交換し、ログイン後のセッションを別の暗号化 cookie に確立します。

// ③ コールバック(IdP から ?code & state 付きで戻る)
const { state, nonce, codeVerifier, returnTo } = unsealCookie("oidc_tx")

const tokens = exchangeCode({
  pkceCodeVerifier: codeVerifier,
  expectedState:    state,
  expectedNonce:    nonce,
})
const claims = tokens.claims()

// ログイン後セッション(最低限のクレームだけ)
sealCookie("session", {
  sub:       claims.sub,
  email:     claims.email,
  idToken:   tokens.id_token,
  expiresAt: now() + tokens.expires_in,
})
clearCookie("oidc_tx")
redirect(returnTo)

cookie の設定はこうしています。

sealCookie config:
  sameSite: "lax"    // IdP からのトップレベル遷移で cookie を送るため(strict だと送られない)
  httpOnly: true     // JS から読めない(XSS 対策)
  secure:   true     // https のみ
  path:     "/"
  // 鍵で暗号化 + 改ざん検知(sign だけでなく暗号化。中身に ID トークンを含むため)

以降のリクエストは、この session cookie を開くだけです。ストアへの照会は発生しません。

ハマりどころ:cookie の 4KB 制約

最初は access token・リフレッシュトークン・userinfo の全クレームまで cookie に入れようとしました。 すると cookie 1個の上限(約 4KB)を超えて入りませんでした。 そこで、本当に必要な sub / email / idToken / expiresAt だけに絞りました。

「何を持たせるか」を最小限に設計する必要がある、というのがこの方式固有の勘所です。

メリットとデメリット

メリット

  • 共有ストア(KVS)が不要になり、環境ごとに立てる手間・運用が消えました
  • サーバがステートレスになり、レプリカ間の整合を気にしなくてよくなりました(インメモリが使えなかった問題そのものが消えます)
  • ストア照会のレイテンシ・可用性依存がなくなりました

デメリット

一番大きいのは、サーバ側から即時無効化できないことです。 サーバは何も覚えていないので、発行済みの cookie は自分の有効期限が切れるまで有効なままです。 退職やパスワード変更のタイミングで「今すぐ締め出す」ができません。 これを担保したいなら結局セッションストアが必要で、つまり セッションストアの方式 に戻ることになります。 cookie 方式のまま影響を小さくするならセッション寿命を短くすることになりますが、短くするほど再認証が増えるので refresh とセットで考える必要があります。

同じ理由で、cookie の中身は失効するまで古いままです。 email や権限を変えても即座には反映されません。 変わりやすい重要な属性は cookie に載せず、都度取りにいくのが安全です。

サイズについては前述の 4KB 制約があり、最低限のクレームに絞って対応しました。 長寿命のリフレッシュトークンをどこに置くかという問題も残りますが、rotation + 再利用検知で無効化を IdP 側に寄せるのが定石のようです。

まとめ

  • 向くケース:セッションが短命で、共有ストアの運用を減らしたいとき
  • 向かないケース:即時無効化が必須、または長寿命トークンを扱う必要があるとき

🎓 学生の方へ | 1day インターンイベント開催!

Speeda ソフトウェアエンジニア職 のインターンイベントを開催します!

Speedaプロダクトチームではアジャイル開発手法の一種であるXP(エクストリームプログラミング)を実践しており、今回のインターンではプラクティスの中でも特徴的なペアプログラミングをみなさんに体験していただきます!

チーム開発を更に深めることができるペアプログラミングの経験は我々の文化を体験することや、今後のエンジニアとしての活動の幅を広げるきっかけになると思います。

少しでもご興味をお持ちいただけた方は、ぜひお気軽にエントリーください。

エントリーはこちら!

Page top