1- import base64
2- import uuid
1+ import secrets
32from datetime import datetime , timezone
43from urllib .parse import urlencode
54
65import requests
76from fastapi import APIRouter , Depends
8- from pydantic import BaseModel
7+ from pydantic import BaseModel , ConfigDict
98from sqlalchemy .orm import Session
109
1110from onyx .auth .permissions import require_permission
11+ from onyx .auth .pkce import generate_pkce_pair
1212from onyx .configs .app_configs import WEB_DOMAIN
1313from onyx .db .engine .sql_engine import get_session
1414from onyx .db .enums import Permission
2222from onyx .external_apps .providers .base import OAuthExternalAppProvider
2323from onyx .external_apps .providers .registry import get_provider_or_raise
2424from onyx .external_apps .token_utils import stamp_expires_at
25- from onyx .redis .redis_pool import get_redis_client
25+ from onyx .oauth .authorization_attempt import (
26+ AuthorizationAttemptStore ,
27+ canonical_json_fingerprint ,
28+ )
29+ from onyx .oauth .models import OAuthConfigurationFingerprint , PKCECodeVerifier
2630from onyx .server .features .build .external_apps .models import (
2731 OAuthCallbackRequest ,
2832 OAuthCallbackResponse ,
2933 OAuthStartResponse ,
3034)
3135from onyx .skills .push import push_skills_for_users
3236from onyx .utils .logger import setup_logger
33- from shared_configs .contextvars import get_current_tenant_id
3437
3538logger = setup_logger ()
3639
4043# console.
4144_FRONTEND_CALLBACK_PATH = "/craft/v1/apps/oauth/callback"
4245
43- # Distinct from `da_oauth:` used by the Slack-connector OAuth flow.
44- _REDIS_KEY_PREFIX = "da_ea_oauth:"
45- _REDIS_STATE_TTL_SECONDS = 600
46+
47+ class _ExternalAppOAuthAttemptPayload (BaseModel ):
48+ model_config = ConfigDict (extra = "forbid" , frozen = True )
49+
50+ external_app_id : int
51+ configuration_fingerprint : OAuthConfigurationFingerprint
52+ code_verifier : PKCECodeVerifier | None = None
53+
54+
55+ _AUTHORIZATION_ATTEMPTS = AuthorizationAttemptStore (
56+ namespace = "external-app" ,
57+ payload_type = _ExternalAppOAuthAttemptPayload ,
58+ )
4659
4760
4861def _oauth_client_credentials (app : ExternalApp ) -> tuple [str , str ]:
@@ -74,11 +87,21 @@ def _oauth_provider_or_raise(app: ExternalApp) -> OAuthExternalAppProvider:
7487 return provider
7588
7689
77- class _OAuthStateRecord (BaseModel ):
78- """Redis state — not part of the HTTP API."""
79-
80- user_id : str
81- external_app_id : int
90+ def _configuration_fingerprint (
91+ app : ExternalApp ,
92+ provider : OAuthExternalAppProvider ,
93+ client_id : str ,
94+ client_secret : str ,
95+ ) -> str :
96+ return canonical_json_fingerprint (
97+ {
98+ "app_type" : app .app_type .value ,
99+ "client_id" : client_id ,
100+ "client_secret" : client_secret ,
101+ "redirect_uri" : _frontend_callback_url (),
102+ "oauth" : provider .spec .oauth .model_dump (mode = "json" ),
103+ },
104+ )
82105
83106
84107@router .get ("/apps/{external_app_id}/oauth/start" )
@@ -99,32 +122,37 @@ def start_external_app_oauth(
99122 "This app is currently disabled by an admin." ,
100123 )
101124 provider = _oauth_provider_or_raise (app )
102- client_id , _client_secret = _oauth_client_credentials (app )
103-
104- oauth_uuid = uuid .uuid4 ()
105- state = base64 .urlsafe_b64encode (oauth_uuid .bytes ).rstrip (b"=" ).decode ("ascii" )
106-
107- tenant_id = get_current_tenant_id ()
108- r = get_redis_client (tenant_id = tenant_id )
109- record = _OAuthStateRecord (user_id = str (user .id ), external_app_id = external_app_id )
110- r .set (
111- f"{ _REDIS_KEY_PREFIX } { oauth_uuid } " ,
112- record .model_dump_json (),
113- ex = _REDIS_STATE_TTL_SECONDS ,
114- )
125+ client_id , client_secret = _oauth_client_credentials (app )
115126
116127 redirect_uri = _frontend_callback_url ()
117128 oauth = provider .spec .oauth
118129 params : dict [str , str ] = {
130+ ** oauth .extra_authorize_params ,
119131 "client_id" : client_id ,
120132 "redirect_uri" : redirect_uri ,
121133 oauth .scope_param : oauth .scope ,
122- "state" : state ,
123- ** oauth .extra_authorize_params ,
124134 }
125- # Set after extra_authorize_params so a provider can't clobber it.
126135 if oauth .optional_scope :
127136 params [oauth .optional_scope_param ] = oauth .optional_scope
137+
138+ code_verifier : str | None = None
139+ if oauth .supports_pkce :
140+ code_verifier , code_challenge = generate_pkce_pair ()
141+ params ["code_challenge" ] = code_challenge
142+ params ["code_challenge_method" ] = "S256"
143+
144+ attempt = _AUTHORIZATION_ATTEMPTS .store (
145+ owner_id = str (user .id ),
146+ payload = _ExternalAppOAuthAttemptPayload (
147+ external_app_id = external_app_id ,
148+ configuration_fingerprint = _configuration_fingerprint (
149+ app , provider , client_id , client_secret
150+ ),
151+ code_verifier = code_verifier ,
152+ ),
153+ )
154+ params ["state" ] = attempt .state
155+
128156 # urlencode so URI-shaped scopes (Google) get `:` and `/`
129157 # percent-encoded.
130158 authorize_url = f"{ oauth .authorize_url } ?{ urlencode (params )} "
@@ -137,37 +165,16 @@ def handle_external_app_oauth_callback(
137165 user : User = Depends (require_permission (Permission .BASIC_ACCESS )),
138166 db_session : Session = Depends (get_session ),
139167) -> OAuthCallbackResponse :
140- tenant_id = get_current_tenant_id ()
141- r = get_redis_client (tenant_id = tenant_id )
142-
143- padded_state = request .state + "=" * (- len (request .state ) % 4 )
144- try :
145- uuid_bytes = base64 .urlsafe_b64decode (padded_state )
146- oauth_uuid = uuid .UUID (bytes = uuid_bytes )
147- except (ValueError , TypeError ):
148- raise OnyxError (OnyxErrorCode .INVALID_INPUT , "Malformed OAuth state." )
149-
150- redis_key = f"{ _REDIS_KEY_PREFIX } { oauth_uuid } "
151- record_bytes = r .get (redis_key )
152- if record_bytes is None :
153- raise OnyxError (
154- OnyxErrorCode .INVALID_INPUT ,
155- "OAuth state expired or unknown — restart the connection flow." ,
156- )
157- record = _OAuthStateRecord .model_validate_json (record_bytes .decode ("utf-8" ))
158-
159- # Prevent one user's state from being redeemed by another.
160- if record .user_id != str (user .id ):
161- raise OnyxError (
162- OnyxErrorCode .UNAUTHENTICATED ,
163- "OAuth state does not match the calling user." ,
164- )
168+ attempt = _AUTHORIZATION_ATTEMPTS .consume (
169+ owner_id = str (user .id ), state = request .state
170+ )
171+ payload = attempt .payload
165172
166- app = get_external_app_by_id (db_session , record .external_app_id )
173+ app = get_external_app_by_id (db_session , payload .external_app_id )
167174 if app is None :
168175 raise OnyxError (
169176 OnyxErrorCode .NOT_FOUND ,
170- f"External app with id { record .external_app_id } no longer exists." ,
177+ f"External app with id { payload .external_app_id } no longer exists." ,
171178 )
172179 if not app .enabled :
173180 raise OnyxError (
@@ -179,9 +186,21 @@ def handle_external_app_oauth_callback(
179186 oauth = provider .spec .oauth
180187 # Re-read in case the admin rotated creds between /start and /callback.
181188 client_id , client_secret = _oauth_client_credentials (app )
189+ if not secrets .compare_digest (
190+ payload .configuration_fingerprint ,
191+ _configuration_fingerprint (app , provider , client_id , client_secret ),
192+ ):
193+ raise OnyxError (
194+ OnyxErrorCode .INVALID_INPUT ,
195+ "External app OAuth configuration changed while authorization was pending." ,
196+ )
182197
183198 token_request = provider .build_token_exchange_request (
184- request .code , client_id , client_secret , _frontend_callback_url ()
199+ request .code ,
200+ client_id ,
201+ client_secret ,
202+ _frontend_callback_url (),
203+ code_verifier = payload .code_verifier ,
185204 )
186205 try :
187206 response = requests .post (
@@ -254,7 +273,4 @@ def handle_external_app_oauth_callback(
254273 push_skills_for_users ({user .id }, db_session )
255274 db_session .commit ()
256275
257- # One-shot — prevent replay.
258- r .delete (redis_key )
259-
260276 return OAuthCallbackResponse (success = True , external_app_id = app .id )
0 commit comments