Skip to content

Commit 2a8380d

Browse files
committed
refactor(oauth): consolidate authorization flow primitives
1 parent 62c269a commit 2a8380d

8 files changed

Lines changed: 87 additions & 55 deletions

File tree

backend/onyx/auth/oauth_token_manager.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import time
2+
from collections.abc import Mapping
23
from typing import Any
34
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
45
from uuid import UUID
@@ -52,6 +53,26 @@ def validate_oauth_endpoint_url(url: str, *, resolve_dns: bool = True) -> None:
5253
"accounts.google.com": {"access_type": "offline", "prompt": "consent"},
5354
}
5455

56+
_PROTOCOL_AUTHORIZATION_PARAMS = frozenset(
57+
{
58+
"client_id",
59+
"code_challenge",
60+
"code_challenge_method",
61+
"redirect_uri",
62+
"resource",
63+
"response_type",
64+
"scope",
65+
"state",
66+
}
67+
)
68+
69+
70+
def conflicting_authorization_params(
71+
additional_params: Mapping[str, object] | None,
72+
) -> frozenset[str]:
73+
"""Return configured parameters whose values the OAuth flow must own."""
74+
return _PROTOCOL_AUTHORIZATION_PARAMS.intersection(additional_params or {})
75+
5576

5677
def ensure_offline_access_auth_params(authorization_url: str) -> str:
5778
"""Merge missing offline-access params into a built authorize URL.

backend/onyx/oauth/authorization_attempt.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import hashlib
2+
import json
23
import re
34
import secrets
45
from datetime import datetime, timedelta, timezone
56
from typing import Generic, cast
67

7-
from pydantic import ValidationError
8+
from pydantic import JsonValue, ValidationError
89

910
from onyx.cache.interface import CacheBackend
1011
from onyx.error_handling.error_codes import OnyxErrorCode
@@ -20,6 +21,18 @@
2021
MAX_AUTHORIZATION_ATTEMPT_TTL_SECONDS = 10 * 60
2122

2223

24+
def canonical_json_fingerprint(value: JsonValue) -> str:
25+
"""Fingerprint configuration captured by a pending authorization attempt."""
26+
serialized = json.dumps(
27+
value,
28+
ensure_ascii=True,
29+
allow_nan=False,
30+
sort_keys=True,
31+
separators=(",", ":"),
32+
)
33+
return hashlib.sha256(serialized.encode()).hexdigest()
34+
35+
2336
class AuthorizationAttemptStore(Generic[PayloadT]):
2437
"""Stores typed OAuth attempts in a tenant-scoped cache."""
2538

backend/onyx/oauth/models.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1-
from typing import Generic, TypeVar
1+
from typing import Annotated, Generic, TypeVar
22

3-
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
3+
from pydantic import AfterValidator, AwareDatetime, BaseModel, ConfigDict, Field
4+
5+
from onyx.utils.url import sanitize_next_url
46

57
PayloadT = TypeVar("PayloadT", bound=BaseModel)
68

79

10+
def _validate_safe_oauth_return_path(value: str) -> str:
11+
if sanitize_next_url(value) != value or any(
12+
not character.isprintable() for character in value
13+
):
14+
raise ValueError("OAuth return path must be a safe internal path")
15+
return value
16+
17+
18+
SafeOAuthReturnPath = Annotated[
19+
str,
20+
Field(max_length=2048),
21+
AfterValidator(_validate_safe_oauth_return_path),
22+
]
23+
24+
825
class AuthorizationAttempt(BaseModel, Generic[PayloadT]):
926
"""One pending authorization request for an authenticated user.
1027

backend/onyx/server/documents/standard_oauth.py

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
33

44
from fastapi import APIRouter, Depends, Query, Request
5-
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
5+
from pydantic import BaseModel, ConfigDict, ValidationError
66
from sqlalchemy.orm import Session
77

88
from onyx.auth.permissions import require_permission
@@ -21,10 +21,10 @@
2121
AuthorizationAttemptStore,
2222
generate_authorization_state,
2323
)
24+
from onyx.oauth.models import SafeOAuthReturnPath
2425
from onyx.server.documents.models import CredentialBase
2526
from onyx.utils.logger import setup_logger
2627
from onyx.utils.subclasses import find_all_subclasses_in_package
27-
from onyx.utils.url import sanitize_next_url
2828

2929
logger = setup_logger()
3030

@@ -83,19 +83,10 @@ def _validate_additional_kwargs(
8383
class _ConnectorOAuthAttemptPayload(BaseModel):
8484
model_config = ConfigDict(extra="forbid", frozen=True)
8585

86-
desired_return_path: str
86+
desired_return_path: SafeOAuthReturnPath
8787
additional_kwargs: dict[str, str]
8888
code_verifier: str | None = None
8989

90-
@field_validator("desired_return_path")
91-
@classmethod
92-
def validate_return_path(cls, value: str) -> str:
93-
if sanitize_next_url(value) != value or any(
94-
not character.isprintable() for character in value
95-
):
96-
raise ValueError("OAuth return path must be a local application path")
97-
return value
98-
9990

10091
def _authorization_attempt_store(
10192
source: DocumentSource,

backend/onyx/server/features/mcp/credentials.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
in ``onyx.db.mcp``. It must not import ``onyx.server.features.mcp.oauth``.
2020
"""
2121

22-
import hashlib
23-
import json
2422
import time
2523
from collections.abc import Mapping
2624
from typing import cast
@@ -32,6 +30,7 @@
3230
from onyx.db.enums import MCPAuthenticationPerformer, MCPAuthenticationType
3331
from onyx.db.mcp import get_user_connection_config
3432
from onyx.db.models import MCPConnectionConfig, MCPServer, User
33+
from onyx.oauth.authorization_attempt import canonical_json_fingerprint
3534
from onyx.server.features.mcp.models import (
3635
DENYLISTED_MCP_HEADERS,
3736
MCPAuthTemplate,
@@ -76,26 +75,19 @@ def mcp_oauth_connection_headers_fingerprint(headers: dict[str, str]) -> str:
7675
for key, value in headers.items()
7776
if key.lower() != "authorization"
7877
)
79-
serialized_headers = json.dumps(
80-
routing_headers, ensure_ascii=True, separators=(",", ":")
81-
)
82-
return hashlib.sha256(serialized_headers.encode()).hexdigest()
78+
return canonical_json_fingerprint(routing_headers)
8379

8480

8581
def mcp_oauth_client_information_fingerprint(
8682
client_information: OAuthClientInformationFull,
8783
) -> str:
88-
serialized_client = json.dumps(
84+
return canonical_json_fingerprint(
8985
client_information.model_dump(
9086
mode="json",
9187
exclude_none=True,
9288
by_alias=True,
9389
),
94-
ensure_ascii=True,
95-
sort_keys=True,
96-
separators=(",", ":"),
9790
)
98-
return hashlib.sha256(serialized_client.encode()).hexdigest()
9991

10092

10193
def requires_user_authentication(

backend/onyx/server/features/mcp/models.py

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.types import Tool as MCPLibTool
1212
from pydantic import AnyUrl, BaseModel, Field, model_validator
1313

14+
from onyx.auth.oauth_token_manager import conflicting_authorization_params
1415
from onyx.db.enums import (
1516
EndpointPolicy,
1617
MCPAuthenticationPerformer,
@@ -19,21 +20,12 @@
1920
MCPServerStatus,
2021
MCPTransport,
2122
)
23+
from onyx.oauth.models import SafeOAuthReturnPath
2224

2325
# Matches `{placeholder_name}` inside header value templates.
2426
_PLACEHOLDER_RE = re.compile(r"\{([^}]+)\}")
2527
# RFC 9110 field-name syntax: a non-empty sequence of HTTP token characters.
2628
_HTTP_FIELD_NAME_RE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
27-
RESERVED_MCP_OAUTH_AUTHORIZATION_PARAMS = {
28-
"client_id",
29-
"code_challenge",
30-
"code_challenge_method",
31-
"redirect_uri",
32-
"resource",
33-
"response_type",
34-
"scope",
35-
"state",
36-
}
3729

3830

3931
def _build_auto_substitution_map(*, user_email: str) -> dict[str, str]:
@@ -387,8 +379,8 @@ def validate_auth_configuration(self) -> "MCPToolCreateRequest":
387379
raise ValueError(
388380
"oauth_token_endpoint is required for known-provider OAuth mode"
389381
)
390-
reserved_params = RESERVED_MCP_OAUTH_AUTHORIZATION_PARAMS.intersection(
391-
self.oauth_additional_auth_params or {}
382+
reserved_params = conflicting_authorization_params(
383+
self.oauth_additional_auth_params
392384
)
393385
if reserved_params:
394386
raise ValueError(
@@ -511,7 +503,9 @@ class MCPOAuthConnectResponse(BaseModel):
511503

512504
class MCPUserOAuthConnectRequest(BaseModel):
513505
server_id: int = Field(..., description="ID of the MCP server")
514-
return_path: str = Field(..., description="Path to redirect to after callback")
506+
return_path: SafeOAuthReturnPath = Field(
507+
..., description="Path to redirect to after callback"
508+
)
515509
include_resource_param: bool = Field(..., description="Include resource parameter")
516510
force_reauthentication: bool = Field(
517511
default=False,
@@ -539,17 +533,6 @@ class MCPUserOAuthConnectRequest(BaseModel):
539533
),
540534
)
541535

542-
@model_validator(mode="after")
543-
def validate_return_path(self) -> "MCPUserOAuthConnectRequest":
544-
if (
545-
not self.return_path.startswith("/")
546-
or self.return_path.startswith("//")
547-
or "\\" in self.return_path
548-
or any(not character.isprintable() for character in self.return_path)
549-
):
550-
raise ValueError("return_path must be a safe internal path")
551-
return self
552-
553536

554537
class MCPUserOAuthConnectResponse(BaseModel):
555538
server_id: int
@@ -592,7 +575,7 @@ class MCPOAuthServerSnapshot(BaseModel):
592575
class MCPOAuthFlowState(BaseModel):
593576
server_id: int
594577
connection_config_id: int
595-
return_path: str
578+
return_path: SafeOAuthReturnPath
596579
code_verifier: str
597580
redirect_uri: AnyUrl
598581
server_snapshot: MCPOAuthServerSnapshot

backend/onyx/server/features/mcp/oauth_flow.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from onyx.auth.oauth_token_manager import (
1919
OAuthFlowParams,
2020
build_oauth_authorization_url,
21+
conflicting_authorization_params,
2122
)
2223
from onyx.cache.factory import get_cache_backend
2324
from onyx.db.enums import MCPTransport
@@ -36,7 +37,6 @@
3637
)
3738
from onyx.server.features.mcp.models import (
3839
DENYLISTED_MCP_HEADERS,
39-
RESERVED_MCP_OAUTH_AUTHORIZATION_PARAMS,
4040
MCPOAuthFlowState,
4141
MCPOAuthServerSnapshot,
4242
MCPPendingOAuthAuthorization,
@@ -85,8 +85,8 @@ def _known_provider_flow_params(
8585
"Known-provider OAuth requires oauth_authorization_endpoint, "
8686
"oauth_token_endpoint, and a non-empty client_id.",
8787
)
88-
reserved_params = RESERVED_MCP_OAUTH_AUTHORIZATION_PARAMS.intersection(
89-
mcp_server.oauth_additional_auth_params or {}
88+
reserved_params = conflicting_authorization_params(
89+
mcp_server.oauth_additional_auth_params
9090
)
9191
if reserved_params:
9292
raise OnyxError(

backend/tests/unit/onyx/oauth/test_authorization_attempt.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
from onyx.error_handling.error_codes import OnyxErrorCode
77
from onyx.error_handling.exceptions import OnyxError
8-
from onyx.oauth.authorization_attempt import AuthorizationAttemptStore
8+
from onyx.oauth.authorization_attempt import (
9+
AuthorizationAttemptStore,
10+
canonical_json_fingerprint,
11+
)
912
from onyx.oauth.models import AuthorizationAttempt
1013
from tests.unit.fakes import FakeCache
1114

@@ -34,6 +37,18 @@ def _store(
3437
)
3538

3639

40+
def test_configuration_fingerprint_is_canonical_and_value_sensitive() -> None:
41+
first = {"client": {"id": "client-id", "secret": "secret"}, "scopes": ["a"]}
42+
reordered = {"scopes": ["a"], "client": {"secret": "secret", "id": "client-id"}}
43+
changed = {
44+
"client": {"id": "client-id", "secret": "rotated"},
45+
"scopes": ["a"],
46+
}
47+
48+
assert canonical_json_fingerprint(first) == canonical_json_fingerprint(reordered)
49+
assert canonical_json_fingerprint(first) != canonical_json_fingerprint(changed)
50+
51+
3752
def test_store_generates_isolated_one_time_attempts() -> None:
3853
cache = FakeCache()
3954
store = _store(cache)

0 commit comments

Comments
 (0)