fix: improve auth step to avoid takeovers

This commit is contained in:
Daniel Hougaard
2025-05-01 04:08:58 +04:00
parent aedc6e16ad
commit e08c5f265e
10 changed files with 109 additions and 53 deletions
@@ -47,7 +47,7 @@ export async function up(knex: Knex): Promise<void> {
table.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).onDelete("CASCADE"); // the ID itself is the workflow integration ID table.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).onDelete("CASCADE"); // the ID itself is the workflow integration ID
table.string("internalTeamsAppId").nullable(); table.string("internalTeamsAppId").nullable();
table.string("tenantId").unique().notNullable(); table.string("tenantId").notNullable();
table.binary("encryptedAccessToken").nullable(); table.binary("encryptedAccessToken").nullable();
table.binary("encryptedBotAccessToken").nullable(); table.binary("encryptedBotAccessToken").nullable();
@@ -61,7 +61,8 @@ export const registerMicrosoftTeamsRouter = async (server: FastifyZodProvider) =
redirectUri: z.string(), redirectUri: z.string(),
tenantId: z.string().uuid(), tenantId: z.string().uuid(),
slug: z.string(), 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, slug: req.body.slug,
description: req.body.description, description: req.body.description,
redirectUri: req.body.redirectUri, redirectUri: req.body.redirectUri,
code: req.body.code,
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
@@ -16,48 +16,77 @@ import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow
import { WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; import { WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types";
import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal";
const ConsentError = "AADSTS65001";
export const verifyTenantFromCode = async ( export const verifyTenantFromCode = async (
tenantId: string, tenantId: string,
code: string,
redirectUri: string, redirectUri: string,
clientId: string, clientId: string,
clientSecret: string clientSecret: string
) => { ) => {
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; const getAccessToken = async (params: URLSearchParams) => {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://graph.microsoft.com/.default",
redirect_uri: redirectUri,
grant_type: "client_credentials"
});
const response = await axios const response = await axios
.post<{ access_token: string }>(tokenEndpoint, params, { .post<{ access_token: string }>(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, params, {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded" "Content-Type": "application/x-www-form-urlencoded"
} }
}) })
.catch((err) => { .catch((err) => {
if (axios.isAxiosError(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"); logger.error(err.response?.data, "Error fetching Microsoft Teams access token");
} }
throw err; throw err;
}); });
const accessToken = response.data.access_token; return response.data.access_token;
const decodedToken = jwt.decode(accessToken) as { tid: string }; };
// the 'tid' claim in the token contains the tenant ID // Azure App-based auth
const tenantIdFromToken = decodedToken.tid; 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"
})
);
if (tenantIdFromToken !== tenantId) { // 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
})
);
// Verify application token
const { tid: tenantIdFromApplicationAccessToken } = jwt.decode(applicationAccessToken) as { tid: string };
if (tenantIdFromApplicationAccessToken !== tenantId) {
throw new BadRequestError({ 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 ( export const getMicrosoftTeamsAccessToken = async (
@@ -211,6 +211,7 @@ export const microsoftTeamsServiceFactory = ({
}; };
const completeMicrosoftTeamsIntegration = async ({ const completeMicrosoftTeamsIntegration = async ({
code,
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
@@ -251,7 +252,7 @@ export const microsoftTeamsServiceFactory = ({
const botAppPassword = decryptWithRoot(encryptedMicrosoftTeamsClientSecret); const botAppPassword = decryptWithRoot(encryptedMicrosoftTeamsClientSecret);
const botId = decryptWithRoot(encryptedMicrosoftTeamsBotId); 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) => { await workflowIntegrationDAL.transaction(async (tx) => {
const workflowIntegration = await workflowIntegrationDAL.create( const workflowIntegration = await workflowIntegrationDAL.create(
@@ -10,6 +10,7 @@ export type TCreateMicrosoftTeamsIntegrationDTO = Omit<TOrgPermission, "orgId">
slug: string; slug: string;
redirectUri: string; redirectUri: string;
description?: string; description?: string;
code: string;
}; };
export type TCheckInstallationStatusDTO = { workflowIntegrationId: string } & Omit<TOrgPermission, "orgId">; export type TCheckInstallationStatusDTO = { workflowIntegrationId: string } & Omit<TOrgPermission, "orgId">;
@@ -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) ![microsoft-teams-configure-bot](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-configure-bot.png)
</Step> </Step>
<Step title="Run an app validation test"> <Step title="Run an app validation test (Recommended)">
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. 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) ![microsoft-teams-app-validation-test](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation.png)
<Note>
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.
</Note>
![microsoft-teams-app-validation-test-results](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png) ![microsoft-teams-app-validation-test-results](/images/platform/workflow-integrations/microsoft-teams-integration/teams-dev-portal-app-validation-result.png)
<Note> <Note>
@@ -59,6 +59,7 @@ export type TUpdateMicrosoftTeamsIntegrationDTO = {
}; };
export type TCreateMicrosoftTeamsIntegrationDTO = { export type TCreateMicrosoftTeamsIntegrationDTO = {
code: string;
tenantId: string; tenantId: string;
slug: string; slug: string;
description?: string; description?: string;
@@ -1,3 +1,5 @@
import crypto from "crypto";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearch } from "@tanstack/react-router"; import { useNavigate, useSearch } from "@tanstack/react-router";
import { z } from "zod"; import { z } from "zod";
@@ -13,7 +15,8 @@ const stateSchema = z.object({
tenantId: z.string(), tenantId: z.string(),
slug: z.string(), slug: z.string(),
description: z.string().optional(), description: z.string().optional(),
csrfToken: z.string() csrfToken: z.string(),
clientId: z.string()
}); });
export const OAuthCallbackPage = () => { export const OAuthCallbackPage = () => {
@@ -27,7 +30,9 @@ export const OAuthCallbackPage = () => {
const createMicrosoftTeamsWorkflowIntegration = useCreateMicrosoftTeamsIntegration(); 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); const state = stateSchema.parse(rawState);
@@ -42,21 +47,29 @@ export const OAuthCallbackPage = () => {
const handleMicrosoftTeams = useCallback(async () => { const handleMicrosoftTeams = useCallback(async () => {
clearState(); clearState();
if (Boolean(adminConsent.toLowerCase()) !== true) { if (!code) {
throw new Error("Failed to grant admin consent"); throw new Error("No code provided");
}
if (stateTenant !== state.tenantId) {
throw new Error(`Invalid tenant ID. Expected ${stateTenant}, got ${state.tenantId}`);
} }
await createMicrosoftTeamsWorkflowIntegration.mutateAsync({ await createMicrosoftTeamsWorkflowIntegration.mutateAsync({
orgId: currentOrg.id, orgId: currentOrg.id,
tenantId: state.tenantId, tenantId: state.tenantId,
code,
slug: state.slug, slug: state.slug,
description: state.description ?? "", description: state.description ?? "",
redirectUri: state.redirectUri 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 // Ensure that the localstorage is ready for use, to avoid the form data being malformed
@@ -72,22 +85,16 @@ export const OAuthCallbackPage = () => {
(async () => { (async () => {
try { try {
await handleMicrosoftTeams(); await handleMicrosoftTeams();
createNotification({
text: "Successfully granted Microsoft Teams admin consent",
type: "success"
});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
createNotification({ 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" type: "error"
}); });
} }
navigate({
to: ROUTE_PATHS.Organization.SettingsPage.path
});
})(); })();
}, [isReady]); }, [isReady]);
@@ -7,6 +7,7 @@ import { OAuthCallbackPage } from "./OauthCallbackPage";
const SettingsOAuthCallbackPageQueryParamsSchema = z.object({ const SettingsOAuthCallbackPageQueryParamsSchema = z.object({
state: z state: z
.object({ .object({
clientId: z.string(),
tenantId: z.string(), tenantId: z.string(),
slug: z.string(), slug: z.string(),
description: z.string().optional(), description: z.string().optional(),
@@ -15,8 +16,7 @@ const SettingsOAuthCallbackPageQueryParamsSchema = z.object({
}) })
.nullable() .nullable()
.catch(null), .catch(null),
tenant: z.string().catch(""), code: z.string().catch("")
admin_consent: z.string().catch("")
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -103,10 +103,20 @@ export const MicrosoftTeamsIntegrationForm = ({ id, onClose }: Props) => {
tenantId, tenantId,
slug, slug,
description, 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; window.location.href = url;
} }
}; };