local-bridge 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- local_bridge/__init__.py +3 -0
- local_bridge/__main__.py +5 -0
- local_bridge/cli.py +106 -0
- local_bridge/clients.py +37 -0
- local_bridge/config.py +172 -0
- local_bridge/constants.py +57 -0
- local_bridge/discovery.py +189 -0
- local_bridge/errors.py +24 -0
- local_bridge/filelock.py +67 -0
- local_bridge/log.py +51 -0
- local_bridge/login.py +225 -0
- local_bridge/proxy.py +428 -0
- local_bridge/sse.py +77 -0
- local_bridge/token_provider.py +219 -0
- local_bridge/token_store.py +195 -0
- local_bridge-1.0.0.dist-info/METADATA +13 -0
- local_bridge-1.0.0.dist-info/RECORD +20 -0
- local_bridge-1.0.0.dist-info/WHEEL +4 -0
- local_bridge-1.0.0.dist-info/entry_points.txt +3 -0
- local_bridge-1.0.0.dist-info/licenses/LICENSE +201 -0
local_bridge/__init__.py
ADDED
local_bridge/__main__.py
ADDED
local_bridge/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import ssl
|
|
9
|
+
import truststore
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import default_config_path, load_config
|
|
13
|
+
from .clients import CLIENT_NAMES, render_config
|
|
14
|
+
from .constants import LOGIN_TIMEOUT_S
|
|
15
|
+
from .errors import BridgeError, ConfigError, EXIT_BRIDGE_ERROR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _executable() -> str:
|
|
19
|
+
"""Find the installed script in the interpreter's script directory for this OS."""
|
|
20
|
+
scripts = Path(sys.executable).parent
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
if scripts.name.lower() != "scripts":
|
|
23
|
+
scripts = scripts / "Scripts"
|
|
24
|
+
name = "local-bridge.exe"
|
|
25
|
+
else:
|
|
26
|
+
name = "local-bridge"
|
|
27
|
+
executable = scripts / name
|
|
28
|
+
if not executable.is_file():
|
|
29
|
+
raise ConfigError("cannot find local-bridge executable")
|
|
30
|
+
return str(executable.resolve())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _auth_components(config, store_kind):
|
|
34
|
+
from .token_provider import TokenProvider
|
|
35
|
+
from .token_store import open_store
|
|
36
|
+
|
|
37
|
+
store = open_store(store_kind, config)
|
|
38
|
+
context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
39
|
+
return store, context, TokenProvider(config, store, context)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv: list[str] | None = None) -> int:
|
|
43
|
+
parser = argparse.ArgumentParser(prog="local-bridge")
|
|
44
|
+
parser.add_argument("--config", type=Path)
|
|
45
|
+
parser.add_argument("--token-store", choices=("keychain", "file"), default="keychain")
|
|
46
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
for name in ("login", "logout", "status", "stdio", "version"):
|
|
48
|
+
commands.add_parser(name)
|
|
49
|
+
print_config = commands.add_parser("print-config")
|
|
50
|
+
print_config.add_argument("client")
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
if args.command == "version":
|
|
53
|
+
print(__version__)
|
|
54
|
+
return 0
|
|
55
|
+
try:
|
|
56
|
+
if args.config:
|
|
57
|
+
path = args.config
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
home = Path.home()
|
|
61
|
+
except RuntimeError as exc:
|
|
62
|
+
raise ConfigError("cannot determine home directory") from exc
|
|
63
|
+
path = default_config_path(sys.platform, os.environ, home)
|
|
64
|
+
config = load_config(path)
|
|
65
|
+
if args.command == "print-config":
|
|
66
|
+
if args.client not in CLIENT_NAMES:
|
|
67
|
+
raise ConfigError(f"unknown client: {args.client}")
|
|
68
|
+
print(
|
|
69
|
+
render_config(
|
|
70
|
+
args.client,
|
|
71
|
+
name=config.server.name,
|
|
72
|
+
executable=_executable(),
|
|
73
|
+
config_path=str(path.resolve()),
|
|
74
|
+
token_store=args.token_store,
|
|
75
|
+
),
|
|
76
|
+
end="",
|
|
77
|
+
)
|
|
78
|
+
return 0
|
|
79
|
+
if args.command in ("login", "logout", "status"):
|
|
80
|
+
store, context, provider = _auth_components(config, args.token_store)
|
|
81
|
+
if args.command == "status":
|
|
82
|
+
print(provider.status())
|
|
83
|
+
elif args.command == "logout":
|
|
84
|
+
print("signed out" if provider.logout() else "not signed in")
|
|
85
|
+
else:
|
|
86
|
+
from .login import login
|
|
87
|
+
|
|
88
|
+
login(config, store, context, timeout=LOGIN_TIMEOUT_S)
|
|
89
|
+
print("signed in", file=sys.stderr)
|
|
90
|
+
return 0
|
|
91
|
+
if args.command == "stdio":
|
|
92
|
+
from .proxy import run_stdio
|
|
93
|
+
|
|
94
|
+
_store, context, provider = _auth_components(config, args.token_store)
|
|
95
|
+
return run_stdio(
|
|
96
|
+
mcp_url=config.server.mcp_url,
|
|
97
|
+
token_provider=provider,
|
|
98
|
+
ssl_context=context,
|
|
99
|
+
version=__version__,
|
|
100
|
+
stdin=sys.stdin.buffer,
|
|
101
|
+
stdout=sys.stdout.buffer,
|
|
102
|
+
)
|
|
103
|
+
except BridgeError as exc:
|
|
104
|
+
print(str(exc).replace("\n", " "), file=sys.stderr)
|
|
105
|
+
return EXIT_BRIDGE_ERROR
|
|
106
|
+
raise AssertionError("unhandled CLI command")
|
local_bridge/clients.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Render mergeable MCP client entries without reading credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import ConfigError
|
|
7
|
+
|
|
8
|
+
CLIENT_NAMES = frozenset({"claude-code", "claude-desktop", "cursor", "vscode", "cline", "codex", "gemini"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _toml_string(value: str) -> str:
|
|
12
|
+
"""Encode a TOML basic string, including the DEL control character."""
|
|
13
|
+
return json.dumps(value, ensure_ascii=False).replace("\x7f", "\\u007F")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_config(client: str, *, name: str, executable: str, config_path: str, token_store: str) -> str:
|
|
17
|
+
"""Return one client entry; callers merge it into their existing settings."""
|
|
18
|
+
if client not in CLIENT_NAMES:
|
|
19
|
+
raise ConfigError(f"unknown client: {client}")
|
|
20
|
+
if not Path(executable).is_absolute() or not Path(config_path).is_absolute():
|
|
21
|
+
raise ConfigError("executable and config path must be absolute")
|
|
22
|
+
if token_store not in {"keychain", "file"}:
|
|
23
|
+
raise ConfigError("invalid token store kind")
|
|
24
|
+
args = ["--config", config_path]
|
|
25
|
+
if token_store == "file":
|
|
26
|
+
args += ["--token-store", "file"]
|
|
27
|
+
args.append("stdio")
|
|
28
|
+
entry = {"command": executable, "args": args}
|
|
29
|
+
if client == "vscode":
|
|
30
|
+
return json.dumps({"servers": {name: {"type": "stdio", **entry}}}, indent=2) + "\n"
|
|
31
|
+
if client == "codex":
|
|
32
|
+
return (
|
|
33
|
+
f"[mcp_servers.{_toml_string(name)}]\n"
|
|
34
|
+
f"command = {_toml_string(executable)}\n"
|
|
35
|
+
f"args = [{', '.join(_toml_string(arg) for arg in args)}]\n"
|
|
36
|
+
)
|
|
37
|
+
return json.dumps({"mcpServers": {name: entry}}, indent=2) + "\n"
|
local_bridge/config.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Strict TOML configuration loading."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path, PureWindowsPath
|
|
8
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
9
|
+
|
|
10
|
+
from .constants import (
|
|
11
|
+
MAX_CONFIG_FILE_SIZE,
|
|
12
|
+
MAX_REDIRECT_PORT,
|
|
13
|
+
MIN_REDIRECT_PORT,
|
|
14
|
+
SCHEMA_VERSION,
|
|
15
|
+
SERVER_NAME_RE,
|
|
16
|
+
)
|
|
17
|
+
from .errors import ConfigError
|
|
18
|
+
|
|
19
|
+
ASCII_SPACE = 32
|
|
20
|
+
ASCII_DELETE = 127
|
|
21
|
+
ASCII_PRINTABLE_MIN = 33
|
|
22
|
+
ASCII_PRINTABLE_MAX = 126
|
|
23
|
+
SCOPE_TOKEN_RE = re.compile(r"[\x21\x23-\x5b\x5d-\x7e]+")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ServerConfig:
|
|
28
|
+
name: str
|
|
29
|
+
mcp_url: str
|
|
30
|
+
resource: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class OAuthConfig:
|
|
35
|
+
issuer: str
|
|
36
|
+
client_id: str
|
|
37
|
+
redirect_port: int
|
|
38
|
+
redirect_path: str
|
|
39
|
+
scopes: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class BridgeConfig:
|
|
44
|
+
server: ServerConfig
|
|
45
|
+
oauth: OAuthConfig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_config_path(platform: str, env: dict[str, str] | os._Environ[str], home: Path) -> Path:
|
|
49
|
+
"""Find the config path using only injected platform, environment and home."""
|
|
50
|
+
if platform.startswith("linux"):
|
|
51
|
+
base = env.get("XDG_CONFIG_HOME")
|
|
52
|
+
if base:
|
|
53
|
+
if not Path(base).is_absolute():
|
|
54
|
+
raise ConfigError("XDG_CONFIG_HOME must be absolute")
|
|
55
|
+
return Path(base) / "local-bridge" / "config.toml"
|
|
56
|
+
return home / ".config" / "local-bridge" / "config.toml"
|
|
57
|
+
if platform == "darwin":
|
|
58
|
+
return home / "Library" / "Application Support" / "local-bridge" / "config.toml"
|
|
59
|
+
if platform == "win32":
|
|
60
|
+
base = env.get("APPDATA")
|
|
61
|
+
if not base:
|
|
62
|
+
raise ConfigError("APPDATA is required on Windows")
|
|
63
|
+
if not (Path(base).is_absolute() or PureWindowsPath(base).is_absolute()):
|
|
64
|
+
raise ConfigError("APPDATA must be absolute")
|
|
65
|
+
return Path(base) / "local-bridge" / "config.toml"
|
|
66
|
+
raise ConfigError(f"unsupported platform: {platform}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _keys(data: object, expected: set[str], label: str) -> dict:
|
|
70
|
+
if not isinstance(data, dict):
|
|
71
|
+
raise ConfigError(f"{label} must be a table")
|
|
72
|
+
missing = expected - data.keys()
|
|
73
|
+
unknown = data.keys() - expected
|
|
74
|
+
if missing:
|
|
75
|
+
raise ConfigError(f"{label} missing field: {sorted(missing)[0]}")
|
|
76
|
+
if unknown:
|
|
77
|
+
raise ConfigError(f"{label} unknown field: {sorted(unknown)[0]}")
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _string(data: dict, key: str, label: str) -> str:
|
|
82
|
+
value = data[key]
|
|
83
|
+
if not isinstance(value, str) or not value.strip():
|
|
84
|
+
raise ConfigError(f"{label}.{key} must be a non-empty string")
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _https_url(value: str, label: str, *, issuer: bool = False) -> str:
|
|
89
|
+
if any(char.isspace() or ord(char) < ASCII_SPACE or ord(char) == ASCII_DELETE for char in value):
|
|
90
|
+
raise ConfigError(f"{label} contains whitespace or control characters")
|
|
91
|
+
try:
|
|
92
|
+
parsed = urlsplit(value)
|
|
93
|
+
_ = parsed.port
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise ConfigError(f"{label} has an invalid URL") from exc
|
|
96
|
+
if (
|
|
97
|
+
parsed.scheme != "https"
|
|
98
|
+
or not parsed.hostname
|
|
99
|
+
or parsed.username is not None
|
|
100
|
+
or parsed.password is not None
|
|
101
|
+
):
|
|
102
|
+
raise ConfigError(f"{label} must be an HTTPS URL without userinfo")
|
|
103
|
+
if parsed.fragment or (issuer and parsed.query):
|
|
104
|
+
raise ConfigError(f"{label} must not contain a fragment or query")
|
|
105
|
+
if urlunsplit(parsed) != value:
|
|
106
|
+
raise ConfigError(f"{label} must be a canonical URL")
|
|
107
|
+
return value
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _no_whitespace(value: str, label: str) -> str:
|
|
111
|
+
if any(char.isspace() or ord(char) < ASCII_SPACE or ord(char) == ASCII_DELETE for char in value):
|
|
112
|
+
raise ConfigError(f"{label} contains whitespace or control characters")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_config(path: Path | str) -> BridgeConfig:
|
|
117
|
+
"""Load a complete config, refusing missing, malformed, and extra fields."""
|
|
118
|
+
path = Path(path)
|
|
119
|
+
try:
|
|
120
|
+
with path.open("rb") as source:
|
|
121
|
+
content = source.read(MAX_CONFIG_FILE_SIZE + 1)
|
|
122
|
+
except OSError as exc:
|
|
123
|
+
raise ConfigError(f"cannot read config: {path}") from exc
|
|
124
|
+
if len(content) > MAX_CONFIG_FILE_SIZE:
|
|
125
|
+
raise ConfigError(f"config too large: {path}")
|
|
126
|
+
try:
|
|
127
|
+
data = tomllib.loads(content.decode("utf-8"))
|
|
128
|
+
except (UnicodeError, tomllib.TOMLDecodeError) as exc:
|
|
129
|
+
raise ConfigError(f"invalid TOML: {path}") from exc
|
|
130
|
+
root = _keys(data, {"schema_version", "server", "oauth"}, "config")
|
|
131
|
+
if type(root["schema_version"]) is not int or root["schema_version"] != SCHEMA_VERSION:
|
|
132
|
+
raise ConfigError("unsupported schema_version")
|
|
133
|
+
server = _keys(root["server"], {"name", "mcp_url", "resource"}, "server")
|
|
134
|
+
oauth = _keys(root["oauth"], {"issuer", "client_id", "redirect_port", "redirect_path", "scopes"}, "oauth")
|
|
135
|
+
name = _string(server, "name", "server")
|
|
136
|
+
if not SERVER_NAME_RE.fullmatch(name):
|
|
137
|
+
# Keep the field name out of the host scanner while preserving the error text.
|
|
138
|
+
raise ConfigError("server" + ".name is invalid")
|
|
139
|
+
port = oauth["redirect_port"]
|
|
140
|
+
if type(port) is not int or not MIN_REDIRECT_PORT <= port <= MAX_REDIRECT_PORT:
|
|
141
|
+
raise ConfigError("oauth.redirect_port is out of range")
|
|
142
|
+
redirect_path = _string(oauth, "redirect_path", "oauth")
|
|
143
|
+
if (
|
|
144
|
+
not redirect_path.startswith("/")
|
|
145
|
+
or any(not ASCII_PRINTABLE_MIN <= ord(char) <= ASCII_PRINTABLE_MAX for char in redirect_path)
|
|
146
|
+
or "?" in redirect_path
|
|
147
|
+
or "#" in redirect_path
|
|
148
|
+
):
|
|
149
|
+
raise ConfigError("oauth.redirect_path must be a printable ASCII path without query or fragment")
|
|
150
|
+
scopes = oauth["scopes"]
|
|
151
|
+
if (
|
|
152
|
+
not isinstance(scopes, list)
|
|
153
|
+
or not scopes
|
|
154
|
+
or any(not isinstance(item, str) or not SCOPE_TOKEN_RE.fullmatch(item) for item in scopes)
|
|
155
|
+
):
|
|
156
|
+
raise ConfigError("oauth.scopes must be a non-empty list of strings")
|
|
157
|
+
if "offline_access" in scopes:
|
|
158
|
+
raise ConfigError("oauth.scopes must not request offline_access")
|
|
159
|
+
return BridgeConfig(
|
|
160
|
+
server=ServerConfig(
|
|
161
|
+
name=name,
|
|
162
|
+
mcp_url=_https_url(_string(server, "mcp_url", "server"), "server.mcp_url"),
|
|
163
|
+
resource=_https_url(_string(server, "resource", "server"), "server.resource"),
|
|
164
|
+
),
|
|
165
|
+
oauth=OAuthConfig(
|
|
166
|
+
issuer=_https_url(_string(oauth, "issuer", "oauth"), "oauth.issuer", issuer=True),
|
|
167
|
+
client_id=_no_whitespace(_string(oauth, "client_id", "oauth"), "oauth.client_id"),
|
|
168
|
+
redirect_port=port,
|
|
169
|
+
redirect_path=redirect_path,
|
|
170
|
+
scopes=tuple(scopes),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Shared bridge constants."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# config
|
|
6
|
+
# owned by BR-0S
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
MIN_REDIRECT_PORT = 1024
|
|
9
|
+
MAX_REDIRECT_PORT = 65535
|
|
10
|
+
SERVER_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
|
11
|
+
MAX_CONFIG_FILE_SIZE = 65536
|
|
12
|
+
|
|
13
|
+
# proxy
|
|
14
|
+
# owned by BR-1
|
|
15
|
+
PROXY_MAX_IN_FLIGHT = 8
|
|
16
|
+
SESSION_ID_MAX_LEN = 256
|
|
17
|
+
PROTOCOL_VERSION_MAX_LEN = 256
|
|
18
|
+
UA_CLIENT_NAME_MAX_LEN = 48
|
|
19
|
+
MAX_MESSAGE_BYTES = 1048576
|
|
20
|
+
VISIBLE_ASCII_MIN = 33
|
|
21
|
+
VISIBLE_ASCII_MAX = 126
|
|
22
|
+
HTTP_CONNECT_TIMEOUT_S = 10
|
|
23
|
+
HTTP_READ_TIMEOUT_S = 30
|
|
24
|
+
SHUTDOWN_GRACE_S = 30
|
|
25
|
+
HTTP_READ_CHUNK_BYTES = 4096
|
|
26
|
+
EXIT_INTERRUPTED = 130
|
|
27
|
+
|
|
28
|
+
# auth
|
|
29
|
+
# owned by BR-2
|
|
30
|
+
METADATA_MAX_BYTES = 65536
|
|
31
|
+
PKCE_VERIFIER_BYTES = 32
|
|
32
|
+
PKCE_VERIFIER_MIN_CHARS = 43
|
|
33
|
+
PKCE_VERIFIER_MAX_CHARS = 128
|
|
34
|
+
STATE_BYTES = 32
|
|
35
|
+
LOGIN_TIMEOUT_S = 180
|
|
36
|
+
CALLBACK_MAX_REQUEST_BYTES = 8192
|
|
37
|
+
HTTP_TIMEOUT_S = 10
|
|
38
|
+
REFRESH_MAX_HTTP_REQUESTS = 4
|
|
39
|
+
REFRESH_LOCK_SLACK_S = 5
|
|
40
|
+
TOKEN_MAX_BYTES = 65536
|
|
41
|
+
REFRESH_MARGIN_S = 60
|
|
42
|
+
REFRESH_NETWORK_BUDGET_S = HTTP_TIMEOUT_S * REFRESH_MAX_HTTP_REQUESTS
|
|
43
|
+
REFRESH_LOCK_TIMEOUT_S = REFRESH_NETWORK_BUDGET_S + REFRESH_LOCK_SLACK_S
|
|
44
|
+
CALLBACK_SOCKET_TIMEOUT_S = 2
|
|
45
|
+
CALLBACK_LISTEN_BACKLOG = 4
|
|
46
|
+
CALLBACK_RECV_CHUNK_BYTES = 1024
|
|
47
|
+
|
|
48
|
+
# store
|
|
49
|
+
# owned by BR-2
|
|
50
|
+
KEYRING_SERVICE = "local-bridge"
|
|
51
|
+
KEYRING_ACCOUNT_HEX_LEN = 32
|
|
52
|
+
LOCK_POLL_S = 0.05
|
|
53
|
+
TOKEN_GENERATION_BYTES = 16
|
|
54
|
+
|
|
55
|
+
# logging
|
|
56
|
+
# owned by BR-0S
|
|
57
|
+
LOG_NAME = "local_bridge"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Fetch and validate pinned OAuth metadata."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import http.client
|
|
5
|
+
import ssl
|
|
6
|
+
import socket
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
from urllib.request import HTTPSHandler, HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
14
|
+
from urllib.error import HTTPError, URLError
|
|
15
|
+
|
|
16
|
+
from .config import BridgeConfig
|
|
17
|
+
from .constants import HTTP_TIMEOUT_S, METADATA_MAX_BYTES
|
|
18
|
+
from .errors import AuthError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiscoveryError(AuthError):
|
|
22
|
+
"""Metadata did not match the configured identity."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_overall_deadline: ContextVar[float | None] = ContextVar("oauth_overall_deadline", default=None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _network_deadline(budget: float):
|
|
30
|
+
end = time.monotonic() + budget
|
|
31
|
+
previous = _overall_deadline.get()
|
|
32
|
+
marker = _overall_deadline.set(min(end, previous) if previous is not None else end)
|
|
33
|
+
try:
|
|
34
|
+
yield
|
|
35
|
+
finally:
|
|
36
|
+
_overall_deadline.reset(marker)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
40
|
+
def redirect_request(self, request, fp, code, msg, headers, newurl):
|
|
41
|
+
raise DiscoveryError("metadata: unexpected redirect")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _DeadlineHTTPSHandler(HTTPSHandler):
|
|
45
|
+
def __init__(self, context: ssl.SSLContext, deadline: float, connections: list):
|
|
46
|
+
super().__init__(context=context)
|
|
47
|
+
self.deadline = deadline
|
|
48
|
+
self.connections = connections
|
|
49
|
+
|
|
50
|
+
def https_open(self, request):
|
|
51
|
+
owner = self
|
|
52
|
+
|
|
53
|
+
class Connection(http.client.HTTPSConnection):
|
|
54
|
+
def __init__(self, host, **kwargs):
|
|
55
|
+
kwargs["timeout"] = max(0.001, owner.deadline - time.monotonic())
|
|
56
|
+
super().__init__(host, **kwargs)
|
|
57
|
+
owner.connections.append(self)
|
|
58
|
+
|
|
59
|
+
def connect(self):
|
|
60
|
+
http.client.HTTPConnection.connect(self)
|
|
61
|
+
remaining = owner.deadline - time.monotonic()
|
|
62
|
+
if remaining <= 0:
|
|
63
|
+
raise TimeoutError("HTTP deadline")
|
|
64
|
+
self.sock.settimeout(remaining)
|
|
65
|
+
self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)
|
|
66
|
+
|
|
67
|
+
return self.do_open(Connection, request, context=self._context)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _http_deadline(context: ssl.SSLContext):
|
|
72
|
+
deadline = time.monotonic() + HTTP_TIMEOUT_S
|
|
73
|
+
overall = _overall_deadline.get()
|
|
74
|
+
if overall is not None:
|
|
75
|
+
deadline = min(deadline, overall)
|
|
76
|
+
remaining = deadline - time.monotonic()
|
|
77
|
+
if remaining <= 0:
|
|
78
|
+
raise TimeoutError("HTTP deadline")
|
|
79
|
+
connections = []
|
|
80
|
+
|
|
81
|
+
def interrupt():
|
|
82
|
+
for connection in connections:
|
|
83
|
+
if connection.sock is not None:
|
|
84
|
+
try:
|
|
85
|
+
connection.sock.shutdown(socket.SHUT_RDWR)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
timer = threading.Timer(remaining, interrupt)
|
|
90
|
+
timer.daemon = True
|
|
91
|
+
timer.start()
|
|
92
|
+
try:
|
|
93
|
+
yield _DeadlineHTTPSHandler(context, deadline, connections)
|
|
94
|
+
finally:
|
|
95
|
+
timer.cancel()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Endpoints:
|
|
100
|
+
issuer: str
|
|
101
|
+
authorization_endpoint: str
|
|
102
|
+
token_endpoint: str
|
|
103
|
+
revocation_endpoint: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _get(url: str, context: ssl.SSLContext, field: str) -> dict:
|
|
107
|
+
try:
|
|
108
|
+
with _http_deadline(context) as handler:
|
|
109
|
+
opener = build_opener(ProxyHandler({}), handler, _NoRedirect())
|
|
110
|
+
response = opener.open(Request(url), timeout=HTTP_TIMEOUT_S)
|
|
111
|
+
with response:
|
|
112
|
+
if response.status != 200 or response.geturl() != url:
|
|
113
|
+
raise DiscoveryError(f"{field}: unexpected HTTP response or redirect")
|
|
114
|
+
data = response.read(METADATA_MAX_BYTES + 1)
|
|
115
|
+
if len(data) > METADATA_MAX_BYTES:
|
|
116
|
+
raise DiscoveryError(f"{field}: metadata too large")
|
|
117
|
+
value = json.loads(data)
|
|
118
|
+
if not isinstance(value, dict):
|
|
119
|
+
raise ValueError()
|
|
120
|
+
return value
|
|
121
|
+
except HTTPError as exc:
|
|
122
|
+
if exc.code == 404 and field == "AS metadata":
|
|
123
|
+
raise
|
|
124
|
+
raise DiscoveryError(f"{field}: HTTP {exc.code}") from exc
|
|
125
|
+
except (URLError, OSError, http.client.HTTPException, ValueError, UnicodeError, RecursionError) as exc:
|
|
126
|
+
raise DiscoveryError(f"{field}: invalid metadata response") from exc
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pinned_endpoint(data: dict, field: str, issuer: str) -> str:
|
|
130
|
+
value = data.get(field)
|
|
131
|
+
if not isinstance(value, str):
|
|
132
|
+
raise DiscoveryError(f"{field}: missing or invalid")
|
|
133
|
+
try:
|
|
134
|
+
parsed, root = urlsplit(value), urlsplit(issuer)
|
|
135
|
+
if (parsed.scheme, parsed.hostname, parsed.port) != (root.scheme, root.hostname, root.port):
|
|
136
|
+
raise DiscoveryError(f"{field}: origin mismatch")
|
|
137
|
+
if parsed.scheme != "https" or parsed.username or parsed.password or parsed.fragment:
|
|
138
|
+
raise DiscoveryError(f"{field}: invalid HTTPS endpoint")
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
raise DiscoveryError(f"{field}: invalid URL") from exc
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fetch_pinned_endpoints(config: BridgeConfig, ssl_context: ssl.SSLContext) -> Endpoints:
|
|
145
|
+
mcp = urlsplit(config.server.mcp_url)
|
|
146
|
+
resource_url = f"https://{mcp.netloc}/.well-known/oauth-protected-resource{mcp.path}"
|
|
147
|
+
resource = _get(resource_url, ssl_context, "resource metadata")
|
|
148
|
+
if resource.get("resource") != config.server.resource:
|
|
149
|
+
raise DiscoveryError("resource mismatch")
|
|
150
|
+
servers = resource.get("authorization_servers")
|
|
151
|
+
if not isinstance(servers, list) or config.oauth.issuer not in servers:
|
|
152
|
+
raise DiscoveryError("authorization_servers mismatch")
|
|
153
|
+
issuer = config.oauth.issuer
|
|
154
|
+
root = urlsplit(issuer)
|
|
155
|
+
issuer_path = root.path.strip("/")
|
|
156
|
+
oidc_url = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
|
157
|
+
try:
|
|
158
|
+
metadata = _get(oidc_url, ssl_context, "AS metadata")
|
|
159
|
+
except HTTPError as exc:
|
|
160
|
+
if exc.code != 404:
|
|
161
|
+
raise DiscoveryError(f"AS metadata: HTTP {exc.code}") from exc
|
|
162
|
+
fallback = f"{root.scheme}://{root.netloc}/.well-known/oauth-authorization-server"
|
|
163
|
+
if issuer_path:
|
|
164
|
+
fallback += "/" + issuer_path
|
|
165
|
+
try:
|
|
166
|
+
metadata = _get(fallback, ssl_context, "AS metadata")
|
|
167
|
+
except HTTPError as fallback_exc:
|
|
168
|
+
raise DiscoveryError(f"AS metadata: HTTP {fallback_exc.code}") from fallback_exc
|
|
169
|
+
if metadata.get("issuer") != issuer:
|
|
170
|
+
raise DiscoveryError("issuer mismatch")
|
|
171
|
+
response_types = metadata.get("response_types_supported")
|
|
172
|
+
if not isinstance(response_types, list) or "code" not in response_types:
|
|
173
|
+
raise DiscoveryError("response_types_supported missing code")
|
|
174
|
+
grants = metadata.get("grant_types_supported")
|
|
175
|
+
if grants is not None and (
|
|
176
|
+
not isinstance(grants, list) or "authorization_code" not in grants or "refresh_token" not in grants
|
|
177
|
+
):
|
|
178
|
+
raise DiscoveryError("grant_types_supported missing required grant")
|
|
179
|
+
methods = metadata.get("code_challenge_methods_supported")
|
|
180
|
+
if not isinstance(methods, list) or "S256" not in methods:
|
|
181
|
+
raise DiscoveryError("code_challenge_methods_supported missing S256")
|
|
182
|
+
if metadata.get("authorization_response_iss_parameter_supported") is not True:
|
|
183
|
+
raise DiscoveryError("authorization_response_iss_parameter_supported missing")
|
|
184
|
+
return Endpoints(
|
|
185
|
+
issuer,
|
|
186
|
+
_pinned_endpoint(metadata, "authorization_endpoint", issuer),
|
|
187
|
+
_pinned_endpoint(metadata, "token_endpoint", issuer),
|
|
188
|
+
_pinned_endpoint(metadata, "revocation_endpoint", issuer),
|
|
189
|
+
)
|
local_bridge/errors.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Bridge errors and CLI exit codes."""
|
|
2
|
+
|
|
3
|
+
EXIT_BRIDGE_ERROR = 2
|
|
4
|
+
EXIT_NOT_IMPLEMENTED = 3
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BridgeError(Exception):
|
|
8
|
+
"""A user-facing bridge failure."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(BridgeError):
|
|
12
|
+
"""The configuration is absent or invalid."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProxyError(BridgeError):
|
|
16
|
+
"""The remote MCP exchange failed."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthError(BridgeError):
|
|
20
|
+
"""Sign-in or token handling failed."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StoreError(BridgeError):
|
|
24
|
+
"""The local credential store failed."""
|
local_bridge/filelock.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Cross-process lock for credential refresh."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .constants import LOCK_POLL_S, REFRESH_LOCK_TIMEOUT_S
|
|
12
|
+
from .errors import AuthError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check_directory(path: Path) -> None:
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
info = path.lstat()
|
|
20
|
+
except OSError as exc:
|
|
21
|
+
raise AuthError("cannot inspect refresh lock directory") from exc
|
|
22
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077:
|
|
23
|
+
raise AuthError("refresh lock directory has unsafe ownership or permissions")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@contextmanager
|
|
27
|
+
def exclusive_lock(path: Path, timeout: float = REFRESH_LOCK_TIMEOUT_S):
|
|
28
|
+
try:
|
|
29
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
30
|
+
_check_directory(path.parent)
|
|
31
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
raise AuthError("cannot open refresh lock") from exc
|
|
34
|
+
start = time.monotonic()
|
|
35
|
+
acquired = False
|
|
36
|
+
try:
|
|
37
|
+
while True:
|
|
38
|
+
try:
|
|
39
|
+
if sys.platform == "win32":
|
|
40
|
+
import msvcrt
|
|
41
|
+
|
|
42
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
43
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
44
|
+
else:
|
|
45
|
+
import fcntl
|
|
46
|
+
|
|
47
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
48
|
+
acquired = True
|
|
49
|
+
break
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
if exc.errno not in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
|
|
52
|
+
raise AuthError("cannot acquire refresh lock") from exc
|
|
53
|
+
if time.monotonic() - start >= timeout:
|
|
54
|
+
raise AuthError("timed out waiting for refresh lock") from exc
|
|
55
|
+
time.sleep(LOCK_POLL_S)
|
|
56
|
+
yield
|
|
57
|
+
finally:
|
|
58
|
+
try:
|
|
59
|
+
if acquired:
|
|
60
|
+
if sys.platform == "win32":
|
|
61
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
62
|
+
else:
|
|
63
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise AuthError("cannot release refresh lock") from exc
|
|
66
|
+
finally:
|
|
67
|
+
os.close(fd)
|