Skip to content

Commit 06989fc

Browse files
authored
Merge pull request #173 from keycardai/feat/multi-zone-oauth-surface
feat(keycardai-oauth)!: multi-zone convergence (issuer-keyed credentials, zone-aware low-level surface)
2 parents 10f49c6 + 04e8dc2 commit 06989fc

19 files changed

Lines changed: 563 additions & 108 deletions

File tree

packages/fastmcp/src/keycardai/fastmcp/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ async def sync_calendar_to_drive(ctx: Context):
7070
zone_url="https://keycard.cloud",
7171
mcp_base_url="https://my-server.com",
7272
application_credential=ClientSecret({
73-
"zone1": ("id1", "secret1"),
74-
"zone2": ("id2", "secret2"),
73+
"https://zone1.keycard.cloud": ("id1", "secret1"),
74+
"https://zone2.keycard.cloud": ("id2", "secret2"),
7575
})
7676
)
7777
"""

packages/mcp/src/keycardai/mcp/server/auth/provider.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ class AuthProvider:
7070
# Passing a multi-zone ClientSecret enables multi-zone automatically;
7171
# pass enable_multi_zone=True/False only to override.
7272
client_secret = ClientSecret({
73-
"zone1": ("client_id_1", "client_secret_1"),
74-
"zone2": ("client_id_2", "client_secret_2"),
73+
"https://zone1.keycard.cloud": ("client_id_1", "client_secret_1"),
74+
"https://zone2.keycard.cloud": ("client_id_2", "client_secret_2"),
7575
})
7676
provider = AuthProvider(
7777
zone_url="https://keycard.cloud",
@@ -276,9 +276,12 @@ async def _get_or_create_client(self, auth_info: dict[str, str] | None = None) -
276276

277277
auth_strategy = self.auth
278278
if isinstance(self.auth, MultiZoneBasicAuth) and auth_info['zone_id']:
279-
if not self.auth.has_zone(auth_info['zone_id']):
279+
# Multi-zone credentials are keyed by the zone's issuer
280+
# URL, which is the same zone-scoped URL the client is
281+
# created against.
282+
if not self.auth.has_issuer(base_url):
280283
raise AuthProviderConfigurationError()
281-
auth_strategy = self.auth.get_auth_for_zone(auth_info['zone_id'])
284+
auth_strategy = self.auth.get_auth_for_issuer(base_url)
282285

283286
# Configure dynamic client registration
284287
# Priority: explicit config > identity provider defaults

packages/mcp/tests/integration/test_auth_provider.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ def test_auth_provider_init_with_basic_auth(self, mock_client_factory):
7575
def test_multi_zone_inferred_from_credential(self, mock_client_factory):
7676
"""A multi-zone ClientSecret turns on multi-zone without an explicit flag."""
7777
credential = ClientSecret({
78-
"zone1": ("client_id_1", "client_secret_1"),
79-
"zone2": ("client_id_2", "client_secret_2"),
78+
"https://zone1.keycard.cloud": ("client_id_1", "client_secret_1"),
79+
"https://zone2.keycard.cloud": ("client_id_2", "client_secret_2"),
8080
})
8181
auth_provider = AuthProvider(
8282
zone_url="https://keycard.cloud",
@@ -101,7 +101,7 @@ def test_single_zone_credential_does_not_infer_multi_zone(self, mock_client_fact
101101
def test_explicit_enable_multi_zone_overrides_inference(self, mock_client_factory):
102102
"""An explicit enable_multi_zone value is respected over inference."""
103103
credential = ClientSecret({
104-
"zone1": ("client_id_1", "client_secret_1"),
104+
"https://zone1.keycard.cloud": ("client_id_1", "client_secret_1"),
105105
})
106106
auth_provider = AuthProvider(
107107
zone_id=mock_zone_id,

packages/oauth/README.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -278,28 +278,35 @@ with Client("https://oauth.example.com", auth=auth) as client:
278278

279279
### MultiZoneBasicAuth
280280

281-
For multi-zone deployments with different credentials per zone:
281+
For multi-zone deployments with different credentials per zone, keyed by
282+
each zone's issuer URL:
282283

283284
```python
284-
from keycardai.oauth import MultiZoneBasicAuth
285+
from keycardai.oauth import Client, MultiZoneBasicAuth
285286

286-
# Configure credentials for multiple zones
287+
# Configure credentials per zone issuer
287288
auth = MultiZoneBasicAuth({
288-
"production": ("prod_client_id", "prod_client_secret"),
289-
"staging": ("staging_client_id", "staging_client_secret"),
290-
"development": ("dev_client_id", "dev_client_secret"),
289+
"https://prod.keycard.cloud": ("prod_client_id", "prod_client_secret"),
290+
"https://staging.keycard.cloud": ("staging_client_id", "staging_client_secret"),
291291
})
292292

293-
# Check available zones
294-
print(auth.get_configured_zones()) # ['production', 'staging', 'development']
293+
# Check configured issuers
294+
print(auth.get_configured_issuers())
295295

296-
# Check if a zone exists
297-
if auth.has_zone("production"):
298-
# Get headers for a specific zone
299-
headers = auth.get_headers_for_zone("production")
296+
# Check if an issuer is configured
297+
if auth.has_issuer("https://prod.keycard.cloud"):
298+
# Get headers for a specific issuer
299+
headers = auth.apply_headers("https://prod.keycard.cloud")
300300

301-
# Or get the BasicAuth instance for a zone
302-
prod_auth = auth.get_auth_for_zone("production")
301+
# Or get the BasicAuth instance for an issuer
302+
prod_auth = auth.get_auth_for_issuer("https://prod.keycard.cloud")
303+
304+
# Token operations select credentials per call with issuer=...
305+
with Client("https://prod.keycard.cloud", auth=auth) as client:
306+
response = client.exchange_token(
307+
subject_token="token",
308+
issuer="https://prod.keycard.cloud",
309+
)
303310
```
304311

305312
## Operations

packages/oauth/src/keycardai/oauth/client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ async def _ensure_initialized(self) -> None:
364364
endpoint=self._discovered_endpoints.register,
365365
transport=self.transport,
366366
auth=self.auth_strategy,
367+
issuer=self.issuer,
367368
user_agent=self.config.user_agent,
368369
custom_headers=self.config.custom_headers,
369370
timeout=self.config.timeout,
@@ -552,6 +553,7 @@ async def register_client(self, request: ClientRegistrationRequest | None = None
552553
endpoint=endpoints.register,
553554
transport=self.transport,
554555
auth=self.auth_strategy,
556+
issuer=self.issuer,
555557
user_agent=self.config.user_agent,
556558
custom_headers=self.config.custom_headers,
557559
timeout=client_registration_args.get("timeout", self.config.timeout),
@@ -618,6 +620,7 @@ async def discover_server_metadata(self, request: ServerMetadataRequest | None =
618620
endpoint=self.issuer,
619621
transport=self.transport,
620622
auth=self.auth_strategy,
623+
issuer=self.issuer,
621624
user_agent=self.config.user_agent,
622625
custom_headers=self.config.custom_headers,
623626
timeout=self.config.timeout,
@@ -632,13 +635,16 @@ async def discover_server_metadata(self, request: ServerMetadataRequest | None =
632635
async def exchange_token(
633636
self,
634637
request: TokenExchangeRequest,
638+
*,
639+
issuer: str | None = None,
635640
) -> TokenResponse: ...
636641

637642
@overload
638643
async def exchange_token(
639644
self,
640645
/,
641646
*,
647+
issuer: str | None = None,
642648
subject_token: str,
643649
subject_token_type: str = ...,
644650
grant_type: str | None = None,
@@ -689,6 +695,10 @@ async def exchange_token(self, request: TokenExchangeRequest | None = None, /, *
689695
690696
Args:
691697
request: TokenExchangeRequest with all exchange parameters
698+
issuer: Issuer URL selecting which credentials a zone-aware auth
699+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
700+
Defaults to the client's issuer. Single-credential strategies
701+
ignore it. May be combined with either calling form.
692702
**token_exchange_args: Alternative to request - provide individual parameters
693703
694704
Returns:
@@ -697,6 +707,7 @@ async def exchange_token(self, request: TokenExchangeRequest | None = None, /, *
697707
Raises:
698708
TypeError: If both request and token_exchange_args are provided
699709
"""
710+
issuer = token_exchange_args.pop("issuer", None)
700711
if request is not None and token_exchange_args:
701712
raise TypeError("Pass either `request` or keyword arguments, not both.")
702713

@@ -709,6 +720,7 @@ async def exchange_token(self, request: TokenExchangeRequest | None = None, /, *
709720
endpoint=endpoints.token,
710721
transport=self.transport,
711722
auth=self.auth_strategy,
723+
issuer=issuer if issuer is not None else self.issuer,
712724
user_agent=self.config.user_agent,
713725
custom_headers=self.config.custom_headers,
714726
timeout=token_exchange_args.get("timeout", self.config.timeout),
@@ -720,13 +732,16 @@ async def exchange_token(self, request: TokenExchangeRequest | None = None, /, *
720732
async def client_credentials_grant(
721733
self,
722734
request: ClientCredentialsRequest,
735+
*,
736+
issuer: str | None = None,
723737
) -> TokenResponse: ...
724738

725739
@overload
726740
async def client_credentials_grant(
727741
self,
728742
/,
729743
*,
744+
issuer: str | None = None,
730745
resource: str | None = None,
731746
scope: str | None = None,
732747
client_assertion: str | None = None,
@@ -755,6 +770,10 @@ async def client_credentials_grant(self, request: ClientCredentialsRequest | Non
755770
756771
Args:
757772
request: ClientCredentialsRequest with all grant parameters
773+
issuer: Issuer URL selecting which credentials a zone-aware auth
774+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
775+
Defaults to the client's issuer. Single-credential strategies
776+
ignore it. May be combined with either calling form.
758777
**client_credentials_args: Alternative to request - provide individual parameters
759778
760779
Returns:
@@ -763,6 +782,7 @@ async def client_credentials_grant(self, request: ClientCredentialsRequest | Non
763782
Raises:
764783
TypeError: If both request and client_credentials_args are provided
765784
"""
785+
issuer = client_credentials_args.pop("issuer", None)
766786
if request is not None and client_credentials_args:
767787
raise TypeError("Pass either `request` or keyword arguments, not both.")
768788

@@ -775,6 +795,7 @@ async def client_credentials_grant(self, request: ClientCredentialsRequest | Non
775795
endpoint=endpoints.token,
776796
transport=self.transport,
777797
auth=self.auth_strategy,
798+
issuer=issuer if issuer is not None else self.issuer,
778799
user_agent=self.config.user_agent,
779800
custom_headers=self.config.custom_headers,
780801
timeout=client_credentials_args.get("timeout", self.config.timeout),
@@ -821,6 +842,7 @@ async def exchange_authorization_code(
821842
endpoint=endpoints.token,
822843
transport=self.transport,
823844
auth=self.auth_strategy,
845+
issuer=self.issuer,
824846
user_agent=self.config.user_agent,
825847
custom_headers=self.config.custom_headers,
826848
timeout=timeout or self.config.timeout,
@@ -889,6 +911,7 @@ async def impersonate(
889911
endpoint=endpoints.token,
890912
transport=self.transport,
891913
auth=self.auth_strategy,
914+
issuer=self.issuer,
892915
user_agent=self.config.user_agent,
893916
custom_headers=self.config.custom_headers,
894917
timeout=timeout or self.config.timeout,
@@ -1048,6 +1071,7 @@ def _ensure_initialized(self) -> None:
10481071
endpoint=self._discovered_endpoints.register,
10491072
transport=self.transport,
10501073
auth=self.auth_strategy,
1074+
issuer=self.issuer,
10511075
user_agent=self.config.user_agent,
10521076
custom_headers=self.config.custom_headers,
10531077
timeout=self.config.timeout,
@@ -1199,6 +1223,7 @@ def register_client(self, request: ClientRegistrationRequest | None = None, /, *
11991223
endpoint=endpoints.register,
12001224
transport=self.transport,
12011225
auth=self.auth_strategy,
1226+
issuer=self.issuer,
12021227
user_agent=self.config.user_agent,
12031228
custom_headers=self.config.custom_headers,
12041229
timeout=client_registration_args.get("timeout", self.config.timeout),
@@ -1264,6 +1289,7 @@ def discover_server_metadata(self, request: ServerMetadataRequest | None = None,
12641289
endpoint=self.issuer,
12651290
transport=self.transport,
12661291
auth=self.auth_strategy,
1292+
issuer=self.issuer,
12671293
user_agent=self.config.user_agent,
12681294
custom_headers=self.config.custom_headers,
12691295
timeout=self.config.timeout,
@@ -1278,13 +1304,16 @@ def discover_server_metadata(self, request: ServerMetadataRequest | None = None,
12781304
def exchange_token(
12791305
self,
12801306
request: TokenExchangeRequest,
1307+
*,
1308+
issuer: str | None = None,
12811309
) -> TokenResponse: ...
12821310

12831311
@overload
12841312
def exchange_token(
12851313
self,
12861314
/,
12871315
*,
1316+
issuer: str | None = None,
12881317
subject_token: str,
12891318
subject_token_type: str = ...,
12901319
grant_type: str | None = None,
@@ -1335,6 +1364,10 @@ def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token
13351364
13361365
Args:
13371366
request: TokenExchangeRequest with all exchange parameters
1367+
issuer: Issuer URL selecting which credentials a zone-aware auth
1368+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
1369+
Defaults to the client's issuer. Single-credential strategies
1370+
ignore it. May be combined with either calling form.
13381371
**token_exchange_args: Alternative to request - provide individual parameters
13391372
13401373
Returns:
@@ -1343,6 +1376,7 @@ def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token
13431376
Raises:
13441377
TypeError: If both request and token_exchange_args are provided
13451378
"""
1379+
issuer = token_exchange_args.pop("issuer", None)
13461380
if request is not None and token_exchange_args:
13471381
raise TypeError("Pass either `request` or keyword arguments, not both.")
13481382

@@ -1355,6 +1389,7 @@ def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token
13551389
endpoint=endpoints.token,
13561390
transport=self.transport,
13571391
auth=self.auth_strategy,
1392+
issuer=issuer if issuer is not None else self.issuer,
13581393
user_agent=self.config.user_agent,
13591394
custom_headers=self.config.custom_headers,
13601395
timeout=token_exchange_args.get("timeout", self.config.timeout),
@@ -1366,13 +1401,16 @@ def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token
13661401
def client_credentials_grant(
13671402
self,
13681403
request: ClientCredentialsRequest,
1404+
*,
1405+
issuer: str | None = None,
13691406
) -> TokenResponse: ...
13701407

13711408
@overload
13721409
def client_credentials_grant(
13731410
self,
13741411
/,
13751412
*,
1413+
issuer: str | None = None,
13761414
resource: str | None = None,
13771415
scope: str | None = None,
13781416
client_assertion: str | None = None,
@@ -1401,6 +1439,10 @@ def client_credentials_grant(self, request: ClientCredentialsRequest | None = No
14011439
14021440
Args:
14031441
request: ClientCredentialsRequest with all grant parameters
1442+
issuer: Issuer URL selecting which credentials a zone-aware auth
1443+
strategy (e.g. MultiZoneBasicAuth) applies for this call.
1444+
Defaults to the client's issuer. Single-credential strategies
1445+
ignore it. May be combined with either calling form.
14041446
**client_credentials_args: Alternative to request - provide individual parameters
14051447
14061448
Returns:
@@ -1409,6 +1451,7 @@ def client_credentials_grant(self, request: ClientCredentialsRequest | None = No
14091451
Raises:
14101452
TypeError: If both request and client_credentials_args are provided
14111453
"""
1454+
issuer = client_credentials_args.pop("issuer", None)
14121455
if request is not None and client_credentials_args:
14131456
raise TypeError("Pass either `request` or keyword arguments, not both.")
14141457

@@ -1421,6 +1464,7 @@ def client_credentials_grant(self, request: ClientCredentialsRequest | None = No
14211464
endpoint=endpoints.token,
14221465
transport=self.transport,
14231466
auth=self.auth_strategy,
1467+
issuer=issuer if issuer is not None else self.issuer,
14241468
user_agent=self.config.user_agent,
14251469
custom_headers=self.config.custom_headers,
14261470
timeout=client_credentials_args.get("timeout", self.config.timeout),
@@ -1467,6 +1511,7 @@ def exchange_authorization_code(
14671511
endpoint=endpoints.token,
14681512
transport=self.transport,
14691513
auth=self.auth_strategy,
1514+
issuer=self.issuer,
14701515
user_agent=self.config.user_agent,
14711516
custom_headers=self.config.custom_headers,
14721517
timeout=timeout or self.config.timeout,
@@ -1535,6 +1580,7 @@ def impersonate(
15351580
endpoint=endpoints.token,
15361581
transport=self.transport,
15371582
auth=self.auth_strategy,
1583+
issuer=self.issuer,
15381584
user_agent=self.config.user_agent,
15391585
custom_headers=self.config.custom_headers,
15401586
timeout=timeout or self.config.timeout,

packages/oauth/src/keycardai/oauth/http/_context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class HTTPContext:
3939
timeout: float | None = None
4040
retries: int = 0
4141
headers: dict[str, str] | None = None
42+
issuer: str | None = None
4243

4344

4445
def build_http_context(
@@ -49,6 +50,7 @@ def build_http_context(
4950
custom_headers: dict[str, str] | None = None,
5051
timeout: float | None = None,
5152
additional_headers: dict[str, str] | None = None,
53+
issuer: str | None = None,
5254
) -> HTTPContext:
5355
"""Build HTTPContext with headers from configuration.
5456
@@ -63,6 +65,8 @@ def build_http_context(
6365
custom_headers: Custom headers from configuration (optional)
6466
timeout: Optional timeout override
6567
additional_headers: Optional additional headers to merge
68+
issuer: Optional issuer URL selecting which credentials a zone-aware
69+
auth strategy applies for this operation
6670
6771
Returns:
6872
HTTPContext configured with headers from config
@@ -94,4 +98,5 @@ def build_http_context(
9498
auth=auth,
9599
timeout=timeout,
96100
headers=headers,
101+
issuer=issuer,
97102
)

0 commit comments

Comments
 (0)