Skip to content

feat(apps): add multi-flow OAuth lifecycle - #744

Open
Mehak Bindra (MehakBindra) wants to merge 5 commits into
mehakbindra-add-per-turn-statefrom
mehakbindra-oauth-lifecycle
Open

feat(apps): add multi-flow OAuth lifecycle#744
Mehak Bindra (MehakBindra) wants to merge 5 commits into
mehakbindra-add-per-turn-statefrom
mehakbindra-oauth-lifecycle

Conversation

@MehakBindra

@MehakBindra Mehak Bindra (MehakBindra) commented Aug 21, 2026

Copy link
Copy Markdown
Member

Summary

Adds a first-class, per-connection OAuth lifecycle to @microsoft/teams.apps, modeled on the current microsoft/teams.net implementation and stacked on #729.

  • introduces OAuthFlow for silent token lookup, interactive sign-in, sign-out, connection status, and completion/failure callbacks
  • supports declarative registration with AppOptions.oauthFlows and imperative registration with app.addOAuthFlow(...)
  • adds app-level getOAuthFlow(...) lookup with case-insensitive connection matching
  • dispatches token exchange, verification, and failure invokes across multiple registered flows
  • tracks pending sign-in attribution and exchange deduplication in turn state
  • deduplicates token exchanges, including concurrent requests and retries after a completed exchange
  • adds OAuth lifecycle telemetry and realistic multi-flow, failure, routing, and compatibility coverage
  • replaces the Graph-only example with an OAuth example covering Graph and GitHub connections

Public API

const app = new App({
  oauthFlows: ['graph', 'github'],
});

const graph = app.getOAuthFlow('graph');

graph
  .onSignInComplete(async (ctx, token) => {
    // Use the returned connection token.
  })
  .onSignInFailure(async (ctx, failure) => {
    // Handle interactive or SSO failure.
  });

await graph.signIn(ctx);
await graph.signOut(ctx);
const token = await graph.getToken(ctx);
const status = await graph.getConnectionStatus(ctx);

Flows may also be registered and configured directly:

const github = app.addOAuthFlow('github', {
  oauthCardText: 'Connect GitHub',
  signInButtonText: 'Connect',
});

Lifecycle behavior

  1. getToken() performs silent token lookup and reuses the existing token models.
  2. signIn() returns a cached token immediately or emits an OAuth card and records pending flow attribution.
  3. signin/tokenExchange resolves the named flow, performs exchange, suppresses duplicates, invokes the completion callback, and emits the existing signin event.
  4. signin/verifyState tries pending flows in attribution order until a connection redeems the code.
  5. signin/failure routes the failure to the most recent pending SSO-capable flow.
  6. signOut() and getConnectionStatus() expose the remaining per-connection operations.

Compatibility

  • retains AppOptions.oauth, ctx.signin(), ctx.signout(), ctx.userToken, ctx.isSignedIn, ctx.userGraph, existing OAuth events, invoke routes, and response shapes
  • preserves the configured default connection only for legacy apps without registered flows
  • rejects combining oauth.defaultConnectionName with registered flows; once flows are registered, deprecated context OAuth helpers must name a connection; a configured default may be omitted or named exactly
  • keeps the TypeScript-only sign-in activity override and existing OAuth card customization options
  • automatically enables turn state when OAuth flows are configured or added, while leaving implicit-default and legacy-only apps state-free; state: false with registered flows is rejected
  • does not enable eager per-turn token lookup when flows are registered; eager lookup remains limited to deprecated context OAuth fields
  • moves app option declarations to app.options.ts, while re-exporting them from app.ts so existing imports continue to work
  • marks legacy OAuth surfaces deprecated only where the flow API provides a replacement
  • represents the legacy default connection as a normal OAuthFlow while keeping legacy-default and registered-flow modes mutually exclusive

Intentional semantics

  • successful token exchange is marked complete before callbacks run; if a completion callback throws, a retry is acknowledged as a duplicate because the exchange itself already completed
  • multi-flow verification returns 404 when no flow redeems the code, matching teams.net, even when an earlier candidate produced a token-service 5xx
  • OAuthFlow.signIn() converts expected token-miss responses (400, 404, and 412) into card initiation and propagates unexpected service or transport errors
  • the legacy default uses the same completion and error semantics as registered flows rather than maintaining a separate handler path
  • telemetry operation and result values are aligned with the .NET OAuth lifecycle
  • the OAuth example uses the token returned by OAuthFlow with a token-specific GraphClient instead of deprecated ctx.userGraph

Deliberate TypeScript differences

  • retains the existing TypeScript sign-in URL and activity override APIs
  • uses per-turn user and conversation state from Add per-turn conversation and user state #729 for pending attribution and exchange deduplication
  • continues emitting the existing TypeScript OAuth events and populating deprecated context token and Graph fields on invoke completion
  • preserves plugin-provided context fields in onSignInComplete and onSignInFailure callback types

Validation

  • npm test --workspace @microsoft/teams.apps -- --runInBand
  • npm run lint --workspace @microsoft/teams.apps
  • npm run build --workspace @microsoft/teams.apps
  • npm run lint --workspace @examples/oauth
  • npm run build --workspace @examples/oauth

Stack

);
}

this.get(connectionName);

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.

we should consider checking registry only if usesRegisteredFlows is true. Previously,
ctx.signin({ connectionName: "github" }) and ctx.signout("github") forwarded explicit connection names even when the app default was different. adding this would mean these existing calls would throw & would be a breaking change

});
} catch (error) {
if (error instanceof AxiosError) {
this.clearPending(ctx, flow);

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.

should these be deferred until routing is resolved? in verifyState, 400/404/412 normally means “this code does not belong to this channel; try the next flow,” not that the flow failed

if graph returns 404 and github subsequently succeeds, graph's failure callback currently fires and its pending attribution is cleared. I think probe misses should remain silent

Comment thread packages/apps/src/app.ts
'Remove defaultConnectionName and name the connection when calling OAuth helpers.'
);
}
if (hasConfiguredOAuthFlows && this.options.state === false) {

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.

hmm I don't think we need to require turn state for all flows, for Python we allow a process-local oauth fallback when state is disabled

}

for (const flow of flows) {
const response = await this.verifyFlow(ctx, flow, activity.value.state, span, telemetry);

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.

token service can return a 5xx here but it get swallowed as a 404

* The Bot Framework endpoint returns all connection statuses; callers can
* select this flow's entry by matching {@link connectionName}.
*/
getConnectionStatus(context: IActivityContext): Promise<TokenStatus[]> {

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.

should this filter to only return for a specific connection name, or rename to pluralize?

Add per-connection OAuth flows, lifecycle callbacks, multi-flow invoke routing, pending attribution, exchange deduplication, telemetry, compatibility fallbacks, and the OAuth example.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d88953d-cdee-4a28-ac2d-36a5fee72da5
Validate deprecated context sign-in connections before initiation, record pending attribution for the selected flow, expose the completed connection on signin events, and keep internal OAuth helpers out of the package barrel.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d88953d-cdee-4a28-ac2d-36a5fee72da5
Treat the implicit default as a normal flow, standardize completion error semantics, and preserve plugin-provided context in OAuth lifecycle callback types.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d88953d-cdee-4a28-ac2d-36a5fee72da5
Automatically enable turn state for explicit OAuth flows, reject state: false, store pending attribution and bounded exchange deduplication in state, and require exact token-exchange routing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d88953d-cdee-4a28-ac2d-36a5fee72da5
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0d88953d-cdee-4a28-ac2d-36a5fee72da5
const pendingFlows = this.getOrderedFlows(ctx, true).filter(
flow => flow.getPending(ctx, true) !== undefined
);
const flows = pendingFlows.length > 0 ? [pendingFlows[0]] : this.getFlows();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if TTL is expired or state is unavailable, this falls back to every registered flow. With the example's handlers both calling ctx.send, a user who leaves a GitHub card sitting sees both "Microsoft Graph sign-in failed." and "GitHub sign-in failed.". Should the no-pending case notify nothing, or only fall back when a single flow is registered? The tests only cover the has-pending path.

private static readonly MAX_EXCHANGE_ENTRIES = 1_000;
private static readonly EXCHANGE_STATE_KEY = '__oauth:exchanges';

private readonly tokenExchangeLocks = new Map<string, Promise<{ status: number, body?: TokenExchangeInvokeResponse }>>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tokenExchangeLocks is keyed on the bare value.id, but the durable dedupe in markExchangeProcessed is conversation-scoped. Should key both the same way. Maybe also add a comment that this map is process-local, so concurrent duplicates only collapse on a single instance.

});
return response.token;
} catch (error) {
if (!isMissingTokenError(error)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This changes ctx.signin() behavior. Previously everything fell through to sending a card; now anything outside 400/404/412 propagates, so a transient token service blip turns a sign-in card into a 500. I think the new behavior is correct, but the description lists ctx.signin() under retained compatibility and the semantics note only mentions OAuthFlow.signIn(). Can we call this out as a breaking change?

Comment on lines +79 to +87
cached: 'token_cached',
cardSent: 'signin_card_sent',
hit: 'token_found',
miss: 'token_not_found',
success: 'operation_succeeded',
failure: 'operation_failed',
duplicate: 'request_deduplicated',
noToken: 'connection_not_matched',
notified: 'failure_callback_notified',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every value here changes (success to operation_succeeded, no_token to connection_not_matched, and so on), and the three oauth span names collapse into one. These are @internal, but anything already dashboarding or alerting on them breaks with no compile error to catch it. Should be noted in PR summary & changelog at release time.

Separately, exception: 'invalid_op' on line 96 seems odd for "a non-HTTP exception was thrown".

Comment thread packages/apps/src/app.ts
'OAuth flows require turn state. Remove state: false or configure state options.'
);
}
this.stateLoader ??= createStateLoader(true, this.storage, this.log);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Registering a flow silently enables state using the app's default storage, which is LocalStorage unless configured. OAuth correctness now depends on that state for pending attribution and exchange dedupe, so across more than one instance sign-in can simply never complete. The existing LocalStorage warning is generic. Can it name the OAuth consequence when state is enabled from here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants