11"""API endpoints for OAuth configuration management."""
22
3+ import secrets
4+
35from fastapi import APIRouter , Depends , HTTPException
6+ from pydantic import BaseModel , ConfigDict
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
815from onyx .configs .app_configs import WEB_DOMAIN
916from onyx .db .engine .sql_engine import get_session
1017from onyx .db .enums import Permission
2128)
2229from onyx .error_handling .error_codes import OnyxErrorCode
2330from onyx .error_handling .exceptions import OnyxError
24- from onyx .federated_connectors .oauth_utils import (
25- generate_oauth_state ,
26- verify_oauth_state ,
31+ from onyx .oauth .authorization_attempt import (
32+ AuthorizationAttemptStore ,
33+ canonical_json_fingerprint ,
34+ generate_authorization_state ,
35+ )
36+ from onyx .oauth .models import (
37+ OAuthConfigurationFingerprint ,
38+ PKCECodeVerifier ,
39+ SafeOAuthReturnPath ,
2740)
2841from onyx .server .features .oauth_config .models import (
2942 OAuthCallbackResponse ,
4053admin_router = APIRouter (prefix = "/admin/oauth-config" )
4154router = APIRouter (prefix = "/oauth-config" )
4255
56+ _OAUTH_CALLBACK_PATH = "/oauth-config/callback"
57+
58+
59+ class _OAuthConfigAttemptPayload (BaseModel ):
60+ model_config = ConfigDict (extra = "forbid" , frozen = True )
61+
62+ oauth_config_id : int
63+ return_path : SafeOAuthReturnPath
64+ configuration_fingerprint : OAuthConfigurationFingerprint
65+ code_verifier : PKCECodeVerifier
66+
67+
68+ _AUTHORIZATION_ATTEMPTS = AuthorizationAttemptStore (
69+ namespace = "oauth-config" ,
70+ payload_type = _OAuthConfigAttemptPayload ,
71+ )
72+
73+
74+ def _oauth_callback_url () -> str :
75+ return f"{ WEB_DOMAIN } { _OAUTH_CALLBACK_PATH } "
76+
77+
78+ def _oauth_config_fingerprint (oauth_config : OAuthConfig ) -> str :
79+ return canonical_json_fingerprint (
80+ {
81+ "redirect_uri" : _oauth_callback_url (),
82+ "flow" : OAuthTokenManager .flow_params (oauth_config ).model_dump (mode = "json" ),
83+ },
84+ )
85+
86+
87+ def _validate_additional_authorization_params (oauth_config : OAuthConfig ) -> None :
88+ reserved = conflicting_authorization_params (oauth_config .additional_params )
89+ if reserved :
90+ raise OnyxError (
91+ OnyxErrorCode .INVALID_INPUT ,
92+ "OAuth additional parameters cannot override: "
93+ f"{ ', ' .join (sorted (reserved ))} " ,
94+ )
95+
4396
4497def _oauth_config_to_snapshot (
4598 oauth_config : OAuthConfig , db_session : Session
@@ -215,18 +268,25 @@ def initiate_oauth_flow(
215268 detail = f"OAuth config with id { request .oauth_config_id } not found" ,
216269 )
217270
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- )
271+ _validate_additional_authorization_params (oauth_config )
272+ code_verifier , code_challenge = generate_pkce_pair ()
273+ state = generate_authorization_state ()
225274
226- # Build authorization URL
227- redirect_uri = f"{ WEB_DOMAIN } /oauth-config/callback"
228275 authorization_url = OAuthTokenManager .build_authorization_url (
229- oauth_config , redirect_uri , state
276+ oauth_config ,
277+ _oauth_callback_url (),
278+ state ,
279+ code_challenge = code_challenge ,
280+ )
281+ _AUTHORIZATION_ATTEMPTS .store (
282+ owner_id = str (user .id ),
283+ state = state ,
284+ payload = _OAuthConfigAttemptPayload (
285+ oauth_config_id = oauth_config .id ,
286+ return_path = request .return_path ,
287+ configuration_fingerprint = _oauth_config_fingerprint (oauth_config ),
288+ code_verifier = code_verifier ,
289+ ),
230290 )
231291
232292 return OAuthInitiateResponse (authorization_url = authorization_url , state = state )
@@ -245,39 +305,39 @@ def handle_oauth_callback(
245305 Exchanges the authorization code for an access token and stores it.
246306 Accepts code and state as query parameters (standard OAuth flow).
247307 """
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- )
308+ attempt = _AUTHORIZATION_ATTEMPTS .consume (owner_id = str (user .id ), state = state )
309+ payload = attempt .payload
310+
311+ oauth_config = get_oauth_config (payload .oauth_config_id , db_session )
312+ if not oauth_config :
313+ raise OnyxError (
314+ OnyxErrorCode .NOT_FOUND ,
315+ f"OAuth config with id { payload .oauth_config_id } not found" ,
316+ )
317+ if not secrets .compare_digest (
318+ payload .configuration_fingerprint ,
319+ _oauth_config_fingerprint (oauth_config ),
320+ ):
321+ raise OnyxError (
322+ OnyxErrorCode .INVALID_INPUT ,
323+ "OAuth configuration changed while authorization was pending." ,
324+ )
268325
326+ try :
269327 # Exchange code for token
270- redirect_uri = f"{ WEB_DOMAIN } /oauth-config/callback"
271328 token_manager = OAuthTokenManager (oauth_config , user .id , db_session )
272- token_data = token_manager .exchange_code_for_token (code , redirect_uri )
329+ token_data = token_manager .exchange_code_for_token (
330+ code ,
331+ _oauth_callback_url (),
332+ code_verifier = payload .code_verifier ,
333+ )
273334
274335 # Store token
275336 upsert_user_oauth_token (oauth_config .id , user .id , token_data , db_session )
276337
277338 # Return success with redirect
278- return_path = session .redirect_uri or "/chat"
279339 return OAuthCallbackResponse (
280- redirect_url = return_path ,
340+ redirect_url = payload . return_path ,
281341 )
282342
283343 except ValueError as e :
0 commit comments