fix: gate Teams media auth retries

This commit is contained in:
Peter Steinberger
2026-02-02 02:07:01 -08:00
parent f6d98a908a
commit 41cc5bcd4f
9 changed files with 115 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import {
isRecord,
isUrlAllowed,
normalizeContentType,
resolveAuthAllowedHosts,
resolveAllowedHosts,
} from "./shared.js";
@@ -85,6 +86,8 @@ async function fetchWithAuthFallback(params: {
url: string;
tokenProvider?: MSTeamsAccessTokenProvider;
fetchFn?: typeof fetch;
allowHosts: string[];
authAllowHosts: string[];
}): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch;
const firstAttempt = await fetchFn(params.url);
@@ -97,6 +100,9 @@ async function fetchWithAuthFallback(params: {
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
if (!isUrlAllowed(params.url, params.authAllowHosts)) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) {
@@ -104,10 +110,30 @@ async function fetchWithAuthFallback(params: {
const token = await params.tokenProvider.getAccessToken(scope);
const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${token}` },
redirect: "manual",
});
if (res.ok) {
return res;
}
const redirectUrl = readRedirectUrl(params.url, res);
if (redirectUrl && isUrlAllowed(redirectUrl, params.allowHosts)) {
const redirectRes = await fetchFn(redirectUrl);
if (redirectRes.ok) {
return redirectRes;
}
if (
(redirectRes.status === 401 || redirectRes.status === 403) &&
isUrlAllowed(redirectUrl, params.authAllowHosts)
) {
const redirectAuthRes = await fetchFn(redirectUrl, {
headers: { Authorization: `Bearer ${token}` },
redirect: "manual",
});
if (redirectAuthRes.ok) {
return redirectAuthRes;
}
}
}
} catch {
// Try the next scope.
}
@@ -116,6 +142,21 @@ async function fetchWithAuthFallback(params: {
return firstAttempt;
}
function readRedirectUrl(baseUrl: string, res: Response): string | null {
if (![301, 302, 303, 307, 308].includes(res.status)) {
return null;
}
const location = res.headers.get("location");
if (!location) {
return null;
}
try {
return new URL(location, baseUrl).toString();
} catch {
return null;
}
}
/**
* Download all file attachments from a Teams message (images, documents, etc.).
* Renamed from downloadMSTeamsImageAttachments to support all file types.
@@ -125,6 +166,7 @@ export async function downloadMSTeamsAttachments(params: {
maxBytes: number;
tokenProvider?: MSTeamsAccessTokenProvider;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
@@ -134,6 +176,7 @@ export async function downloadMSTeamsAttachments(params: {
return [];
}
const allowHosts = resolveAllowedHosts(params.allowHosts);
const authAllowHosts = resolveAuthAllowedHosts(params.authAllowHosts);
// Download ANY downloadable attachment (not just images)
const downloadable = list.filter(isDownloadableAttachment);
@@ -199,6 +242,8 @@ export async function downloadMSTeamsAttachments(params: {
url: candidate.url,
tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn,
allowHosts,
authAllowHosts,
});
if (!res.ok) {
continue;

View File

@@ -215,6 +215,7 @@ export async function downloadMSTeamsGraphMedia(params: {
tokenProvider?: MSTeamsAccessTokenProvider;
maxBytes: number;
allowHosts?: string[];
authAllowHosts?: string[];
fetchFn?: typeof fetch;
/** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean;
@@ -336,6 +337,7 @@ export async function downloadMSTeamsGraphMedia(params: {
maxBytes: params.maxBytes,
tokenProvider: params.tokenProvider,
allowHosts,
authAllowHosts: params.authAllowHosts,
fetchFn: params.fetchFn,
preserveFilenames: params.preserveFilenames,
});

View File

@@ -48,6 +48,15 @@ export const DEFAULT_MEDIA_HOST_ALLOWLIST = [
"microsoft.com",
] as const;
export const DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST = [
"api.botframework.com",
"botframework.com",
"graph.microsoft.com",
"graph.microsoft.us",
"graph.microsoft.de",
"graph.microsoft.cn",
] as const;
export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
export function isRecord(value: unknown): value is Record<string, unknown> {
@@ -250,6 +259,17 @@ export function resolveAllowedHosts(input?: string[]): string[] {
return normalized;
}
export function resolveAuthAllowedHosts(input?: string[]): string[] {
if (!Array.isArray(input) || input.length === 0) {
return DEFAULT_MEDIA_AUTH_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) {
return true;