Skip to content

Commit 4348c7d

Browse files
YeonghyeonKOclaude
andcommitted
feat(sso): PKCE, nonce 검증, Keycloak end_session 로그아웃 보안 강화
레퍼런스 구현(https://github.com/Wonki4/llm-ops) 분석을 통해 식별한 세 가지 보안 취약점을 Keycloak SSO 플러그인에 적용합니다. - PKCE S256: /login에서 code_verifier/code_challenge 생성, state JWT에 code_verifier 저장, /callback에서 token exchange 시 code_verifier 전달 - Nonce 검증: /login에서 nonce 생성 후 auth URL과 state JWT에 포함, /callback에서 id_token nonce claim 비교로 재전송 공격 방지 - Keycloak end_session 로그아웃: 로그인 시 id_token을 kc_id_token_lf 쿠키에 저장, /logout에서 id_token_hint로 Keycloak end_session_endpoint 호출하여 SSO 세션 완전 종료 - settings.py에 end_session_endpoint 프로퍼티 및 LOGOUT_REDIRECT_URI 설정 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3654d47 commit 4348c7d

3 files changed

Lines changed: 112 additions & 15 deletions

File tree

src/backend/langflow-keycloak-sso/src/langflow_keycloak_sso/keycloak_client.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,23 @@ def __init__(self, token_endpoint: str, jwks_uri: str, client_id: str, client_se
1717
self._client_secret = client_secret
1818
self._jwks_client = PyJWKClient(jwks_uri)
1919

20-
async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]:
20+
async def exchange_code(
21+
self, code: str, redirect_uri: str, code_verifier: str | None = None
22+
) -> dict[str, Any]:
2123
"""Exchange authorization code for tokens. Returns the token response dict."""
24+
post_data: dict[str, str] = {
25+
"grant_type": "authorization_code",
26+
"client_id": self._client_id,
27+
"client_secret": self._client_secret,
28+
"code": code,
29+
"redirect_uri": redirect_uri,
30+
}
31+
if code_verifier is not None:
32+
post_data["code_verifier"] = code_verifier
2233
async with httpx.AsyncClient() as client:
2334
resp = await client.post(
2435
self._token_endpoint,
25-
data={
26-
"grant_type": "authorization_code",
27-
"client_id": self._client_id,
28-
"client_secret": self._client_secret,
29-
"code": code,
30-
"redirect_uri": redirect_uri,
31-
},
36+
data=post_data,
3237
headers={"Content-Type": "application/x-www-form-urlencoded"},
3338
timeout=15,
3439
)

src/backend/langflow-keycloak-sso/src/langflow_keycloak_sso/router.py

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from __future__ import annotations
22

3+
import base64
4+
import hashlib
5+
import os
36
import secrets
47
import urllib.parse
58
from datetime import datetime, timedelta, timezone
69
from typing import Annotated
710

811
import jwt as pyjwt
9-
from fastapi import APIRouter, HTTPException, status
12+
from fastapi import APIRouter, HTTPException, Request, status
1013
from fastapi.responses import RedirectResponse
1114

1215
from langflow.api.utils.core import DbSession
@@ -30,9 +33,23 @@ def _get_state_secret() -> str:
3033
return secret.get_secret_value() if hasattr(secret, "get_secret_value") else str(secret)
3134

3235

33-
def _create_state_token(redirect_after: str) -> str:
36+
def _generate_pkce() -> tuple[str, str]:
37+
"""Return (code_verifier, code_challenge) for PKCE S256."""
38+
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
39+
challenge = base64.urlsafe_b64encode(
40+
hashlib.sha256(verifier.encode()).digest()
41+
).rstrip(b"=").decode()
42+
return verifier, challenge
43+
44+
45+
def _create_state_token(redirect_after: str, nonce: str, code_verifier: str) -> str:
3446
exp = datetime.now(timezone.utc) + timedelta(seconds=_STATE_TTL_SECONDS)
35-
payload = {"redirect_after": redirect_after, "nonce": secrets.token_hex(16), "exp": exp}
47+
payload = {
48+
"redirect_after": redirect_after,
49+
"nonce": nonce,
50+
"code_verifier": code_verifier,
51+
"exp": exp,
52+
}
3653
return pyjwt.encode(payload, _get_state_secret(), algorithm=_STATE_ALGORITHM)
3754

3855

@@ -69,13 +86,18 @@ async def keycloak_login(redirect_after: str = "/"):
6986
if not s.ENABLED:
7087
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keycloak SSO is not enabled")
7188

72-
state = _create_state_token(redirect_after)
89+
nonce = secrets.token_hex(16)
90+
code_verifier, code_challenge = _generate_pkce()
91+
state = _create_state_token(redirect_after, nonce=nonce, code_verifier=code_verifier)
7392
params = {
7493
"client_id": s.CLIENT_ID,
7594
"redirect_uri": s.REDIRECT_URI,
7695
"response_type": "code",
7796
"scope": "openid email profile",
7897
"state": state,
98+
"nonce": nonce,
99+
"code_challenge": code_challenge,
100+
"code_challenge_method": "S256",
79101
}
80102
url = s.authorization_endpoint + "?" + urllib.parse.urlencode(params)
81103
return RedirectResponse(url=url, status_code=status.HTTP_302_FOUND)
@@ -109,11 +131,13 @@ async def keycloak_callback(
109131
# 1. Validate state (CSRF protection)
110132
state_payload = _decode_state_token(state)
111133
redirect_after = state_payload.get("redirect_after", "/")
134+
nonce = state_payload.get("nonce", "")
135+
code_verifier = state_payload.get("code_verifier", "") or None
112136

113-
# 2. Exchange code for tokens
137+
# 2. Exchange code for tokens (with PKCE code_verifier if present)
114138
client = _get_keycloak_client()
115139
try:
116-
token_response = await client.exchange_code(code, s.REDIRECT_URI)
140+
token_response = await client.exchange_code(code, s.REDIRECT_URI, code_verifier=code_verifier)
117141
except ValueError as exc:
118142
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
119143

@@ -127,6 +151,13 @@ async def keycloak_callback(
127151
detail=f"Keycloak token verification failed: {exc}",
128152
) from exc
129153

154+
# 3b. Verify nonce in id_token to prevent replay attacks
155+
id_token: str = token_response.get("id_token", "")
156+
if id_token and nonce:
157+
id_claims = pyjwt.decode(id_token, options={"verify_signature": False})
158+
if id_claims.get("nonce") != nonce:
159+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nonce mismatch in id_token")
160+
130161
# 4. Log into the shared account (auto-created on first login)
131162
user = await get_or_create_shared_user(db, s.SHARED_USERNAME)
132163
await db.commit()
@@ -164,24 +195,42 @@ async def keycloak_callback(
164195
expires=None,
165196
domain=auth_settings.COOKIE_DOMAIN,
166197
)
198+
if id_token:
199+
redirect.set_cookie(
200+
"kc_id_token_lf",
201+
id_token,
202+
httponly=True,
203+
samesite=auth_settings.ACCESS_SAME_SITE,
204+
secure=auth_settings.ACCESS_SECURE,
205+
expires=auth_settings.ACCESS_TOKEN_EXPIRE_SECONDS,
206+
domain=auth_settings.COOKIE_DOMAIN,
207+
)
167208
return redirect
168209

169210

170211
@router.get("/logout", include_in_schema=False)
171-
async def keycloak_logout():
212+
async def keycloak_logout(request: Request):
172213
"""Clear Langflow session cookies and redirect to the login page.
173214
215+
When Keycloak SSO end_session_endpoint is available and an id_token cookie
216+
is present, also terminates the upstream Keycloak SSO session so that the
217+
user is fully logged out across all applications sharing the same realm.
218+
174219
Using a server-side redirect (rather than a JS fetch) guarantees that the
175220
Set-Cookie headers that expire the cookies are delivered to the browser
176221
even when the frontend's IS_AUTO_LOGIN constant skips the normal logout
177222
API call.
178223
"""
224+
s = get_keycloak_settings()
179225
auth_settings = get_settings_service().auth_settings
226+
id_token = request.cookies.get("kc_id_token_lf", "")
227+
180228
redirect = RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)
181229
for name, httponly, samesite, secure in [
182230
("refresh_token_lf", auth_settings.REFRESH_HTTPONLY, auth_settings.REFRESH_SAME_SITE, auth_settings.REFRESH_SECURE),
183231
("access_token_lf", auth_settings.ACCESS_HTTPONLY, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
184232
("apikey_tkn_lflw", auth_settings.ACCESS_HTTPONLY, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
233+
("kc_id_token_lf", True, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
185234
]:
186235
redirect.delete_cookie(
187236
name,
@@ -190,4 +239,39 @@ async def keycloak_logout():
190239
secure=secure,
191240
domain=auth_settings.COOKIE_DOMAIN,
192241
)
242+
243+
if s.end_session_endpoint and id_token:
244+
# Determine where Keycloak should send the browser after its logout page.
245+
if s.LOGOUT_REDIRECT_URI:
246+
post_logout_uri = s.LOGOUT_REDIRECT_URI
247+
else:
248+
# Try to derive the origin from REDIRECT_URI (e.g. https://app.company.com/api/v1/keycloak/callback
249+
# → https://app.company.com/login).
250+
try:
251+
parsed = urllib.parse.urlparse(s.REDIRECT_URI)
252+
post_logout_uri = urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "/login", "", "", ""))
253+
except Exception:
254+
post_logout_uri = "/login"
255+
256+
kc_logout_params = {
257+
"id_token_hint": id_token,
258+
"post_logout_redirect_uri": post_logout_uri,
259+
}
260+
kc_logout_url = s.end_session_endpoint + "?" + urllib.parse.urlencode(kc_logout_params)
261+
redirect = RedirectResponse(url=kc_logout_url, status_code=status.HTTP_302_FOUND)
262+
# Re-delete the cookies on the new redirect response as well.
263+
for name, httponly, samesite, secure in [
264+
("refresh_token_lf", auth_settings.REFRESH_HTTPONLY, auth_settings.REFRESH_SAME_SITE, auth_settings.REFRESH_SECURE),
265+
("access_token_lf", auth_settings.ACCESS_HTTPONLY, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
266+
("apikey_tkn_lflw", auth_settings.ACCESS_HTTPONLY, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
267+
("kc_id_token_lf", True, auth_settings.ACCESS_SAME_SITE, auth_settings.ACCESS_SECURE),
268+
]:
269+
redirect.delete_cookie(
270+
name,
271+
httponly=httponly,
272+
samesite=samesite,
273+
secure=secure,
274+
domain=auth_settings.COOKIE_DOMAIN,
275+
)
276+
193277
return redirect

src/backend/langflow-keycloak-sso/src/langflow_keycloak_sso/settings.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ class KeycloakSettings(BaseSettings):
3939
# Falls back to LANGFLOW_SECRET_KEY at runtime if not set.
4040
STATE_SECRET: str = Field(default="")
4141

42+
# Where Keycloak should redirect the browser after its own logout page.
43+
# When empty, the router constructs a fallback from REDIRECT_URI base + "/login".
44+
LOGOUT_REDIRECT_URI: str = ""
45+
4246
# Removed: GROUPS_CLAIM — group-based mapping is no longer used.
4347
# Authorization is fully delegated to Keycloak (client-level access control).
4448

@@ -59,6 +63,10 @@ def jwks_uri(self) -> str:
5963
def userinfo_endpoint(self) -> str:
6064
return f"{self.SERVER_URL}/realms/{self.REALM}/protocol/openid-connect/userinfo"
6165

66+
@property
67+
def end_session_endpoint(self) -> str:
68+
return f"{self.SERVER_URL}/realms/{self.REALM}/protocol/openid-connect/logout"
69+
6270

6371
@lru_cache(maxsize=1)
6472
def get_keycloak_settings() -> KeycloakSettings:

0 commit comments

Comments
 (0)