Skip to content

Commit c033d98

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

10 files changed

Lines changed: 140 additions & 82 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: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import hashlib
2+
import json
23
import re
34
import secrets
5+
from collections.abc import Callable
46
from datetime import datetime, timedelta, timezone
57
from typing import Generic, cast
68

7-
from pydantic import ValidationError
9+
from pydantic import JsonValue, ValidationError
810

911
from onyx.cache.interface import CacheBackend
1012
from onyx.error_handling.error_codes import OnyxErrorCode
@@ -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,
2942
*,
43+
cache_backend_provider: Callable[[], CacheBackend],
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_backend_provider = cache_backend_provider
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_provider().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_provider().getdel(self._key(owner_id, state))
89103
if stored is None:
90104
raise _invalid_attempt_error()
91105

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: 5 additions & 14 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,25 +83,16 @@ 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(
10192
source: DocumentSource,
10293
) -> AuthorizationAttemptStore[_ConnectorOAuthAttemptPayload]:
10394
return AuthorizationAttemptStore(
104-
get_cache_backend(),
95+
cache_backend_provider=get_cache_backend,
10596
namespace=f"{_OAUTH_ATTEMPT_NAMESPACE_PREFIX}-{source.value}",
10697
payload_type=_ConnectorOAuthAttemptPayload,
10798
ttl_seconds=_OAUTH_STATE_EXPIRATION_SECONDS,

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

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,18 @@
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
2725

2826
from mcp.shared.auth import OAuthClientInformationFull
29-
from pydantic import BaseModel, ConfigDict
27+
from pydantic import BaseModel, ConfigDict, JsonValue
3028
from sqlalchemy.orm import Session
3129

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,
@@ -71,31 +70,27 @@ def mcp_token_expired(config_data: MCPConnectionData) -> bool:
7170

7271

7372
def mcp_oauth_connection_headers_fingerprint(headers: dict[str, str]) -> str:
74-
routing_headers = sorted(
75-
(key.lower(), value)
76-
for key, value in headers.items()
77-
if key.lower() != "authorization"
78-
)
79-
serialized_headers = json.dumps(
80-
routing_headers, ensure_ascii=True, separators=(",", ":")
81-
)
82-
return hashlib.sha256(serialized_headers.encode()).hexdigest()
73+
routing_headers: JsonValue = [
74+
[key, value]
75+
for key, value in sorted(
76+
(key.lower(), value)
77+
for key, value in headers.items()
78+
if key.lower() != "authorization"
79+
)
80+
]
81+
return canonical_json_fingerprint(routing_headers)
8382

8483

8584
def mcp_oauth_client_information_fingerprint(
8685
client_information: OAuthClientInformationFull,
8786
) -> str:
88-
serialized_client = json.dumps(
87+
return canonical_json_fingerprint(
8988
client_information.model_dump(
9089
mode="json",
9190
exclude_none=True,
9291
by_alias=True,
9392
),
94-
ensure_ascii=True,
95-
sort_keys=True,
96-
separators=(",", ":"),
9793
)
98-
return hashlib.sha256(serialized_client.encode()).hexdigest()
9994

10095

10196
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: 4 additions & 4 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(
@@ -180,7 +180,7 @@ def validate_mcp_oauth_flow_configuration(
180180

181181
def mcp_oauth_attempt_store() -> AuthorizationAttemptStore[MCPOAuthFlowState]:
182182
return AuthorizationAttemptStore(
183-
get_cache_backend(),
183+
cache_backend_provider=get_cache_backend,
184184
namespace="mcp",
185185
payload_type=MCPOAuthFlowState,
186186
ttl_seconds=MCP_OAUTH_FLOW_TTL_SECONDS,

0 commit comments

Comments
 (0)