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
12+ from onyx .cache .factory import get_cache_backend
1213from onyx .configs .app_configs import WEB_DOMAIN
1314from onyx .db .engine .sql_engine import get_session
1415from onyx .db .enums import Permission
2223from onyx .external_apps .providers .base import OAuthExternalAppProvider
2324from onyx .external_apps .providers .registry import get_provider_or_raise
2425from onyx .external_apps .token_utils import stamp_expires_at
25- from onyx .redis .redis_pool import get_redis_client
26+ from onyx .oauth .authorization_attempt import (
27+ AuthorizationAttemptStore ,
28+ canonical_json_fingerprint ,
29+ )
30+ from onyx .oauth .models import OAuthConfigurationFingerprint , PKCECodeVerifier
2631from onyx .server .features .build .external_apps .models import (
2732 OAuthCallbackRequest ,
2833 OAuthCallbackResponse ,
2934 OAuthStartResponse ,
3035)
3136from onyx .skills .push import push_skills_for_users
3237from onyx .utils .logger import setup_logger
33- from shared_configs .contextvars import get_current_tenant_id
3438
3539logger = setup_logger ()
3640
4044# console.
4145_FRONTEND_CALLBACK_PATH = "/craft/v1/apps/oauth/callback"
4246
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
47+
48+ class _ExternalAppOAuthAttemptPayload (BaseModel ):
49+ model_config = ConfigDict (extra = "forbid" , frozen = True )
50+
51+ external_app_id : int
52+ configuration_fingerprint : OAuthConfigurationFingerprint
53+ code_verifier : PKCECodeVerifier | None = None
54+
55+
56+ _AUTHORIZATION_ATTEMPTS = AuthorizationAttemptStore (
57+ cache_backend_provider = lambda : get_cache_backend (),
58+ namespace = "external-app" ,
59+ payload_type = _ExternalAppOAuthAttemptPayload ,
60+ )
4661
4762
4863def _oauth_client_credentials (app : ExternalApp ) -> tuple [str , str ]:
@@ -74,11 +89,21 @@ def _oauth_provider_or_raise(app: ExternalApp) -> OAuthExternalAppProvider:
7489 return provider
7590
7691
77- class _OAuthStateRecord (BaseModel ):
78- """Redis state — not part of the HTTP API."""
79-
80- user_id : str
81- external_app_id : int
92+ def _configuration_fingerprint (
93+ app : ExternalApp ,
94+ provider : OAuthExternalAppProvider ,
95+ client_id : str ,
96+ client_secret : str ,
97+ ) -> str :
98+ return canonical_json_fingerprint (
99+ {
100+ "app_type" : app .app_type .value ,
101+ "client_id" : client_id ,
102+ "client_secret" : client_secret ,
103+ "redirect_uri" : _frontend_callback_url (),
104+ "oauth" : provider .spec .oauth .model_dump (mode = "json" ),
105+ },
106+ )
82107
83108
84109@router .get ("/apps/{external_app_id}/oauth/start" )
@@ -99,32 +124,37 @@ def start_external_app_oauth(
99124 "This app is currently disabled by an admin." ,
100125 )
101126 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- )
127+ client_id , client_secret = _oauth_client_credentials (app )
115128
116129 redirect_uri = _frontend_callback_url ()
117130 oauth = provider .spec .oauth
118131 params : dict [str , str ] = {
132+ ** oauth .extra_authorize_params ,
119133 "client_id" : client_id ,
120134 "redirect_uri" : redirect_uri ,
121135 oauth .scope_param : oauth .scope ,
122- "state" : state ,
123- ** oauth .extra_authorize_params ,
124136 }
125- # Set after extra_authorize_params so a provider can't clobber it.
126137 if oauth .optional_scope :
127138 params [oauth .optional_scope_param ] = oauth .optional_scope
139+
140+ code_verifier : str | None = None
141+ if oauth .supports_pkce :
142+ code_verifier , code_challenge = generate_pkce_pair ()
143+ params ["code_challenge" ] = code_challenge
144+ params ["code_challenge_method" ] = "S256"
145+
146+ attempt = _AUTHORIZATION_ATTEMPTS .store (
147+ owner_id = str (user .id ),
148+ payload = _ExternalAppOAuthAttemptPayload (
149+ external_app_id = external_app_id ,
150+ configuration_fingerprint = _configuration_fingerprint (
151+ app , provider , client_id , client_secret
152+ ),
153+ code_verifier = code_verifier ,
154+ ),
155+ )
156+ params ["state" ] = attempt .state
157+
128158 # urlencode so URI-shaped scopes (Google) get `:` and `/`
129159 # percent-encoded.
130160 authorize_url = f"{ oauth .authorize_url } ?{ urlencode (params )} "
@@ -137,37 +167,16 @@ def handle_external_app_oauth_callback(
137167 user : User = Depends (require_permission (Permission .BASIC_ACCESS )),
138168 db_session : Session = Depends (get_session ),
139169) -> 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- )
170+ attempt = _AUTHORIZATION_ATTEMPTS .consume (
171+ owner_id = str (user .id ), state = request .state
172+ )
173+ payload = attempt .payload
165174
166- app = get_external_app_by_id (db_session , record .external_app_id )
175+ app = get_external_app_by_id (db_session , payload .external_app_id )
167176 if app is None :
168177 raise OnyxError (
169178 OnyxErrorCode .NOT_FOUND ,
170- f"External app with id { record .external_app_id } no longer exists." ,
179+ f"External app with id { payload .external_app_id } no longer exists." ,
171180 )
172181 if not app .enabled :
173182 raise OnyxError (
@@ -179,9 +188,21 @@ def handle_external_app_oauth_callback(
179188 oauth = provider .spec .oauth
180189 # Re-read in case the admin rotated creds between /start and /callback.
181190 client_id , client_secret = _oauth_client_credentials (app )
191+ if not secrets .compare_digest (
192+ payload .configuration_fingerprint ,
193+ _configuration_fingerprint (app , provider , client_id , client_secret ),
194+ ):
195+ raise OnyxError (
196+ OnyxErrorCode .INVALID_INPUT ,
197+ "External app OAuth configuration changed while authorization was pending." ,
198+ )
182199
183200 token_request = provider .build_token_exchange_request (
184- request .code , client_id , client_secret , _frontend_callback_url ()
201+ request .code ,
202+ client_id ,
203+ client_secret ,
204+ _frontend_callback_url (),
205+ code_verifier = payload .code_verifier ,
185206 )
186207 try :
187208 response = requests .post (
@@ -254,7 +275,4 @@ def handle_external_app_oauth_callback(
254275 push_skills_for_users ({user .id }, db_session )
255276 db_session .commit ()
256277
257- # One-shot — prevent replay.
258- r .delete (redis_key )
259-
260278 return OAuthCallbackResponse (success = True , external_app_id = app .id )
0 commit comments