Skip to content

Commit 1b18a5a

Browse files
authored
Merge pull request #1602 from billtarr-aws/fix/egress-configure-custom-provider-flags
fix(cli): forward the custom-OIDC fields so egress-configure --provider custom works
2 parents bc77015 + 15f5b83 commit 1b18a5a

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

api/registry_client.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3883,6 +3883,11 @@ def configure_egress_auth(
38833883
client_secret: str | None = None,
38843884
scopes: list[str] | None = None,
38853885
target_audience: str | None = None,
3886+
custom_authorize_url: str | None = None,
3887+
custom_token_url: str | None = None,
3888+
custom_scope_separator: str | None = None,
3889+
custom_token_auth_style: str | None = None,
3890+
custom_resource: str | None = None,
38863891
) -> dict[str, Any]:
38873892
"""Configure per-user egress auth on a server (admin only).
38883893
@@ -3898,6 +3903,18 @@ def configure_egress_auth(
38983903
client_secret: OAuth client secret (oauth_user only, write-only).
38993904
scopes: Optional list of OAuth scopes.
39003905
target_audience: Target audience (obo_exchange only).
3906+
custom_authorize_url: Authorize endpoint. REQUIRED when
3907+
``provider="custom"`` (server-side ``resolve_provider`` rejects
3908+
the config without it).
3909+
custom_token_url: Token endpoint. REQUIRED when
3910+
``provider="custom"``.
3911+
custom_scope_separator: Scope delimiter when the provider does not
3912+
use a space (custom only).
3913+
custom_token_auth_style: Where the client secret goes on the token
3914+
request -- "post_body" (default) or "basic_header" (custom only).
3915+
custom_resource: RFC 8707 resource indicator, sent on both the
3916+
authorize and token requests to bind the token to one protected
3917+
resource (custom only).
39013918
39023919
Returns:
39033920
Non-secret egress config view dict.
@@ -3920,6 +3937,19 @@ def configure_egress_auth(
39203937
body["scopes"] = scopes
39213938
if target_audience is not None:
39223939
body["target_audience"] = target_audience
3940+
# Custom-OIDC overrides. Only meaningful when provider == "custom"; the
3941+
# server ignores them for built-in providers, so they are forwarded
3942+
# unconditionally like the fields above rather than gated here.
3943+
if custom_authorize_url is not None:
3944+
body["custom_authorize_url"] = custom_authorize_url
3945+
if custom_token_url is not None:
3946+
body["custom_token_url"] = custom_token_url
3947+
if custom_scope_separator is not None:
3948+
body["custom_scope_separator"] = custom_scope_separator
3949+
if custom_token_auth_style is not None:
3950+
body["custom_token_auth_style"] = custom_token_auth_style
3951+
if custom_resource is not None:
3952+
body["custom_resource"] = custom_resource
39233953

39243954
response = self._make_request(
39253955
method="POST",

api/registry_management.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,11 @@ def cmd_egress_configure(args: argparse.Namespace) -> int:
20352035
client_secret=args.client_secret,
20362036
scopes=_parse_scopes(args.scopes),
20372037
target_audience=args.target_audience,
2038+
custom_authorize_url=args.custom_authorize_url,
2039+
custom_token_url=args.custom_token_url,
2040+
custom_scope_separator=args.custom_scope_separator,
2041+
custom_token_auth_style=args.custom_token_auth_style,
2042+
custom_resource=args.custom_resource,
20382043
)
20392044
print(json.dumps(response, indent=2, default=str))
20402045
return 0
@@ -6730,6 +6735,30 @@ def main() -> int:
67306735
egress_configure_parser.add_argument(
67316736
"--target-audience", help="Target audience (obo_exchange only)"
67326737
)
6738+
# Custom-OIDC provider overrides. --provider custom is unusable without the
6739+
# two URLs: the server's resolve_provider() rejects the config with
6740+
# "custom requires custom_authorize_url and custom_token_url".
6741+
egress_configure_parser.add_argument(
6742+
"--custom-authorize-url",
6743+
help="Authorize endpoint (REQUIRED with --provider custom)",
6744+
)
6745+
egress_configure_parser.add_argument(
6746+
"--custom-token-url",
6747+
help="Token endpoint (REQUIRED with --provider custom)",
6748+
)
6749+
egress_configure_parser.add_argument(
6750+
"--custom-scope-separator",
6751+
help="Scope delimiter when the provider does not use a space (custom only)",
6752+
)
6753+
egress_configure_parser.add_argument(
6754+
"--custom-token-auth-style",
6755+
choices=["post_body", "basic_header"],
6756+
help="Where the client secret goes on the token request (custom only, default post_body)",
6757+
)
6758+
egress_configure_parser.add_argument(
6759+
"--custom-resource",
6760+
help="RFC 8707 resource indicator, sent on authorize and token requests (custom only)",
6761+
)
67336762

67346763
# Get egress config command
67356764
egress_config_get_parser = subparsers.add_parser(
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"""Unit tests for the custom-OIDC fields on egress-auth configuration.
2+
3+
``POST /api/servers/{path}/egress-auth`` accepts five ``custom_*`` fields, and the
4+
server's ``resolve_provider`` REQUIRES two of them when ``provider="custom"``
5+
(rejecting the config with "custom requires custom_authorize_url and
6+
custom_token_url"). The frontend server-edit modal already exposes the URLs, but
7+
``RegistryClient.configure_egress_auth`` and the ``egress-configure`` CLI command
8+
did not forward any of them -- so ``--provider custom`` was accepted by argparse
9+
and then always failed server-side.
10+
11+
These lock in that the fields reach the request body, and that the CLI parses the
12+
corresponding flags, so the CLI/UI parity gap cannot silently reopen.
13+
"""
14+
15+
import sys
16+
from pathlib import Path
17+
from types import SimpleNamespace
18+
from unittest.mock import MagicMock, patch
19+
20+
import pytest
21+
22+
from api.registry_client import RegistryClient
23+
24+
# registry_management.py lives under api/ and imports sibling modules by name.
25+
_API_DIR = Path(__file__).resolve().parents[3] / "api"
26+
sys.path.insert(0, str(_API_DIR))
27+
28+
SALESFORCE_AUTHORIZE = "https://example.my.salesforce.com/services/oauth2/authorize"
29+
# nosec B105 - test URL, not a credential
30+
SALESFORCE_TOKEN = "https://example.my.salesforce.com/services/oauth2/token"
31+
32+
33+
@pytest.fixture
34+
def client() -> RegistryClient:
35+
"""A RegistryClient pointed at a dummy URL with a dummy token."""
36+
return RegistryClient(registry_url="http://localhost", token="dummy-token-1234567890")
37+
38+
39+
def _sent_body(mock_req: MagicMock) -> dict:
40+
"""The JSON body passed to the mocked _make_request."""
41+
return mock_req.call_args.kwargs["data"]
42+
43+
44+
class TestConfigureEgressAuthCustomFields:
45+
"""Tests for configure_egress_auth custom-OIDC passthrough."""
46+
47+
def test_custom_urls_reach_the_request_body(
48+
self,
49+
client: RegistryClient,
50+
) -> None:
51+
"""The two REQUIRED custom URLs are forwarded for provider=custom.
52+
53+
Without these the server rejects every provider=custom config, which is
54+
the bug this guards.
55+
"""
56+
response = MagicMock()
57+
response.json.return_value = {"path": "/sf", "egress_provider": "custom"}
58+
59+
with patch.object(client, "_make_request", return_value=response) as mock_req:
60+
client.configure_egress_auth(
61+
server_path="/sf",
62+
mode="oauth_user",
63+
provider="custom",
64+
client_id="cid",
65+
client_secret="csec", # nosec B106 - dummy test value
66+
scopes=["mcp_api", "refresh_token"],
67+
custom_authorize_url=SALESFORCE_AUTHORIZE,
68+
custom_token_url=SALESFORCE_TOKEN,
69+
)
70+
71+
body = _sent_body(mock_req)
72+
assert body["custom_authorize_url"] == SALESFORCE_AUTHORIZE
73+
assert body["custom_token_url"] == SALESFORCE_TOKEN
74+
# The pre-existing fields must still be sent.
75+
assert body["egress_auth_mode"] == "oauth_user"
76+
assert body["egress_provider"] == "custom"
77+
assert body["scopes"] == ["mcp_api", "refresh_token"]
78+
79+
def test_all_five_custom_fields_are_forwarded(
80+
self,
81+
client: RegistryClient,
82+
) -> None:
83+
"""Every custom_* field the API accepts is passed through."""
84+
response = MagicMock()
85+
response.json.return_value = {}
86+
87+
with patch.object(client, "_make_request", return_value=response) as mock_req:
88+
client.configure_egress_auth(
89+
server_path="/sf",
90+
mode="oauth_user",
91+
provider="custom",
92+
custom_authorize_url=SALESFORCE_AUTHORIZE,
93+
custom_token_url=SALESFORCE_TOKEN,
94+
custom_scope_separator=",",
95+
custom_token_auth_style="basic_header",
96+
custom_resource="https://api.example.com/mcp",
97+
)
98+
99+
body = _sent_body(mock_req)
100+
assert body["custom_scope_separator"] == ","
101+
assert body["custom_token_auth_style"] == "basic_header"
102+
assert body["custom_resource"] == "https://api.example.com/mcp"
103+
104+
def test_custom_fields_omitted_when_not_supplied(
105+
self,
106+
client: RegistryClient,
107+
) -> None:
108+
"""A built-in provider config carries no custom_* keys.
109+
110+
The fields follow the existing ``is not None`` convention, so omitting
111+
them must not send nulls that could overwrite stored values on edit.
112+
"""
113+
response = MagicMock()
114+
response.json.return_value = {}
115+
116+
with patch.object(client, "_make_request", return_value=response) as mock_req:
117+
client.configure_egress_auth(
118+
server_path="/github",
119+
mode="oauth_user",
120+
provider="github",
121+
client_id="cid",
122+
)
123+
124+
body = _sent_body(mock_req)
125+
assert not [k for k in body if k.startswith("custom_")]
126+
127+
def test_endpoint_and_method_unchanged(
128+
self,
129+
client: RegistryClient,
130+
) -> None:
131+
"""The custom fields do not alter the request target."""
132+
response = MagicMock()
133+
response.json.return_value = {}
134+
135+
with patch.object(client, "_make_request", return_value=response) as mock_req:
136+
client.configure_egress_auth(
137+
server_path="/sf",
138+
mode="oauth_user",
139+
provider="custom",
140+
custom_authorize_url=SALESFORCE_AUTHORIZE,
141+
custom_token_url=SALESFORCE_TOKEN,
142+
)
143+
144+
kwargs = mock_req.call_args.kwargs
145+
assert kwargs["method"] == "POST"
146+
assert kwargs["endpoint"] == "/api/servers/sf/egress-auth"
147+
148+
149+
class TestEgressConfigureCommandWiring:
150+
"""Tests that cmd_egress_configure forwards the custom-OIDC args.
151+
152+
The parser is built inside ``main()``, so these exercise the command handler
153+
directly with a SimpleNamespace (the convention used by
154+
test_registry_management_m2m_secret.py). That covers the wiring this change
155+
adds: args -> client kwargs.
156+
"""
157+
158+
@staticmethod
159+
def _args(**overrides) -> SimpleNamespace:
160+
"""Args as argparse would produce them, with custom_* defaulting to None."""
161+
base = {
162+
"path": "/sf",
163+
"mode": "oauth_user",
164+
"provider": "custom",
165+
"client_id": "cid",
166+
"client_secret": "csec",
167+
"scopes": "mcp_api,refresh_token",
168+
"target_audience": None,
169+
"custom_authorize_url": None,
170+
"custom_token_url": None,
171+
"custom_scope_separator": None,
172+
"custom_token_auth_style": None,
173+
"custom_resource": None,
174+
}
175+
base.update(overrides)
176+
return SimpleNamespace(**base)
177+
178+
def test_command_forwards_custom_args_to_client(self) -> None:
179+
"""Every custom_* arg reaches configure_egress_auth as a kwarg."""
180+
import registry_management
181+
182+
mock_client = MagicMock()
183+
mock_client.configure_egress_auth.return_value = {"path": "/sf"}
184+
args = self._args(
185+
custom_authorize_url=SALESFORCE_AUTHORIZE,
186+
custom_token_url=SALESFORCE_TOKEN,
187+
custom_scope_separator=",",
188+
custom_token_auth_style="basic_header",
189+
custom_resource="https://api.example.com/mcp",
190+
)
191+
192+
with patch.object(registry_management, "_create_client", return_value=mock_client):
193+
rc = registry_management.cmd_egress_configure(args)
194+
195+
assert rc == 0
196+
kwargs = mock_client.configure_egress_auth.call_args.kwargs
197+
assert kwargs["custom_authorize_url"] == SALESFORCE_AUTHORIZE
198+
assert kwargs["custom_token_url"] == SALESFORCE_TOKEN
199+
assert kwargs["custom_scope_separator"] == ","
200+
assert kwargs["custom_token_auth_style"] == "basic_header"
201+
assert kwargs["custom_resource"] == "https://api.example.com/mcp"
202+
# Pre-existing args must still be forwarded.
203+
assert kwargs["mode"] == "oauth_user"
204+
assert kwargs["provider"] == "custom"
205+
assert kwargs["scopes"] == ["mcp_api", "refresh_token"]
206+
207+
def test_omitted_custom_args_forward_as_none(self) -> None:
208+
"""Unset flags pass None, which the client then skips entirely."""
209+
import registry_management
210+
211+
mock_client = MagicMock()
212+
mock_client.configure_egress_auth.return_value = {}
213+
214+
with patch.object(registry_management, "_create_client", return_value=mock_client):
215+
rc = registry_management.cmd_egress_configure(self._args(provider="github"))
216+
217+
assert rc == 0
218+
kwargs = mock_client.configure_egress_auth.call_args.kwargs
219+
assert all(kwargs[k] is None for k in kwargs if k.startswith("custom_"))

0 commit comments

Comments
 (0)