Skip to content

feat(oauth): dedup, update oauth/graph samples, add dep markers on legacy methods - #576

Merged
Lily Du (lilyydu) merged 10 commits into
mainfrom
lilyydu-oauth-token-exchange-dedup
Aug 28, 2026
Merged

feat(oauth): dedup, update oauth/graph samples, add dep markers on legacy methods#576
Lily Du (lilyydu) merged 10 commits into
mainfrom
lilyydu-oauth-token-exchange-dedup

Conversation

@lilyydu

@lilyydu Lily Du (lilyydu) commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

PR 5 of the multi-connection OAuth stack.

Problem

Teams fans signin/tokenExchange out to every endpoint the user is signed in on, so one login reaches the bot several times — each copy redeems the token, emits sign_in, and runs the flow's success handlers.

Dedup

Two layers, mirroring the C# SDK:

  1. In-flight guard — keyed by exchange id. A duplicate arriving mid-flight awaits the original and returns an equivalent result, including the 412 body on failure. Released on settle, so retrying a failed exchange still works.
  2. Completed marker — after success the id is recorded so a late duplicate short-circuits to 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, the sign_in emission, 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 no await between 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/verifyState and signin/failure are deliberately not deduplicated — the verify code is single-use, failure is one informational notice. Tests pin both.

Also included

  • ctx.get_token_statusctx.get_connection_status — it shadowed api.users.get_token_status while behaving differently (the context method adds a silent-SSO correction pass). Matches getConnectionStatus in 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_in now call api.users.* directly instead of the deprecated context methods, as teams.ts does. Verified equivalent: same request params, logs, return values, and 404 handling. sign_in still delegates — extracting it is a follow-up.
  • Legacy single-connection surface deprecated in docstrings only, no decorators, so the supported flow API never warns callers about an API they did not touch.
  • examples/oauth now shows multi-connection; examples/graph moved to the flow API, fixing its own copy of the bug — it built a Graph client from ctx.user_token, i.e. whichever connection last exchanged.

NOTE: Once we fully remove the old logic, we need to extractsign_in into a shared module

Comment thread packages/apps/src/microsoft_teams/apps/app_oauth.py
Copilot AI lite review requested due to automatic review settings August 27, 2026 17:45
@lilyydu
Lily Du (lilyydu) force-pushed the lilyydu-oauth-token-exchange-dedup branch from bb1399d to ddd20c0 Compare August 27, 2026 17:45
Comment thread packages/apps/src/microsoft_teams/apps/app_oauth.py Outdated
@lilyydu Lily Du (lilyydu) changed the title feat(oauth): deduplicate signin/tokenExchange callbacks feat(oauth): deduplicate signin/tokenExchange callbacks & update oauth example Aug 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/apps/src/microsoft_teams/apps/app_oauth.py
Comment thread packages/apps/src/microsoft_teams/apps/app_oauth.py Outdated
Comment thread packages/apps/src/microsoft_teams/apps/app_oauth.py Outdated
@lilyydu
Lily Du (lilyydu) force-pushed the lilyydu-oauth-token-exchange-dedup branch from ddd20c0 to b112b48 Compare August 27, 2026 23:21
Base automatically changed from lilyydu-implement-python-oauth-state-routing to main August 27, 2026 23:57
LilyDu and others added 4 commits August 27, 2026 16: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>
@lilyydu
Lily Du (lilyydu) force-pushed the lilyydu-oauth-token-exchange-dedup branch from 4ba1326 to 4ccbbdc Compare August 27, 2026 23:57
lilydu and others added 5 commits August 27, 2026 17:00
…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>
@lilyydu Lily Du (lilyydu) changed the title feat(oauth): deduplicate signin/tokenExchange callbacks & update oauth example feat(oauth): dedup, update oauth/graph samples, add dep markers on legacy methods Aug 28, 2026
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>
@lilyydu
Lily Du (lilyydu) merged commit 60db934 into main Aug 28, 2026
8 checks passed
@lilyydu
Lily Du (lilyydu) deleted the lilyydu-oauth-token-exchange-dedup branch August 28, 2026 20:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants