Skip to content
Draft
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
5 changes: 3 additions & 2 deletions packages/api/src/microsoft_teams/api/clients/user/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""


Expand Down
3 changes: 3 additions & 0 deletions packages/apps/src/microsoft_teams/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,6 +47,8 @@
"HttpStream",
"ActivityContext",
"AppTokenProvider",
"OAuthFlow",
"OAuthFlowRegistry",
"StateOptions",
"TurnState",
"TurnStateContainer",
Expand Down
51 changes: 51 additions & 0 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 "<none>"
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.

Expand Down
11 changes: 10 additions & 1 deletion packages/apps/src/microsoft_teams/apps/app_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/apps/src/microsoft_teams/apps/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
CoreActivity,
ErrorEvent,
SignInEvent,
SignInFailureEvent,
StartEvent,
StopEvent,
)
Expand All @@ -24,6 +25,7 @@
"StopEvent",
"EventType",
"SignInEvent",
"SignInFailureEvent",
"get_event_type_from_signature",
"is_registered_event",
"ActivitySentEvent",
Expand Down
6 changes: 5 additions & 1 deletion packages/apps/src/microsoft_teams/apps/events/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +31,7 @@
"start": StartEvent,
"stop": StopEvent,
"sign_in": SignInEvent,
"sign_in_failure": SignInFailureEvent,
"activity_response": ActivityResponseEvent,
"activity_sent": ActivitySentEvent,
}
Expand Down
11 changes: 11 additions & 0 deletions packages/apps/src/microsoft_teams/apps/events/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ConversationReference,
InvokeResponse,
SentActivity,
SignInFailureInvokeActivity,
SignInTokenExchangeInvokeActivity,
SignInVerifyStateInvokeActivity,
TokenProtocol,
Expand Down Expand Up @@ -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
115 changes: 115 additions & 0 deletions packages/apps/src/microsoft_teams/apps/oauth_flow.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions packages/apps/src/microsoft_teams/apps/routing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading