fix(external-apps): harden OAuth authorization attempts (#14147)

This commit is contained in:
Wenxi authored and GitHub committed 2026-10-15 20:49:49 +00:00
1 parent dd7f0db47b
commit c8f36fe968
10 files changed
+619 -131

No files matched your search

+16 -8
View File
@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict
from onyx.db.enums import ExternalAppType
from onyx.external_apps.presentation.payload_decoders import PayloadDecoder
from onyx.external_apps.providers.actions import EndpointSpec
from onyx.oauth.protocol import build_authorization_code_token_body
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -102,6 +103,7 @@ class OAuthFlowSpec(BaseModel):
# The query param `optional_scope` rides under, mirroring `scope_param`.
optional_scope_param: str = "optional_scope"
extra_authorize_params: dict[str, str] = {}
supports_pkce: bool = False
class AdminDescriptorSpec(BaseModel):
@@ -288,7 +290,13 @@ class OAuthExternalAppProvider(ExternalAppProvider, abstract=True):
# --- Initial-grant token exchange (override for divergent client auth) ---
def build_token_exchange_request(
self, code: str, client_id: str, client_secret: str, redirect_uri: str
self,
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
*,
code_verifier: str | None = None,
) -> TokenExchangeRequest:
"""Build the authorization-code → token exchange POST. The default sends
RFC-6749 form-encoded client credentials in the body. Override for a
@@ -299,13 +307,13 @@ class OAuthExternalAppProvider(ExternalAppProvider, abstract=True):
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
body={
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
body=build_authorization_code_token_body(
code=code,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
code_verifier=code_verifier,
),
)
# --- Refresh template method (override a hook below, not this) ---
@@ -233,6 +233,7 @@ class GitHubProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
token_url="https://github.com/login/oauth/access_token",
scope=" ".join(["repo", "read:org", "read:user"]),
scope_param="scope",
supports_pkce=True,
),
descriptor=AdminDescriptorSpec(
upstream_url_patterns=["https://api\\.github\\.com/.*"],
@@ -87,7 +87,6 @@ class GoogleOAuthProvider(OAuthExternalAppProvider, abstract=True):
# access_type=offline issues a refresh_token; prompt=consent
# forces fresh consent so Google reissues it on re-auth.
extra_authorize_params={
"response_type": "code",
"access_type": "offline",
"prompt": "consent",
},
@@ -106,10 +106,10 @@ class LinearProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
token_url="https://api.linear.app/oauth/token",
scope="read,write",
scope_param="scope",
supports_pkce=True,
# actor=user is Linear's default but explicit — actor=application
# would mint an app-acting token instead of user-acting.
extra_authorize_params={
"response_type": "code",
"actor": "user",
},
),
+15 -7
View File
@@ -22,6 +22,7 @@ from onyx.external_apps.providers.base import (
OrgCredentialField,
TokenExchangeRequest,
)
from onyx.oauth.protocol import build_authorization_code_token_body
# Pinned across the provider and the sandbox skill so request-shaping stays
# consistent with what the OAuth exchange negotiated. 2025-09-03 is the current
@@ -208,7 +209,6 @@ class NotionProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
# `owner=user` is required by Notion's public OAuth flow so the
# authorization is granted on behalf of the connecting user.
extra_authorize_params={
"response_type": "code",
"owner": "user",
},
),
@@ -253,11 +253,20 @@ class NotionProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
}
def build_token_exchange_request(
self, code: str, client_id: str, client_secret: str, redirect_uri: str
self,
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
*,
code_verifier: str | None = None,
) -> TokenExchangeRequest:
# Notion requires HTTP Basic client authentication and a JSON body for
# the token exchange (client_id/client_secret are NOT accepted in the
# form body), so override the default RFC-6749 form-encoded request.
if code_verifier is not None:
raise ValueError("Notion OAuth does not support PKCE")
basic = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode(
"ascii"
)
@@ -268,11 +277,10 @@ class NotionProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
"Accept": "application/json",
"Notion-Version": _NOTION_VERSION,
},
body={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
body=build_authorization_code_token_body(
code=code,
redirect_uri=redirect_uri,
),
json_encoded=True,
)
+17 -4
View File
@@ -111,6 +111,18 @@ _ENDPOINTS: list[EndpointSpec] = [
]
def _oauth_response_object(response_data: dict[str, Any], key: str) -> dict[str, Any]:
value = response_data.get(key)
if value is None:
return {}
if not isinstance(value, dict):
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Slack returned an invalid OAuth response.",
)
return value
class SlackProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
spec = OAuthProviderSpec(
app_type=ExternalAppType.SLACK,
@@ -186,7 +198,7 @@ class SlackProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
# Slack v2 with `user_scope` returns the user token nested
# under `authed_user`; the top-level `access_token` would be
# the bot token, which we don't request.
authed_user = response_data.get("authed_user") or {}
authed_user = _oauth_response_object(response_data, "authed_user")
access_token = authed_user.get("access_token")
if not access_token:
raise OnyxError(
@@ -195,10 +207,11 @@ class SlackProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
"token. Make sure the Slack app has user token scopes "
"configured.",
)
team = _oauth_response_object(response_data, "team")
creds: dict[str, Any] = {
"access_token": access_token,
"team_id": (response_data.get("team") or {}).get("id"),
"team_name": (response_data.get("team") or {}).get("name"),
"team_id": team.get("id"),
"team_name": team.get("name"),
"authed_user_id": authed_user.get("id"),
"scope": authed_user.get("scope"),
}
@@ -211,5 +224,5 @@ class SlackProvider(OAuthExternalAppProvider, OnyxManagedExtApp):
def extract_granted_scopes(self, response_data: dict[str, Any]) -> list[str] | None:
# The user token's granted scopes live under `authed_user.scope` (the
# top-level `scope` would be the bot's), comma-delimited.
authed_user = response_data.get("authed_user") or {}
authed_user = _oauth_response_object(response_data, "authed_user")
return parse_granted_scopes(authed_user.get("scope"))
@@ -1,14 +1,14 @@
import base64
import uuid
import secrets
from datetime import datetime, timezone
from urllib.parse import urlencode
import requests
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.auth.pkce import generate_pkce_pair
from onyx.cache.factory import get_cache_backend
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
@@ -22,7 +22,13 @@ from onyx.error_handling.exceptions import OnyxError
from onyx.external_apps.providers.base import OAuthExternalAppProvider
from onyx.external_apps.providers.registry import get_provider_or_raise
from onyx.external_apps.token_utils import stamp_expires_at
from onyx.redis.redis_pool import get_redis_client
from onyx.oauth.authorization_attempt import (
AuthorizationAttemptStore,
canonical_json_fingerprint,
generate_authorization_state,
)
from onyx.oauth.models import OAuthConfigurationFingerprint, PKCECodeVerifier
from onyx.oauth.protocol import build_authorization_url
from onyx.server.features.build.external_apps.models import (
OAuthCallbackRequest,
OAuthCallbackResponse,
@@ -30,7 +36,6 @@ from onyx.server.features.build.external_apps.models import (
)
from onyx.skills.push import push_skills_for_users
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
@@ -38,11 +43,22 @@ router = APIRouter()
# Must be registered as a redirect URI in each provider's developer
# console.
_FRONTEND_CALLBACK_PATH = "/craft/v1/apps/oauth/callback"
_FRONTEND_CALLBACK_URL = f"{WEB_DOMAIN}/craft/v1/apps/oauth/callback"
# Distinct from `da_oauth:` used by the Slack-connector OAuth flow.
_REDIS_KEY_PREFIX = "da_ea_oauth:"
_REDIS_STATE_TTL_SECONDS = 600
class _ExternalAppOAuthAttemptPayload(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
external_app_id: int
configuration_fingerprint: OAuthConfigurationFingerprint
code_verifier: PKCECodeVerifier | None = None
_AUTHORIZATION_ATTEMPTS = AuthorizationAttemptStore(
cache_backend_provider=lambda: get_cache_backend(),
namespace="external-app",
payload_type=_ExternalAppOAuthAttemptPayload,
)
def _oauth_client_credentials(app: ExternalApp) -> tuple[str, str]:
@@ -58,10 +74,6 @@ def _oauth_client_credentials(app: ExternalApp) -> tuple[str, str]:
return client_id, client_secret
def _frontend_callback_url() -> str:
return f"{WEB_DOMAIN}{_FRONTEND_CALLBACK_PATH}"
def _oauth_provider_or_raise(app: ExternalApp) -> OAuthExternalAppProvider:
"""Resolve the app's provider and assert it authenticates via OAuth, or
400. Only the OAuth subset of built-in providers can drive these routes."""
@@ -74,11 +86,21 @@ def _oauth_provider_or_raise(app: ExternalApp) -> OAuthExternalAppProvider:
return provider
class _OAuthStateRecord(BaseModel):
"""Redis state — not part of the HTTP API."""
user_id: str
external_app_id: int
def _configuration_fingerprint(
app: ExternalApp,
provider: OAuthExternalAppProvider,
client_id: str,
client_secret: str,
) -> str:
return canonical_json_fingerprint(
{
"app_type": app.app_type.value,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": _FRONTEND_CALLBACK_URL,
"oauth": provider.spec.oauth.model_dump(mode="json"),
},
)
@router.get("/apps/{external_app_id}/oauth/start")
@@ -99,35 +121,40 @@ def start_external_app_oauth(
"This app is currently disabled by an admin.",
)
provider = _oauth_provider_or_raise(app)
client_id, _client_secret = _oauth_client_credentials(app)
client_id, client_secret = _oauth_client_credentials(app)
oauth_uuid = uuid.uuid4()
state = base64.urlsafe_b64encode(oauth_uuid.bytes).rstrip(b"=").decode("ascii")
tenant_id = get_current_tenant_id()
r = get_redis_client(tenant_id=tenant_id)
record = _OAuthStateRecord(user_id=str(user.id), external_app_id=external_app_id)
r.set(
f"{_REDIS_KEY_PREFIX}{oauth_uuid}",
record.model_dump_json(),
ex=_REDIS_STATE_TTL_SECONDS,
)
redirect_uri = _frontend_callback_url()
oauth = provider.spec.oauth
params: dict[str, str] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
oauth.scope_param: oauth.scope,
"state": state,
**oauth.extra_authorize_params,
}
# Set after extra_authorize_params so a provider can't clobber it.
additional_params = dict(oauth.extra_authorize_params)
if oauth.optional_scope:
params[oauth.optional_scope_param] = oauth.optional_scope
# urlencode so URI-shaped scopes (Google) get `:` and `/`
# percent-encoded.
authorize_url = f"{oauth.authorize_url}?{urlencode(params)}"
additional_params[oauth.optional_scope_param] = oauth.optional_scope
code_verifier: str | None = None
code_challenge: str | None = None
if oauth.supports_pkce:
code_verifier, code_challenge = generate_pkce_pair()
state = generate_authorization_state()
authorize_url = build_authorization_url(
authorization_url=oauth.authorize_url,
client_id=client_id,
redirect_uri=_FRONTEND_CALLBACK_URL,
state=state,
scope=oauth.scope,
scope_param=oauth.scope_param,
additional_params=additional_params,
code_challenge=code_challenge,
)
_AUTHORIZATION_ATTEMPTS.store(
owner_id=str(user.id),
state=state,
payload=_ExternalAppOAuthAttemptPayload(
external_app_id=external_app_id,
configuration_fingerprint=_configuration_fingerprint(
app, provider, client_id, client_secret
),
code_verifier=code_verifier,
),
)
return OAuthStartResponse(authorize_url=authorize_url)
@@ -137,37 +164,16 @@ def handle_external_app_oauth_callback(
user: User = Depends(require_permission(Permission.BASIC_ACCESS)),
db_session: Session = Depends(get_session),
) -> OAuthCallbackResponse:
tenant_id = get_current_tenant_id()
r = get_redis_client(tenant_id=tenant_id)
attempt = _AUTHORIZATION_ATTEMPTS.consume(
owner_id=str(user.id), state=request.state
)
payload = attempt.payload
padded_state = request.state + "=" * (-len(request.state) % 4)
try:
uuid_bytes = base64.urlsafe_b64decode(padded_state)
oauth_uuid = uuid.UUID(bytes=uuid_bytes)
except (ValueError, TypeError):
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Malformed OAuth state.")
redis_key = f"{_REDIS_KEY_PREFIX}{oauth_uuid}"
record_bytes = r.get(redis_key)
if record_bytes is None:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"OAuth state expired or unknown — restart the connection flow.",
)
record = _OAuthStateRecord.model_validate_json(record_bytes.decode("utf-8"))
# Prevent one user's state from being redeemed by another.
if record.user_id != str(user.id):
raise OnyxError(
OnyxErrorCode.UNAUTHENTICATED,
"OAuth state does not match the calling user.",
)
app = get_external_app_by_id(db_session, record.external_app_id)
app = get_external_app_by_id(db_session, payload.external_app_id)
if app is None:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"External app with id {record.external_app_id} no longer exists.",
f"External app with id {payload.external_app_id} no longer exists.",
)
if not app.enabled:
raise OnyxError(
@@ -179,9 +185,21 @@ def handle_external_app_oauth_callback(
oauth = provider.spec.oauth
# Re-read in case the admin rotated creds between /start and /callback.
client_id, client_secret = _oauth_client_credentials(app)
if not secrets.compare_digest(
payload.configuration_fingerprint,
_configuration_fingerprint(app, provider, client_id, client_secret),
):
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
"External app OAuth configuration changed while authorization was pending.",
)
token_request = provider.build_token_exchange_request(
request.code, client_id, client_secret, _frontend_callback_url()
request.code,
client_id,
client_secret,
_FRONTEND_CALLBACK_URL,
code_verifier=payload.code_verifier,
)
try:
response = requests.post(
@@ -190,6 +208,7 @@ def handle_external_app_oauth_callback(
data=None if token_request.json_encoded else token_request.body,
json=token_request.body if token_request.json_encoded else None,
timeout=30,
allow_redirects=False,
)
except requests.RequestException as exc:
logger.warning(
@@ -203,6 +222,12 @@ def handle_external_app_oauth_callback(
f"Could not reach {app.name} to complete OAuth.",
)
if 300 <= response.status_code < 400:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"{app.name} redirected its OAuth token request.",
)
try:
response_data = response.json()
except ValueError:
@@ -214,7 +239,12 @@ def handle_external_app_oauth_callback(
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"{app.name} returned a non-JSON response during OAuth.",
status_code_override=response.status_code,
)
if not isinstance(response_data, dict):
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"{app.name} returned an invalid token response during OAuth.",
)
error = provider.classify_token_response(response, response_data)
@@ -233,9 +263,14 @@ def handle_external_app_oauth_callback(
# Stamp an absolute `expires_at` now so the lazy-refresh path can later
# decide staleness without "when was this written" bookkeeping.
stored_credentials = stamp_expires_at(
provider.extract_credentials(response_data), datetime.now(timezone.utc)
)
credentials = provider.extract_credentials(response_data)
access_token = credentials.get("access_token")
if not isinstance(access_token, str) or not access_token:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"{app.name} returned an invalid access token during OAuth.",
)
stored_credentials = stamp_expires_at(credentials, datetime.now(timezone.utc))
# The grant is authoritative and captured only here (a refresh can't change
# it); None when the provider gives no signal.
@@ -254,7 +289,4 @@ def handle_external_app_oauth_callback(
push_skills_for_users({user.id}, db_session)
db_session.commit()
# One-shot — prevent replay.
r.delete(redis_key)
return OAuthCallbackResponse(success=True, external_app_id=app.id)
@@ -14,10 +14,10 @@ import pytest
from onyx.db.enums import ExternalAppType
from onyx.db.models import User
from onyx.external_apps.providers.base import OAuthFlowSpec
from onyx.external_apps.providers.hubspot import HubspotProvider
from onyx.external_apps.providers.registry import PROVIDERS
from onyx.server.features.build.external_apps import oauth as oauth_route
from tests.unit.fakes import FakeCache
def _provider() -> HubspotProvider:
@@ -52,30 +52,6 @@ def test_optional_scope_is_exactly_the_writes() -> None:
}
def test_optional_scope_defaults_empty() -> None:
"""`optional_scope` is opt-in: a spec that doesn't set it sends nothing."""
spec = OAuthFlowSpec(
authorize_url="https://example.com/authorize",
token_url="https://example.com/token",
scope="read",
scope_param="scope",
)
assert spec.optional_scope == ""
def test_optional_scope_is_carried_on_the_spec() -> None:
"""When set, the value round-trips onto the (frozen) spec unchanged so the
authorize-URL builder can emit it under the `optional_scope` param."""
spec = OAuthFlowSpec(
authorize_url="https://example.com/authorize",
token_url="https://example.com/token",
scope="read",
scope_param="scope",
optional_scope="write extra.write",
)
assert spec.optional_scope == "write extra.write"
def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -> None:
"""The bug this fixes lives in the authorize URL, so exercise the route end
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) -
),
)
monkeypatch.setattr(oauth_route, "get_external_app_by_id", lambda *_: app)
monkeypatch.setattr(oauth_route, "get_current_tenant_id", lambda: "tenant")
monkeypatch.setattr(oauth_route, "get_redis_client", lambda **_: MagicMock())
monkeypatch.setattr(oauth_route, "get_cache_backend", lambda: FakeCache())
response = oauth_route.start_external_app_oauth(
external_app_id=app.id,
@@ -106,3 +81,4 @@ def test_authorize_url_carries_optional_scope(monkeypatch: pytest.MonkeyPatch) -
query = parse_qs(urlparse(response.authorize_url).query)
assert set(query["optional_scope"][0].split()) == set(oauth.optional_scope.split())
assert not any(s.endswith(".write") for s in query["scope"][0].split())
assert "code_challenge" not in query
@@ -0,0 +1,444 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import MagicMock
from urllib.parse import parse_qs, urlparse
import pytest
import requests
from sqlalchemy.orm import Session
from onyx.auth.pkce import compute_s256_challenge
from onyx.db.enums import ExternalAppType
from onyx.db.models import ExternalApp, User
from onyx.error_handling.exceptions import OnyxError
from onyx.external_apps.providers.base import OAuthExternalAppProvider
from onyx.external_apps.providers.registry import PROVIDERS
from onyx.server.features.build.external_apps import oauth as oauth_route
from onyx.server.features.build.external_apps.models import OAuthCallbackRequest
from tests.unit.fakes import FakeCache
class _OrganizationCredentials:
def __init__(self) -> None:
self.values = {
"client_id": "linear-client-id",
"client_secret": "linear-client-secret",
}
def get_value(self, **_: object) -> dict[str, str]:
return self.values.copy()
def _app(credentials: _OrganizationCredentials) -> ExternalApp:
return cast(
ExternalApp,
SimpleNamespace(
id=7,
name="Linear",
enabled=True,
app_type=ExternalAppType.LINEAR,
organization_credentials=credentials,
),
)
def _user(user_id: str) -> User:
return cast(User, SimpleNamespace(id=user_id))
def _authorize_query(
*,
app: ExternalApp,
user: User,
db_session: Session,
) -> dict[str, list[str]]:
response = oauth_route.start_external_app_oauth(
external_app_id=app.id,
user=user,
db_session=db_session,
)
return parse_qs(urlparse(response.authorize_url).query)
def _successful_token_response() -> requests.Response:
response = MagicMock(spec=requests.Response)
response.status_code = 200
response.json.return_value = {
"access_token": "access-token",
"refresh_token": "refresh-token",
"expires_in": 3600,
"scope": "read,write",
"token_type": "Bearer",
}
return response
def _configure_route(
monkeypatch: pytest.MonkeyPatch,
) -> tuple[ExternalApp, _OrganizationCredentials, MagicMock, MagicMock]:
cache = FakeCache()
credentials = _OrganizationCredentials()
app = _app(credentials)
db_session = MagicMock(spec=Session)
token_post = MagicMock(return_value=_successful_token_response())
persist_credentials = MagicMock()
monkeypatch.setattr(oauth_route, "get_cache_backend", lambda: cache)
monkeypatch.setattr(oauth_route, "get_external_app_by_id", lambda *_: app)
monkeypatch.setattr(oauth_route.requests, "post", token_post)
monkeypatch.setattr(
oauth_route,
"upsert_external_app_user_credential",
persist_credentials,
)
monkeypatch.setattr(oauth_route, "push_skills_for_users", MagicMock())
return app, credentials, db_session, token_post
def test_overlapping_pkce_attempts_use_their_own_verifiers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
owner = _user("owner")
first = _authorize_query(app=app, user=owner, db_session=db_session)
second = _authorize_query(app=app, user=owner, db_session=db_session)
first_state = first["state"][0]
second_state = second["state"][0]
assert first_state != second_state
for query in (first, second):
assert query["code_challenge_method"] == ["S256"]
assert "code_verifier" not in query
for code, state in (("second-code", second_state), ("first-code", first_state)):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code=code, state=state),
user=owner,
db_session=db_session,
)
challenges_by_state = {
first_state: first["code_challenge"][0],
second_state: second["code_challenge"][0],
}
states_by_code = {"first-code": first_state, "second-code": second_state}
token_request_bodies: list[dict[str, Any]] = [
call.kwargs["data"] for call in token_post.call_args_list
]
assert {body["code"] for body in token_request_bodies} == set(states_by_code)
for body in token_request_bodies:
state = states_by_code[body["code"]]
assert (
compute_s256_challenge(body["code_verifier"]) == challenges_by_state[state]
)
def test_attempt_is_owner_bound_and_claimed_before_token_exchange(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
owner = _user("owner")
state = _authorize_query(app=app, user=owner, db_session=db_session)["state"][0]
with pytest.raises(
OnyxError, match="Invalid or expired OAuth authorization attempt"
):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="stolen-code", state=state),
user=_user("other-user"),
db_session=db_session,
)
token_post.assert_not_called()
token_post.side_effect = requests.RequestException("provider unavailable")
with pytest.raises(OnyxError, match="Could not reach Linear to complete OAuth"):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="owner-code", state=state),
user=owner,
db_session=db_session,
)
assert token_post.call_count == 1
with pytest.raises(
OnyxError, match="Invalid or expired OAuth authorization attempt"
):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="retried-code", state=state),
user=owner,
db_session=db_session,
)
assert token_post.call_count == 1
def test_callback_rejects_configuration_changed_after_start(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app, credentials, db_session, token_post = _configure_route(monkeypatch)
user = _user("owner")
query = _authorize_query(app=app, user=user, db_session=db_session)
credentials.values["client_secret"] = "rotated-client-secret"
with pytest.raises(
OnyxError,
match="External app OAuth configuration changed while authorization was pending",
):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="authorization-code", state=query["state"][0]),
user=user,
db_session=db_session,
)
token_post.assert_not_called()
@pytest.mark.parametrize(
("app_type", "token_body", "expected_scopes", "json_encoded"),
[
(
ExternalAppType.LINEAR,
{
"access_token": "access",
"refresh_token": "refresh",
"scope": "read,write",
"expires_in": 3600,
},
["read", "write"],
False,
),
(
ExternalAppType.GITHUB,
{"access_token": "access", "scope": "repo,read:org"},
["repo", "read:org"],
False,
),
(
ExternalAppType.SLACK,
{
"ok": True,
"access_token": "bot-token-must-not-be-stored",
"authed_user": {
"access_token": "access",
"scope": "chat:write,search:read",
},
},
["chat:write", "search:read"],
False,
),
(
ExternalAppType.NOTION,
{"access_token": "access", "workspace_id": "workspace"},
None,
True,
),
(
ExternalAppType.HUBSPOT,
{"access_token": "access", "refresh_token": "refresh"},
["oauth", "crm.objects.contacts.read"],
False,
),
],
)
def test_callback_preserves_provider_exchange_and_grant(
monkeypatch: pytest.MonkeyPatch,
app_type: ExternalAppType,
token_body: dict[str, Any],
expected_scopes: list[str] | None,
json_encoded: bool,
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
app.app_type = app_type
owner = _user("owner")
persist = MagicMock()
push = MagicMock()
monkeypatch.setattr(oauth_route, "upsert_external_app_user_credential", persist)
monkeypatch.setattr(oauth_route, "push_skills_for_users", push)
info_response = MagicMock(spec=requests.Response)
info_response.json.return_value = {"scopes": expected_scopes}
monkeypatch.setattr(
oauth_route.requests, "get", MagicMock(return_value=info_response)
)
token_post.return_value.json.return_value = token_body
query = _authorize_query(app=app, user=owner, db_session=db_session)
uses_pkce = app_type in (ExternalAppType.LINEAR, ExternalAppType.GITHUB)
assert ("code_challenge" in query) == uses_pkce
if app_type == ExternalAppType.SLACK:
assert "user_scope" in query
assert "scope" not in query
result = oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="auth-code", state=query["state"][0]),
user=owner,
db_session=db_session,
)
assert result.success
assert result.external_app_id == app.id
request = token_post.call_args.kwargs
body = request["json" if json_encoded else "data"]
assert request["data" if json_encoded else "json"] is None
assert body["code"] == "auth-code"
assert ("code_verifier" in body) == uses_pkce
if json_encoded:
assert request["headers"]["Authorization"].startswith("Basic ")
assert "client_id" not in body
assert "client_secret" not in body
else:
assert body["client_id"] == "linear-client-id"
assert body["client_secret"] == "linear-client-secret"
persisted = persist.call_args.kwargs
assert persisted["user_credentials"]["access_token"] == "access"
if token_body.get("refresh_token"):
assert persisted["user_credentials"]["refresh_token"] == "refresh"
if token_body.get("expires_in"):
assert "expires_at" in persisted["user_credentials"]
assert persisted["granted_scopes"] == expected_scopes
assert persisted["user_id"] == owner.id
push.assert_called_once_with({owner.id}, db_session)
db_session.commit.assert_called_once()
@pytest.mark.parametrize("malformed_body", [None, [], "not-an-object"])
def test_callback_rejects_non_object_token_response(
monkeypatch: pytest.MonkeyPatch, malformed_body: object
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
owner = _user("owner")
query = _authorize_query(app=app, user=owner, db_session=db_session)
token_post.return_value.json.return_value = malformed_body
with pytest.raises(OnyxError, match="invalid token response") as error:
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="code", state=query["state"][0]),
user=owner,
db_session=db_session,
)
assert error.value.status_code == 502
db_session.commit.assert_not_called()
def test_non_json_success_response_is_a_gateway_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
owner = _user("owner")
query = _authorize_query(app=app, user=owner, db_session=db_session)
token_post.return_value.json.side_effect = ValueError("invalid JSON")
with pytest.raises(OnyxError, match="non-JSON") as error:
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="code", state=query["state"][0]),
user=owner,
db_session=db_session,
)
assert error.value.status_code == 502
@pytest.mark.parametrize("removed", [True, False])
def test_callback_rechecks_app_availability(
monkeypatch: pytest.MonkeyPatch, removed: bool
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
owner = _user("owner")
query = _authorize_query(app=app, user=owner, db_session=db_session)
if removed:
monkeypatch.setattr(oauth_route, "get_external_app_by_id", lambda *_: None)
else:
app.enabled = False
with pytest.raises(OnyxError, match="no longer exists|disabled"):
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="code", state=query["state"][0]),
user=owner,
db_session=db_session,
)
token_post.assert_not_called()
@pytest.mark.parametrize(
"app_type",
[
key
for key, provider in PROVIDERS.items()
if isinstance(provider, OAuthExternalAppProvider)
],
)
def test_all_oauth_providers_preserve_authorization_parameters(
monkeypatch: pytest.MonkeyPatch, app_type: ExternalAppType
) -> None:
app, _, db_session, _ = _configure_route(monkeypatch)
app.app_type = app_type
provider = PROVIDERS[app_type]
assert isinstance(provider, OAuthExternalAppProvider)
oauth = provider.spec.oauth
query = _authorize_query(app=app, user=_user("owner"), db_session=db_session)
assert query["response_type"] == ["code"]
assert query["client_id"] == ["linear-client-id"]
assert "client_secret" not in query
if oauth.scope:
assert query[oauth.scope_param] == [oauth.scope]
if oauth.optional_scope:
assert query[oauth.optional_scope_param] == [oauth.optional_scope]
for key, value in oauth.extra_authorize_params.items():
assert query[key] == [value]
assert ("code_challenge" in query) == oauth.supports_pkce
@pytest.mark.parametrize(
("app_type", "token_body"),
[
(ExternalAppType.LINEAR, {"access_token": 123}),
(ExternalAppType.NOTION, {"access_token": ["not-a-token"]}),
(ExternalAppType.SLACK, {"authed_user": ["invalid"]}),
(ExternalAppType.SLACK, {"authed_user": {"access_token": 123}}),
(
ExternalAppType.SLACK,
{"authed_user": {"access_token": "token"}, "team": "invalid"},
),
],
)
def test_callback_rejects_invalid_provider_credentials(
monkeypatch: pytest.MonkeyPatch,
app_type: ExternalAppType,
token_body: dict[str, Any],
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
app.app_type = app_type
persist = MagicMock()
monkeypatch.setattr(oauth_route, "upsert_external_app_user_credential", persist)
owner = _user("owner")
query = _authorize_query(app=app, user=owner, db_session=db_session)
token_post.return_value.json.return_value = token_body
callback = OAuthCallbackRequest(code="code", state=query["state"][0])
with pytest.raises(OnyxError, match="invalid") as error:
oauth_route.handle_external_app_oauth_callback(
callback, user=owner, db_session=db_session
)
assert error.value.status_code == 502
persist.assert_not_called()
db_session.commit.assert_not_called()
with pytest.raises(OnyxError, match="Invalid or expired"):
oauth_route.handle_external_app_oauth_callback(
callback, user=owner, db_session=db_session
)
token_post.assert_called_once()
@pytest.mark.parametrize("status_code", [301, 302, 303, 307, 308])
def test_callback_does_not_follow_token_redirects(
monkeypatch: pytest.MonkeyPatch, status_code: int
) -> None:
app, _, db_session, token_post = _configure_route(monkeypatch)
persist = MagicMock()
monkeypatch.setattr(oauth_route, "upsert_external_app_user_credential", persist)
owner = _user("owner")
query = _authorize_query(app=app, user=owner, db_session=db_session)
token_post.return_value.status_code = status_code
with pytest.raises(OnyxError, match="redirected") as error:
oauth_route.handle_external_app_oauth_callback(
OAuthCallbackRequest(code="code", state=query["state"][0]),
user=owner,
db_session=db_session,
)
assert error.value.status_code == 502
assert token_post.call_args.kwargs["allow_redirects"] is False
persist.assert_not_called()
db_session.commit.assert_not_called()