From 3cb3dac592502fbe9b8dca6996451dafa297799e Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:30:45 -0700 Subject: [PATCH] oauth registry & API --- .../api/clients/user/params.py | 5 +- .../apps/src/microsoft_teams/apps/__init__.py | 3 + packages/apps/src/microsoft_teams/apps/app.py | 51 ++++ .../src/microsoft_teams/apps/app_oauth.py | 11 +- .../microsoft_teams/apps/events/__init__.py | 2 + .../microsoft_teams/apps/events/registry.py | 6 +- .../src/microsoft_teams/apps/events/types.py | 11 + .../src/microsoft_teams/apps/oauth_flow.py | 115 +++++++++ .../microsoft_teams/apps/routing/__init__.py | 4 +- .../apps/routing/activity_context.py | 63 ++++- packages/apps/tests/test_activity_context.py | 153 ++++++++++++ packages/apps/tests/test_app_oauth.py | 28 ++- packages/apps/tests/test_oauth_flow.py | 219 ++++++++++++++++++ 13 files changed, 654 insertions(+), 17 deletions(-) create mode 100644 packages/apps/src/microsoft_teams/apps/oauth_flow.py create mode 100644 packages/apps/tests/test_oauth_flow.py diff --git a/packages/api/src/microsoft_teams/api/clients/user/params.py b/packages/api/src/microsoft_teams/api/clients/user/params.py index f5327c86b..d069ae192 100644 --- a/packages/api/src/microsoft_teams/api/clients/user/params.py +++ b/packages/api/src/microsoft_teams/api/clients/user/params.py @@ -61,9 +61,10 @@ class GetUserTokenStatusParams(CustomBaseModel): """ The channel ID. """ - include_filter: str + include_filter: Optional[str] = None """ - The include filter. + The include filter. When omitted, status for every connection registered on + the bot is returned. """ diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 19a191ce1..3f0a9a84d 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -14,6 +14,7 @@ from .files import * # noqa: F403 from .http import FastAPIAdapter, HttpServer, HttpServerAdapter from .http_stream import HttpStream +from .oauth_flow import OAuthFlow, OAuthFlowRegistry from .options import AppOptions, AppTelemetryOptions from .plugins import * # noqa: F401, F403 from .routing import ActivityContext @@ -46,6 +47,8 @@ "HttpStream", "ActivityContext", "AppTokenProvider", + "OAuthFlow", + "OAuthFlowRegistry", "StateOptions", "TurnState", "TurnStateContainer", diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 20bd830b6..46c4d5c27 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -56,6 +56,7 @@ from .http import FastAPIAdapter from .http.adapter import HttpRequest, HttpResponse from .http.http_server import HttpServer +from .oauth_flow import DEFAULT_OAUTH_CARD_TEXT, DEFAULT_SIGN_IN_BUTTON_TEXT, OAuthFlow, OAuthFlowRegistry from .options import AppOptions, InternalAppOptions from .plugins import PluginBase, PluginStartEvent from .routing import ActivityHandlerMixin, ActivityRouter @@ -99,6 +100,7 @@ def __init__(self, **options: Unpack[AppOptions]): self._events = EventEmitter[EventType]() self._router = ActivityRouter() + self._oauth_registry = OAuthFlowRegistry() self.credentials = self._init_credentials() @@ -439,6 +441,55 @@ def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[No """Add middleware to run on all activities.""" self.router.add_handler(lambda _: True, middleware) + def add_oauth_flow( + self, + connection_name: str, + *, + oauth_card_text: str = DEFAULT_OAUTH_CARD_TEXT, + sign_in_button_text: str = DEFAULT_SIGN_IN_BUTTON_TEXT, + ) -> OAuthFlow: + """Register an OAuth connection and return its object. + + Args: + connection_name: The OAuth connection name configured on the bot. + oauth_card_text: Default text shown on the OAuth card for this flow. + sign_in_button_text: Default sign-in button label for this flow. + + Returns: + The registered ``OAuthFlow``. + + Raises: + ValueError: if a flow for this connection is already registered + (connection names are case-insensitive). + """ + return self._oauth_registry.add( + OAuthFlow( + connection_name, + oauth_card_text=oauth_card_text, + sign_in_button_text=sign_in_button_text, + ) + ) + + def get_oauth_flow(self, connection_name: str) -> OAuthFlow: + """Retrieve a previously registered OAuth flow by connection name. + + Args: + connection_name: The OAuth connection name (case-insensitive). + + Returns: + The registered ``OAuthFlow``. + + Raises: + ValueError: if no flow is registered for this connection. + """ + flow = self._oauth_registry.get(connection_name) + if flow is None: + registered = ", ".join(f.connection_name for f in self._oauth_registry.values()) or "" + raise ValueError( + f"No OAuth flow registered for connection '{connection_name}'. Registered connections: {registered}." + ) + return flow + def _init_http_client(self) -> Client: """Initialize the HTTP client from options or create a default one. diff --git a/packages/apps/src/microsoft_teams/apps/app_oauth.py b/packages/apps/src/microsoft_teams/apps/app_oauth.py index 0cb404a06..2da21f064 100644 --- a/packages/apps/src/microsoft_teams/apps/app_oauth.py +++ b/packages/apps/src/microsoft_teams/apps/app_oauth.py @@ -29,7 +29,7 @@ APP_SPAN_NAMES, ) from .diagnostics._helpers import get_tracer, record_exception, record_oauth_error, record_oauth_operation -from .events import ErrorEvent, EventType, SignInEvent +from .events import ErrorEvent, EventType, SignInEvent, SignInFailureEvent from .routing import ActivityContext logger = logging.getLogger(__name__) @@ -198,6 +198,15 @@ async def sign_in_failure( context={"activity": activity}, ), ) + self.event_emitter.emit( + "sign_in_failure", + SignInFailureEvent( + activity_ctx=ctx, + connection_name=connection_name, + code=failure.code, + message=failure.message, + ), + ) span.set_attribute(APP_ATTRIBUTE_NAMES.oauth_callback_invoked, True) span.set_attribute(APP_ATTRIBUTE_NAMES.oauth_result, result) return None diff --git a/packages/apps/src/microsoft_teams/apps/events/__init__.py b/packages/apps/src/microsoft_teams/apps/events/__init__.py index 750047597..7462ca318 100644 --- a/packages/apps/src/microsoft_teams/apps/events/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/events/__init__.py @@ -12,6 +12,7 @@ CoreActivity, ErrorEvent, SignInEvent, + SignInFailureEvent, StartEvent, StopEvent, ) @@ -24,6 +25,7 @@ "StopEvent", "EventType", "SignInEvent", + "SignInFailureEvent", "get_event_type_from_signature", "is_registered_event", "ActivitySentEvent", diff --git a/packages/apps/src/microsoft_teams/apps/events/registry.py b/packages/apps/src/microsoft_teams/apps/events/registry.py index 9b57edd23..24118fe37 100644 --- a/packages/apps/src/microsoft_teams/apps/events/registry.py +++ b/packages/apps/src/microsoft_teams/apps/events/registry.py @@ -13,12 +13,15 @@ ActivitySentEvent, ErrorEvent, SignInEvent, + SignInFailureEvent, StartEvent, StopEvent, ) # Core event type literals for type safety -CoreEventType = Literal["activity", "error", "start", "stop", "sign_in", "activity_response", "activity_sent"] +CoreEventType = Literal[ + "activity", "error", "start", "stop", "sign_in", "sign_in_failure", "activity_response", "activity_sent" +] EventType = Union[CoreEventType, str] # Registry mapping event names to their corresponding event classes @@ -28,6 +31,7 @@ "start": StartEvent, "stop": StopEvent, "sign_in": SignInEvent, + "sign_in_failure": SignInFailureEvent, "activity_response": ActivityResponseEvent, "activity_sent": ActivitySentEvent, } diff --git a/packages/apps/src/microsoft_teams/apps/events/types.py b/packages/apps/src/microsoft_teams/apps/events/types.py index 821e326f9..2e827f73b 100644 --- a/packages/apps/src/microsoft_teams/apps/events/types.py +++ b/packages/apps/src/microsoft_teams/apps/events/types.py @@ -11,6 +11,7 @@ ConversationReference, InvokeResponse, SentActivity, + SignInFailureInvokeActivity, SignInTokenExchangeInvokeActivity, SignInVerifyStateInvokeActivity, TokenProtocol, @@ -117,3 +118,13 @@ class SignInEvent: ActivityContext[SignInTokenExchangeInvokeActivity], ] token_response: TokenResponse + + +@dataclass +class SignInFailureEvent: + """Event emitted when a sign-in (silent SSO) attempt fails.""" + + activity_ctx: ActivityContext[SignInFailureInvokeActivity] + connection_name: Optional[str] = None + code: Optional[str] = None + message: Optional[str] = None diff --git a/packages/apps/src/microsoft_teams/apps/oauth_flow.py b/packages/apps/src/microsoft_teams/apps/oauth_flow.py new file mode 100644 index 000000000..032a67c13 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/oauth_flow.py @@ -0,0 +1,115 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import logging +from collections import OrderedDict +from dataclasses import replace +from typing import Any, Awaitable, Callable, Iterator, List, Mapping, Optional + +from .events import SignInEvent, SignInFailureEvent +from .routing import ActivityContext, SignInOptions + +logger = logging.getLogger(__name__) + +SignInHandler = Callable[[SignInEvent], Awaitable[None]] +SignInFailureHandler = Callable[[SignInFailureEvent], Awaitable[None]] + +DEFAULT_OAUTH_CARD_TEXT = "Please Sign In..." +DEFAULT_SIGN_IN_BUTTON_TEXT = "Sign In" + + +class OAuthFlow: + """One named OAuth connection, plus the handlers attached to it. + + Created via ``app.add_oauth_flow(...)`` — not constructed directly. + """ + + def __init__( + self, + connection_name: str, + *, + oauth_card_text: str = DEFAULT_OAUTH_CARD_TEXT, + sign_in_button_text: str = DEFAULT_SIGN_IN_BUTTON_TEXT, + ) -> None: + self.connection_name = connection_name + self._defaults = SignInOptions( + oauth_card_text=oauth_card_text, + sign_in_button_text=sign_in_button_text, + connection_name=connection_name, + ) + self._on_signin: List[SignInHandler] = [] + self._on_signin_failure: List[SignInFailureHandler] = [] + + def __repr__(self) -> str: + return f"OAuthFlow(connection_name={self.connection_name!r})" + + # -- handler registration ------------------------------------------------- + + def on_signin(self, func: SignInHandler) -> SignInHandler: + """Register a handler for a successful sign-in on this connection.""" + self._on_signin.append(func) + return func + + def on_signin_failure(self, func: SignInFailureHandler) -> SignInFailureHandler: + """Register a handler for a failed silent-SSO attempt on this connection.""" + self._on_signin_failure.append(func) + return func + + # -- operations ----------------------------------------------------------- + + async def sign_in(self, ctx: ActivityContext[Any], options: Optional[SignInOptions] = None) -> Optional[str]: + """Start sign-in. + + Returns a token immediately if one already exists, otherwise sends an + OAuth card and returns ``None``. If the caller passes their own + ``SignInOptions`` their card text wins, but the connection name is + always forced to this flow's — you cannot accidentally sign in on the + wrong connection through a flow object. + """ + base = self._defaults if options is None else options + return await ctx.sign_in(replace(base, connection_name=self.connection_name)) + + async def sign_out(self, ctx: ActivityContext[Any]) -> None: + """Sign the user out of this connection.""" + await ctx.sign_out(connection_name=self.connection_name) + + async def get_token(self, ctx: ActivityContext[Any]) -> Optional[str]: + """The user's token for this connection, or ``None`` if not signed in.""" + return await ctx.get_user_token(connection_name=self.connection_name) + + async def is_signed_in(self, ctx: ActivityContext[Any]) -> bool: + """Whether the user currently has a token for this connection.""" + return await ctx.get_user_token(connection_name=self.connection_name) is not None + + +class OAuthFlowRegistry(Mapping[str, OAuthFlow]): + """Case-insensitive, insertion-ordered collection of ``OAuthFlow``. + + Subclasses ``Mapping``, so ``in``, ``.get()``, ``.values()``, ``len()`` and + truthiness all work without extra code. + """ + + def __init__(self) -> None: + self._flows: "OrderedDict[str, OAuthFlow]" = OrderedDict() + + def __getitem__(self, connection_name: str) -> OAuthFlow: + return self._flows[connection_name.lower()] + + def __iter__(self) -> Iterator[str]: + return iter(self._flows) + + def __len__(self) -> int: + return len(self._flows) + + def add(self, flow: OAuthFlow) -> OAuthFlow: + """Register a flow. Raises ``ValueError`` if the connection already exists.""" + key = flow.connection_name.lower() + if key in self._flows: + raise ValueError( + f"An OAuth flow for connection '{flow.connection_name}' is already " + f"registered. Connection names are case-insensitive." + ) + self._flows[key] = flow + return flow diff --git a/packages/apps/src/microsoft_teams/apps/routing/__init__.py b/packages/apps/src/microsoft_teams/apps/routing/__init__.py index 5923d2e40..eb9206813 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/routing/__init__.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from .activity_context import ActivityContext +from .activity_context import ActivityContext, SignInOptions from .activity_handlers import ActivityHandlerMixin from .router import ActivityRouter -__all__ = ["ActivityHandlerMixin", "ActivityRouter", "ActivityContext"] +__all__ = ["ActivityHandlerMixin", "ActivityRouter", "ActivityContext", "SignInOptions"] diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index b11bb1112..264518c33 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -8,8 +8,9 @@ import logging import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeGuard, TypeVar +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, List, Optional, TypeGuard, TypeVar +from httpx import HTTPStatusError from microsoft_teams.api import ( Account, ActivityBase, @@ -20,6 +21,7 @@ ConversationReference, GetBotSignInResourceParams, GetUserTokenParams, + GetUserTokenStatusParams, JsonWebToken, MessageActivity, MessageActivityInput, @@ -28,6 +30,7 @@ TokenExchangeResource, TokenExchangeState, TokenPostResource, + TokenStatus, ) from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.api.models.attachment.card_attachment import ( @@ -398,19 +401,71 @@ async def sign_in(self, options: Optional[SignInOptions] = None) -> Optional[str return None - async def sign_out(self) -> None: + async def sign_out(self, connection_name: Optional[str] = None) -> None: """ Sign out the user by clearing their token. This method will remove the user's token from the storage. + + Args: + connection_name: The connection to sign out of. Defaults to the + app's default connection. """ + connection_name = connection_name or self.connection_name try: sign_out_params = SignOutUserParams( channel_id=self.activity.channel_id, user_id=self.activity.from_.id, - connection_name=self.connection_name, + connection_name=connection_name, ) await self.api.users.sign_out(sign_out_params) - self.logger.debug(f"User {self.activity.from_.id} signed out successfully.") + self.logger.debug(f"User {self.activity.from_.id} signed out of '{connection_name}'.") except Exception as e: self.logger.error(f"Failed to sign out user: {e}") + + async def get_user_token(self, connection_name: Optional[str] = None) -> Optional[str]: + """ + Get the user's token for a connection. + + Args: + connection_name: The connection to read. Defaults to the app's + default connection. + + Returns: + The token if the user is signed in, or ``None`` if they are not + (the Token Service returns 404 when no token is cached). + + Raises: + HTTPStatusError: for any non-404 failure (e.g. the Token Service is + unavailable). Such errors are surfaced rather than being masked + as "not signed in", so a genuine outage is not mistaken for a + logged-out user. + """ + try: + res = await self.api.users.get_token( + GetUserTokenParams( + channel_id=self.activity.channel_id, + user_id=self.activity.from_.id, + connection_name=connection_name or self.connection_name, + ) + ) + return res.token + except HTTPStatusError as e: + if e.response.status_code == 404: + return None + raise + + async def get_token_status(self) -> List[TokenStatus]: + """ + Get the token status for every OAuth connection registered on the bot. + + A single Token Service call returns the status for all connections, so + the developer never needs to enumerate connection names manually. + Service failures propagate rather than being reported as signed out. + """ + return await self.api.users.get_token_status( + GetUserTokenStatusParams( + channel_id=self.activity.channel_id, + user_id=self.activity.from_.id, + ) + ) diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 0f97e92af..9264aa6e7 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from httpx import HTTPStatusError, Request, Response from microsoft_teams.api import ( Account, ConversationAccount, @@ -106,6 +107,13 @@ async def update_targeted_activity(conversation_id: str, activity_id: str, activ return ctx, mock_activity_sender +def _http_status_error(status_code: int) -> HTTPStatusError: + """Build an httpx.HTTPStatusError carrying the given status code.""" + request = Request("GET", "https://token.example/api/usertoken/GetToken") + response = Response(status_code, request=request) + return HTTPStatusError(f"HTTP {status_code}", request=request, response=response) + + class TestActivityContextSendTargeted: """Tests for ActivityContext.send() with targeted message recipient inference.""" @@ -829,6 +837,151 @@ async def test_sign_out_logs_error_and_does_not_raise_on_failure(self) -> None: assert "Failed to sign out user" in logged_message +class TestActivityContextTokenHelpers: + """Tests for sign_out(connection_name=), get_user_token, and get_token_status.""" + + @pytest.mark.asyncio + async def test_sign_out_uses_override_connection_name(self) -> None: + """sign_out(connection_name=...) targets the named connection.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.sign_out = AsyncMock(return_value=None) + + await ctx.sign_out(connection_name="github") + + ctx.api.users.sign_out.assert_awaited_once() + params = ctx.api.users.sign_out.call_args[0][0] + assert params.connection_name == "github" + assert params.user_id == "user-1" + + @pytest.mark.asyncio + async def test_sign_out_defaults_to_ctx_connection(self) -> None: + """sign_out() with no argument falls back to the context's connection.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.sign_out = AsyncMock(return_value=None) + + await ctx.sign_out() + + params = ctx.api.users.sign_out.call_args[0][0] + assert params.connection_name == "test-connection" + + @pytest.mark.asyncio + async def test_get_user_token_returns_token(self) -> None: + """get_user_token returns the token for the requested connection.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + token_res = MagicMock() + token_res.token = "the-token" + ctx.api.users.get_token = AsyncMock(return_value=token_res) + + result = await ctx.get_user_token(connection_name="github") + + assert result == "the-token" + params = ctx.api.users.get_token.call_args[0][0] + assert params.connection_name == "github" + assert params.user_id == "user-1" + + @pytest.mark.asyncio + async def test_get_user_token_defaults_to_ctx_connection(self) -> None: + """get_user_token() defaults to the context's connection name.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + token_res = MagicMock() + token_res.token = "the-token" + ctx.api.users.get_token = AsyncMock(return_value=token_res) + + await ctx.get_user_token() + + params = ctx.api.users.get_token.call_args[0][0] + assert params.connection_name == "test-connection" + + @pytest.mark.asyncio + async def test_get_user_token_returns_none_when_not_signed_in(self) -> None: + """A 404 from the Token Service means 'not signed in' and yields None.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.get_token = AsyncMock(side_effect=_http_status_error(404)) + + result = await ctx.get_user_token() + + assert result is None + + @pytest.mark.asyncio + async def test_get_user_token_propagates_service_errors(self) -> None: + """A non-404 HTTP error (e.g. Token Service down) is surfaced, not masked as logged-out.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.get_token = AsyncMock(side_effect=_http_status_error(500)) + + with pytest.raises(HTTPStatusError): + await ctx.get_user_token() + + @pytest.mark.asyncio + async def test_get_user_token_propagates_non_http_errors(self) -> None: + """Network/transport errors propagate rather than being reported as not signed in.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.get_token = AsyncMock(side_effect=RuntimeError("connection reset")) + + with pytest.raises(RuntimeError): + await ctx.get_user_token() + + @pytest.mark.asyncio + async def test_get_token_status_returns_all_connections(self) -> None: + """get_token_status makes a single call and returns the status list unfiltered.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + statuses = [MagicMock(), MagicMock()] + ctx.api.users.get_token_status = AsyncMock(return_value=statuses) + + result = await ctx.get_token_status() + + assert result is statuses + ctx.api.users.get_token_status.assert_awaited_once() + params = ctx.api.users.get_token_status.call_args[0][0] + assert params.include_filter is None + assert params.user_id == "user-1" + assert params.channel_id == "msteams" + + @pytest.mark.asyncio + async def test_get_token_status_propagates_errors(self) -> None: + """Unlike get_user_token, get_token_status lets service failures surface.""" + mock_activity = MagicMock() + mock_activity.channel_id = "msteams" + mock_activity.from_.id = "user-1" + + ctx, _ = _create_activity_context(activity=mock_activity) + ctx.api.users.get_token_status = AsyncMock(side_effect=RuntimeError("service down")) + + with pytest.raises(RuntimeError): + await ctx.get_token_status() + + class TestActivityContextPromptPreview: """Tests for reactive auto-population of targetedMessageInfo entity.""" diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py index 005f9d949..ee0f05b66 100644 --- a/packages/apps/tests/test_app_oauth.py +++ b/packages/apps/tests/test_app_oauth.py @@ -29,7 +29,7 @@ ) from microsoft_teams.apps.app_oauth import OauthHandlers from microsoft_teams.apps.app_process import ActivityProcessor -from microsoft_teams.apps.events import ErrorEvent, SignInEvent +from microsoft_teams.apps.events import ErrorEvent, SignInEvent, SignInFailureEvent from microsoft_teams.apps.routing import ActivityContext from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES from microsoft_teams.apps.routing.router import ActivityRouter @@ -661,21 +661,35 @@ async def test_sign_in_failure_logs_warning(self, oauth_handlers, mock_context, @pytest.mark.asyncio async def test_sign_in_failure_emits_error_event(self, oauth_handlers, mock_context, failure_activity): - """Test that sign_in_failure emits an error event.""" + """Test that sign_in_failure still emits an error event (kept for backwards compatibility).""" mock_context.activity = failure_activity await oauth_handlers.sign_in_failure(mock_context) - # Verify error event emitted - oauth_handlers.event_emitter.emit.assert_called_once() - call_args = oauth_handlers.event_emitter.emit.call_args - assert call_args[0][0] == "error" - error_event = call_args[0][1] + error_calls = [c for c in oauth_handlers.event_emitter.emit.call_args_list if c[0][0] == "error"] + assert len(error_calls) == 1 + error_event = error_calls[0][0][1] assert isinstance(error_event, ErrorEvent) assert "resourcematchfailed" in str(error_event.error) assert error_event.context is not None assert error_event.context["activity"] == failure_activity + @pytest.mark.asyncio + async def test_sign_in_failure_emits_sign_in_failure_event(self, oauth_handlers, mock_context, failure_activity): + """Test that sign_in_failure additionally emits a structured SignInFailureEvent.""" + mock_context.activity = failure_activity + + await oauth_handlers.sign_in_failure(mock_context) + + failure_calls = [c for c in oauth_handlers.event_emitter.emit.call_args_list if c[0][0] == "sign_in_failure"] + assert len(failure_calls) == 1 + event = failure_calls[0][0][1] + assert isinstance(event, SignInFailureEvent) + assert event.code == "resourcematchfailed" + assert event.message == "Resource match failed" + assert event.connection_name == "test-connection" + assert event.activity_ctx is mock_context + @pytest.mark.asyncio async def test_sign_in_failure_returns_none(self, oauth_handlers, mock_context, failure_activity): """Test that sign_in_failure returns None (process_activity wraps into 200).""" diff --git a/packages/apps/tests/test_oauth_flow.py b/packages/apps/tests/test_oauth_flow.py new file mode 100644 index 000000000..2657c072f --- /dev/null +++ b/packages/apps/tests/test_oauth_flow.py @@ -0,0 +1,219 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# pyright: basic + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from microsoft_teams.apps import App, OAuthFlow, OAuthFlowRegistry +from microsoft_teams.apps.routing import SignInOptions + + +class TestOAuthFlowHandlers: + """Handler registration on an OAuthFlow.""" + + def test_on_signin_registers_and_returns_handler(self) -> None: + flow = OAuthFlow("graph") + + async def handler(event) -> None: + pass + + returned = flow.on_signin(handler) + + assert returned is handler + assert flow._on_signin == [handler] + + def test_on_signin_failure_registers_and_returns_handler(self) -> None: + flow = OAuthFlow("graph") + + async def handler(event) -> None: + pass + + returned = flow.on_signin_failure(handler) + + assert returned is handler + assert flow._on_signin_failure == [handler] + + def test_multiple_handlers_preserve_order(self) -> None: + flow = OAuthFlow("graph") + + async def first(event) -> None: + pass + + async def second(event) -> None: + pass + + flow.on_signin(first) + flow.on_signin(second) + + assert flow._on_signin == [first, second] + + +class TestOAuthFlowOperations: + """sign_in / sign_out / get_token / is_signed_in delegate to the context.""" + + @pytest.mark.asyncio + async def test_sign_in_forces_flow_connection_name(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.sign_in = AsyncMock(return_value="tok") + + result = await flow.sign_in(ctx) + + assert result == "tok" + ctx.sign_in.assert_awaited_once() + passed = ctx.sign_in.call_args[0][0] + assert isinstance(passed, SignInOptions) + assert passed.connection_name == "graph" + + @pytest.mark.asyncio + async def test_sign_in_keeps_caller_card_text_but_overrides_connection(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.sign_in = AsyncMock(return_value=None) + + custom = SignInOptions(oauth_card_text="Custom text", connection_name="wrong") + await flow.sign_in(ctx, custom) + + passed = ctx.sign_in.call_args[0][0] + assert passed.oauth_card_text == "Custom text" + assert passed.connection_name == "graph" + + @pytest.mark.asyncio + async def test_sign_in_uses_flow_defaults(self) -> None: + flow = OAuthFlow("graph", oauth_card_text="Sign in here", sign_in_button_text="Go") + ctx = MagicMock() + ctx.sign_in = AsyncMock(return_value=None) + + await flow.sign_in(ctx) + + passed = ctx.sign_in.call_args[0][0] + assert passed.oauth_card_text == "Sign in here" + assert passed.sign_in_button_text == "Go" + assert passed.connection_name == "graph" + + @pytest.mark.asyncio + async def test_sign_out_targets_flow_connection(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.sign_out = AsyncMock(return_value=None) + + await flow.sign_out(ctx) + + ctx.sign_out.assert_awaited_once_with(connection_name="graph") + + @pytest.mark.asyncio + async def test_get_token_returns_ctx_token(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.get_user_token = AsyncMock(return_value="tok") + + result = await flow.get_token(ctx) + + assert result == "tok" + ctx.get_user_token.assert_awaited_once_with(connection_name="graph") + + @pytest.mark.asyncio + async def test_is_signed_in_true_when_token_present(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.get_user_token = AsyncMock(return_value="tok") + + assert await flow.is_signed_in(ctx) is True + + @pytest.mark.asyncio + async def test_is_signed_in_false_when_no_token(self) -> None: + flow = OAuthFlow("graph") + ctx = MagicMock() + ctx.get_user_token = AsyncMock(return_value=None) + + assert await flow.is_signed_in(ctx) is False + + +class TestOAuthFlowRegistry: + """The case-insensitive, insertion-ordered flow registry.""" + + def test_add_and_get_case_insensitive(self) -> None: + registry = OAuthFlowRegistry() + flow = OAuthFlow("Graph") + + registry.add(flow) + + assert registry["graph"] is flow + assert registry["GRAPH"] is flow + assert registry["Graph"] is flow + + def test_add_returns_flow(self) -> None: + registry = OAuthFlowRegistry() + flow = OAuthFlow("graph") + + assert registry.add(flow) is flow + + def test_add_duplicate_raises_value_error(self) -> None: + registry = OAuthFlowRegistry() + registry.add(OAuthFlow("Graph")) + + with pytest.raises(ValueError, match="already"): + registry.add(OAuthFlow("graph")) + + def test_contains_and_get(self) -> None: + registry = OAuthFlowRegistry() + flow = OAuthFlow("graph") + registry.add(flow) + + assert "GRAPH" in registry + assert registry.get("graph") is flow + assert registry.get("missing") is None + + def test_len_and_iter_preserve_insertion_order(self) -> None: + registry = OAuthFlowRegistry() + registry.add(OAuthFlow("Graph")) + registry.add(OAuthFlow("GitHub")) + + assert len(registry) == 2 + assert list(registry) == ["graph", "github"] + assert [f.connection_name for f in registry.values()] == ["Graph", "GitHub"] + + def test_empty_registry_is_falsy(self) -> None: + assert not OAuthFlowRegistry() + + +class TestAppOAuthFlowIntegration: + """app.add_oauth_flow / app.get_oauth_flow.""" + + @pytest.fixture + def app(self) -> App: + return App(storage=MagicMock(), client_id="test-client-id", client_secret="test-secret") + + def test_add_oauth_flow_returns_registered_flow(self, app: App) -> None: + flow = app.add_oauth_flow("graph", oauth_card_text="Hi", sign_in_button_text="Go") + + assert isinstance(flow, OAuthFlow) + assert flow.connection_name == "graph" + assert app.get_oauth_flow("graph") is flow + + def test_get_oauth_flow_is_case_insensitive(self, app: App) -> None: + flow = app.add_oauth_flow("Graph") + + assert app.get_oauth_flow("graph") is flow + assert app.get_oauth_flow("GRAPH") is flow + + def test_add_duplicate_flow_raises(self, app: App) -> None: + app.add_oauth_flow("graph") + + with pytest.raises(ValueError, match="already"): + app.add_oauth_flow("Graph") + + def test_get_missing_flow_raises_with_registered_names(self, app: App) -> None: + app.add_oauth_flow("graph") + app.add_oauth_flow("github") + + with pytest.raises(ValueError, match="graph, github"): + app.get_oauth_flow("missing") + + def test_get_missing_flow_on_empty_registry_lists_none(self, app: App) -> None: + with pytest.raises(ValueError, match=""): + app.get_oauth_flow("missing")