Skip to content

Commit 0295284

Browse files
committed
OBO flow changes
1 parent 07160b5 commit 0295284

File tree

6 files changed

+240
-6
lines changed

6 files changed

+240
-6
lines changed

fabric_rti_mcp/authentication/auth_middleware.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
from starlette.requests import Request
1111
from starlette.responses import JSONResponse
1212

13+
from fabric_rti_mcp.authentication.token_obo_exchanger import TokenOboExchanger
14+
from fabric_rti_mcp.common import global_config as config
1315
from fabric_rti_mcp.common import logger
16+
from fabric_rti_mcp.config.obo_config import obo_config
1417
from fabric_rti_mcp.kusto.kusto_connection import set_auth_token
1518

1619

@@ -122,7 +125,33 @@ async def check_auth(
122125

123126
token = extract_token_from_header(auth_header)
124127

125-
# Store the original token without modification
128+
try:
129+
130+
if config.use_obo_flow:
131+
logger.info("Started performing OBO token exchange")
132+
# Create token exchanger and perform OBO token exchange
133+
token_exchanger = TokenOboExchanger()
134+
exchanged_token = await token_exchanger.perform_obo_token_exchange(
135+
user_token=token, resource_uri=obo_config.kusto_audience
136+
)
137+
# Update token to use the exchanged token
138+
token = exchanged_token
139+
logger.info("Successfully performed OBO token exchange")
140+
else:
141+
logger.info("OBO flow not enabled; using original token")
142+
143+
except Exception as e:
144+
# Log the error and raise it to fail the request
145+
logger.error(f"Error during OBO token exchange: {e}")
146+
return JSONResponse(
147+
{
148+
"error": "unauthorized",
149+
"message": "Unauthorized to get the required token to access the resource",
150+
},
151+
status_code=401,
152+
)
153+
154+
# Store the token for use by services
126155
set_auth_token(token)
127156

128157
token_payload = decode_jwt_token(token)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
from typing import Any, Dict, Optional
4+
5+
import msal
6+
from azure.identity import ManagedIdentityCredential
7+
8+
from fabric_rti_mcp.common import logger
9+
from fabric_rti_mcp.config.obo_config import OBOFlowEnvVarNames, obo_config
10+
11+
12+
class TokenOboExchanger:
13+
14+
def __init__(self, config: Optional[Dict[str, Any]] = None):
15+
"""
16+
Initialize the TokenOboExchanger with optional configuration.
17+
18+
Args:
19+
config: Optional configuration dictionary
20+
"""
21+
self.config = config or {}
22+
self.logger = logger
23+
self.tenant_id = obo_config.azure_tenant_id
24+
self.entra_app_client_id = obo_config.entra_app_client_id
25+
self.umi_client_id = obo_config.umi_client_id
26+
self.logger.info(
27+
f"TokenOboExchanger initialized with tenant_id: {self.tenant_id}, "
28+
f"entra_app_client_id: {self.entra_app_client_id} and umi_client_id: {self.umi_client_id}"
29+
)
30+
31+
async def perform_obo_token_exchange(self, user_token: str, resource_uri: str) -> str:
32+
"""
33+
Perform an On-Behalf-Of token exchange to get a new token for a resource.
34+
35+
Args:
36+
user_token: The original user token
37+
resource_uri: The URI of the target resource to get a token (ex. https://kusto.kusto.windows.net)
38+
39+
Returns:
40+
New access token for the specified resource
41+
"""
42+
self.logger.info(f"TokenOboExchanger: Performing OBO token exchange for target resource: {resource_uri}")
43+
44+
client_id = self.entra_app_client_id
45+
46+
if not client_id:
47+
self.logger.error("TokenOboExchanger: Entra App client ID is not provided for OBO token exchange")
48+
raise ValueError(
49+
f"Entra App client ID is required for OBO token exchange. "
50+
f"Set {OBOFlowEnvVarNames.entra_app_client_id} environment variable."
51+
)
52+
53+
if not self.tenant_id:
54+
self.logger.error("TokenOboExchanger: Tenant ID not available for OBO token exchange")
55+
raise ValueError(
56+
f"{OBOFlowEnvVarNames.azure_tenant_id} environment variable is required for OBO token exchange"
57+
)
58+
59+
if not self.umi_client_id:
60+
self.logger.error("TokenOboExchanger: UMI Client ID not available for OBO token exchange")
61+
raise ValueError(
62+
f"{OBOFlowEnvVarNames.umi_client_id} environment variable is required for OBO token exchange"
63+
)
64+
65+
try:
66+
authority = f"https://login.microsoftonline.com/{self.tenant_id}"
67+
self.logger.info(
68+
f"TokenOboExchanger: Using Managed Identity for OBO token exchange tenant_id: {self.tenant_id}, "
69+
f"entra_app_client_id: {self.entra_app_client_id} and umi_client_id: {self.umi_client_id}"
70+
)
71+
72+
managed_identity_credential = ManagedIdentityCredential(client_id=self.umi_client_id)
73+
miScopes = "api://AzureADTokenExchange/.default" # this is the default scope to be used
74+
self.logger.info(f"TokenOboExchanger: Start managed identity token acquire for scopes {miScopes}")
75+
access_token_result = managed_identity_credential.get_token(
76+
miScopes
77+
) # get the MI token to be used as client assesrtion for OBO
78+
assertion_token = access_token_result.token
79+
80+
app = msal.ConfidentialClientApplication(
81+
client_id=client_id,
82+
authority=authority,
83+
client_credential={
84+
"client_assertion": assertion_token,
85+
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
86+
},
87+
)
88+
89+
# Set the scopes for the target resource we want to access
90+
target_scopes = [f"{resource_uri}/.default"]
91+
self.logger.info(f"TokenOboExchanger: Requesting access to scopes: {target_scopes}")
92+
93+
# Use the user token to acquire a new token for the target resource
94+
result = app.acquire_token_on_behalf_of(user_assertion=user_token, scopes=target_scopes)
95+
96+
if "access_token" not in result:
97+
error_msg = result.get("error_description") or result.get("error") or "Unknown error"
98+
error_message = f"TokenOboExchanger: Failed to acquire token: {error_msg}"
99+
self.logger.error(error_message)
100+
raise Exception(error_message)
101+
102+
self.logger.info("TokenOboExchanger: Successfully acquired OBO token")
103+
access_token: str = result["access_token"]
104+
return access_token
105+
except Exception as e:
106+
self.logger.error(f"TokenOboExchanger: Error performing OBO token exchange: {e}")
107+
raise Exception(f"OBO token exchange failed: {e}") from e

fabric_rti_mcp/common.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ class GlobalFabricRTIEnvVarNames:
1818
functions_deployment_default_port = "FUNCTIONS_CUSTOMHANDLER_PORT" # Azure Functions uses this port name
1919
http_path = "FABRIC_RTI_HTTP_PATH"
2020
stateless_http = "FABRIC_RTI_STATELESS_HTTP"
21+
use_obo_flow = "USE_OBO_FLOW"
2122

2223

2324
DEFAULT_FABRIC_API_BASE = "https://api.fabric.microsoft.com/v1"
2425
DEFAULT_FABRIC_RTI_TRANSPORT = "stdio"
2526
DEFAULT_FABRIC_RTI_HTTP_PORT = 3000
2627
DEFAULT_FABRIC_RTI_HTTP_PATH = "/mcp"
2728
DEFAULT_FABRIC_RTI_HTTP_HOST = "127.0.0.1"
28-
DEFAULT_FABRIC_RTI_STATELESS_HTTP = False
29+
DEFAULT_FABRIC_RTI_STATELESS_HTTP = True
30+
DEFAULT_USE_OBO_FLOW = False
2931

3032

3133
@dataclass(slots=True, frozen=True)
@@ -36,6 +38,7 @@ class GlobalFabricRTIConfig:
3638
http_port: int
3739
http_path: str
3840
stateless_http: bool
41+
use_obo_flow: bool
3942

4043
@staticmethod
4144
def from_env() -> GlobalFabricRTIConfig:
@@ -56,6 +59,7 @@ def from_env() -> GlobalFabricRTIConfig:
5659
stateless_http=bool(
5760
os.getenv(GlobalFabricRTIEnvVarNames.stateless_http, DEFAULT_FABRIC_RTI_STATELESS_HTTP)
5861
),
62+
use_obo_flow=bool(os.getenv(GlobalFabricRTIEnvVarNames.use_obo_flow, DEFAULT_USE_OBO_FLOW)),
5963
)
6064

6165
@staticmethod
@@ -69,6 +73,7 @@ def existing_env_vars() -> List[str]:
6973
GlobalFabricRTIEnvVarNames.http_port,
7074
GlobalFabricRTIEnvVarNames.http_path,
7175
GlobalFabricRTIEnvVarNames.stateless_http,
76+
GlobalFabricRTIEnvVarNames.use_obo_flow,
7277
]
7378
for env_var in env_vars:
7479
if os.getenv(env_var) is not None:
@@ -85,7 +90,8 @@ def with_args() -> GlobalFabricRTIConfig:
8590
parser.add_argument("--http", action="store_true", help="Use HTTP transport")
8691
parser.add_argument("--host", type=str, help="HTTP host to listen on")
8792
parser.add_argument("--port", type=int, help="HTTP port to listen on")
88-
parser.add_argument("--stateless-http", type=bool, help="Enable or disable stateless HTTP")
93+
parser.add_argument("--stateless-http", action="store_true", help="Enable or disable stateless HTTP")
94+
parser.add_argument("--use-obo-flow", action="store_true", help="Enable or disable OBO flow")
8995
args, _ = parser.parse_known_args()
9096

9197
transport = base_config.transport
@@ -97,6 +103,7 @@ def with_args() -> GlobalFabricRTIConfig:
97103
stateless_http = args.stateless_http if args.stateless_http is not None else base_config.stateless_http
98104
http_host = args.host if args.host is not None else base_config.http_host
99105
http_port = args.port if args.port is not None else base_config.http_port
106+
use_obo_flow = args.use_obo_flow if args.use_obo_flow is not None else base_config.use_obo_flow
100107

101108
return GlobalFabricRTIConfig(
102109
fabric_api_base=base_config.fabric_api_base,
@@ -105,8 +112,9 @@ def with_args() -> GlobalFabricRTIConfig:
105112
http_port=http_port,
106113
http_path=base_config.http_path,
107114
stateless_http=stateless_http,
115+
use_obo_flow=use_obo_flow,
108116
)
109117

110118

111119
# Global configuration instance
112-
config = GlobalFabricRTIConfig.with_args()
120+
global_config = GlobalFabricRTIConfig.with_args()
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import argparse
2+
import os
3+
from dataclasses import dataclass
4+
from typing import List
5+
6+
7+
class OBOFlowEnvVarNames:
8+
"""Environment variable names for OBO Flow configuration."""
9+
10+
azure_tenant_id = "AZURE_TENANT_ID"
11+
entra_app_client_id = (
12+
"ENTRA_APP_CLIENT_ID" # client id for the AAD App which is used to authenticate the user from gateway (APIM)
13+
)
14+
# user assigned managed identity client id used as Federated credentials on the Entra App (entra_app_client_id)
15+
umi_client_id = "USER_MANAGED_IDENTITY_CLIENT_ID"
16+
kusto_audience = "KUSTO_AUDIENCE" # Kusto audience, ex: https://<clustername>.kusto.windows.net
17+
18+
19+
# Default values for OBO Flow configuration
20+
DEFAULT_AZURE_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd011db47" # MS tenant id
21+
DEFAULT_ENTRA_APP_CLIENT_ID = ""
22+
DEFAULT_USER_MANAGED_IDENTITY_CLIENT_ID = ""
23+
DEFAULT_KUSTO_AUDIENCE = "https://kusto.kusto.windows.net"
24+
25+
26+
@dataclass(slots=True, frozen=True)
27+
class OBOFlowConfig:
28+
"""Configuration for OBO (On-Behalf-Of) Flow authentication."""
29+
30+
azure_tenant_id: str
31+
entra_app_client_id: str
32+
umi_client_id: str
33+
kusto_audience: str
34+
35+
@staticmethod
36+
def from_env() -> "OBOFlowConfig":
37+
"""Load OBO Flow configuration from environment variables."""
38+
return OBOFlowConfig(
39+
azure_tenant_id=os.getenv(OBOFlowEnvVarNames.azure_tenant_id, DEFAULT_AZURE_TENANT_ID),
40+
entra_app_client_id=os.getenv(OBOFlowEnvVarNames.entra_app_client_id, DEFAULT_ENTRA_APP_CLIENT_ID),
41+
umi_client_id=os.getenv(OBOFlowEnvVarNames.umi_client_id, DEFAULT_USER_MANAGED_IDENTITY_CLIENT_ID),
42+
kusto_audience=os.getenv(OBOFlowEnvVarNames.kusto_audience, DEFAULT_KUSTO_AUDIENCE),
43+
)
44+
45+
@staticmethod
46+
def existing_env_vars() -> List[str]:
47+
"""Return a list of environment variable names that are currently set."""
48+
result: List[str] = []
49+
env_vars = [
50+
OBOFlowEnvVarNames.azure_tenant_id,
51+
OBOFlowEnvVarNames.entra_app_client_id,
52+
OBOFlowEnvVarNames.umi_client_id,
53+
OBOFlowEnvVarNames.kusto_audience,
54+
]
55+
for env_var in env_vars:
56+
if os.getenv(env_var) is not None:
57+
result.append(env_var)
58+
return result
59+
60+
@staticmethod
61+
def with_args() -> "OBOFlowConfig":
62+
"""Load OBO Flow configuration from environment variables and command line arguments."""
63+
obo_config = OBOFlowConfig.from_env()
64+
65+
parser = argparse.ArgumentParser(description="Fabric RTI MCP Server OBO Flow Configuration")
66+
parser.add_argument("--entra-app-client-id", type=str, help="Azure AAD App Client ID")
67+
parser.add_argument("--umi-client-id", type=str, help="User Managed Identity Client ID")
68+
args, _ = parser.parse_known_args()
69+
70+
entra_app_client_id = (
71+
args.entra_app_client_id if args.entra_app_client_id is not None else obo_config.entra_app_client_id
72+
)
73+
umi_client_id = args.umi_client_id if args.umi_client_id is not None else obo_config.umi_client_id
74+
75+
return OBOFlowConfig(
76+
azure_tenant_id=obo_config.azure_tenant_id,
77+
entra_app_client_id=entra_app_client_id,
78+
umi_client_id=umi_client_id,
79+
kusto_audience=obo_config.kusto_audience,
80+
)
81+
82+
83+
obo_config = OBOFlowConfig.with_args()

fabric_rti_mcp/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111

1212
from fabric_rti_mcp import __version__
1313
from fabric_rti_mcp.authentication.auth_middleware import add_auth_middleware
14-
from fabric_rti_mcp.common import config, logger
14+
from fabric_rti_mcp.common import global_config as config
15+
from fabric_rti_mcp.common import logger
16+
from fabric_rti_mcp.config.obo_config import obo_config
1517
from fabric_rti_mcp.eventstream import eventstream_tools
1618
from fabric_rti_mcp.kusto import kusto_config, kusto_tools
1719

@@ -80,9 +82,13 @@ def main() -> None:
8082
logger.info(f"Port: {config.http_port}")
8183
logger.info(f"Path: {config.http_path}")
8284
logger.info(f"Stateless HTTP: {config.stateless_http}")
85+
logger.info(f"Use OBO flow: {config.use_obo_flow}")
8386

8487
# TODO: Add telemetry configuration here
8588

89+
if config.use_obo_flow and (not obo_config.entra_app_client_id or not obo_config.umi_client_id):
90+
raise ValueError("OBO flow is enabled but required client IDs are missing")
91+
8692
name = "fabric-rti-mcp-server"
8793
if config.transport == "http":
8894
fastmcp_server = FastMCP(

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ dependencies = [
2121
"fastmcp~=2.5.0",
2222
"azure-kusto-data~=5.0.0",
2323
"azure-identity",
24-
"azure-kusto-ingest~=5.0.0"
24+
"azure-kusto-ingest~=5.0.0",
25+
"msal~=1.28.0"
2526
]
2627

2728
[project.optional-dependencies]

0 commit comments

Comments
 (0)