fix(msteams): validate oauth token lifetimes

This commit is contained in:
Peter Steinberger
2026-05-29 12:01:59 -04:00
parent 64e6ea0727
commit 7a750100c9
2 changed files with 60 additions and 3 deletions

View File

@@ -247,6 +247,25 @@ describe("exchangeMSTeamsCodeForTokens", () => {
}),
).rejects.toThrow("MSTeams token exchange failed: malformed JSON response");
});
it("rejects unsafe token exchange expiry values", async () => {
fetchSpy.mockResolvedValueOnce(
new Response('{"access_token":"at-unsafe","refresh_token":"rt-unsafe","expires_in":1e309}', {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
await expect(
exchangeMSTeamsCodeForTokens({
tenantId: "t",
clientId: "c",
clientSecret: "s", // pragma: allowlist secret
code: "unsafe-expiry",
verifier: "v",
}),
).rejects.toThrow("MSTeams token exchange failed: invalid token response fields");
});
});
describe("refreshMSTeamsDelegatedTokens", () => {

View File

@@ -1,3 +1,4 @@
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { createMSTeamsHttpError } from "./http-error.js";
@@ -15,7 +16,7 @@ const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
type MSTeamsTokenResponse = {
access_token: string;
refresh_token?: string;
expires_in: number;
expiresAt: number;
scope?: string;
};
@@ -40,6 +41,42 @@ function createMSTeamsTokenBody(params: {
return body;
}
function resolveMSTeamsTokenExpiresAt(value: unknown): number | undefined {
const expiresInSeconds = parseStrictPositiveInteger(value);
if (expiresInSeconds === undefined) {
return undefined;
}
const lifetimeMs = expiresInSeconds * 1000;
const expiresAt = Date.now() + lifetimeMs - EXPIRY_BUFFER_MS;
return Number.isSafeInteger(lifetimeMs) && Number.isSafeInteger(expiresAt)
? expiresAt
: undefined;
}
function parseMSTeamsTokenResponse(
data: Record<string, unknown>,
failureLabel: string,
): MSTeamsTokenResponse {
const expiresAt = resolveMSTeamsTokenExpiresAt(data.expires_in);
if (
typeof data.access_token !== "string" ||
!data.access_token ||
expiresAt === undefined ||
(data.refresh_token !== undefined && typeof data.refresh_token !== "string") ||
(data.scope !== undefined && typeof data.scope !== "string")
) {
throw new Error(`MSTeams ${failureLabel} failed: invalid token response fields`);
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
expiresAt,
scope: data.scope,
};
}
async function fetchMSTeamsTokens(params: {
tokenUrl: string;
body: URLSearchParams;
@@ -66,10 +103,11 @@ async function fetchMSTeamsTokens(params: {
if (!response.ok) {
throw await createMSTeamsHttpError(response, `MSTeams ${params.failureLabel} failed`);
}
return await readProviderJsonResponse<MSTeamsTokenResponse>(
const data = await readProviderJsonResponse<Record<string, unknown>>(
response,
`MSTeams ${params.failureLabel} failed`,
);
return parseMSTeamsTokenResponse(data, params.failureLabel);
} finally {
await release();
}
@@ -104,7 +142,7 @@ async function requestMSTeamsDelegatedTokens(params: {
return {
accessToken: data.access_token,
refreshToken: params.resolveRefreshToken(data),
expiresAt: Date.now() + data.expires_in * 1000 - EXPIRY_BUFFER_MS,
expiresAt: data.expiresAt,
scopes: data.scope ? data.scope.split(" ") : [...scopes],
};
}