1+ from typing import Any , Dict
2+ import httpx
13import jwt
24from fastapi import Depends , HTTPException , WebSocket , status
35from fastapi .security import OAuth2AuthorizationCodeBearer
@@ -49,7 +51,7 @@ def get_current_user_id(token: str = Depends(oauth2_scheme)):
4951async def websocket_authenticate (websocket : WebSocket ) -> str | None :
5052 """
5153 Authenticate a WebSocket connection using a JWT token from query params.
52- Returns the ID of the authenticated user payload if valid, otherwise closes the connection.
54+ Returns the token of the authenticated user payload if valid, otherwise closes the connection.
5355 """
5456 logger .debug ("Authenticating websocket" )
5557 token = websocket .query_params .get ("token" )
@@ -59,10 +61,82 @@ async def websocket_authenticate(websocket: WebSocket) -> str | None:
5961 return None
6062
6163 try :
62- user_id = get_current_user_id (token )
6364 await websocket .accept ()
64- return user_id
65+ return token
6566 except Exception as e :
6667 logger .error (f"Invalid token in websocket authentication: { e } " )
6768 await websocket .close (code = 1008 , reason = "Invalid token" )
6869 return None
70+
71+
72+ async def exchange_token_for_provider (
73+ initial_token : str , provider : str
74+ ) -> Dict [str , Any ]:
75+ """
76+ Exchange a Keycloak access token for a token/audience targeted at `provider`
77+ using the Keycloak Token Exchange (grant_type=urn:ietf:params:oauth:grant-type:token-exchange).
78+
79+ :param initial_token: token obtained from the client (Bearer token)
80+ :param provider: target provider name or client_id.
81+
82+ :return: The token response (dict) on success.
83+
84+ :raise: Raises HTTPException with an appropriate status and message on error.
85+ """
86+ token_url = f"{ KEYCLOAK_BASE_URL } /protocol/openid-connect/token"
87+
88+ # Check if the necessary settings are in place
89+ if not settings .keycloak_client_id or not settings .keycloak_client_secret :
90+ raise HTTPException (
91+ status_code = status .HTTP_500_INTERNAL_SERVER_ERROR ,
92+ detail = "Token exchange not configured on the server (missing client credentials)." ,
93+ )
94+
95+ payload = {
96+ "grant_type" : "urn:ietf:params:oauth:grant-type:token-exchange" ,
97+ "client_id" : settings .keycloak_client_id ,
98+ "client_secret" : settings .keycloak_client_secret ,
99+ "subject_token" : initial_token ,
100+ "requested_issuer" : provider ,
101+ }
102+
103+ try :
104+ async with httpx .AsyncClient (timeout = 10.0 ) as client :
105+ resp = await client .post (token_url , data = payload )
106+ except httpx .RequestError as exc :
107+ logger .error (f"Token exchange network error for provider={ provider } : { exc } " )
108+ raise HTTPException (
109+ status_code = status .HTTP_502_BAD_GATEWAY ,
110+ detail = "Failed to contact the identity provider for token exchange." ,
111+ )
112+
113+ # Parse response
114+ try :
115+ body = resp .json ()
116+ except ValueError :
117+ logger .error (
118+ f"Token exchange invalid JSON response (status={ resp .status_code } )"
119+ )
120+ raise HTTPException (
121+ status_code = status .HTTP_502_BAD_GATEWAY ,
122+ detail = "Invalid response from identity provider during token exchange." ,
123+ )
124+
125+ if resp .status_code != 200 :
126+ # Keycloak returns error and error_description fields for token errors
127+ err = body .get ("error_description" ) or body .get ("error" ) or resp .text
128+ logger .error (
129+ "Token exchange failed" ,
130+ extra = {"provider" : provider , "status" : resp .status_code , "error" : err },
131+ )
132+ # Map common upstream statuses to meaningful client statuses
133+ client_status = (
134+ status .HTTP_401_UNAUTHORIZED
135+ if resp .status_code in (400 , 401 , 403 )
136+ else status .HTTP_502_BAD_GATEWAY
137+ )
138+
139+ raise HTTPException (client_status , detail = body )
140+
141+ # Successful exchange, return token response (access_token, expires_in, etc.)
142+ return body
0 commit comments