11from __future__ import annotations
22
3+ import base64
4+ import hashlib
5+ import os
36import secrets
47import urllib .parse
58from datetime import datetime , timedelta , timezone
69from typing import Annotated
710
811import jwt as pyjwt
9- from fastapi import APIRouter , HTTPException , status
12+ from fastapi import APIRouter , HTTPException , Request , status
1013from fastapi .responses import RedirectResponse
1114
1215from 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
0 commit comments