feat(oauth): dedup, update oauth/graph samples, add dep markers on legacy methods - #576
Merged
Merged
Conversation
Lily Du (lilyydu)
force-pushed
the
lilyydu-oauth-token-exchange-dedup
branch
from
August 27, 2026 17:45
bb1399d to
ddd20c0
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds deduplication for repeated signin/tokenExchange callbacks in the Teams OAuth handler stack, preventing multiple token redemptions and repeated sign-in side effects when Teams fans out the same exchange to multiple client endpoints.
Changes:
- Add in-process “in-flight” deduplication keyed by exchange id, with waiters mirroring the owning request’s result.
- Persist a completed-exchange marker in conversation state (
__oauth:exchange:{id}) plus an in-memory marker to short-circuit late duplicates. - Add comprehensive regression coverage for concurrent/sequential dedup behavior and update the OAuth example app to demonstrate multi-connection flows (including Graph usage).
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| uv.lock | Adds the Graph workspace package to the oauth example dependency set. |
| packages/apps/tests/test_app_oauth.py | Updates routing-path test to assert tokenExchange side effects run once for duplicates. |
| packages/apps/tests/test_app_oauth_dedup.py | New end-to-end tests pinning dedup contract (concurrent, late, failure, state interactions). |
| packages/apps/src/microsoft_teams/apps/oauth_state.py | Adds persisted completed-exchange marker utilities and pruning logic. |
| packages/apps/src/microsoft_teams/apps/diagnostics/_constants.py | Introduces an oauth_result=duplicate constant for telemetry. |
| packages/apps/src/microsoft_teams/apps/app_oauth.py | Implements in-flight guard + completed marker behavior in sign_in_token_exchange. |
| examples/oauth/src/main.py | Updates example to use state, show multi-connection OAuth flows, and call Graph. |
| examples/oauth/pyproject.toml | Adds microsoft-teams-graph to example dependencies. |
Suppressed comments (1)
packages/apps/src/microsoft_teams/apps/app_oauth.py:363
- In-flight duplicates only stamp the completed marker on the success path. If the owner redeemed the token and then fails (e.g., a sign-in handler raises), the waiter should still stamp the completion marker into its own snapshot before re-raising, otherwise its later save can erase the owner's completion marker (last-write-wins state).
if outcome.error is not None:
result = APP_OAUTH_RESULTS.failure
span.set_attribute(APP_ATTRIBUTE_NAMES.oauth_result, result)
raise outcome.error
if outcome.token_redeemed:
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Lily Du (lilyydu)
force-pushed
the
lilyydu-oauth-token-exchange-dedup
branch
from
August 27, 2026 23:21
ddd20c0 to
b112b48
Compare
Base automatically changed from
lilyydu-implement-python-oauth-state-routing
to
main
August 27, 2026 23:57
Teams fans `signin/tokenExchange` out to every signed-in client endpoint,
so the same logical exchange reaches the bot several times. Without dedup
each copy ran a full exchange, re-emitted `sign_in`, re-invoked the flow's
success handlers, and re-ran the middleware chain.
Two cooperating layers, mirroring the C# and TypeScript SDKs:
An in-flight guard keyed by exchange id. The first request installs a
future and owns the exchange; concurrent duplicates await it and mirror
its result, so a caller that lost the race still learns the exchange
failed instead of being told the sign-in succeeded. The claim sequence
holds no `await`, which is what makes check-and-insert atomic on a single
event loop.
A completed marker for duplicates that arrive after the original settles.
Held in memory (5-minute TTL, capped at 1000 entries) and persisted to
conversation state under the reserved `__oauth:exchange:{id}` key, so a
duplicate handled by another process instance short-circuits too. Turn
state is last-write-wins with no compare-and-set, so that cross-instance
layer is best-effort; the in-memory guard stays authoritative for the
same instance. C# carries the identical caveat. The marker is never
cleared on completion — a late duplicate from a second endpoint arrives
after the exchange finishes, and a cleared marker would let it run anew.
Deduplicated requests return a 200 no-op and skip `ctx.next()`, so the
sign-in side effects and the rest of the middleware chain run exactly
once per exchange. The owning request keeps PR4's guarantee that `next`
runs on every path. `signin/verifyState` and `signin/failure` are left
undeduplicated: the verify code is single-use and failure is a single
informational notice.
An exchange with no id is run undeduplicated rather than collapsed onto a
shared empty key, which would drop unrelated sign-ins.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rewrite examples/oauth around two registered flows -- "profile"
(User.Read) and "mail" (Mail.Read) -- driven through add_oauth_flow,
replacing the single default-connection ctx.sign_in() path.
Each flow gets its own on_signin/on_signin_failure handler, and those
handlers call Microsoft Graph with that connection's token so a
completed sign-in returns real per-connection data rather than a static
string. A "status" command reports both connections independently,
showing that a user can be signed in to one and out of the other.
The global @app.event("sign_in") handler is kept to show that it now
carries connection_name alongside the per-flow handlers.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Completed-exchange markers were pruned by TTL but never counted, so a conversation that began many distinct sign-ins inside the five-minute window could grow its stored document without limit. The in-memory completed set was already capped at 1000; this gives the persisted set the same ceiling. Enforcement runs at the existing write chokepoint, after the marker is written, so the exchange being recorded can never be the one evicted to make room. Expired markers are pruned first, so eviction only ever gives up live coverage as a last resort, and it starts with the oldest markers because those are closest to ageing out on their own. Ties are broken on the key so two instances holding the same document evict identically rather than following dict insertion order. The TTL remains the primary bound and the only one expected to bind in practice: markers are scoped to one conversation and every duplicate of an exchange reuses its id. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rebase onto the latest sign-in routing work brought in handler isolation: a raising on_signin listener is now logged and skipped rather than propagated, so it no longer turns a successful callback into a failed invoke response. Three dedup tests still asserted the old propagating contract and failed on the new base. Their dedup intent is unchanged, only the contract they assert: - the concurrent-exception test now proves a broken listener does not break dedup -- one exchange, one sign_in event, both callers get 200 - the mid-callback test drops the raise and instead pins the invariant it was always about, asserting the duplicate is still parked on the owner's future and that the owner's callbacks finish first - the spent-marker test keeps its subject, that the marker is stamped even when a listener fails, without expecting the failure to surface Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Lily Du (lilyydu)
force-pushed
the
lilyydu-oauth-token-exchange-dedup
branch
from
August 27, 2026 23:57
4ba1326 to
4ccbbdc
Compare
…n failures Addresses five review findings on the dedup path: - Persisted-marker reads run before `_run_token_exchange` opens its try/finally, so a state error escaped past `ctx.next()` and stalled the middleware chain. Reads and writes are now best-effort, degrading to in-memory dedup rather than taking the turn down. - The completed marker is flushed mid-turn instead of at end of turn. The owner still has sign-in callbacks to run after redeeming the token, and a duplicate racing on another process instance would otherwise load a snapshot with no marker and redeem the spent exchange again. - A failure *after* the token was redeemed reported `token_redeemed=False` to waiters, and waiters checked `outcome.error` before stamping. Either alone lost the completion marker to a waiter's last-write-wins save. Both are fixed; `_clear_pending` is the reachable trigger, since flow listener failures are isolated and cannot fail the exchange. - Waiters no longer re-raise the owner's exception object. A `CancelledError` made an uncancelled waiter claim it was cancelled, and one instance shared across waiters had them all append frames to the same traceback. Waiters now mirror the failure as a 412, matching the TypeScript SDK. - Both duplicate paths resolve the connection name through the registry, so the casing Teams echoes back from the card does not split one connection into several telemetry series. Each fix is covered by a test verified to fail when the fix is reverted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… telemetry Teams echoes back whatever casing the sign-in card carried, so an app that registers "Graph" can receive "graph" on the wire. `_run_token_exchange` already resolved the registered name for the `SignInEvent`, but reported the raw name to the span attribute, both error counters and the operations and duration metrics, splitting one connection into several series. The registry lookup is hoisted above the `try` so the metric write in `finally` can always reach it. Resolving it inside the `try`, as before, left the name unbound whenever anything above it raised, which would have turned that `finally` into an `UnboundLocalError` masking the original exception. Deliberately unchanged: the name sent to the Token Service stays as Teams sent it, since rewriting the wire value is a behavior change rather than a telemetry fix, and the unregistered-connection warning keeps the raw name -- it only fires when no flow matched, where raw and registered are the same string anyway. This also makes the owner path agree with the duplicate paths, which were canonicalized in the previous commit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The example is @mentioned in group chats and channels, so the activity text arrives as "<at>BotName</at> sign in profile" and never matches the command strings. Every command silently did nothing outside 1:1 chat, which defeats the point of an example meant to demonstrate multi-connection OAuth across scopes. Verified in a group chat and a channel. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ugh the deprecated API `ctx.get_token_status` shadowed `api.users.get_token_status` while behaving differently: the context method adds a silent-SSO correction pass that the Token Service endpoint does not. One name, two layers, two behaviors. Rename the context method to `get_connection_status`, matching `getConnectionStatus` in teams.ts and `GetConnectionStatusAsync` in teams.net. The API client keeps its name, since that one is the transport endpoint and has already shipped. The context method landed in #561, after every published tag, so no released version ever exposed the old name. Point `OAuthFlow.sign_out`, `get_token` and `is_signed_in` straight at `api.users.*`, as teams.ts does, instead of delegating to the deprecated single-connection methods on `ActivityContext`. Verified equivalent: identical request params, log output, return values, and 404/non-404 handling. `sign_in` still delegates; extracting its card, pending-hint and rollback logic is a follow-up. Mark the legacy single-connection OAuth surface deprecated in docstrings only, matching the JSDoc-only approach in teams.ts. No decorators, so the supported flow API never warns callers about an API they did not call. Migrate examples/graph to the flow API, fixing the example's own version of the multi-connection bug: it built a Graph client from `ctx.user_token`, which is whichever connection last exchanged, rather than from the Graph connection's token. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A failed sign-in left its pending hint behind, so a later callback could be attributed to an attempt that was already over. Retire the hint on every path that ends a sign-in, matching the C# and TS SDKs: - signin/failure clears both the base hint and its SSO marker, on every flow it notifies (previously only the SSO marker, only on a resolved target) - signin/tokenExchange clears on an unexpected status - signin/verifyState clears on an unexpected status Expected 400/404/412 responses still keep the hint, since those hand off to the interactive fallback rather than ending the sign-in. Hints are cleared before the failure events are emitted, not inside the callback loop. A global sign_in_failure handler may start a fresh sign-in, and clearing afterwards would wipe the replacement hint it just recorded. C# clears before its callback likewise; TS clears immediately before each flow's handler and has no global event in between. This retires mark_pending_oauth_sso_consumed and its helper chain, which only existed to keep the base hint alive past a failure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Mehak Bindra (MehakBindra)
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR 5 of the multi-connection OAuth stack.
Problem
Teams fans
signin/tokenExchangeout to every endpoint the user is signed in on, so one login reaches the bot several times — each copy redeems the token, emitssign_in, and runs the flow's success handlers.Dedup
Two layers, mirroring the C# SDK:
412body on failure. Released on settle, so retrying a failed exchange still works.200. Held in memory and in conversation state (__oauth:exchange:{id}) for cross-instance coverage, and never cleared — otherwise a late duplicate runs as a fresh exchange.exchange_token, thesign_inemission, and the success handlers each run exactly once. In-flight is checked first, since the marker is stamped at redemption while the owner still has callbacks to run. Check-and-insert has noawaitbetween lookup and insert — that is what makes it atomic on one event loop. Both in-memory structures are bounded (5-minute TTL, 1000-entry cap, pruned on access).Deduplicated requests return before
next(), so concurrent duplicates call it once in total, matching TS.signin/verifyStateandsignin/failureare deliberately not deduplicated — the verify code is single-use, failure is one informational notice. Tests pin both.Also included
ctx.get_token_status→ctx.get_connection_status— it shadowedapi.users.get_token_statuswhile behaving differently (the context method adds a silent-SSO correction pass). MatchesgetConnectionStatusin teams.ts. Added in feat: add multi-connection OAuth registry and flow APIs #561, after every published tag, so no release exposed the old name.OAuthFlow.sign_out/get_token/is_signed_innow callapi.users.*directly instead of the deprecated context methods, as teams.ts does. Verified equivalent: same request params, logs, return values, and 404 handling.sign_instill delegates — extracting it is a follow-up.examples/oauthnow shows multi-connection;examples/graphmoved to the flow API, fixing its own copy of the bug — it built a Graph client fromctx.user_token, i.e. whichever connection last exchanged.NOTE: Once we fully remove the old logic, we need to extract
sign_ininto a shared module