Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion python/samples/concepts/setup/ALL_SETTINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
| | [AzureResponsesAgent](../../../semantic_kernel/agents/open_ai/azure_responses_agent.py) | deployment_name, <br> api_key, <br> endpoint, <br> api_version, <br> base_url | AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME, <br> AZURE_OPENAI_API_KEY, <br> AZURE_OPENAI_ENDPOINT, <br> AZURE_OPENAI_API_VERSION, <br> AZURE_OPENAI_BASE_URL | Yes, <br> No, <br> Yes, <br> Yes, <br> No | [AzureOpenAISettings](../../../semantic_kernel/connectors/ai/open_ai/settings/azure_open_ai_settings.py) |
| Azure AI | [AzureAIAgent](../../../semantic_kernel/agents/azure_ai/azure_ai_agent.py) | model_deployment_name, <br> endpoint, <br> agent_id, <br> bing_connection_id, <br> azure_ai_search_connection_id, <br> azure_ai_search_index_name, <br> api_version | AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME, <br> AZURE_AI_AGENT_ENDPOINT, <br> AZURE_AI_AGENT_AGENT_ID, <br> AZURE_AI_AGENT_BING_CONNECTION_ID, <br> AZURE_AI_AGENT_AZURE_AI_SEARCH_CONNECTION_ID, <br> AZURE_AI_AGENT_AZURE_AI_SEARCH_INDEX_NAME, <br> AZURE_AI_AGENT_API_VERSION | Yes, <br> Yes, <br> No, <br> No, <br> No, <br> No, <br> No | [AzureAIAgentSettings](../../../semantic_kernel/agents/azure_ai/azure_ai_agent_settings.py) |
| Bedrock | [BedrockAgent](../../../semantic_kernel/agents/bedrock/bedrock_agent.py) | agent_resource_role_arn, <br> foundation_model | BEDROCK_AGENT_AGENT_RESOURCE_ROLE_ARN, <br> BEDROCK_AGENT_FOUNDATION_MODEL | Yes, <br> Yes | [BedrockAgentSettings](../../../semantic_kernel/agents/bedrock/bedrock_agent_settings.py) |
| Copilot Studio | [CopilotStudioAgent](../../../semantic_kernel/agents/copilot_studio/copilot_studio_agent.py) | app_client_id, <br> tenant_id, <br> environment_id, <br> agent_identifier, <br> cloud, <br> copilot_agent_type, <br> custom_power_platform_cloud, <br> client_secret, <br> client_certificate, <br> user_assertion, <br> auth_mode | COPILOT_STUDIO_AGENT_APP_CLIENT_ID, <br> COPILOT_STUDIO_AGENT_TENANT_ID, <br> COPILOT_STUDIO_AGENT_ENVIRONMENT_ID, <br> COPILOT_STUDIO_AGENT_AGENT_IDENTIFIER, <br> COPILOT_STUDIO_AGENT_CLOUD, <br> COPILOT_STUDIO_AGENT_COPILOT_AGENT_TYPE, <br> COPILOT_STUDIO_AGENT_CUSTOM_POWER_PLATFORM_CLOUD, <br> COPILOT_STUDIO_AGENT_CLIENT_SECRET, <br> COPILOT_STUDIO_AGENT_CLIENT_CERTIFICATE, <br> COPILOT_STUDIO_AGENT_USER_ASSERTION, <br> COPILOT_STUDIO_AGENT_AUTH_MODE | No (varies by mode) | [CopilotStudioAgentSettings](../../../semantic_kernel/agents/copilot_studio/copilot_studio_agent_settings.py) |
| Copilot Studio | [CopilotStudioAgent](../../../semantic_kernel/agents/copilot_studio/copilot_studio_agent.py) | app_client_id, <br> tenant_id, <br> environment_id, <br> agent_identifier, <br> cloud, <br> copilot_agent_type, <br> custom_power_platform_cloud, <br> user_assertion, <br> auth_mode | COPILOT_STUDIO_AGENT_APP_CLIENT_ID, <br> COPILOT_STUDIO_AGENT_TENANT_ID, <br> COPILOT_STUDIO_AGENT_ENVIRONMENT_ID, <br> COPILOT_STUDIO_AGENT_AGENT_IDENTIFIER, <br> COPILOT_STUDIO_AGENT_CLOUD, <br> COPILOT_STUDIO_AGENT_COPILOT_AGENT_TYPE, <br> COPILOT_STUDIO_AGENT_CUSTOM_POWER_PLATFORM_CLOUD, <br> COPILOT_STUDIO_AGENT_USER_ASSERTION, <br> COPILOT_STUDIO_AGENT_AUTH_MODE | No (varies by configuration) | [CopilotStudioAgentSettings](../../../semantic_kernel/agents/copilot_studio/copilot_studio_agent_settings.py) |



Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,6 @@ COPILOT_STUDIO_AGENT_AGENT_IDENTIFIER=<your-agent-id>
COPILOT_STUDIO_AGENT_AUTH_MODE=interactive
```

## Create an Application Registration in Entra ID – Service Principal Login

> **Warning**: Service Principal login is **not yet supported** in the current version of the `CopilotStudioClient`.

## Creating a `CopilotStudioAgent` Client

If all required environment variables are set correctly, you don't need to manually create or pass a `client`. Semantic Kernel will automatically construct the client using the environment configuration.
Expand All @@ -96,11 +92,9 @@ However, if you need to override any environment variables—such as when specif

```python
client: CopilotClient = CopilotStudioAgent.create_client(
auth_mode: CopilotStudioAgentAuthMode | Literal["interactive", "service"] | None = None,
auth_mode: CopilotStudioAgentAuthMode | Literal["interactive"] | None = None,
agent_identifier: str | None = None,
app_client_id: str | None = None,
client_secret: str | None = None,
client_certificate: str | None = None,
cloud: PowerPlatformCloud | None = None,
copilot_agent_type: AgentType | None = None,
custom_power_platform_cloud: str | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
from os import environ, path
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal

from microsoft_agents.activity import ActivityTypes
from microsoft_agents.copilotstudio.client import AgentType, CopilotClient, PowerPlatformCloud
from msal import ConfidentialClientApplication, PublicClientApplication
from msal import PublicClientApplication
from msal_extensions import FilePersistence, PersistedTokenCache, build_encrypted_persistence
from pydantic import ValidationError

Expand Down Expand Up @@ -69,18 +68,10 @@ def __init__(
*,
settings: CopilotStudioAgentSettings,
cache_path: str,
mode: CopilotStudioAgentAuthMode,
client_secret: str | None = None,
client_certificate: Path | None = None,
user_assertion: str | None = None,
scopes: Sequence[str] | None = None,
) -> None:
self.settings = settings
self.cache = self._get_msal_token_cache(cache_path)
self.mode = mode
self.client_secret = client_secret
self.client_cert_path = client_certificate
self.user_assertion = user_assertion
self.scopes = scopes or ["https://api.powerplatform.com/.default"]

@staticmethod
Expand All @@ -105,52 +96,7 @@ def _get_msal_token_cache(cache_path: str, fallback_to_plaintext=True) -> Persis

def acquire(self) -> str:
"""Return a valid bearer token or raise AgentInitializationException."""
if self.mode is CopilotStudioAgentAuthMode.SERVICE:
# SERVICE auth wiring is present but not yet supported end-to-end.
logger.warning("SERVICE authentication mode is not yet supported; falling back to error.")
raise AgentInitializationException(
"Copilot Studio SERVICE authentication is not available yet. Please use INTERACTIVE mode instead."
)

match self.mode:
case CopilotStudioAgentAuthMode.SERVICE:
return self._acquire_service_token() # unreachable until the guard is removed
case _:
return self._acquire_interactive_token()

def _new_confidential_client(self, **extra_kwargs) -> ConfidentialClientApplication:
return ConfidentialClientApplication(
client_id=self.settings.app_client_id,
authority=f"https://login.microsoftonline.com/{self.settings.tenant_id}",
token_cache=self.cache,
**extra_kwargs,
)

def _acquire_service_token(self) -> str:
if not self.client_secret and not self.client_cert_path:
raise AgentInitializationException(
"client_secret *or* client_certificate is required for service-to-service auth."
)

kwargs: dict[str, Any] = {}
if self.client_secret:
kwargs["client_credential"] = self.client_secret
else: # certificate
if not self.client_cert_path:
raise AgentInitializationException(
"If no client_secret is provided, a client_certificate is required for service-to-service auth."
)
kwargs["client_credential"] = {
"private_key": Path(self.client_cert_path).read_text(),
"thumbprint": self._cert_thumbprint(self.client_cert_path),
}

app = self._new_confidential_client(**kwargs)

# proactive caching
result = app.acquire_token_silent(self.scopes, account=None) or app.acquire_token_for_client(scopes=self.scopes)

return self._unwrap(result)
return self._acquire_interactive_token()

# interactive
def _acquire_interactive_token(self) -> str:
Expand All @@ -174,18 +120,6 @@ def _unwrap(result: dict[str, Any]) -> str:
_log_auth_failure(result)
raise AgentInitializationException("Authentication failed; see logs for category and correlation code.")

@staticmethod
def _cert_thumbprint(cert_path: Path) -> str:
import hashlib
import ssl

pem_bytes = Path(cert_path).read_bytes()
der_bytes = ssl.PEM_cert_to_DER_cert(pem_bytes.decode())
# SHA-1 is not used here as a security primitive; it is required to compute the X.509 certificate thumbprint
# (the `x5t` JWT header value), which MSAL and Microsoft Entra ID mandate to be a SHA-1 digest for the
# `thumbprint` client credential. Hence the `usedforsecurity=False` flag.
return hashlib.sha1(der_bytes, usedforsecurity=False).hexdigest().upper() # CodeQL [SM02167] x5t thumbprint


# endregion

Expand Down Expand Up @@ -329,11 +263,9 @@ def model_post_init(self, __ctx: Any) -> None:
@staticmethod
def create_client(
*,
auth_mode: CopilotStudioAgentAuthMode | Literal["interactive", "service"] | None = None,
auth_mode: CopilotStudioAgentAuthMode | Literal["interactive"] | None = None,
agent_identifier: str | None = None,
app_client_id: str | None = None,
client_secret: str | None = None,
client_certificate: str | None = None,
cloud: PowerPlatformCloud | None = None,
copilot_agent_type: AgentType | None = None,
custom_power_platform_cloud: str | None = None,
Expand All @@ -346,12 +278,10 @@ def create_client(
"""Create the Copilot Studio Agent Client.

Args:
auth_mode: The authentication mode. This can be either `interactive` or `service`.
auth_mode: The authentication mode. Only `interactive` is currently supported.
agent_identifier: The agent identifier. This is the `Schema Name` of the agent from the
Copilot Studio Advanced Metadata settings.
app_client_id: The app client ID. This is the app ID of the app registration configured in Entra.
client_secret: The client secret. This is the secret of the app registration.
client_certificate: The client certificate. This is the certificate of the app registration.
cloud: The cloud environment.
copilot_agent_type: The type of Copilot agent.
custom_power_platform_cloud: The custom Power Platform cloud.
Expand All @@ -378,8 +308,6 @@ def create_client(
custom_power_platform_cloud=custom_power_platform_cloud,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
client_secret=client_secret,
client_certificate=client_certificate,
user_assertion=user_assertion,
auth_mode=auth_mode,
)
Expand All @@ -397,12 +325,6 @@ def create_client(
token = _CopilotStudioAgentTokenFactory(
settings=connection_settings,
cache_path=cache_file,
mode=connection_settings.auth_mode,
client_secret=connection_settings.client_secret.get_secret_value()
if connection_settings.client_secret
else None,
client_certificate=Path(client_certificate) if client_certificate else None,
user_assertion=user_assertion,
).acquire()

return CopilotClient(connection_settings, token)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
AgentType,
PowerPlatformCloud,
)
from pydantic import Field, SecretStr
from pydantic import Field

from semantic_kernel.kernel_pydantic import KernelBaseSettings
from semantic_kernel.utils.feature_stage_decorator import experimental
Expand All @@ -17,8 +17,7 @@
class CopilotStudioAgentAuthMode(str, Enum):
"""The Copilot Studio agent authentication mode."""

INTERACTIVE = "interactive" # user authentication
SERVICE = "service" # client-credentials (app secret/cert)
INTERACTIVE = "interactive"


@experimental
Expand All @@ -34,7 +33,5 @@ class CopilotStudioAgentSettings(KernelBaseSettings):
cloud: PowerPlatformCloud = Field(default=PowerPlatformCloud.UNKNOWN)
copilot_agent_type: AgentType = Field(default=AgentType.PUBLISHED)
custom_power_platform_cloud: str | None = None
client_secret: SecretStr | None = None
client_certificate: str | None = None
user_assertion: str | None = None
auth_mode: CopilotStudioAgentAuthMode = Field(default=CopilotStudioAgentAuthMode.INTERACTIVE)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic import SecretStr, ValidationError
from pydantic import ValidationError

try:
import microsoft_agents.copilotstudio.client # type: ignore # noqa: F401
Expand Down Expand Up @@ -226,7 +226,6 @@ def test_setup_resources_happy_path(tmp_path, monkeypatch):
dummy_settings.app_client_id = "appid"
dummy_settings.tenant_id = "tenantid"
dummy_settings.auth_mode = CopilotStudioAgentAuthMode.INTERACTIVE
dummy_settings.client_secret = SecretStr("test-secret")

monkeypatch.setattr(
"semantic_kernel.agents.copilot_studio.copilot_studio_agent.CopilotStudioAgentSettings",
Expand All @@ -246,7 +245,7 @@ def test_setup_resources_happy_path(tmp_path, monkeypatch):
tenant_id="tenantid",
environment_id="env-id",
agent_identifier="agent-name",
auth_mode=CopilotStudioAgentAuthMode.SERVICE,
auth_mode=CopilotStudioAgentAuthMode.INTERACTIVE,
)

mock_client_ctor.assert_called_once_with(dummy_settings, "fake-token")
Expand All @@ -255,19 +254,6 @@ def test_setup_resources_happy_path(tmp_path, monkeypatch):
class DummyCache:
pass

class FakeAppSilent:
def __init__(self, client_id, authority, token_cache, client_credential=None, **kwargs):
pass

def get_accounts(self):
return [{"home_account_id": "acct1"}]

def acquire_token_silent(self, scopes, account):
return {"access_token": "silent-token"}

def acquire_token_interactive(self, scopes):
pytest.skip("Unexpected interactive flow in silent test")

class FakeAppInteractive:
def __init__(self, client_id, authority, token_cache):
pass
Expand Down Expand Up @@ -306,54 +292,28 @@ def stub_cache(monkeypatch):
staticmethod(lambda cache_path, fallback_to_plaintext=True: DummyCache()),
)

@pytest.mark.parametrize(
"fake_app, expected_token, mode",
[
pytest.param(
FakeAppSilent,
"silent-token",
CopilotStudioAgentAuthMode.SERVICE,
marks=pytest.mark.skip(reason="Skipping SERVICE auth mode test as the mode is not yet supported."),
),
(FakeAppInteractive, "interactive-token", CopilotStudioAgentAuthMode.INTERACTIVE),
],
)
def test_acquire_token_success(monkeypatch, tmp_path, fake_app, expected_token, mode):
def test_acquire_token_success(monkeypatch, tmp_path):
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
cache_path = str(tmp_path / "cache.bin")

monkeypatch.setattr(csa_mod, "PublicClientApplication", fake_app)
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", fake_app)

client_secret = None
if fake_app == FakeAppSilent:
client_secret = "test-secret"
monkeypatch.setattr(csa_mod, "PublicClientApplication", FakeAppInteractive)

factory = _CopilotStudioAgentTokenFactory(
settings=settings,
cache_path=cache_path,
mode=mode,
client_secret=client_secret,
client_certificate=None,
user_assertion=None,
)
token = factory.acquire()
assert token == expected_token
assert token == "interactive-token"

def test_acquire_token_error(monkeypatch, tmp_path):
settings = CopilotStudioAgentSettings(app_client_id="id", tenant_id="tid")
cache_path = str(tmp_path / "cache.bin")

monkeypatch.setattr(csa_mod, "PublicClientApplication", FakeAppError)
monkeypatch.setattr(csa_mod, "ConfidentialClientApplication", FakeAppError)

factory = _CopilotStudioAgentTokenFactory(
settings=settings,
cache_path=cache_path,
mode=CopilotStudioAgentAuthMode.INTERACTIVE,
client_secret=None,
client_certificate=None,
user_assertion=None,
)
with pytest.raises(AgentInitializationException):
factory.acquire()
Loading