From a72f1022593cf75d014ca2a29b4d645a5e3d822e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 15:45:41 +0100 Subject: [PATCH] refactor: share msteams token request flow --- extensions/msteams/src/oauth.token.ts | 235 +++++++++++++------------- 1 file changed, 122 insertions(+), 113 deletions(-) diff --git a/extensions/msteams/src/oauth.token.ts b/extensions/msteams/src/oauth.token.ts index 4f38ebb3f89..2ab0bd3ab81 100644 --- a/extensions/msteams/src/oauth.token.ts +++ b/extensions/msteams/src/oauth.token.ts @@ -10,6 +10,101 @@ import { /** Five-minute buffer subtracted from token expiry to avoid edge-case clock drift. */ const EXPIRY_BUFFER_MS = 5 * 60 * 1000; +type MSTeamsTokenResponse = { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; +}; + +function createMSTeamsTokenBody(params: { + clientId: string; + clientSecret: string; + grantType: string; + scopes: readonly string[]; + values?: Record; +}): URLSearchParams { + const body = new URLSearchParams({ + client_id: params.clientId, + client_secret: params.clientSecret, + grant_type: params.grantType, + scope: [...params.scopes].join(" "), + }); + + for (const [key, value] of Object.entries(params.values ?? {})) { + body.set(key, value); + } + + return body; +} + +async function fetchMSTeamsTokens(params: { + tokenUrl: string; + body: URLSearchParams; + auditContext: string; + failureLabel: string; +}): Promise { + const currentFetch = globalThis.fetch; + const { response, release } = await fetchWithSsrFGuard({ + url: params.tokenUrl, + fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), + init: { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: "application/json", + }, + body: params.body, + signal: AbortSignal.timeout(MSTEAMS_DEFAULT_TOKEN_FETCH_TIMEOUT_MS), + }, + auditContext: params.auditContext, + }); + + try { + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`MSTeams ${params.failureLabel} failed (${response.status}): ${errorText}`); + } + return (await response.json()) as MSTeamsTokenResponse; + } finally { + await release(); + } +} + +async function requestMSTeamsDelegatedTokens(params: { + tenantId: string; + clientId: string; + clientSecret: string; + scopes?: readonly string[]; + grantType: string; + values: Record; + auditContext: string; + failureLabel: string; + resolveRefreshToken: (data: MSTeamsTokenResponse) => string; +}): Promise { + const scopes = params.scopes ?? MSTEAMS_DEFAULT_DELEGATED_SCOPES; + const body = createMSTeamsTokenBody({ + clientId: params.clientId, + clientSecret: params.clientSecret, + grantType: params.grantType, + scopes, + values: params.values, + }); + const data = await fetchMSTeamsTokens({ + tokenUrl: buildMSTeamsTokenEndpoint(params.tenantId), + body, + auditContext: params.auditContext, + failureLabel: params.failureLabel, + }); + + return { + accessToken: data.access_token, + refreshToken: params.resolveRefreshToken(data), + expiresAt: Date.now() + data.expires_in * 1000 - EXPIRY_BUFFER_MS, + scopes: data.scope ? data.scope.split(" ") : [...scopes], + }; +} + export async function exchangeMSTeamsCodeForTokens(params: { tenantId: string; clientId: string; @@ -18,68 +113,26 @@ export async function exchangeMSTeamsCodeForTokens(params: { verifier: string; scopes?: readonly string[]; }): Promise { - const scopes = params.scopes ?? MSTEAMS_DEFAULT_DELEGATED_SCOPES; - const tokenUrl = buildMSTeamsTokenEndpoint(params.tenantId); - - const body = new URLSearchParams({ - client_id: params.clientId, - client_secret: params.clientSecret, - code: params.code, - grant_type: "authorization_code", - redirect_uri: MSTEAMS_OAUTH_REDIRECT_URI, - code_verifier: params.verifier, - scope: [...scopes].join(" "), - }); - - const currentFetch = globalThis.fetch; - const { response, release } = await fetchWithSsrFGuard({ - url: tokenUrl, - fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), - init: { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "application/json", - }, - body, - signal: AbortSignal.timeout(MSTEAMS_DEFAULT_TOKEN_FETCH_TIMEOUT_MS), + return await requestMSTeamsDelegatedTokens({ + tenantId: params.tenantId, + clientId: params.clientId, + clientSecret: params.clientSecret, + grantType: "authorization_code", + scopes: params.scopes, + values: { + code: params.code, + redirect_uri: MSTEAMS_OAUTH_REDIRECT_URI, + code_verifier: params.verifier, }, auditContext: "msteams-oauth-token-exchange", + failureLabel: "token exchange", + resolveRefreshToken: (data) => { + if (!data.refresh_token) { + throw new Error("No refresh token received from Azure AD. Please try again."); + } + return data.refresh_token; + }, }); - - let data: { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; - }; - try { - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`MSTeams token exchange failed (${response.status}): ${errorText}`); - } - data = (await response.json()) as { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; - }; - } finally { - await release(); - } - - if (!data.refresh_token) { - throw new Error("No refresh token received from Azure AD. Please try again."); - } - - const expiresAt = Date.now() + data.expires_in * 1000 - EXPIRY_BUFFER_MS; - - return { - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresAt, - scopes: data.scope ? data.scope.split(" ") : [...scopes], - }; } export async function refreshMSTeamsDelegatedTokens(params: { @@ -89,61 +142,17 @@ export async function refreshMSTeamsDelegatedTokens(params: { refreshToken: string; scopes?: readonly string[]; }): Promise { - const scopes = params.scopes ?? MSTEAMS_DEFAULT_DELEGATED_SCOPES; - const tokenUrl = buildMSTeamsTokenEndpoint(params.tenantId); - - const body = new URLSearchParams({ - client_id: params.clientId, - client_secret: params.clientSecret, - grant_type: "refresh_token", - refresh_token: params.refreshToken, - scope: [...scopes].join(" "), - }); - - const currentFetch = globalThis.fetch; - const { response, release } = await fetchWithSsrFGuard({ - url: tokenUrl, - fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), - init: { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", - Accept: "application/json", - }, - body, - signal: AbortSignal.timeout(MSTEAMS_DEFAULT_TOKEN_FETCH_TIMEOUT_MS), + return await requestMSTeamsDelegatedTokens({ + tenantId: params.tenantId, + clientId: params.clientId, + clientSecret: params.clientSecret, + grantType: "refresh_token", + scopes: params.scopes, + values: { + refresh_token: params.refreshToken, }, auditContext: "msteams-oauth-token-refresh", + failureLabel: "token refresh", + resolveRefreshToken: (data) => data.refresh_token ?? params.refreshToken, }); - - let data: { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; - }; - try { - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`MSTeams token refresh failed (${response.status}): ${errorText}`); - } - data = (await response.json()) as { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; - }; - } finally { - await release(); - } - - const expiresAt = Date.now() + data.expires_in * 1000 - EXPIRY_BUFFER_MS; - - // Azure may not return a new refresh token on refresh; keep the old one - return { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? params.refreshToken, - expiresAt, - scopes: data.scope ? data.scope.split(" ") : [...scopes], - }; }