diff --git a/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts index fa0a39446..ed1b9333c 100644 --- a/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts +++ b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts @@ -47,7 +47,7 @@ export async function up(knex: Knex): Promise { table.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).onDelete("CASCADE"); // the ID itself is the workflow integration ID table.string("internalTeamsAppId").nullable(); - table.string("tenantId").unique().notNullable(); + table.string("tenantId").notNullable(); table.binary("encryptedAccessToken").nullable(); table.binary("encryptedBotAccessToken").nullable(); diff --git a/backend/src/server/routes/v1/microsoft-teams-router.ts b/backend/src/server/routes/v1/microsoft-teams-router.ts index f624c2cdf..bf24717d5 100644 --- a/backend/src/server/routes/v1/microsoft-teams-router.ts +++ b/backend/src/server/routes/v1/microsoft-teams-router.ts @@ -61,7 +61,8 @@ export const registerMicrosoftTeamsRouter = async (server: FastifyZodProvider) = redirectUri: z.string(), tenantId: z.string().uuid(), slug: z.string(), - description: z.string().optional() + description: z.string().optional(), + code: z.string().trim() }) }, @@ -72,6 +73,7 @@ export const registerMicrosoftTeamsRouter = async (server: FastifyZodProvider) = slug: req.body.slug, description: req.body.description, redirectUri: req.body.redirectUri, + code: req.body.code, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts index 9de532e8c..4111115bf 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -16,48 +16,77 @@ import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow import { WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; +const ConsentError = "AADSTS65001"; + export const verifyTenantFromCode = async ( tenantId: string, + code: string, redirectUri: string, clientId: string, clientSecret: string ) => { - const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + const getAccessToken = async (params: URLSearchParams) => { + const response = await axios + .post<{ access_token: string }>(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }) + .catch((err) => { + if (axios.isAxiosError(err)) { + if ((err.response?.data as { error_description?: string })?.error_description?.includes(ConsentError)) { + throw new BadRequestError({ + message: "Unable to verify tenant, please ensure that you have granted admin consent." + }); + } + logger.error(err.response?.data, "Error fetching Microsoft Teams access token"); + } + throw err; + }); - const params = new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - scope: "https://graph.microsoft.com/.default", - redirect_uri: redirectUri, - grant_type: "client_credentials" - }); + return response.data.access_token; + }; - const response = await axios - .post<{ access_token: string }>(tokenEndpoint, params, { - headers: { - "Content-Type": "application/x-www-form-urlencoded" - } + // Azure App-based auth + const applicationAccessToken = await getAccessToken( + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default", + redirect_uri: redirectUri, + grant_type: "client_credentials" }) - .catch((err) => { - if (axios.isAxiosError(err)) { - logger.error(err.response?.data, "Error fetching Microsoft Teams access token"); - } - throw err; - }); + ); - const accessToken = response.data.access_token; - const decodedToken = jwt.decode(accessToken) as { tid: string }; + // User-based auth + const authorizationAccessToken = await getAccessToken( + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: "https://graph.microsoft.com/.default", + redirect_uri: redirectUri, + grant_type: "authorization_code", + code + }) + ); - // the 'tid' claim in the token contains the tenant ID - const tenantIdFromToken = decodedToken.tid; + // Verify application token + const { tid: tenantIdFromApplicationAccessToken } = jwt.decode(applicationAccessToken) as { tid: string }; - if (tenantIdFromToken !== tenantId) { + if (tenantIdFromApplicationAccessToken !== tenantId) { throw new BadRequestError({ - message: `Invalid tenant state ID. Expected ${tenantId}, got ${tenantIdFromToken}` + message: `Invalid application token tenant ID. Expected ${tenantId}, got ${tenantIdFromApplicationAccessToken}` }); } - return tenantIdFromToken; + // Verify user authorization token + const { tid: tenantIdFromAuthorizationAccessToken } = jwt.decode(authorizationAccessToken) as { tid: string }; + + if (tenantIdFromAuthorizationAccessToken !== tenantId) { + throw new BadRequestError({ + message: `Invalid authorization token tenant ID. Expected ${tenantId}, got ${tenantIdFromAuthorizationAccessToken}` + }); + } }; export const getMicrosoftTeamsAccessToken = async ( diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts index 6c56fe991..3712a0793 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-service.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -211,6 +211,7 @@ export const microsoftTeamsServiceFactory = ({ }; const completeMicrosoftTeamsIntegration = async ({ + code, actor, actorId, actorOrgId, @@ -251,7 +252,7 @@ export const microsoftTeamsServiceFactory = ({ const botAppPassword = decryptWithRoot(encryptedMicrosoftTeamsClientSecret); const botId = decryptWithRoot(encryptedMicrosoftTeamsBotId); - await verifyTenantFromCode(tenantId, redirectUri, botAppId.toString(), botAppPassword.toString()); + await verifyTenantFromCode(tenantId, code, redirectUri, botAppId.toString(), botAppPassword.toString()); await workflowIntegrationDAL.transaction(async (tx) => { const workflowIntegration = await workflowIntegrationDAL.create( diff --git a/backend/src/services/microsoft-teams/microsoft-teams-types.ts b/backend/src/services/microsoft-teams/microsoft-teams-types.ts index ccae18f8a..7427cb0d5 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-types.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-types.ts @@ -10,6 +10,7 @@ export type TCreateMicrosoftTeamsIntegrationDTO = Omit slug: string; redirectUri: string; description?: string; + code: string; }; export type TCheckInstallationStatusDTO = { workflowIntegrationId: string } & Omit; diff --git a/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx b/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx index fd774deb2..57f05984b 100644 --- a/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx +++ b/docs/documentation/platform/workflow-integrations/microsoft-teams-integration.mdx @@ -126,12 +126,17 @@ This guide will provide step by step instructions on how to configure Microsoft ![microsoft-teams-configure-bot](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png) - - To ensure that the Microsoft Teams App is working correctly, you can run an app validation test. + + To ensure that the Microsoft Teams App is working correctly, you can run an app validation test. This step is optional, but recommended to ensure the app is working correctly. You should expect to see two errors related to sending welcome messages, because we haven't configured the Microsoft Teams App inside Infisical yet, which is required for proactive messages. ![microsoft-teams-app-validation-test](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png) + + + You may see manifest validation errors. Before running an app validation test, you must ensure that your app has all errors resolved, such as having a description and a valid name. + + ![microsoft-teams-app-validation-test-results](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png) diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index 9c9eae430..4d2baf4bf 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -59,6 +59,7 @@ export type TUpdateMicrosoftTeamsIntegrationDTO = { }; export type TCreateMicrosoftTeamsIntegrationDTO = { + code: string; tenantId: string; slug: string; description?: string; diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx index a968401c9..e678e9de9 100644 --- a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx @@ -1,3 +1,5 @@ +import crypto from "crypto"; + import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { z } from "zod"; @@ -13,7 +15,8 @@ const stateSchema = z.object({ tenantId: z.string(), slug: z.string(), description: z.string().optional(), - csrfToken: z.string() + csrfToken: z.string(), + clientId: z.string() }); export const OAuthCallbackPage = () => { @@ -27,7 +30,9 @@ export const OAuthCallbackPage = () => { const createMicrosoftTeamsWorkflowIntegration = useCreateMicrosoftTeamsIntegration(); - const { state: rawState, tenant: stateTenant, admin_consent: adminConsent } = search; + const { state: rawState, code } = search; + + console.log("the code is ", code); const state = stateSchema.parse(rawState); @@ -42,21 +47,29 @@ export const OAuthCallbackPage = () => { const handleMicrosoftTeams = useCallback(async () => { clearState(); - if (Boolean(adminConsent.toLowerCase()) !== true) { - throw new Error("Failed to grant admin consent"); - } - - if (stateTenant !== state.tenantId) { - throw new Error(`Invalid tenant ID. Expected ${stateTenant}, got ${state.tenantId}`); + if (!code) { + throw new Error("No code provided"); } await createMicrosoftTeamsWorkflowIntegration.mutateAsync({ orgId: currentOrg.id, tenantId: state.tenantId, + code, slug: state.slug, description: state.description ?? "", redirectUri: state.redirectUri }); + + createNotification({ + text: "Successfully granted Microsoft Teams admin consent", + type: "success" + }); + + navigate({ + to: ROUTE_PATHS.Organization.SettingsPage.path + }); + + return; }, []); // Ensure that the localstorage is ready for use, to avoid the form data being malformed @@ -72,22 +85,16 @@ export const OAuthCallbackPage = () => { (async () => { try { await handleMicrosoftTeams(); - - createNotification({ - text: "Successfully granted Microsoft Teams admin consent", - type: "success" - }); } catch (err) { console.error(err); createNotification({ - text: "Failed to grant Microsoft Teams admin consent", + text: + code !== "" + ? "Failed to create Microsoft Teams workflow integration" + : "Failed to grant Microsoft Teams admin consent", type: "error" }); } - - navigate({ - to: ROUTE_PATHS.Organization.SettingsPage.path - }); })(); }, [isReady]); diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx index 7cc262518..8c6af10f3 100644 --- a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx @@ -7,6 +7,7 @@ import { OAuthCallbackPage } from "./OauthCallbackPage"; const SettingsOAuthCallbackPageQueryParamsSchema = z.object({ state: z .object({ + clientId: z.string(), tenantId: z.string(), slug: z.string(), description: z.string().optional(), @@ -15,8 +16,7 @@ const SettingsOAuthCallbackPageQueryParamsSchema = z.object({ }) .nullable() .catch(null), - tenant: z.string().catch(""), - admin_consent: z.string().catch("") + code: z.string().catch("") }); export const Route = createFileRoute( diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx index 90c0dfe97..09e5d6583 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx @@ -103,10 +103,20 @@ export const MicrosoftTeamsIntegrationForm = ({ id, onClose }: Props) => { tenantId, slug, description, - csrfToken + csrfToken, + clientId: microsoftTeamsClientId.clientId }; - const url = `https://login.microsoftonline.com/${tenantId}/adminconsent?client_id=${microsoftTeamsClientId.clientId}&redirect_uri=${state.redirectUri}&state=${encodeURIComponent(JSON.stringify(state))}`; + const url = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize? + client_id=${microsoftTeamsClientId.clientId} + &redirect_uri=${state.redirectUri} + &response_type=code + &response_mode=query + &scope=https://graph.microsoft.com/.default + &state=${encodeURIComponent(JSON.stringify(state))} + &prompt=consent + &admin_consent=true`; + window.location.href = url; } };