11"""API endpoints for OAuth configuration management."""
22
3+ import secrets
4+
35from fastapi import APIRouter , Depends , HTTPException
6+ from pydantic import BaseModel , ConfigDict , Field
47from sqlalchemy .orm import Session
58
6- from onyx .auth .oauth_token_manager import OAuthTokenManager
9+ from onyx .auth .oauth_token_manager import (
10+ OAuthTokenManager ,
11+ conflicting_authorization_params ,
12+ )
713from onyx .auth .permissions import has_global_permission , require_permission
14+ from onyx .auth .pkce import generate_pkce_pair
15+ from onyx .cache .factory import get_cache_backend
816from onyx .configs .app_configs import WEB_DOMAIN
917from onyx .db .engine .sql_engine import get_session
1018from onyx .db .enums import Permission
2129)
2230from onyx .error_handling .error_codes import OnyxErrorCode
2331from onyx .error_handling .exceptions import OnyxError
24- from onyx .federated_connectors .oauth_utils import (
25- generate_oauth_state ,
26- verify_oauth_state ,
32+ from onyx .oauth .authorization_attempt import (
33+ AuthorizationAttemptStore ,
34+ canonical_json_fingerprint ,
35+ generate_authorization_state ,
2736)
37+ from onyx .oauth .models import SafeOAuthReturnPath
2838from onyx .server .features .oauth_config .models import (
2939 OAuthCallbackResponse ,
3040 OAuthConfigCreate ,
4050admin_router = APIRouter (prefix = "/admin/oauth-config" )
4151router = APIRouter (prefix = "/oauth-config" )
4252
53+ _OAUTH_CALLBACK_PATH = "/oauth-config/callback"
54+ _AUTHORIZATION_ATTEMPT_TTL_SECONDS = 10 * 60
55+
56+
57+ class _OAuthConfigAttemptPayload (BaseModel ):
58+ model_config = ConfigDict (extra = "forbid" , frozen = True )
59+
60+ oauth_config_id : int
61+ return_path : SafeOAuthReturnPath
62+ configuration_fingerprint : str = Field (pattern = r"^[0-9a-f]{64}$" )
63+ code_verifier : str = Field (min_length = 43 , max_length = 128 )
64+
65+
66+ def _authorization_attempt_store () -> AuthorizationAttemptStore [
67+ _OAuthConfigAttemptPayload
68+ ]:
69+ return AuthorizationAttemptStore (
70+ get_cache_backend (),
71+ namespace = "oauth-config" ,
72+ payload_type = _OAuthConfigAttemptPayload ,
73+ ttl_seconds = _AUTHORIZATION_ATTEMPT_TTL_SECONDS ,
74+ )
75+
76+
77+ def _oauth_callback_url () -> str :
78+ return f"{ WEB_DOMAIN } { _OAUTH_CALLBACK_PATH } "
79+
80+
81+ def _oauth_config_fingerprint (oauth_config : OAuthConfig ) -> str :
82+ return canonical_json_fingerprint (
83+ {
84+ "redirect_uri" : _oauth_callback_url (),
85+ "flow" : OAuthTokenManager .flow_params (oauth_config ).model_dump (mode = "json" ),
86+ },
87+ )
88+
89+
90+ def _validate_additional_authorization_params (oauth_config : OAuthConfig ) -> None :
91+ reserved = conflicting_authorization_params (oauth_config .additional_params )
92+ if reserved :
93+ raise OnyxError (
94+ OnyxErrorCode .INVALID_INPUT ,
95+ "OAuth additional parameters cannot override: "
96+ f"{ ', ' .join (sorted (reserved ))} " ,
97+ )
98+
4399
44100def _oauth_config_to_snapshot (
45101 oauth_config : OAuthConfig , db_session : Session
@@ -215,18 +271,25 @@ def initiate_oauth_flow(
215271 detail = f"OAuth config with id { request .oauth_config_id } not found" ,
216272 )
217273
218- # Generate state parameter and store in Redis
219- state = generate_oauth_state (
220- federated_connector_id = request .oauth_config_id ,
221- user_id = str (user .id ),
222- redirect_uri = request .return_path ,
223- additional_data = {"oauth_config_id" : request .oauth_config_id },
224- )
274+ _validate_additional_authorization_params (oauth_config )
275+ code_verifier , code_challenge = generate_pkce_pair ()
276+ state = generate_authorization_state ()
225277
226- # Build authorization URL
227- redirect_uri = f"{ WEB_DOMAIN } /oauth-config/callback"
228278 authorization_url = OAuthTokenManager .build_authorization_url (
229- oauth_config , redirect_uri , state
279+ oauth_config ,
280+ _oauth_callback_url (),
281+ state ,
282+ code_challenge = code_challenge ,
283+ )
284+ _authorization_attempt_store ().store (
285+ owner_id = str (user .id ),
286+ state = state ,
287+ payload = _OAuthConfigAttemptPayload (
288+ oauth_config_id = oauth_config .id ,
289+ return_path = request .return_path ,
290+ configuration_fingerprint = _oauth_config_fingerprint (oauth_config ),
291+ code_verifier = code_verifier ,
292+ ),
230293 )
231294
232295 return OAuthInitiateResponse (authorization_url = authorization_url , state = state )
@@ -245,39 +308,39 @@ def handle_oauth_callback(
245308 Exchanges the authorization code for an access token and stores it.
246309 Accepts code and state as query parameters (standard OAuth flow).
247310 """
248- try :
249- # Verify state and retrieve session data
250- session = verify_oauth_state (state )
251-
252- # Verify the user_id matches
253- if str (user .id ) != session .user_id :
254- raise HTTPException (
255- status_code = 403 , detail = "User mismatch in OAuth callback"
256- )
257-
258- # Extract oauth_config_id from session (stored during initiate)
259- oauth_config_id = session .federated_connector_id
260-
261- # Get OAuth config
262- oauth_config = get_oauth_config (oauth_config_id , db_session )
263- if not oauth_config :
264- raise HTTPException (
265- status_code = 404 ,
266- detail = f"OAuth config with id { oauth_config_id } not found" ,
267- )
311+ attempt = _authorization_attempt_store ().consume (owner_id = str (user .id ), state = state )
312+ payload = attempt .payload
268313
314+ oauth_config = get_oauth_config (payload .oauth_config_id , db_session )
315+ if not oauth_config :
316+ raise OnyxError (
317+ OnyxErrorCode .NOT_FOUND ,
318+ f"OAuth config with id { payload .oauth_config_id } not found" ,
319+ )
320+ if not secrets .compare_digest (
321+ payload .configuration_fingerprint ,
322+ _oauth_config_fingerprint (oauth_config ),
323+ ):
324+ raise OnyxError (
325+ OnyxErrorCode .INVALID_INPUT ,
326+ "OAuth configuration changed while authorization was pending." ,
327+ )
328+
329+ try :
269330 # Exchange code for token
270- redirect_uri = f"{ WEB_DOMAIN } /oauth-config/callback"
271331 token_manager = OAuthTokenManager (oauth_config , user .id , db_session )
272- token_data = token_manager .exchange_code_for_token (code , redirect_uri )
332+ token_data = token_manager .exchange_code_for_token (
333+ code ,
334+ _oauth_callback_url (),
335+ code_verifier = payload .code_verifier ,
336+ )
273337
274338 # Store token
275339 upsert_user_oauth_token (oauth_config .id , user .id , token_data , db_session )
276340
277341 # Return success with redirect
278- return_path = session .redirect_uri or "/chat"
279342 return OAuthCallbackResponse (
280- redirect_url = return_path ,
343+ redirect_url = payload . return_path ,
281344 )
282345
283346 except ValueError as e :
0 commit comments