Skip to content

Commit f32f450

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

10 files changed

Lines changed: 132 additions & 72 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: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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

10+
from onyx.cache.factory import get_cache_backend
911
from onyx.cache.interface import CacheBackend
1012
from onyx.error_handling.error_codes import OnyxErrorCode
1113
from onyx.error_handling.exceptions import OnyxError
@@ -20,16 +22,28 @@
2022
MAX_AUTHORIZATION_ATTEMPT_TTL_SECONDS = 10 * 60
2123

2224

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

2640
def __init__(
2741
self,
28-
cache: CacheBackend,
42+
cache: CacheBackend | None = None,
2943
*,
3044
namespace: str,
3145
payload_type: type[PayloadT],
32-
ttl_seconds: int,
46+
ttl_seconds: int = MAX_AUTHORIZATION_ATTEMPT_TTL_SECONDS,
3347
) -> None:
3448
if not _NAMESPACE_PATTERN.fullmatch(namespace) or len(namespace) > 64:
3549
raise ValueError("OAuth attempt namespace is invalid")
@@ -39,7 +53,7 @@ def __init__(
3953
f"{MAX_AUTHORIZATION_ATTEMPT_TTL_SECONDS} seconds"
4054
)
4155

42-
self._cache = cache
56+
self._cache_override = cache
4357
self._namespace = namespace
4458
self._attempt_type = cast(
4559
type[AuthorizationAttempt[PayloadT]],
@@ -67,7 +81,7 @@ def store(
6781
expires_at=expires_at,
6882
payload=payload,
6983
)
70-
stored = self._cache.set_if_absent(
84+
stored = self._cache_backend().set_if_absent(
7185
self._key(attempt.owner_id, attempt.state),
7286
attempt.model_dump_json(),
7387
ex=self._ttl_seconds,
@@ -85,7 +99,7 @@ def consume(
8599
owner_id: str,
86100
state: str,
87101
) -> AuthorizationAttempt[PayloadT]:
88-
stored = self._cache.getdel(self._key(owner_id, state))
102+
stored = self._cache_backend().getdel(self._key(owner_id, state))
89103
if stored is None:
90104
raise _invalid_attempt_error()
91105

@@ -112,6 +126,11 @@ def _key(self, owner_id: str, state: str) -> str:
112126
state_hash = hashlib.sha256(state.encode()).hexdigest()
113127
return f"{_KEY_PREFIX}:{self._namespace}:{owner_hash}:{state_hash}"
114128

129+
def _cache_backend(self) -> CacheBackend:
130+
if self._cache_override is not None:
131+
return self._cache_override
132+
return get_cache_backend()
133+
115134

116135
def generate_authorization_state() -> str:
117136
"""Generate a 256-bit, URL-safe OAuth state value."""

backend/onyx/oauth/models.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,29 @@
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+
OAuthConfigurationFingerprint = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
24+
PKCECodeVerifier = Annotated[str, Field(min_length=43, max_length=128)]
25+
26+
827
class AuthorizationAttempt(BaseModel, Generic[PayloadT]):
928
"""One pending authorization request for an authenticated user.
1029

backend/onyx/server/documents/standard_oauth.py

Lines changed: 4 additions & 13 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 PKCECodeVerifier, 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,18 +83,9 @@ 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]
88-
code_verifier: str | None = None
89-
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
88+
code_verifier: PKCECodeVerifier | None = None
9889

9990

10091
def _authorization_attempt_store(

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: 16 additions & 29 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,16 @@
1920
MCPServerStatus,
2021
MCPTransport,
2122
)
23+
from onyx.oauth.models import (
24+
OAuthConfigurationFingerprint,
25+
PKCECodeVerifier,
26+
SafeOAuthReturnPath,
27+
)
2228

2329
# Matches `{placeholder_name}` inside header value templates.
2430
_PLACEHOLDER_RE = re.compile(r"\{([^}]+)\}")
2531
# RFC 9110 field-name syntax: a non-empty sequence of HTTP token characters.
2632
_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-
}
3733

3834

3935
def _build_auto_substitution_map(*, user_email: str) -> dict[str, str]:
@@ -387,8 +383,8 @@ def validate_auth_configuration(self) -> "MCPToolCreateRequest":
387383
raise ValueError(
388384
"oauth_token_endpoint is required for known-provider OAuth mode"
389385
)
390-
reserved_params = RESERVED_MCP_OAUTH_AUTHORIZATION_PARAMS.intersection(
391-
self.oauth_additional_auth_params or {}
386+
reserved_params = conflicting_authorization_params(
387+
self.oauth_additional_auth_params
392388
)
393389
if reserved_params:
394390
raise ValueError(
@@ -511,7 +507,9 @@ class MCPOAuthConnectResponse(BaseModel):
511507

512508
class MCPUserOAuthConnectRequest(BaseModel):
513509
server_id: int = Field(..., description="ID of the MCP server")
514-
return_path: str = Field(..., description="Path to redirect to after callback")
510+
return_path: SafeOAuthReturnPath = Field(
511+
..., description="Path to redirect to after callback"
512+
)
515513
include_resource_param: bool = Field(..., description="Include resource parameter")
516514
force_reauthentication: bool = Field(
517515
default=False,
@@ -539,17 +537,6 @@ class MCPUserOAuthConnectRequest(BaseModel):
539537
),
540538
)
541539

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-
553540

554541
class MCPUserOAuthConnectResponse(BaseModel):
555542
server_id: int
@@ -574,7 +561,7 @@ def validate_outcome(self) -> "MCPUserOAuthConnectResponse":
574561
class MCPPendingOAuthAuthorization(BaseModel):
575562
authorization_url: str
576563
state: str
577-
code_verifier: str
564+
code_verifier: PKCECodeVerifier
578565

579566

580567
class MCPOAuthServerSnapshot(BaseModel):
@@ -592,12 +579,12 @@ class MCPOAuthServerSnapshot(BaseModel):
592579
class MCPOAuthFlowState(BaseModel):
593580
server_id: int
594581
connection_config_id: int
595-
return_path: str
596-
code_verifier: str
582+
return_path: SafeOAuthReturnPath
583+
code_verifier: PKCECodeVerifier
597584
redirect_uri: AnyUrl
598585
server_snapshot: MCPOAuthServerSnapshot
599-
connection_headers_fingerprint: str
600-
client_information_fingerprint: str
586+
connection_headers_fingerprint: OAuthConfigurationFingerprint
587+
client_information_fingerprint: OAuthConfigurationFingerprint
601588
protected_resource_metadata: ProtectedResourceMetadata | None = None
602589
oauth_metadata: OAuthMetadata | None = None
603590
authorization_server_url: str | None = None

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(

0 commit comments

Comments
 (0)