Skip to content

Commit 8115ace

Browse files
committed
refactor(oauth-config): use shared authorization attempts
1 parent dfafe8e commit 8115ace

4 files changed

Lines changed: 346 additions & 46 deletions

File tree

backend/onyx/auth/oauth_token_manager.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,8 +276,14 @@ def is_token_expired(cls, token_data: dict[str, Any]) -> bool:
276276
# Add 60 second buffer to avoid race conditions
277277
return int(time.time()) + 60 >= expires_at
278278

279-
def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]:
280-
"""Exchange authorization code for access token"""
279+
def exchange_code_for_token(
280+
self,
281+
code: str,
282+
redirect_uri: str,
283+
*,
284+
code_verifier: str | None = None,
285+
) -> dict[str, Any]:
286+
"""Exchange an authorization code, including a PKCE verifier when supplied."""
281287
if (
282288
self.oauth_config.client_id is None
283289
or self.oauth_config.client_secret is None
@@ -287,22 +293,33 @@ def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any
287293
)
288294

289295
return exchange_oauth_code_for_token(
290-
self._flow_params(self.oauth_config), code, redirect_uri
296+
self.flow_params(self.oauth_config),
297+
code,
298+
redirect_uri,
299+
code_verifier=code_verifier,
291300
)
292301

293302
@staticmethod
294303
def build_authorization_url(
295-
oauth_config: OAuthConfig, redirect_uri: str, state: str
304+
oauth_config: OAuthConfig,
305+
redirect_uri: str,
306+
state: str,
307+
*,
308+
code_challenge: str | None = None,
296309
) -> str:
297-
"""Build OAuth authorization URL"""
310+
"""Build an authorization URL, including a PKCE challenge when supplied."""
298311
if oauth_config.client_id is None:
299312
raise ValueError("OAuth client_id is required to build authorization URL")
300313
return build_oauth_authorization_url(
301-
OAuthTokenManager._flow_params(oauth_config), redirect_uri, state
314+
OAuthTokenManager.flow_params(oauth_config),
315+
redirect_uri,
316+
state,
317+
code_challenge=code_challenge,
302318
)
303319

304320
@staticmethod
305-
def _flow_params(oauth_config: OAuthConfig) -> OAuthFlowParams:
321+
def flow_params(oauth_config: OAuthConfig) -> OAuthFlowParams:
322+
"""Return the protocol inputs represented by an OAuthConfig."""
306323
if oauth_config.client_id is None:
307324
raise ValueError("OAuth client_id is required")
308325
client_secret = (

backend/onyx/server/features/oauth_config/api.py

Lines changed: 101 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"""API endpoints for OAuth configuration management."""
22

3+
import secrets
4+
35
from fastapi import APIRouter, Depends, HTTPException
6+
from pydantic import BaseModel, ConfigDict, Field
47
from 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+
)
713
from 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
816
from onyx.configs.app_configs import WEB_DOMAIN
917
from onyx.db.engine.sql_engine import get_session
1018
from onyx.db.enums import Permission
@@ -21,10 +29,12 @@
2129
)
2230
from onyx.error_handling.error_codes import OnyxErrorCode
2331
from 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
2838
from onyx.server.features.oauth_config.models import (
2939
OAuthCallbackResponse,
3040
OAuthConfigCreate,
@@ -40,6 +50,52 @@
4050
admin_router = APIRouter(prefix="/admin/oauth-config")
4151
router = 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

44100
def _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:

backend/onyx/server/features/oauth_config/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from pydantic import BaseModel
55

6+
from onyx.oauth.models import SafeOAuthReturnPath
7+
68

79
class OAuthConfigCreate(BaseModel):
810
name: str
@@ -40,7 +42,7 @@ class OAuthConfigSnapshot(BaseModel):
4042

4143
class OAuthInitiateRequest(BaseModel):
4244
oauth_config_id: int
43-
return_path: str = "/chat" # Where to redirect after OAuth flow
45+
return_path: SafeOAuthReturnPath = "/chat"
4446

4547

4648
class OAuthInitiateResponse(BaseModel):

0 commit comments

Comments
 (0)