diff --git a/extensions/msteams/src/oauth.test.ts b/extensions/msteams/src/oauth.test.ts index ea33b5dc391..7b8e02cce6d 100644 --- a/extensions/msteams/src/oauth.test.ts +++ b/extensions/msteams/src/oauth.test.ts @@ -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", () => { diff --git a/extensions/msteams/src/oauth.token.ts b/extensions/msteams/src/oauth.token.ts index 9ffcea7d4bf..073e7f3360f 100644 --- a/extensions/msteams/src/oauth.token.ts +++ b/extensions/msteams/src/oauth.token.ts @@ -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, + 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( + const data = await readProviderJsonResponse>( 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], }; }