Skip to content

Commit dfafe8e

Browse files
committed
fix(external-apps): harden OAuth authorization attempts
1 parent 2a8380d commit dfafe8e

7 files changed

Lines changed: 308 additions & 95 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: 82 additions & 59 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, Field
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,17 @@
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+
)
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,15 @@
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

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

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)