refactor: share msteams token request flow

This commit is contained in:
Peter Steinberger
2026-04-20 15:45:41 +01:00
parent 629b5b034a
commit a72f102259

View File

@@ -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<string, string>;
}): 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<MSTeamsTokenResponse> {
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<string, string>;
auditContext: string;
failureLabel: string;
resolveRefreshToken: (data: MSTeamsTokenResponse) => string;
}): Promise<MSTeamsDelegatedTokens> {
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<MSTeamsDelegatedTokens> {
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<MSTeamsDelegatedTokens> {
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],
};
}