feat(apps): add multi-flow OAuth lifecycle - #744
feat(apps): add multi-flow OAuth lifecycle#744Mehak Bindra (MehakBindra) wants to merge 5 commits into
Conversation
| ); | ||
| } | ||
|
|
||
| this.get(connectionName); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
| 'Remove defaultConnectionName and name the connection when calling OAuth helpers.' | ||
| ); | ||
| } | ||
| if (hasConfiguredOAuthFlows && this.options.state === false) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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[]> { |
There was a problem hiding this comment.
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
dfcdccf to
3513be0
Compare
| const pendingFlows = this.getOrderedFlows(ctx, true).filter( | ||
| flow => flow.getPending(ctx, true) !== undefined | ||
| ); | ||
| const flows = pendingFlows.length > 0 ? [pendingFlows[0]] : this.getFlows(); |
There was a problem hiding this comment.
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 }>>(); |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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?
| 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', |
There was a problem hiding this comment.
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".
| 'OAuth flows require turn state. Remove state: false or configure state options.' | ||
| ); | ||
| } | ||
| this.stateLoader ??= createStateLoader(true, this.storage, this.log); |
There was a problem hiding this comment.
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?
Summary
Adds a first-class, per-connection OAuth lifecycle to
@microsoft/teams.apps, modeled on the currentmicrosoft/teams.netimplementation and stacked on #729.OAuthFlowfor silent token lookup, interactive sign-in, sign-out, connection status, and completion/failure callbacksAppOptions.oauthFlowsand imperative registration withapp.addOAuthFlow(...)getOAuthFlow(...)lookup with case-insensitive connection matchingPublic API
Flows may also be registered and configured directly:
Lifecycle behavior
getToken()performs silent token lookup and reuses the existing token models.signIn()returns a cached token immediately or emits an OAuth card and records pending flow attribution.signin/tokenExchangeresolves the named flow, performs exchange, suppresses duplicates, invokes the completion callback, and emits the existingsigninevent.signin/verifyStatetries pending flows in attribution order until a connection redeems the code.signin/failureroutes the failure to the most recent pending SSO-capable flow.signOut()andgetConnectionStatus()expose the remaining per-connection operations.Compatibility
AppOptions.oauth,ctx.signin(),ctx.signout(),ctx.userToken,ctx.isSignedIn,ctx.userGraph, existing OAuth events, invoke routes, and response shapesoauth.defaultConnectionNamewith registered flows; once flows are registered, deprecated context OAuth helpers must name a connection; a configured default may be omitted or named exactlystate: falsewith registered flows is rejectedapp.options.ts, while re-exporting them fromapp.tsso existing imports continue to workOAuthFlowwhile keeping legacy-default and registered-flow modes mutually exclusiveIntentional semantics
404when no flow redeems the code, matchingteams.net, even when an earlier candidate produced a token-service5xxOAuthFlow.signIn()converts expected token-miss responses (400,404, and412) into card initiation and propagates unexpected service or transport errorsOAuthFlowwith a token-specificGraphClientinstead of deprecatedctx.userGraphDeliberate TypeScript differences
onSignInCompleteandonSignInFailurecallback typesValidation
npm test --workspace @microsoft/teams.apps -- --runInBandnpm run lint --workspace @microsoft/teams.appsnpm run build --workspace @microsoft/teams.appsnpm run lint --workspace @examples/oauthnpm run build --workspace @examples/oauthStack
mehakbindra-add-per-turn-state