Skip to content

feat(msteams): handle signin/tokenExchange and signin/verifyState for SSO (#60956)#63964

Closed
sudie-codes wants to merge 1 commit intoopenclaw:mainfrom
sudie-codes:feat/msteams-sso-token-exchange-60956
Closed

feat(msteams): handle signin/tokenExchange and signin/verifyState for SSO (#60956)#63964
sudie-codes wants to merge 1 commit intoopenclaw:mainfrom
sudie-codes:feat/msteams-sso-token-exchange-60956

Conversation

@sudie-codes
Copy link
Copy Markdown
Contributor

Summary

Adds Microsoft Teams SSO support by handling the signin/tokenExchange and signin/verifyState invoke activities. Enables seamless single-sign-on for users in tenants where the bot's AAD app is configured for SSO.

What changed

  • New config: channels.msteams.sso.enabled and channels.msteams.sso.connectionName (additive, opt-in).
  • New invoke handlers for signin/tokenExchange and signin/verifyState in the existing monitor-handler chain. Both always ack with invokeResponse status 200 so the Teams card UI stops reporting "Something went wrong".
  • When SSO is enabled, the plugin exchanges the Teams-provided token (or a magic-code state) with the Bot Framework User Token service (https://token.botframework.com/api/usertoken/exchange / GetToken) and persists the delegated user token.
  • New sso.ts (User Token service client + handlers) and sso-token-store.ts (file-backed store keyed by connection name + AAD object ID, with an in-memory store for tests).
  • monitor.ts constructs MSTeamsSsoDeps when sso.enabled && sso.connectionName and passes it through to registerMSTeamsHandlers; default off preserves pre-SSO behavior.

AAD app requirements

  • The bot's AAD app exposes an API scope (for example access_as_user).
  • knownClientApplications lists the Teams client IDs (5e3ce6c0-2b1f-4285-8d4b-75ee78787346 desktop, 1fec8e78-bce4-4aaf-ab1b-5451cc387264 mobile, 4765445b-32c6-49b0-83e6-1d93765276ca Teams web).
  • The Bot Framework channel registration has an OAuth Connection Setting whose name matches channels.msteams.sso.connectionName, pointing at the same AAD app.

Test plan

  • Unit tests for both invoke handler functions (success + error paths, missing fields, token persistence)
  • Unit tests for the registered handler wrapper acking SSO invokes when sso deps are present or absent
  • Regression: full extensions/msteams suite green (627 tests)
  • Manual: configure SSO connection in Bot Framework, sign in via Teams

Closes #60956

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: msteams Channel integration: msteams size: XL r: too-many-prs Auto-close: author has more than twenty active PRs. labels Apr 9, 2026
@openclaw-barnacle
Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@openclaw-barnacle openclaw-barnacle Bot closed this Apr 9, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 9, 2026

Greptile Summary

Adds opt-in Microsoft Teams SSO support by handling signin/tokenExchange and signin/verifyState invoke activities, wiring them through the existing monitor-handler chain with an immediate invokeResponse ack. The implementation is cleanly scoped: new config, new files, no pre-SSO behaviour change when sso is not configured, and solid Zod validation enforcing connectionName when enabled=true.

Confidence Score: 5/5

Safe to merge; SSO is opt-in and all pre-SSO paths are unaffected.

Both findings are P2: the connectionName inconsistency is harmless in practice (Teams echoes back the configured name), and the missing expiry check affects only future consumers of the get API. No data loss, no correctness breakage on existing paths.

extensions/msteams/src/sso.ts (connectionName storage key consistency) and extensions/msteams/src/sso-token-store.ts (expiry check on get)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/sso.ts
Line: 200-242

Comment:
**Inconsistent `connectionName` key between the two handlers**

`handleSigninTokenExchangeInvoke` stores the token under `value.connectionName?.trim() || deps.connectionName`, so if Teams sends an activity `connectionName` that differs from the configured one, the token is persisted under a different key. `handleSigninVerifyStateInvoke` (line 291) always saves under `deps.connectionName`. Downstream callers that look up the token by `deps.connectionName` will never find a token stored via the exchange path when the two names diverge.

In practice Teams echoes back the same `connectionName` the bot sent in the OAuth card, so this is harmless today. But aligning both save sites to use `deps.connectionName` (or explicitly normalise the activity value against the configured name) makes the behaviour less surprising and removes a latent mismatch.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/sso-token-store.ts
Line: 81-83

Comment:
**`get` returns tokens without checking `expiresAt`**

The store returns whatever is on disk regardless of whether `expiresAt` has passed. A consumer that calls `get` after the Bot Framework token has expired will receive an already-expired token and may only discover the problem after a downstream API call fails. Adding an optional expiry guard (e.g. return `null` when `expiresAt` is set and is in the past) would surface stale tokens early and make the caller re-trigger the SSO flow.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(msteams): handle signin/tokenExchan..." | Re-trigger Greptile

Comment on lines +200 to +242
}
const connectionName = value.connectionName?.trim() || deps.connectionName;
if (!connectionName) {
return { ok: false, code: "missing_connection", message: "no OAuth connection name" };
}
if (!value.token) {
return { ok: false, code: "missing_token", message: "no exchangeable token on invoke" };
}

const bearer = await deps.tokenProvider.getAccessToken(BOT_FRAMEWORK_TOKEN_SCOPE);
const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as unknown as MSTeamsSsoFetch);
const result = await callUserTokenService({
baseUrl: deps.userTokenBaseUrl ?? BOT_FRAMEWORK_USER_TOKEN_BASE_URL,
path: "/api/usertoken/exchange",
query: {
userId: user.userId,
connectionName,
channelId: user.channelId ?? "msteams",
},
method: "POST",
body: { token: value.token },
bearerToken: bearer,
fetchImpl,
});

if ("error" in result) {
return {
ok: false,
code: result.status >= 500 ? "service_error" : "unexpected_response",
message: result.error,
status: result.status,
};
}

await deps.tokenStore.save({
connectionName,
userId: user.userId,
token: result.token,
expiresAt: result.expiration,
updatedAt: new Date().toISOString(),
});

return { ok: true, token: result.token, expiresAt: result.expiration };
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.

P2 Inconsistent connectionName key between the two handlers

handleSigninTokenExchangeInvoke stores the token under value.connectionName?.trim() || deps.connectionName, so if Teams sends an activity connectionName that differs from the configured one, the token is persisted under a different key. handleSigninVerifyStateInvoke (line 291) always saves under deps.connectionName. Downstream callers that look up the token by deps.connectionName will never find a token stored via the exchange path when the two names diverge.

In practice Teams echoes back the same connectionName the bot sent in the OAuth card, so this is harmless today. But aligning both save sites to use deps.connectionName (or explicitly normalise the activity value against the configured name) makes the behaviour less surprising and removes a latent mismatch.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/sso.ts
Line: 200-242

Comment:
**Inconsistent `connectionName` key between the two handlers**

`handleSigninTokenExchangeInvoke` stores the token under `value.connectionName?.trim() || deps.connectionName`, so if Teams sends an activity `connectionName` that differs from the configured one, the token is persisted under a different key. `handleSigninVerifyStateInvoke` (line 291) always saves under `deps.connectionName`. Downstream callers that look up the token by `deps.connectionName` will never find a token stored via the exchange path when the two names diverge.

In practice Teams echoes back the same `connectionName` the bot sent in the OAuth card, so this is harmless today. But aligning both save sites to use `deps.connectionName` (or explicitly normalise the activity value against the configured name) makes the behaviour less surprising and removes a latent mismatch.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +81 to +83
async get({ connectionName, userId }) {
const store = await readStore();
return store.tokens[makeKey(connectionName, userId)] ?? null;
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.

P2 get returns tokens without checking expiresAt

The store returns whatever is on disk regardless of whether expiresAt has passed. A consumer that calls get after the Bot Framework token has expired will receive an already-expired token and may only discover the problem after a downstream API call fails. Adding an optional expiry guard (e.g. return null when expiresAt is set and is in the past) would surface stale tokens early and make the caller re-trigger the SSO flow.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/sso-token-store.ts
Line: 81-83

Comment:
**`get` returns tokens without checking `expiresAt`**

The store returns whatever is on disk regardless of whether `expiresAt` has passed. A consumer that calls `get` after the Bot Framework token has expired will receive an already-expired token and may only discover the problem after a downstream API call fails. Adding an optional expiry guard (e.g. return `null` when `expiresAt` is set and is in the past) would surface stale tokens early and make the caller re-trigger the SSO flow.

How can I resolve this? If you propose a fix, please make it concise.

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

Labels

channel: msteams Channel integration: msteams docs Improvements or additions to documentation r: too-many-prs Auto-close: author has more than twenty active PRs. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: MS Teams plugin should handle signin/tokenExchange and signin/verifyState invokes for OAuth SSO

1 participant