TT Lab
ブログ

한국어 日本語

はじめる
ブログ

ブログ

SSO連携の2つの方式:アプリ内のエージェント(SDK)とアプリ前のゲートウェイ

ステータス:ドラフト(2026-10-26)、実測反映。 実測は3つあります。① TT LabクラスターのKeycloakから、誰でも読み込める公開メタデータ(/.well-known/openid-configuration、JWKS)を読み込みました。ホスト・レルム名とバージョン番号はマスクしています。② ノートPCに 実習用Keycloak(devモード、本番とは別の実習環境) を新しく立ち上げ、実習用レルム・クライアント・ユーザーをランダムな値で作成して、Authorization Code + PKCEをPythonの標準ライブラリのみで実行しました。実際のユーザーアカウントではログインしていません。③ 実習用キーで作成したJWTをローカルのゲートウェイ(agentgateway)に送信して判定を確認しました。実習用キーの秘密鍵部分は記事に含めていません。仕様は同日の原文を開いて確認しました。 作成ルール:実測データを基本とし、測定できなかったものは「未実測」、確認できなかったものは「確認できず」、推測は「推定」と記載します。

目次

1. 入館証は1カ所で、確認はどこで?

会社のビルを想像してみましょう。入館証はセキュリティルームという1カ所で発行されます。これが IdP(Identity Provider、例:Keycloak)です。入館証を確認する方法は2つあります。

2つの方式は 同じ規約(OIDC) を使用します。異なるのは、その規約を どのプロセスが実行するのかだけです。そのため、まず規約を確認し(第2〜4章)、それをアプリ内で行う場合(第5章)とアプリ前で行う場合(第6章)を比較したのち、ログアウト(第7章)と罠(第8章)に進みます。

2. OAuth 2.0、OIDC、SAML:何が何を解決するのか

OAuth 2.0 OpenID Connect (OIDC) SAML 2.0
解決する課題 認可の委任:アプリがユーザーの代わりにAPIを呼び出す権限 認証:「この人は誰か」をアプリに通知 認証(および属性伝送)、主に企業Web SSO
成果物 アクセストークン(形式自由) IDトークン(必ずJWT) + OAuthのトークン類 署名付きXML Assertion
位置づけ ベース OAuth 2.0の上に構築されたレイヤー 別個の規約(2005)
ディスカバリー — /.well-known/openid-configuration(Discovery) メタデータXML
伝送 リダイレクト + バックチャネル /token 同上 HTTP-Redirect(リクエスト)・HTTP-POST(レスポンス)バインディング

OAuth 2.0だけでは「ログイン」ができないという点が重要です。アクセストークンはAPIのためのものであり、「誰がログインしたのか」をアプリに証明する文書ではないからです。OIDCはその空白を IDトークンで埋めます。OIDC Core §2では、IDトークンに iss·sub·aud·exp·iat を含めることが義務付けられています。

SAMLのSP起点フローも形状は似ています。SP(アプリ)が AuthnRequest を圧縮してリダイレクトで送信し、IdPがログインさせた後に 署名された Response をHTMLフォームのPOSTでアプリのACSアドレスに渡します(OASIS SAML 2.0技術概要 §5.1.2、§4.6)。この記事では、新規構築する連携で主に使用されるOIDCを中心に見ていきます。SAMLの規範文書(core・bindings PDF)は本文を直接引用できず、概要文書で確認しました。

3. Authorization Code + PKCE:トークンをブラウザに経由させない

Authorization Code + PKCE: 토큰은 브라우저를 지나지 않는다 브라우저 사용자 앱 (SDK) 또는 게이트웨이 IdP Keycloak 등 ① 보호된 페이지 요청 ② verifier 난수 → challenge S256(verifier). state·nonce 는 세션에 ③ 302 → IdP /auth challenge · state · nonce · redirect_uri ④ 로그인 화면 · 비밀번호 · MFA IdP 쿠키 = SSO 세션 ⑤ 302 → redirect_uri?code&state&iss code 는 한 번만, 짧게(초 단위) ⑥ /callback 도착 state 가 저장한 값과 같은가 ⑦ 뒤채널 POST /token code + verifier + 클라이언트 인증 ⑧ ID · 액세스 · 리프레시 토큰 ⑨ 서명·iss·aud·exp·nonce 확인 → 앱 세션 쿠키만 브라우저로 주황 화살표만 토큰이 오간다. 가로챈 code 만으로는 verifier 가 없어 교환이 안 된다. 실측(노트북 실습용 Keycloak): ③–⑥ 117ms, ⑦–⑧ 9ms.
図:ブラウザ、アプリ、IdPの間でAuthorization Code + PKCEが往復する9つのステップ

フローの核心は、 ブラウザを介して渡るのは短命なcodeだけであり、トークンはアプリとIdPがバックチャネルで直接やり取りするということです。ブラウザのアドレスバー、履歴、リファラー、拡張機能はすべて情報漏洩の経路となるためです。以前のimplicitフローでは、トークンをアドレスの #fragment でブラウザに直接渡していましたが、OAuthセキュリティBCP(RFC 9700、2025-01)§2.1.2では、もはやimplicitを使用しないよう求めています。同文書では、アプリがパスワードを受け取ってIdPに渡すpassword grantも禁止(§2.4)しています。

3つの乱数:state、nonce、PKCE

1つのログインリクエストに3つの乱数が紐づきます。それぞれ役割が異なります。

乱数 どこで確認するか 防ぐもの
state コールバックでアプリが 第三者が作成したコールバックを自分のブラウザに強制挿入するCSRF
nonce IDトークン内でアプリが 他のログインのIDトークンをリプレイすること
PKCE code_verifier /token でIdPが 横取りされたcodeを別人がトークンに交換すること

PKCE(RFC 7636)の計算は1行です。アプリが43〜128文字の乱数 code_verifier を作成し(§4.1)、ログインリクエストにはそのハッシュ code_challenge = BASE64URL(SHA256(code_verifier)) のみを送信します(§4.2、S256)。codeをトークンに交換する際には元のverifierを提示する必要があるため、codeだけを盗んだ者には何もできません。

import base64, hashlib, secrets
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
verifier = b64u(secrets.token_bytes(32))                       # 43자
challenge = b64u(hashlib.sha256(verifier.encode()).digest())
print(b64u(hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk").digest()))

最後の行はRFC 7636付録Bのサンプルverifierです。ローカルで実行したところ E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM となり、付録の期待値と一致しました(2026-10-26)。RFC 9700 §2.1.1は、パブリッククライアント(秘密情報を保持できないSPA・モバイルアプリ)に対してPKCEを 必ず 使用するよう義務付けており、秘密情報を保持するサーバーアプリに対しても推奨しています。

実測:ローカルのKeycloakで1存

実習用Keycloakに秘密情報を持つ(confidential)クライアントを1つ作成し、PKCEをS256に強制し、redirect URIを正確に1つだけ登録しました。その後、ブラウザの役割をPythonの urllib で模倣し、上記図の③〜⑨をそのまま実行しました。

ステップ 結果(2026-10-26、ノートPC、ローカルIdP)
③〜⑥ ログイン画面 → パスワード送信 → コールバック 117〜122ms。コールバックに code·state·session_state·iss 4つの値
codeの長さ 110文字
⑦–⑧ /token 交換 200、9ms。取得した内容: access_token·id_token·refresh_token·expires_in など9個のキー
同じcodeをもう一度 400 invalid_grant 「Code not valid」
verifierを間違えた場合 400 invalid_grant 「PKCE verification failed: Code mismatch」
ログインリクエストからPKCEを外すと IdPがコールバックとして invalid_request 「Missing parameter: code_challenge_method」
redirect_uriの末尾に / を1つ追加すると 400エラー画面「Invalid parameter: redirect_uri」、コールバックにも戻らない

コールバックに iss が付与されて返ってくるのは、IdPが authorization_response_iss_parameter_supported を有効にしているためです。アプリが複数のIdPを使用している際に、「このcodeをどちらのIdPが送信したのか」を混同させるミックスアップ攻撃を防ぎます。

1つの挙動は予想以上に強力でした。 同じcodeを再利用すると、そのcodeですでに取得済みのトークンのセッションまで切断されました。 再利用を試みた直後にそのセッションのアクセストークンをintrospectionすると active: falseとなり、リフレッシュトークンで更新しようとすると400「Session doesn't have required client」となりました。codeが2回使用されたということは誰かがcodeを横取りしたというシグナルであるため、すでに発行したトークンも信用しないという動作です。RFC 6749 §4.1.2が推奨しているまさにその処理です。ただし、そのアクセストークンは 署名検証のみを行う側では依然として有効でした(第7章の問題と同様です)。

4. 3つのトークンとJWT署名検証

トークン 受け取る側 何に使用するか 実測寿命(実習レルムの設定) 実測サイズ
IDトークン アプリ(クライアント) 「誰がログインしたか」。アプリセッション作成時に1回 300秒 1,145バイト
アクセストークン API(リソースサーバー) API呼び出しの Authorization: Bearer 300秒 1,403バイト
リフレッシュトークン IdPのみ 新しいアクセストークンの取得 1,800秒(SSOセッションのアイドル上限と同じ) —

アクセストークンとIDトークンは、1つのCookie(通常4KB制限)の3分の1以上を占めます。ゲートウェイがトークンをCookieに格納する方式を採用する場合、複数のCookieに分割するか、サーバー側のセッションストアを使用する必要がある理由がここにあります。

JWT 세 조각과, 받는 쪽이 지켜야 할 검사 순서 헤더 페이로드(클레임) 서명 . . alg · typ · kid iss · sub · aud · exp · iat · nonce RS256: 개인키로 서명 base64url 로 감싼 JSON — 암호화가 아니라 그냥 보인다 1 alg 를 내가 정한 목록으로 고정 none · HS256 바꿔치기 거절 2 kid 로 JWKS 의 공개키 고르기 모르는 kid 면 JWKS 다시 받기(횟수 제한) 3 서명 검증 s^e mod n 이 SHA-256 과 맞나 4 iss 가 글자까지 같은가 끝의 / 하나도 다르면 실패 5 aud 에 내 이름이 있는가 다른 앱에 준 토큰 재사용 막기 6 exp · nbf (시계 오차 허용) 보통 60초 안팎 7 ID 토큰이면 nonce 로그인 요청과 묶였나 1–3 은 「누가 만들었나」, 4–7 은 「나에게, 지금, 이 로그인으로 온 것인가」.
図:JWTのヘッダー・ペイロード・署名の3つのパーツと、受け取る側が遵守すべき7つの検証ステップ

JWTの3つのパーツ

JWT(RFC 7519)はJWS(RFC 7515)のコンパクト形式であり、 BASE64URL(헤더).BASE64URL(페이로드).BASE64URL(서명) です。 base64urlは暗号化ではありません。 誰でもデコードして読み取ることができるため、トークンに秘密情報を入れてはなりません。実測したトークンのヘッダーは {"alg":"RS256","typ":"JWT","kid":"…"} であり、 kid はIdPの公開鍵リスト(JWKS、RFC 7517)の中からどの鍵で検証すべきかを選択するための識別子です。

RS256の検証は結局のところべき乗計算1回です。署名sを公開鍵(n, e)で s^e mod n すると、PKCS#1 v1.5の形状(00 01 FF … FF 00 + DigestInfo + SHA-256(헤더.페이로드))になるはずです。理解のために標準ライブラリで直接記述してみました(本番コードでは検証済みのライブラリを使用してください)。

def rs256_ok(token, jwk):
    h64, p64, s64 = token.split(".")
    n = int.from_bytes(ub64(jwk["n"]), "big"); e = int.from_bytes(ub64(jwk["e"]), "big")
    k = (n.bit_length() + 7) // 8
    em = pow(int.from_bytes(ub64(s64), "big"), e, n).to_bytes(k, "big")
    di = bytes.fromhex("3031300d060960864801650304020105000420") + hashlib.sha256(f"{h64}.{p64}".encode()).digest()
    return em == b"\x00\x01" + b"\xff" * (k - 3 - len(di)) + b"\x00" + di

実測:実習用IdPが発行したアクセストークン、IDトークン、ログアウトトークンはいずれもJWKSの2048ビットRSAキーでこの関数を通過し、ペイロードの1ビットを変更すると失敗しました。

署名が合っていても終わりではない

署名は「IdPが作成した」ことしか証明しません。「自分宛ての、今、このログインによって届いたトークンであるか」はクレームで個別に確認する必要があります。OIDC Core §3.1.3.7ではIDトークンの検証を13段階で定めており、核心は図の4〜7番です。 iss は文字列まで一致している必要があり、 aud には自分のclient_idが含まれており、 exp を過ぎておらず、 nonce が自分が送信した値である必要があります。JWTベストプラクティス(RFC 8725)では、ライブラリが受け入れる アルゴリズムのリストを呼び出し側が指定し (§3.1)、 aud を必ず検証し(§3.9)、 alg: none は「明示的に要求しない限り受け入れない」(SHOULD NOT、§3.2)よう求めています。

実測したトークンから2つの落とし穴が見つかりました。

  1. Keycloakアクセストークンの aud はデフォルトで account でした。 これは自分のAPIの名前ではありません。API側で aud を正しく検証するには、IdPにオーディエンス(audience)マッパーを追加してAPI名を含める必要があります。面倒だからとAPI側で aud の検証を無効にすると、同じIdPが別のアプリに発行したトークンで自分のAPIを呼び出せるようになってしまいます。
  2. JWTアクセストークンの標準(RFC 9068)ではヘッダーの typ を at+jwt にするよう規定していますが、実測したトークンは JWT でした(クレームの typ は Bearer)。RFC 9068 §4に従って typ を厳格にチェックするリソースサーバーであれば、このトークンを拒否します。どちらに合わせるか連携前に決めておく必要があります。

exp には時計の誤差の許容範囲(leeway)が適用されます。RFC 7519 §4.1.4では「通常数分を超えない小さな余裕」が許可されています。実測したIDトークンを 有効期限の1秒後の時計で検証した場合、余裕を持たせないと exp で失敗し、60秒の余裕を持たせると通過しました。Spring Securityリソースサーバーのデフォルトの余裕は60秒(公式ドキュメント)であり、セクション6のゲートウェイでも実測値は約60秒でした。

クラスターIdPの公開メタデータ

TT LabクラスターのKeycloakからDiscoveryドキュメントとJWKSを読み取りました(2026-10-26)。どちらも認証なしでアクセス可能な公開情報であり、以下の値はKeycloakがレルムごとにデフォルトで提供しているリストです。

項目 値
Discoveryドキュメントのキー数 57
エンドポイント authorization·token·userinfo·end_session·jwks·introspection·revocation·device_authorization·PAR·CIBA·registration
grant_types_supported 10個:authorization_code·refresh_token·client_credentials·device_code·token-exchange·jwt-bearer·uma-ticket·ciba、 およびimplicit·password
code_challenge_methods_supported plain, S256
id_token_signing_alg_values_supported 13個(RS·PS·ES·HS系およびEdDSA)
ログアウト backchannel·frontchannelの両方をサポート、セッション(sid)を含む
JWKS キー2個:署名用RS256(2048ビット)、暗号化用RSA-OAEP

ここで注目すべき点は、 Discoveryドキュメントはサーバーが「実行できること」のリストであり、クライアントに「許可されているもの」ではないということです。implicit·password·plainがリストに存在していても、クライアントごとに無効化することが可能であり、また無効化すべきです。クライアントごとに標準フロー(code)のみを有効にし、implicitやダイレクトアクセス(password)フローは無効にするのが基本姿勢です。

5. エージェント(SDK)方式:アプリ内のライブラリが行うこと

같은 OIDC, 다른 자리: 앱 안에서 하나, 앱 앞에서 하나 에이전트(SDK) 방식 브라우저 앱 프로세스 OIDC 라이브러리 리디렉션·콜백·세션·갱신·로그아웃 업무 코드가 사용자·역할을 직접 본다 IdP 뒤채널 /token · 로그아웃 게이트웨이(프록시) 방식 브라우저 게이트웨이 oauth2-proxy: 로그인·쿠키 Envoy·agentgateway: JWT 서명·aud·exp 검사 통과하면 헤더를 붙여 넘김 앱 헤더만 읽음 IdP 뒤채널 SDK 게이트웨이 앱 코드 변경 필요(언어마다) 거의 없음 세분화된 권한 쉽다(코드 안) 헤더·경로 단위 앱이 직접 노출되면 그래도 막힌다 뚫린다(우회 경로 금지) 로그아웃 반영 뒤채널 받으면 즉시 쿠키·토큰 수명까지
図:エージェント(SDK)方式とゲートウェイ方式の構成、および5つの比較

アプリ内のOIDCライブラリは、上記のセクション3〜4の内容をすべて代行します。リストアップすると、想像以上に長くなります。

  1. 開始時にDiscoveryドキュメントを読み込み、JWKSを取得してキャッシュする。未知の kid が届いた場合は(回数制限を設けた上で)再取得する。
  2. セッションなしで保護されたパスにアクセスがあった場合、state·nonce·verifier を作成して サーバー側のセッションに保存し、IdPへリダイレクトする。
  3. コールバックで state を照合し、code と verifier でトークンを取得し、IDトークンを検証する。
  4. アプリセッションクッキー(HttpOnly·Secure·SameSite)を発行する。トークンはサーバー側に保持する。
  5. アクセストークンの有効期限が切れる前にリフレッシュトークンで更新する。失敗した場合は再ログインさせる。
  6. ログアウト:アプリセッションを削除し、IdPの end_session_endpoint へ送信する(RP-Initiated Logout)。IdPが送信する バックチャネルログアウト リクエストを受け取るエンドポイントも公開する(セクション7)。
  7. APIを提供する側(リソースサーバー)の場合、受信したBearerトークンの署名·iss·aud·expを検証する。

ローカルの実測において、Pythonでこのプロセスを模倣したコードは約150行でしたが、それですらセッション保存、キーローテーション、並行処理は除外されています。自作せずに検証済みのライブラリを使用する理由はここにあります。

どのようなライブラリがあるか、そしてKeycloakアダプターの退場

かつてはKeycloakが言語別の アダプターを直接提供していました。現在はそのほとんどが姿を消しています。公式ブログをたどると、その経緯は以下の通りです。

日付 日
2022-02 OIDC Javaアダプター(WildFly·Spring Bootなど)およびNode.jsアダプターの非推奨化予告。代替として各プラットフォームの標準OIDCサポート(WildFly標準OIDC、Spring Security、Quarkus OIDC)を推奨
2023-03 サポート期間延長の告知。代替例としてSpring Security、Elytron OIDC、Quarkus OIDC
2023-11, 23.0.0 WildFly·EAP用OIDCアダプターの削除
2024-06, 25.0.0 Tomcat·サーブレットフィルター·Spring·Spring Boot アダプターなどの削除
2025-02 ブラウザー向けの keycloak-js は維持され、別リポジトリ・別リリースサイクルに分離

この流れの意味は明確です。「 IdP企業が言語ごとにSDKを維持する必要がないほど、OIDCが標準化された」ということです。JavaであればSpring Securityが代表格です。

# 로그인(oauth2Login) — 앱이 OIDC 클라이언트가 된다
spring.security.oauth2.client.provider.idp.issuer-uri: https://<IdP>/realms/<렐름>
spring.security.oauth2.client.registration.idp: { client-id: <앱>, client-secret: <비밀>, scope: [openid, profile] }
# 토큰 검사(oauth2ResourceServer) — API 가 Bearer 를 검증한다
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<IdP>/realms/<렐름>

issuer-uri により、1つの設定でDiscoveryドキュメントからJWKSアドレスを検索し(初回リクエスト時)、時計の許容範囲のデフォルトは60秒となります(Spring Securityドキュメント)。Spring Boot 4.xではスターター名が spring-boot-starter-security-oauth2-client·…-resource-server に変更され、古い名前は廃止予定となります。ログインしたユーザーの認可されたクライアント(トークン)はデフォルトで メモリ上の OAuth2AuthorizedClientService に保存されます。セッションに保存されるのは匿名リクエストのみです。アプリを複数のインスタンスにスケールアウトする場合、このストレージを共有ストレージに変更する必要があります。

Argo CD、Grafana、Hargo、Giteaなどの製品には 内部にOIDCクライアントが組み込まれており 、設定にIdPのアドレスとクライアントシークレットを入力するだけで動作します。これもエージェント方式の一種です。ライブラリを自ら組み込んだか、製品にあらかじめ含まれていたかの違いにすぎません。

6. ゲートウェイ方式:アプリの手前で代行して検証する

アプリを修正できない場合(他人が作成したダッシュボード)や、言語がバラバラなサービスが多数ある場合は、入口で一括処理する方が得策です。ゲートウェイの役割は大きく分けて2つあります。

これら2つのアプローチを組み合わせて使用する構成もよく見られます。ログインプロキシがPKCEでログインさせ、IdPのグループクレームでアクセス者を絞り込んだ後にトークンを Authorization ヘッダーとして付与し、その背後にあるサービスメッシュが同じJWTを 再度検証するような形です。この際の原則は1つです。「 プロキシのクッキー更新周期はIdPのアクセストークンの寿命より短くし、クッキーの有効期限はIdPのSSOセッションの最大長を超えないように する」ことです。これにより、背後でJWTを検証する側が有効期限切れのトークンを確認する前にプロキシが新しいトークンを取得でき、IdPでセッションが終了したユーザーがクッキーだけで継続して侵入するのを防ぐことができます。oauth2-proxyの公式設定表では、クッキー更新(ズレ)(cookie_refresh)のデフォルト値は空で、0の場合は無効になり、クッキーの有効期限(cookie_expire)のデフォルト値は168時間(7日)です。トークンを背後に転送する構成にする場合、双方はIdPの設定に合わせて手動で決定する必要があります。

実測:ゲートウェイはどのようなトークンを拒否するか

実習用のRSAキーでJWTを作成し、 jwtAuth(strict、iss·aud指定、JWKSファイル)を有効にしたagentgatewayをローカルに立ち上げ、リクエストを1件ずつ送信しました(2026-10-26)。

送信したトークン 結果 ゲートウェイが出力した理由
正常 200 —
トークンなし 401 no bearer token found
audが異なるアプリ 401 InvalidAudience
issが異なる 401 InvalidIssuer
ペイロードの1文字を変更 401 InvalidSignature
alg: none、署名なし 401 ヘッダーの解析失敗(不明な alg)
alg: HS256、公開鍵PEMをHMACシークレットに指定(アルゴリズムの混同) 401 InvalidAlgorithm
不明な kid 401 unknown key
nbf は2分後 401 ImmatureSignature
有効期限が10秒経過 200 —

最後の行が興味深い結果です。有効期限を10・30・55・58・62・65・90・120秒前に変更してみたところ、 58秒までは通過し、62秒からは拒否されました。また、 nbf も55秒後までは通過し、65秒後からは拒否されました。これは、前後に約60秒の時計の許容範囲(leeway)がデフォルトで組み込まれていることを意味します(ドキュメントでは未確認、実測値に基づく)。有効期限を「その秒数ぴったり」と信頼して設計してはならない理由はここにあります。

コストも計測しました。同じバックエンドを直接呼び出す場合、認証なしのゲートウェイを介する場合、JWT検証ありのゲートウェイを介する場合の3パターンで、それぞれ400回ずつ交互にリクエストを送信しました(50回のウォームアップ後)。

パス p50 p90 p99
バックエンド直接 0.60ms 0.73ms 1.19ms
ゲートウェイ(認証なし) 2.51ms 3.05ms 4.82ms
ゲートウェイ(JWT検証) 2.59ms 3.17ms 5.21ms

ゲートウェイをもう1つ経由することによるオーバーヘッド(約1.9ms、ノートPC上のコンテナネットワークの往復を含む)に比べ、 RS256検証そのものは約0.08ms でした。検証コストを理由にゲートウェイの導入をためらう必要はありません。

2つの方式のメリット・デメリット

エージェント(SDK) ゲートウェイ
アプリコード 変更が必要(言語やフレームワークごと) ほとんどそのまま
権限の判定 ビジネスロジック内で詳細に制御可能(所有者のみなど) パス・メソッド・ヘッダー単位で制御可能
アプリが予期せぬパスで露出した場合 それでもブロックされる そのままアクセスが通ってしまう アプリへの他の経路(サービス直接露出すべき、ポートフォワード)をネットワークポリシーでブロックする必要がある
ヘッダー信頼 該当なし アプリはゲートウェイが付与したヘッダーのみを信頼すべきであり、外部から同名のヘッダーが流入した場合はゲートウェイが削除すべきである
ログアウト反映 バックチャンネルを受信したら直ちに クッキーやトークンの寿命まで残る可能性がある
運用 アプリごとにライブラリのバージョン管理 一箇所でバージョン・設定を管理、ただしその場所が単一障害点になる

現実には組み合わせて使用します。ブラウザログインはゲートウェイ(またはBFF)が、APIの細やかな権限はアプリ内のリソースサーバーライブラリが担当する形です。

7. ログアウトとセッション同期

SSOのログアウトが難しい理由は、 セッションが3重になっているためです。IdPのSSOセッション(IdPクッキー)、アプリのセッション(アプリクッキー)、そしてすでに発行されて各自が保持しているトークンです。1つだけ削除しても残りが残存します。

方式 誰から誰へ 特徴
RP-Initiated Logout アプリ → IdP の end_session_endpoint id_token_hint 推奨、 post_logout_redirect_uri は登録された値と正確に一致している必要がある
Front-Channel Logout IdPがログアウト画面に各アプリのアドレスをiframeで埋め込む ブラウザがサードパーティクッキーをブロックしていると失敗しやすい
Back-Channel Logout IdP → アプリサーバーへ直接POST ブラウザとは無関係。 logout_token(JWT)に events と sub または sid, nonce は含まれていてはならない

実測: ログアウトしたのにアクセストークンがさらに5分間生存する

テスト用IdPのクライアントにバックチャンネルログアウトのアドレスを登録し(ノートPCで受信する小さなHTTPサーバー)、新しくログインした後にリフレッシュトークンでログアウトをリクエストしました。

観測 値
ログアウト要求 204
バックチャンネル通知の到着 66ms後, application/x-www-form-urlencoded の logout_token
logout_token ヘッダー alg: RS256, typ: logout+jwt(仕様の推奨値)
logout_token クレーム iss·aud·iat·exp·jti·events·sid·sub·typ. nonce なし。寿命120秒
events {"http://schemas.openid.net/event/backchannel-logout": {}}
sid ログイン時に取得したIDトークンの sid と一致 → アプリはどのセッションを削除すべきかを把握する
ログアウト後のリフレッシュ 400 「Session not active」
ログアウト後のintrospection (RFC 7662) active: false
ログアウト後の同じアクセストークンの署名・有効期限検証 パス、残り寿命299秒

最後の行が核心です。JWTアクセストークンは 自己完結型の証明書であるため、署名のみを確認するAPIやゲートウェイはIdPがセッションを削除したことを認識できません。ログアウト後もトークンの寿命が切れるまで(ここでは5分間)APIを呼び出すことができます。対策は3つのいずれかです。アクセストークンの寿命を短くするか、重要なAPIはintrospectionでIdPに問い合わせるか(リクエストごとのラウンドトリップコストが発生)、バックチャンネル通知を受信してその sid をブロックリストに登録します。

リフレッシュトークンにも同様の悩みがあります。テスト用レルムのデフォルト値(ローテーション無効)では、 すでに一度使用したリフレッシュトークンを再使用しても200 でした。レルムの設定 revokeRefreshToken を有効にすると(ローテーション)、古いリフレッシュトークンは400 「Maximum allowed refresh token reuse exceeded」として拒否され、 その直後に新しいリフレッシュトークンまで「Session doesn't have required client」としてブロックされました。 再利用を盗難の兆候と見なし、そのセッション全体を切断する動作です。RFC 9700 §2.2.2では、パブリッククライアントのリフレッシュトークンはローテーションするか、送信者にバインドするよう求めています。

8. よくある落とし穴

① redirect_uriの不一致。 IdPは登録されたアドレスと 文字列として厳密に 比較します(RFC 9700 §2.1、ネイティブアプリのlocalhostポートのみ例外)。実測において、末尾の / が1文字違うだけで400画面が表示されました。プロキシ配下でアプリが自身のアドレスを http:// として認識している場合(TLSを前段で終端している場合)、またはポートやパスのプレフィックスが異なると同様のエラーが発生します。ワイルドカードのredirect URIは攻撃者がcodeを自身のパスに取得させる原因となるため、使用しません。

② issの文字列不一致。 トークンの iss とアプリ設定のissuerが http/https、ホスト名、末尾の / まで一致している必要があります。IdPを内部アドレスで呼び出し、ブラウザは公開アドレス経由でアクセスする構成で特に頻繁に発生します。Keycloakはリクエストを受け付けたホスト名で iss を生成できるため、固定された公開ホスト名を設定しておく方が安全です。

③ クロックズキュー(時刻のずれ)。 IdP、ゲートウェイ、アプリサーバーの時刻が異なると、取得したばかりのトークンが「まだ有効ではありません」(nbf・iat)や「有効期限切れ」になってしまいます。実測されたデフォルトの許容誤差はゲートウェイ、Springともに約60秒でした。許容誤差を増やす前に、まずNTPを同期させます。

④ トークンをブラウザストレージに保存する。 SPAがアクセストークンやリフレッシュトークンを localStorage に保存すると、1度のXSSですべて流出します。2026年8月にRFC 10017(BCP 212)となった「ブラウザベースアプリケーション向けOAuth 2.0」では、パターンをセキュリティの順にBFF(Backend for Frontend)→トークン仲介バックエンド→ブラウザ単体の順に並べ、ブラウザ単体は前述したすべての攻撃に対して脆弱であると記載しています。BFFはシークレットを持つサーバー側のクライアントとしてトークンをサーバーに保持し、ブラウザにはHttpOnlyセッションクッキーのみを渡します。つまり、 第5節のSDK方式をサーバー側で行うことになります。

⑤ audを確認しない。 第4節のKeycloakのデフォルト aud: account のように、トークンの対象が自身のAPIではないにもかかわらず受け入れてしまうと、同じIdP内の他のアプリのトークンで自身のAPIを呼び出せてしまいます。

⑥ Secureクッキーと平文http。 IdPのログインクッキーには Secure が付与されています(実測)。ブラウザは localhost·127.0.0.1 を安全な送信元と見なして送信しますが、LAN内の平文httpアドレスでは送信されないため、「ログインしたのに再度ログイン画面が表示される」現象が繰り返されます。実測中にPythonのクッキーリポジトリが同様の理由でクッキーを送信せず、400エラーを受け取りました。

⑦ JWKSキーのローテーション。 IdPがキーを更新すると、新しい kid のトークンが送られてきます。キャッシュを長く保持している検証器(Envoyのデフォルトは10分)はその間拒否してしまうため、IdPは新しいキーを事前に公開してしばらく経ってから署名に使用し、検証器側には不明な kid からJWKSを再取得させます。

⑧ ゲートウェイのバイパスとヘッダー偽造。 ゲートウェイ方式においてアプリが X-Auth-Request-User を信頼する場合、外部からそのヘッダーを直接付与してアプリを直接呼び出す経路が存在してはなりません。

9. どちらを選ぶべきか

状況 推奨される選択肢
サードパーティ製のWeb UI(ダッシュボード・ノートブック)の前段にログインを配置したい場合 ゲートウェイ(oauth2-proxy)
製品にOIDCがすでに組み込まれている場合(Argo CD・Grafanaなど) 製品内蔵(=SDK方式)
自社開発のWebアプリ、細やかな権限管理が必要な場合 サーバー側SDK(BFF)、Spring Security、各言語の認証ライブラリ
サービス間、CLI、エージェントがBearerトークンで呼び出すAPI ゲートウェイでのJWT検証 + アプリ内のリソースサーバー検証(2重チェック)
純粋なSPA、サーバーレス 可能であればBFFを1つ配置する。難しい場合はPKCE + 短命トークン + ローテーション

10. まとめ

Titiの一言。 通行証(トークン)にスタンプ(署名)が押してあっても、「うちの卓球場のものか」(aud)、「今日の日付か」(exp)は個別に確認しなければなりません。また、退場させても手に持っている通行証は有効期限まで有効なため、有効期限を短く設定するのが賢明です。

参考

ログインするといいねできます

コメント

まだコメントはありません。

ログインするとコメントできます