Skip to content

Commit 8b82831

Browse files
Shira SassoonCopilot
andcommitted
feat: add issuer (iss) to drift detection key for sovereign cloud safety
- Add FAB_AZURE_CLI_ISSUER constant and store iss claim at login - Check iss before tid and oid on every token acquisition - Drift key is now iss + tid + oid (environment + tenant + identity) - Add azure_cli_environment_mismatch error message - Add TestAzureCliEnvironmentDrift tests (public vs sovereign) - Update JWT test helpers to include iss claim Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1785775 commit 8b82831

5 files changed

Lines changed: 82 additions & 12 deletions

File tree

src/fabric_cli/core/fab_auth.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -455,16 +455,18 @@ def set_azure_cli(self, tenant_id=None):
455455

456456
# Set identity_type after tenant to survive any logout triggered by tenant change
457457
auth_props: dict = {con.IDENTITY_TYPE: "azure_cli"}
458-
# Store OID for drift detection (immutable, no PII)
458+
# Store OID and issuer for drift detection (immutable, no PII)
459459
if claims.get("oid"):
460460
auth_props[con.FAB_AZURE_CLI_PRINCIPAL_ID] = claims["oid"]
461+
if claims.get("iss"):
462+
auth_props[con.FAB_AZURE_CLI_ISSUER] = claims["iss"]
461463
self._set_auth_properties(auth_props)
462464

463465
@staticmethod
464466
def _decode_jwt_claims(token: str) -> dict:
465467
"""Decode JWT payload claims without signature validation.
466468
467-
Used to extract identity claims (tid, oid) from tokens
469+
Used to extract identity claims (iss, tid, oid) from tokens
468470
returned by AzureCliCredential. Signature validation is
469471
unnecessary here — the token was just returned by the
470472
Azure CLI SDK over a local subprocess call.
@@ -485,8 +487,8 @@ def _acquire_token_from_azure_cli(self, scope: list[str]) -> dict:
485487
"""Acquire a token using Azure CLI's AzureCliCredential.
486488
487489
After acquiring the token, decodes JWT claims and verifies
488-
that tid and oid match the stored values from login to detect
489-
identity drift (e.g., user ran 'az login' as a different user).
490+
that iss, tid, and oid match the stored values from login to detect
491+
identity or environment drift.
490492
"""
491493
stored_tenant = self.get_tenant_id()
492494

@@ -504,6 +506,15 @@ def _acquire_token_from_azure_cli(self, scope: list[str]) -> dict:
504506
# Post-acquisition drift detection from actual token claims
505507
claims = self._decode_jwt_claims(azure_token.token)
506508

509+
# Environment drift check (issuer encodes cloud: public vs sovereign)
510+
stored_issuer = self._auth_info.get(con.FAB_AZURE_CLI_ISSUER)
511+
if stored_issuer and claims.get("iss"):
512+
if claims["iss"] != stored_issuer:
513+
raise FabricCLIError(
514+
ErrorMessages.Auth.azure_cli_environment_mismatch(),
515+
status_code=con.ERROR_AUTHENTICATION_FAILED,
516+
)
517+
507518
# Tenant drift check
508519
if stored_tenant and claims.get("tid"):
509520
if claims["tid"] != stored_tenant:

src/fabric_cli/core/fab_constant.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757

5858
FAB_REFRESH_TOKEN = "fab_refresh_token"
5959
FAB_AZURE_CLI_PRINCIPAL_ID = "fab_azure_cli_principal_id"
60+
FAB_AZURE_CLI_ISSUER = "fab_azure_cli_issuer"
6061
IDENTITY_TYPE = "identity_type"
6162
FAB_AUTH_MODE = "fab_auth_mode" # Kept for backward compatibility
6263
FAB_AUTHORITY = "fab_authority"

src/fabric_cli/errors/auth.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ def azure_cli_tenant_mismatch(stored_tenant: str, current_tenant: str) -> str:
128128
"Run 'fab auth login --azure-cli' to re-authenticate."
129129
)
130130

131+
@staticmethod
132+
def azure_cli_environment_mismatch() -> str:
133+
return (
134+
"Azure CLI cloud environment has changed since 'fab auth login --azure-cli' was run. "
135+
"Run 'fab auth login --azure-cli' to re-authenticate in the current environment."
136+
)
137+
131138
@staticmethod
132139
def azure_cli_principal_mismatch() -> str:
133140
return (

tests/test_core/test_fab_auth_azure_cli.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@
1414
from fabric_cli.errors import ErrorMessages
1515

1616

17-
def _make_jwt(tid: str = "test-tenant", oid: str = "test-oid", **extra_claims) -> str:
17+
def _make_jwt(tid: str = "test-tenant", oid: str = "test-oid",
18+
iss: str = "https://sts.windows.net/test-tenant/", **extra_claims) -> str:
1819
"""Create a fake JWT with specified claims (no signature validation needed)."""
1920
header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode()
20-
claims = {"tid": tid, "oid": oid, **extra_claims}
21+
claims = {"tid": tid, "oid": oid, "iss": iss, **extra_claims}
2122
payload = base64.urlsafe_b64encode(_json.dumps(claims).encode()).rstrip(b"=").decode()
2223
return f"{header}.{payload}.fakesig"
2324

2425

25-
def _mock_credential_with_jwt(mock_class, tid="test-tenant", oid="test-oid", **extra):
26+
def _mock_credential_with_jwt(mock_class, tid="test-tenant", oid="test-oid",
27+
iss="https://sts.windows.net/test-tenant/", **extra):
2628
"""Set up a mock AzureCliCredential that returns a JWT with given claims."""
27-
token_str = _make_jwt(tid=tid, oid=oid, **extra)
29+
token_str = _make_jwt(tid=tid, oid=oid, iss=iss, **extra)
2830
mock_token = MagicMock()
2931
mock_token.token = token_str
3032
mock_token.expires_on = int(time.time()) + 3600
@@ -280,6 +282,53 @@ def test_tenant_match_allows_token_acquisition(
280282
assert "access_token" in result
281283

282284

285+
class TestAzureCliEnvironmentDrift:
286+
"""Test cloud environment drift detection via JWT iss claim."""
287+
288+
@patch("fabric_cli.core.fab_auth.AzureCliCredential")
289+
def test_environment_drift_blocks_token_acquisition(
290+
self, mock_credential_class, temp_dir_fixture
291+
):
292+
"""Should block when token issuer differs from stored environment."""
293+
# Login in Azure Public
294+
_mock_credential_with_jwt(
295+
mock_credential_class, tid="t1", oid="u1",
296+
iss="https://sts.windows.net/t1/"
297+
)
298+
auth = FabAuth()
299+
auth.set_access_mode("azure_cli")
300+
auth.set_azure_cli()
301+
auth._azure_cli_credential = None
302+
303+
# Now credential returns token from Azure Government
304+
_mock_credential_with_jwt(
305+
mock_credential_class, tid="t1", oid="u1",
306+
iss="https://sts.microsoftonline.us/t1/"
307+
)
308+
309+
with pytest.raises(FabricCLIError) as exc_info:
310+
auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT)
311+
312+
assert "environment has changed" in str(exc_info.value)
313+
314+
@patch("fabric_cli.core.fab_auth.AzureCliCredential")
315+
def test_same_environment_allows_token_acquisition(
316+
self, mock_credential_class, temp_dir_fixture
317+
):
318+
"""Should allow when token issuer matches stored environment."""
319+
_mock_credential_with_jwt(
320+
mock_credential_class, tid="t1", oid="u1",
321+
iss="https://sts.windows.net/t1/"
322+
)
323+
auth = FabAuth()
324+
auth.set_access_mode("azure_cli")
325+
auth.set_azure_cli()
326+
auth._azure_cli_credential = None
327+
328+
result = auth._acquire_token_from_azure_cli(con.SCOPE_FABRIC_DEFAULT)
329+
assert "access_token" in result
330+
331+
283332
class TestAzureCliPrincipalDrift:
284333
"""Test principal (identity) drift detection via JWT OID claims."""
285334

@@ -462,14 +511,16 @@ def test_login_discovers_tenant_from_jwt(self, mock_credential_class, temp_dir_f
462511
assert auth.get_tenant_id() == "discovered-tenant"
463512

464513
@patch("fabric_cli.core.fab_auth.AzureCliCredential")
465-
def test_login_stores_oid_for_drift_detection(self, mock_credential_class, temp_dir_fixture):
466-
"""set_azure_cli should store OID from JWT for drift detection."""
467-
_mock_credential_with_jwt(mock_credential_class, tid="t1", oid="user-oid-123")
514+
def test_login_stores_oid_and_issuer_for_drift_detection(self, mock_credential_class, temp_dir_fixture):
515+
"""set_azure_cli should store OID and issuer from JWT for drift detection."""
516+
_mock_credential_with_jwt(mock_credential_class, tid="t1", oid="user-oid-123",
517+
iss="https://sts.windows.net/t1/")
468518

469519
auth = FabAuth()
470520
auth.set_access_mode("azure_cli")
471521
auth.set_azure_cli()
472522
assert auth._auth_info.get(con.FAB_AZURE_CLI_PRINCIPAL_ID) == "user-oid-123"
523+
assert auth._auth_info.get(con.FAB_AZURE_CLI_ISSUER) == "https://sts.windows.net/t1/"
473524

474525
@patch("fabric_cli.core.fab_auth.AzureCliCredential")
475526
def test_re_login_updates_tenant_and_oid(self, mock_credential_class, temp_dir_fixture):

tests/test_core/test_fab_msal_bridge_azure_cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
def _make_jwt(tid: str = "test-tenant", oid: str = "test-oid") -> str:
1919
"""Create a fake JWT with specified claims."""
2020
header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode()
21-
claims = {"tid": tid, "oid": oid}
21+
claims = {"tid": tid, "oid": oid, "iss": f"https://sts.windows.net/{tid}/"}
2222
payload = base64.urlsafe_b64encode(_json.dumps(claims).encode()).rstrip(b"=").decode()
2323
return f"{header}.{payload}.fakesig"
2424

0 commit comments

Comments
 (0)