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 , Field
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+ )
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+ _AUTHORIZATION_ATTEMPT_TTL_SECONDS = 10 * 60
47+
48+
49+ class _ExternalAppOAuthAttemptPayload (BaseModel ):
50+ model_config = ConfigDict (extra = "forbid" , frozen = True )
51+
52+ external_app_id : int
53+ configuration_fingerprint : str = Field (pattern = r"^[0-9a-f]{64}$" )
54+ code_verifier : str | None = Field (default = None , min_length = 43 , max_length = 128 )
4655
4756
4857def _oauth_client_credentials (app : ExternalApp ) -> tuple [str , str ]:
@@ -74,11 +83,32 @@ def _oauth_provider_or_raise(app: ExternalApp) -> OAuthExternalAppProvider:
7483 return provider
7584
7685
77- class _OAuthStateRecord (BaseModel ):
78- """Redis state — not part of the HTTP API."""
86+ def _authorization_attempt_store () -> AuthorizationAttemptStore [
87+ _ExternalAppOAuthAttemptPayload
88+ ]:
89+ return AuthorizationAttemptStore (
90+ get_cache_backend (),
91+ namespace = "external-app" ,
92+ payload_type = _ExternalAppOAuthAttemptPayload ,
93+ ttl_seconds = _AUTHORIZATION_ATTEMPT_TTL_SECONDS ,
94+ )
7995
80- user_id : str
81- external_app_id : int
96+
97+ def _configuration_fingerprint (
98+ app : ExternalApp ,
99+ provider : OAuthExternalAppProvider ,
100+ client_id : str ,
101+ client_secret : str ,
102+ ) -> str :
103+ return canonical_json_fingerprint (
104+ {
105+ "app_type" : app .app_type .value ,
106+ "client_id" : client_id ,
107+ "client_secret" : client_secret ,
108+ "redirect_uri" : _frontend_callback_url (),
109+ "oauth" : provider .spec .oauth .model_dump (mode = "json" ),
110+ },
111+ )
82112
83113
84114@router .get ("/apps/{external_app_id}/oauth/start" )
@@ -99,32 +129,37 @@ def start_external_app_oauth(
99129 "This app is currently disabled by an admin." ,
100130 )
101131 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- )
132+ client_id , client_secret = _oauth_client_credentials (app )
115133
116134 redirect_uri = _frontend_callback_url ()
117135 oauth = provider .spec .oauth
118136 params : dict [str , str ] = {
137+ ** oauth .extra_authorize_params ,
119138 "client_id" : client_id ,
120139 "redirect_uri" : redirect_uri ,
121140 oauth .scope_param : oauth .scope ,
122- "state" : state ,
123- ** oauth .extra_authorize_params ,
124141 }
125- # Set after extra_authorize_params so a provider can't clobber it.
126142 if oauth .optional_scope :
127143 params [oauth .optional_scope_param ] = oauth .optional_scope
144+
145+ code_verifier : str | None = None
146+ if oauth .supports_pkce :
147+ code_verifier , code_challenge = generate_pkce_pair ()
148+ params ["code_challenge" ] = code_challenge
149+ params ["code_challenge_method" ] = "S256"
150+
151+ attempt = _authorization_attempt_store ().store (
152+ owner_id = str (user .id ),
153+ payload = _ExternalAppOAuthAttemptPayload (
154+ external_app_id = external_app_id ,
155+ configuration_fingerprint = _configuration_fingerprint (
156+ app , provider , client_id , client_secret
157+ ),
158+ code_verifier = code_verifier ,
159+ ),
160+ )
161+ params ["state" ] = attempt .state
162+
128163 # urlencode so URI-shaped scopes (Google) get `:` and `/`
129164 # percent-encoded.
130165 authorize_url = f"{ oauth .authorize_url } ?{ urlencode (params )} "
@@ -137,37 +172,16 @@ def handle_external_app_oauth_callback(
137172 user : User = Depends (require_permission (Permission .BASIC_ACCESS )),
138173 db_session : Session = Depends (get_session ),
139174) -> 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- )
175+ attempt = _authorization_attempt_store ().consume (
176+ owner_id = str (user .id ), state = request .state
177+ )
178+ payload = attempt .payload
165179
166- app = get_external_app_by_id (db_session , record .external_app_id )
180+ app = get_external_app_by_id (db_session , payload .external_app_id )
167181 if app is None :
168182 raise OnyxError (
169183 OnyxErrorCode .NOT_FOUND ,
170- f"External app with id { record .external_app_id } no longer exists." ,
184+ f"External app with id { payload .external_app_id } no longer exists." ,
171185 )
172186 if not app .enabled :
173187 raise OnyxError (
@@ -179,9 +193,21 @@ def handle_external_app_oauth_callback(
179193 oauth = provider .spec .oauth
180194 # Re-read in case the admin rotated creds between /start and /callback.
181195 client_id , client_secret = _oauth_client_credentials (app )
196+ if not secrets .compare_digest (
197+ payload .configuration_fingerprint ,
198+ _configuration_fingerprint (app , provider , client_id , client_secret ),
199+ ):
200+ raise OnyxError (
201+ OnyxErrorCode .INVALID_INPUT ,
202+ "External app OAuth configuration changed while authorization was pending." ,
203+ )
182204
183205 token_request = provider .build_token_exchange_request (
184- request .code , client_id , client_secret , _frontend_callback_url ()
206+ request .code ,
207+ client_id ,
208+ client_secret ,
209+ _frontend_callback_url (),
210+ code_verifier = payload .code_verifier ,
185211 )
186212 try :
187213 response = requests .post (
@@ -254,7 +280,4 @@ def handle_external_app_oauth_callback(
254280 push_skills_for_users ({user .id }, db_session )
255281 db_session .commit ()
256282
257- # One-shot — prevent replay.
258- r .delete (redis_key )
259-
260283 return OAuthCallbackResponse (success = True , external_app_id = app .id )
0 commit comments