Skip to content

Commit 7c6e8b2

Browse files
committed
fix(external-apps): harden OAuth authorization attempts
1 parent f32f450 commit 7c6e8b2

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: 76 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
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
1212
from onyx.configs.app_configs import WEB_DOMAIN
1313
from onyx.db.engine.sql_engine import get_session
1414
from onyx.db.enums import Permission
@@ -22,15 +22,18 @@
2222
from onyx.external_apps.providers.base import OAuthExternalAppProvider
2323
from onyx.external_apps.providers.registry import get_provider_or_raise
2424
from 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
2630
from onyx.server.features.build.external_apps.models import (
2731
OAuthCallbackRequest,
2832
OAuthCallbackResponse,
2933
OAuthStartResponse,
3034
)
3135
from onyx.skills.push import push_skills_for_users
3236
from onyx.utils.logger import setup_logger
33-
from shared_configs.contextvars import get_current_tenant_id
3437

3538
logger = setup_logger()
3639

@@ -40,9 +43,19 @@
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

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

backend/tests/unit/external_apps/test_hubspot_provider.py

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
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
19+
from onyx.oauth import authorization_attempt
2020
from onyx.server.features.build.external_apps import oauth as oauth_route
21+
from tests.unit.fakes import FakeCache
2122

2223

2324
def _provider() -> HubspotProvider:
@@ -52,30 +53,6 @@ def test_optional_scope_is_exactly_the_writes() -> None:
5253
}
5354

5455

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-
7956
def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -> None:
8057
"""The bug this fixes lives in the authorize URL, so exercise the route end
8158
to end: `start_external_app_oauth` must emit the writes under HubSpot's
@@ -94,8 +71,7 @@ def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -
9471
),
9572
)
9673
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())
74+
monkeypatch.setattr(authorization_attempt, "get_cache_backend", lambda: FakeCache())
9975

10076
response = oauth_route.start_external_app_oauth(
10177
external_app_id=app.id,
@@ -106,3 +82,4 @@ def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -
10682
query = parse_qs(urlparse(response.authorize_url).query)
10783
assert set(query["optional_scope"][0].split()) == set(oauth.optional_scope.split())
10884
assert not any(s.endswith(".write") for s in query["scope"][0].split())
85+
assert "code_challenge" not in query

0 commit comments

Comments
 (0)