Files
openclaw/extensions/msteams/src/oauth.ts
sudie-codes 355794c24a msteams: add reaction support with delegated auth and pagination helper (#51646)
* msteams: add reaction support (inbound handlers + outbound Graph API)

* msteams: address PR #51646 review feedback

* msteams: remove react from advertised actions (requires Delegated auth)

* msteams: address PR #51646 remaining review feedback (dmPolicy, groupPolicy, reactions auth)

- Fix 1: DM reaction authorization now uses resolveDmGroupAccessWithLists to enforce
  dmPolicy modes (open/disabled/allowlist/pairing), matching the message handler.
- Fix 2: Group policy in reaction handler already uses resolveDefaultGroupPolicy
  for global defaults; moved declaration earlier to share with DM path.
- Fix 3: Restore read-only "reactions" (list) action with listReactionsMSTeams,
  which uses GET and works with Application auth. Keep "react" (write) gated
  behind delegated-auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: add shared Graph pagination helper (fetchAllGraphPages)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: add OAuth2 delegated auth flow (PKCE + authorization code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: integrate delegated auth (config, token storage, react enablement)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: fix critical bugs found in architect review

- Fix fetchGraphJson→postGraphJson for setReaction/unsetReaction (was sending GET instead of POST)
- Fix CSRF bypass in OAuth parseCallbackInput (missing state no longer falls back silently)
- Remove stale delegated-auth warning logs (delegated auth is now implemented)
- Add CSRF test case for parseCallbackInput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* msteams: fix 6 PR #51646 review blockers (PKCE/state separation, CSRF, imports, routing, delegated auth bootstrap)

* msteams: fix channel.runtime.ts duplicate imports + graph.ts test mock compat

* msteams: fix lint/boundary blockers revealed by CI after rebase

- token.ts/graph.test.ts: add curly braces around single-statement ifs
  (eslint/curly).
- oauth.flow.ts: rename unused parseCallbackInput param to _expectedState.
- reaction-handler.test.ts: rename unused buildDeps param to _runtime.
- send.reactions.ts: drop unnecessary non-null assertions on tuple entries.
- setup-surface.ts: drop empty-object spread fallback flagged by
  unicorn/no-useless-fallback-in-spread.
- graph.ts: move GraphPagedResponse/PaginatedResult type defs below
  requestGraph so the raw fetch() stays on line 47 to match the existing
  no-raw-channel-fetch allowlist entry.
- oauth.token.ts: route the Azure AD token exchange and refresh calls
  through fetchWithSsrFGuard (matches the pattern in sdk.ts), removing
  the unguarded raw fetch() callsites flagged by
  lint:tmp:no-raw-channel-fetch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(msteams): restore absolute Graph pagination helper

* fix(msteams): satisfy reaction handler lint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com>
2026-04-11 07:41:47 -05:00

131 lines
3.8 KiB
TypeScript

import {
buildMSTeamsAuthUrl,
generateOAuthState,
generatePkce,
parseCallbackInput,
shouldUseManualOAuthFlow,
waitForLocalCallback,
} from "./oauth.flow.js";
import {
MSTEAMS_DEFAULT_DELEGATED_SCOPES,
MSTEAMS_OAUTH_CALLBACK_PORT,
type MSTeamsDelegatedOAuthContext,
type MSTeamsDelegatedTokens,
} from "./oauth.shared.js";
import { exchangeMSTeamsCodeForTokens } from "./oauth.token.js";
export type { MSTeamsDelegatedOAuthContext, MSTeamsDelegatedTokens };
export async function loginMSTeamsDelegated(
ctx: MSTeamsDelegatedOAuthContext,
params: {
tenantId: string;
clientId: string;
clientSecret: string;
scopes?: readonly string[];
},
): Promise<MSTeamsDelegatedTokens> {
const scopes = params.scopes ?? MSTEAMS_DEFAULT_DELEGATED_SCOPES;
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
await ctx.note(
needsManual
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Microsoft authentication.",
`Sign in to grant delegated permissions for MSTeams.`,
`The callback will be captured automatically on localhost:${MSTEAMS_OAUTH_CALLBACK_PORT}.`,
].join("\n"),
"MSTeams Delegated OAuth",
);
const { verifier, challenge } = generatePkce();
const state = generateOAuthState();
const authUrl = buildMSTeamsAuthUrl({
tenantId: params.tenantId,
clientId: params.clientId,
challenge,
state,
scopes,
});
if (needsManual) {
return manualFlow(ctx, authUrl, state, verifier, params);
}
ctx.progress.update("Complete sign-in in browser...");
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: state,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update("Exchanging authorization code for tokens...");
return await exchangeMSTeamsCodeForTokens({
tenantId: params.tenantId,
clientId: params.clientId,
clientSecret: params.clientSecret,
code,
verifier,
scopes,
});
} catch (err) {
// EADDRINUSE or other listen errors: fall back to manual flow
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
ctx.progress.update("Local callback server failed. Switching to manual mode...");
return manualFlow(ctx, authUrl, state, verifier, params, err);
}
throw err;
}
}
async function manualFlow(
ctx: MSTeamsDelegatedOAuthContext,
authUrl: string,
state: string,
verifier: string,
params: {
tenantId: string;
clientId: string;
clientSecret: string;
scopes?: readonly string[];
},
cause?: Error,
): Promise<MSTeamsDelegatedTokens> {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, state);
if ("error" in parsed) {
throw new Error(parsed.error, cause ? { cause } : undefined);
}
if (parsed.state !== state) {
throw new Error("OAuth state mismatch - please try again", cause ? { cause } : undefined);
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeMSTeamsCodeForTokens({
tenantId: params.tenantId,
clientId: params.clientId,
clientSecret: params.clientSecret,
code: parsed.code,
verifier,
scopes: params.scopes,
});
}