Skip to content

Commit b140fcf

Browse files
committed
fix(external-apps): harden OAuth authorization attempts
1 parent c033d98 commit b140fcf

7 files changed

Lines changed: 304 additions & 96 deletions

File tree

backend/onyx/external_apps/providers/base.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class OAuthFlowSpec(BaseModel):
102102
# The query param `optional_scope` rides under, mirroring `scope_param`.
103103
optional_scope_param: str = "optional_scope"
104104
extra_authorize_params: dict[str, str] = {}
105+
supports_pkce: bool = False
105106

106107

107108
class AdminDescriptorSpec(BaseModel):
@@ -288,24 +289,34 @@ def extract_granted_scopes(self, response_data: dict[str, Any]) -> list[str] | N
288289
# --- Initial-grant token exchange (override for divergent client auth) ---
289290

290291
def build_token_exchange_request(
291-
self, code: str, client_id: str, client_secret: str, redirect_uri: str
292+
self,
293+
code: str,
294+
client_id: str,
295+
client_secret: str,
296+
redirect_uri: str,
297+
*,
298+
code_verifier: str | None = None,
292299
) -> TokenExchangeRequest:
293300
"""Build the authorization-code → token exchange POST. The default sends
294301
RFC-6749 form-encoded client credentials in the body. Override for a
295302
provider that requires HTTP Basic client auth and/or a JSON body (e.g.
296303
Notion)."""
304+
body = {
305+
"grant_type": "authorization_code",
306+
"client_id": client_id,
307+
"client_secret": client_secret,
308+
"code": code,
309+
"redirect_uri": redirect_uri,
310+
}
311+
if code_verifier is not None:
312+
body["code_verifier"] = code_verifier
313+
297314
return TokenExchangeRequest(
298315
headers={
299316
"Content-Type": "application/x-www-form-urlencoded",
300317
"Accept": "application/json",
301318
},
302-
body={
303-
"grant_type": "authorization_code",
304-
"client_id": client_id,
305-
"client_secret": client_secret,
306-
"code": code,
307-
"redirect_uri": redirect_uri,
308-
},
319+
body=body,
309320
)
310321

311322
# --- Refresh template method (override a hook below, not this) ---

backend/onyx/external_apps/providers/github.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ class GitHubProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
233233
token_url="https://github.com/login/oauth/access_token",
234234
scope=" ".join(["repo", "read:org", "read:user"]),
235235
scope_param="scope",
236+
supports_pkce=True,
236237
),
237238
descriptor=AdminDescriptorSpec(
238239
upstream_url_patterns=["https://api\\.github\\.com/.*"],

backend/onyx/external_apps/providers/linear.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ class LinearProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
106106
token_url="https://api.linear.app/oauth/token",
107107
scope="read,write",
108108
scope_param="scope",
109+
supports_pkce=True,
109110
# actor=user is Linear's default but explicit — actor=application
110111
# would mint an app-acting token instead of user-acting.
111112
extra_authorize_params={

backend/onyx/external_apps/providers/notion.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,20 @@ class NotionProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
253253
}
254254

255255
def build_token_exchange_request(
256-
self, code: str, client_id: str, client_secret: str, redirect_uri: str
256+
self,
257+
code: str,
258+
client_id: str,
259+
client_secret: str,
260+
redirect_uri: str,
261+
*,
262+
code_verifier: str | None = None,
257263
) -> TokenExchangeRequest:
258264
# Notion requires HTTP Basic client authentication and a JSON body for
259265
# the token exchange (client_id/client_secret are NOT accepted in the
260266
# form body), so override the default RFC-6749 form-encoded request.
267+
if code_verifier is not None:
268+
raise ValueError("Notion OAuth does not support PKCE")
269+
261270
basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode(
262271
"ascii"
263272
)

backend/onyx/server/features/build/external_apps/oauth.py

Lines changed: 78 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import base64
2-
import uuid
1+
import secrets
32
from datetime import datetime, timezone
43
from urllib.parse import urlencode
54

65
import requests
76
from fastapi import APIRouter, Depends
8-
from pydantic import BaseModel
7+
from pydantic import BaseModel, ConfigDict
98
from sqlalchemy.orm import Session
109

1110
from onyx.auth.permissions import require_permission
11+
from onyx.auth.pkce import generate_pkce_pair
12+
from onyx.cache.factory import get_cache_backend
1213
from onyx.configs.app_configs import WEB_DOMAIN
1314
from onyx.db.engine.sql_engine import get_session
1415
from onyx.db.enums import Permission
@@ -22,15 +23,18 @@
2223
from onyx.external_apps.providers.base import OAuthExternalAppProvider
2324
from onyx.external_apps.providers.registry import get_provider_or_raise
2425
from 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
2631
from onyx.server.features.build.external_apps.models import (
2732
OAuthCallbackRequest,
2833
OAuthCallbackResponse,
2934
OAuthStartResponse,
3035
)
3136
from onyx.skills.push import push_skills_for_users
3237
from onyx.utils.logger import setup_logger
33-
from shared_configs.contextvars import get_current_tenant_id
3438

3539
logger = setup_logger()
3640

@@ -40,9 +44,20 @@
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

4863
def _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)

backend/tests/unit/external_apps/test_hubspot_provider.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414

1515
from onyx.db.enums import ExternalAppType
1616
from onyx.db.models import User
17-
from onyx.external_apps.providers.base import OAuthFlowSpec
1817
from onyx.external_apps.providers.hubspot import HubspotProvider
1918
from onyx.external_apps.providers.registry import PROVIDERS
2019
from onyx.server.features.build.external_apps import oauth as oauth_route
20+
from tests.unit.fakes import FakeCache
2121

2222

2323
def _provider() -> HubspotProvider:
@@ -52,30 +52,6 @@ def test_optional_scope_is_exactly_the_writes() -> None:
5252
}
5353

5454

55-
def test_optional_scope_defaults_empty() -> None:
56-
"""`optional_scope` is opt-in: a spec that doesn't set it sends nothing."""
57-
spec = OAuthFlowSpec(
58-
authorize_url="https://example.com/authorize",
59-
token_url="https://example.com/token",
60-
scope="read",
61-
scope_param="scope",
62-
)
63-
assert spec.optional_scope == ""
64-
65-
66-
def test_optional_scope_is_carried_on_the_spec() -> None:
67-
"""When set, the value round-trips onto the (frozen) spec unchanged so the
68-
authorize-URL builder can emit it under the `optional_scope` param."""
69-
spec = OAuthFlowSpec(
70-
authorize_url="https://example.com/authorize",
71-
token_url="https://example.com/token",
72-
scope="read",
73-
scope_param="scope",
74-
optional_scope="write extra.write",
75-
)
76-
assert spec.optional_scope == "write extra.write"
77-
78-
7955
def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -> None:
8056
"""The bug this fixes lives in the authorize URL, so exercise the route end
8157
to end: `start_external_app_oauth` must emit the writes under HubSpot's
@@ -94,8 +70,7 @@ def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -
9470
),
9571
)
9672
monkeypatch.setattr(oauth_route, "get_external_app_by_id", lambda *_: app)
97-
monkeypatch.setattr(oauth_route, "get_current_tenant_id", lambda: "tenant")
98-
monkeypatch.setattr(oauth_route, "get_redis_client", lambda **_: MagicMock())
73+
monkeypatch.setattr(oauth_route, "get_cache_backend", lambda: FakeCache())
9974

10075
response = oauth_route.start_external_app_oauth(
10176
external_app_id=app.id,
@@ -106,3 +81,4 @@ def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -
10681
query = parse_qs(urlparse(response.authorize_url).query)
10782
assert set(query["optional_scope"][0].split()) == set(oauth.optional_scope.split())
10883
assert not any(s.endswith(".write") for s in query["scope"][0].split())
84+
assert "code_challenge" not in query

0 commit comments

Comments
 (0)