using System.Net; using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Security.Cryptography.X509Certificates; using System.Text.Json; using Duplicati.Library.Interface; using Duplicati.Library.Logging; using Duplicati.Library.Utility; using Duplicati.Library.Utility.Options; using Jose; using NetUri = System.Uri; namespace Duplicati.Proprietary.Office365; /// /// Implements the API helper class for Microsoft Graph API access /// internal class APIHelper : IDisposable { /// /// The log tag for this class /// private static readonly string LOGTAG = Log.LogTagFromType(); /// /// The default page size for API requests /// internal const int GENERAL_PAGE_SIZE = 100; /// /// The default page size for chat API requests /// internal const int CHATS_PAGE_SIZE = 50; /// /// The HTTP client used for API requests /// private readonly HttpClient _httpClient; /// /// The cached Graph access token /// private string? _graphAccessToken; /// /// The authentication values /// private readonly AuthOptionsHelper.AuthOptions _authOptions; /// /// The tenant ID for the API requests /// private readonly string _tenantId; /// /// The base URL for Graph API requests /// private readonly string _graphBaseUrl; /// /// The base URL for Graph API requests /// public string GraphBaseUrl => _graphBaseUrl; /// /// The path to the certificate file /// private readonly string? _certificatePath; /// /// The password for the certificate file /// private readonly string? _certificatePassword; /// /// The scope for the API requests /// private readonly string _scope; /// /// The timeout options for the backend /// private readonly TimeoutOptionsHelper.Timeouts _timeouts; /// /// Initializes a new instance of the class. /// /// The HTTP client to use for API requests /// The authentication options /// The tenant ID /// The base URL for Graph API requests /// The timeout options /// The path to the certificate file /// The password for the certificate file /// The scope for the API requests private APIHelper(HttpClient httpClient, AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath, string? certificatePassword, string scope) { _httpClient = httpClient; _authOptions = authOptions; _tenantId = tenantId; _graphBaseUrl = graphBaseUrl; _timeouts = timeouts; _certificatePath = certificatePath; _certificatePassword = certificatePassword; _scope = scope; } /// /// Creates a new instance of the class. /// /// The authentication options /// The tenant ID /// The base URL for Graph API requests /// The timeout options /// The path to the certificate file /// The password for the certificate file /// The scope for the API requests /// A new instance of the class public static APIHelper Create(AuthOptionsHelper.AuthOptions authOptions, string tenantId, string graphBaseUrl, TimeoutOptionsHelper.Timeouts timeouts, string? certificatePath = null, string? certificatePassword = null, string scope = "https://graph.microsoft.com/.default") { var handler = new HttpClientHandler { UseCookies = false }; var httpClient = HttpClientHelper.CreateClient(handler); httpClient.Timeout = Timeout.InfiniteTimeSpan; httpClient.DefaultRequestHeaders.Add("User-Agent", $"Duplicati/{System.Reflection.Assembly.GetExecutingAssembly().GetName().Version}"); return new APIHelper(httpClient, authOptions, tenantId, graphBaseUrl, timeouts, certificatePath, certificatePassword, scope); } /// /// Gets the authentication header for the API requests. /// /// Whether to refresh the access token /// The cancellation token /// The authentication header public async Task GetAuthenticationHeaderAsync(bool refresh, CancellationToken cancellationToken) { var accessToken = await AcquireAccessTokenAsync(refresh, cancellationToken).ConfigureAwait(false); return new AuthenticationHeaderValue("Bearer", accessToken); } /// /// Acquires an access token for the API requests. /// /// Whether to refresh the access token /// The cancellation token /// The access token public async Task AcquireAccessTokenAsync(bool refresh, CancellationToken cancellationToken) { if (!refresh && !string.IsNullOrWhiteSpace(_graphAccessToken)) return _graphAccessToken; var tokenEndpoint = new NetUri($"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token"); var parameters = new Dictionary { ["grant_type"] = "client_credentials", ["client_id"] = _authOptions.Username!, ["scope"] = _scope }; if (!string.IsNullOrWhiteSpace(_certificatePath)) { if (!File.Exists(_certificatePath)) throw new UserInformationException($"Certificate file not found: {_certificatePath}", "CertificateFileNotFound"); try { var cert = X509CertificateLoader.LoadPkcs12FromFile(_certificatePath, _certificatePassword); var rsa = cert.GetRSAPrivateKey(); if (rsa == null) throw new UserInformationException("Certificate does not contain a private key", "CertificateNoPrivateKey"); var payload = new Dictionary { { "aud", tokenEndpoint.ToString() }, { "exp", DateTimeOffset.UtcNow.AddMinutes(10).ToUnixTimeSeconds() }, { "iss", _authOptions.Username! }, { "jti", Guid.NewGuid().ToString() }, { "nbf", DateTimeOffset.UtcNow.AddMinutes(-1).ToUnixTimeSeconds() }, { "sub", _authOptions.Username! } }; var extraHeaders = new Dictionary { { "x5t", Convert.ToBase64String(cert.GetCertHash()) } }; var clientAssertion = JWT.Encode(payload, rsa, JwsAlgorithm.RS256, extraHeaders); parameters["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; parameters["client_assertion"] = clientAssertion; } catch (Exception ex) { throw new UserInformationException($"Failed to load certificate: {ex.Message}", "CertificateLoadFailed", ex); } } else { parameters["client_secret"] = _authOptions.Password!; } using var content = new FormUrlEncodedContent(parameters); using var client = HttpClientHelper.CreateClient(); using var response = await client.PostAsync(tokenEndpoint, content, cancellationToken).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(response, cancellationToken).ConfigureAwait(false); var token = await ParseResponseJson(response, cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(token?.AccessToken)) throw new UserInformationException(Strings.MissingAccessToken, "MissingAccessTokenInResponse"); return token.AccessToken; } /// /// Parses the response JSON. /// /// The type to deserialize to /// The HTTP response message /// The cancellation token /// The deserialized object public static async Task ParseResponseJson(HttpResponseMessage response, CancellationToken cancellationToken) { using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var reader = new StreamReader(stream); var content = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); try { return JsonSerializer.Deserialize(content) ?? throw new JsonException("Deserialized object is null"); } catch (JsonException ex) { throw new UserInformationException($"Failed to parse response JSON: {ex.Message}, JSON: {content}", "FailedToParseResponseJson", ex); } } /// /// Ensures that the response from the Office API is successful. /// /// The HTTP response message /// The cancellation token /// An awaitable task public static async Task EnsureOfficeApiSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) { if (response.IsSuccessStatusCode) return; string? responseBody = null; try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(1)); responseBody = response.Content == null ? null : await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false); } catch { // Ignore failures when reading the response body; we'll throw with status code regardless. } var message = TryExtractOfficeApiErrorMessage(responseBody) ?? responseBody ?? response.ReasonPhrase ?? "Request failed."; // Ensure the status code is both in the message (human readable) and on the exception (machine readable). var statusCode = response.StatusCode; var detailedMessage = $"{message} (HTTP {(int)statusCode} {statusCode}) - Request URI: {response.RequestMessage?.RequestUri}"; if (response?.Headers?.TryGetValues("Location", out var location) == true) detailedMessage += $" - Location: {location.First()}"; throw new HttpRequestException(detailedMessage, inner: null, statusCode); } /// /// Attempts to extract the error message from the Office API response. /// /// The response body /// The extracted error message, or null if none could be found private static string? TryExtractOfficeApiErrorMessage(string? responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) return null; // Fast filter: if it doesn't look like JSON, don't attempt to parse. var trimmed = responseBody.AsSpan().TrimStart(); if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '[')) return null; try { using var doc = JsonDocument.Parse(responseBody); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; if (doc.RootElement.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object) { if (error.TryGetProperty("message", out var messageElement) && messageElement.ValueKind == JsonValueKind.String) { var message = messageElement.GetString(); return string.IsNullOrWhiteSpace(message) ? null : message; } } } catch (JsonException) { // Not JSON (or invalid JSON) - fall back to entire response body. } return null; } /// /// Gets a single page of Graph API results. /// /// The type of items in the page /// The URL to fetch the page from /// A callback to determine if a request should be retried /// The cancellation token /// >The page of results public async Task?> GetGraphPageAsync(string url, Func? shouldRetry, CancellationToken ct) { async Task requestFactory(CancellationToken ct) { var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, ct).ConfigureAwait(false); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, _timeouts.ListTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); return await ParseResponseJson>(resp, ct).ConfigureAwait(false) ?? new GraphPage(); } /// /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable. /// /// The type of items in the page /// The initial URL to fetch results from /// The cancellation token /// An asynchronous enumerable of all items public IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, CancellationToken ct) => GetAllGraphItemsAsync(initialUrl, null, ct); /// /// Gets all Graph API results from a paged endpoint as an asynchronous enumerable. /// /// The type of items in the page /// The initial URL to fetch results from /// A callback to determine if a request should be retried /// The cancellation token /// An asynchronous enumerable of all items public async IAsyncEnumerable GetAllGraphItemsAsync(string initialUrl, Func? shouldRetry, [EnumeratorCancellation] CancellationToken ct) { var next = initialUrl; while (!string.IsNullOrWhiteSpace(next)) { ct.ThrowIfCancellationRequested(); var page = await GetGraphPageAsync(next, shouldRetry, ct).ConfigureAwait(false); // If page is null, the shouldRetry callback indicated we should treat this as empty results // (e.g., 503 when OneDrive is not provisioned for a user) if (page == null) yield break; foreach (var item in page.Value) yield return item; next = page.NextLink; } } /// /// Gets a single Graph API item. /// /// The graph item to get /// The URL to fetch /// The cancellation token /// >The graph item public async Task GetGraphItemAsync(string url, CancellationToken ct) { var select = GraphSelectBuilder.BuildSelect(); async Task requestFactory(CancellationToken cancellationToken) { var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); return await ParseResponseJson(resp, ct).ConfigureAwait(false) ?? throw new UserInformationException("Failed to parse Graph item response.", nameof(SourceProvider)); } /// /// Posts a Graph API item. /// /// The type of the item to return /// The URL to post to /// The stream to post /// The cancellation token /// The posted item public async Task PostGraphItemStreamAsync(string url, Stream stream, string contentType, CancellationToken ct) { async Task requestFactory(CancellationToken cancellationToken) { var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); stream.Position = 0; req.Content = new StreamContent(stream); req.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); return await ParseResponseJson(resp, ct).ConfigureAwait(false); } /// /// Posts a Graph API item. /// /// The type of the item to return /// The URL to post to /// The content to post /// The cancellation token /// The posted item public async Task PostGraphItemAsync(string url, HttpContent content, CancellationToken ct) { #if DEBUG if (content is StreamContent) throw new InvalidOperationException("Use PostGraphItemStreamAsync for stream content."); #endif async Task requestFactory(CancellationToken cancellationToken) { var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Content = content; return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); return await ParseResponseJson(resp, ct).ConfigureAwait(false); } /// /// Posts a Graph API item and ignores the response body. /// /// The URL to post to /// The content to post /// The cancellation token /// An awaitable task public async Task PostGraphItemNoResponseAsync(string url, HttpContent content, CancellationToken ct) { async Task requestFactory(CancellationToken cancellationToken) { var req = new HttpRequestMessage(HttpMethod.Post, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Content = content; return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); } /// /// Patches a Graph API item. /// /// The URL to patch /// The content to patch /// The cancellation token /// An awaitable task public async Task PatchGraphItemAsync(string url, HttpContent content, CancellationToken ct) { async Task requestFactory(CancellationToken cancellationToken) { var req = new HttpRequestMessage(HttpMethod.Patch, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, cancellationToken); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); req.Content = content; return req; } using var resp = await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); } /// /// Returns the response from the Graph API as a stream. /// This method will buffer the response in memory. /// /// The URL to fetch /// The Accept header value /// The Prefer header value /// The cancellation token /// The response stream public Task GetGraphItemAsStreamAsync(string url, string accept, string prefer, CancellationToken ct) => GetGraphItemAsStreamAsync(url, accept, prefer, null, ct); /// /// Returns the response from the Graph API as a stream. /// This method will buffer the response in memory. /// /// The URL to fetch /// The Accept header value /// The cancellation token /// The response stream public Task GetGraphItemAsStreamAsync(string url, string accept, CancellationToken ct) => GetGraphItemAsStreamAsync(url, accept, null, null, ct); /// /// Returns the response from the Graph API as a stream. /// This method will buffer the response in memory. /// /// The URL to fetch /// The Accept header value /// The Prefer header value /// A callback to determine if a request should be retried /// The cancellation token /// The response stream private async Task GetGraphItemAsStreamAsync( string url, string accept, string? prefer, Func? shouldRetry, CancellationToken ct) { async Task requestFactory(CancellationToken rct) { var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); if (!string.IsNullOrWhiteSpace(prefer)) req.Headers.TryAddWithoutValidation("Prefer", prefer); return req; } using var resp = await SendWithRetryAsync( requestFactory, HttpCompletionOption.ResponseHeadersRead, shouldRetry, _timeouts.ShortTimeout, ct).ConfigureAwait(false); if (resp.StatusCode == HttpStatusCode.NotFound) return Stream.Null; await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); // Return a stream the caller can own safely after HttpResponseMessage disposal. var ms = new MemoryStream(); using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); using var timeoutStream = stream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); await timeoutStream.CopyToAsync(ms, ct).ConfigureAwait(false); ms.Position = 0; return ms; } /// /// Returns the response from the Graph API as a stream. /// /// The URL to fetch /// The Accept header value /// The cancellation token /// The response stream public async Task GetGraphResponseAsRealStreamAsync(string url, string accept, CancellationToken ct) { async Task requestFactory(CancellationToken rct) { var req = new HttpRequestMessage(HttpMethod.Get, new NetUri(url)); req.Headers.Authorization = await GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false); req.Headers.Accept.Clear(); req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(accept)); return req; } using var resp = await SendWithRetryAsync( requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); if (resp.Content.Headers.ContentLength == 0) return Stream.Null; // Return a stream the caller can own safely after HttpResponseMessage disposal. using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); using var timeoutStream = stream.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); // The caller expects a FileStream-like stream, so we need to buffer to a temp file. // We could wrap this in a stream that exposes the Length property, which would avoid the temp file. var tempStream = TempFileStream.Create(); await timeoutStream.CopyToAsync(tempStream, ct).ConfigureAwait(false); tempStream.Position = 0; // Return the stream, will delete on dispose. return tempStream; } /// /// Gets the HTTP client, acquiring an access token if needed. /// /// The cancellation token /// The HTTP client private async Task GetHttpClientAsync(CancellationToken ct) { if (string.IsNullOrWhiteSpace(_graphAccessToken)) _graphAccessToken = await AcquireAccessTokenAsync(false, ct).ConfigureAwait(false); return _httpClient; } /// /// Sends an HTTP request with retry logic for transient failures, using short timeouts. /// /// The factory to create the HTTP request /// The cancellation token /// >The HTTP response message public async Task SendWithRetryShortAsync( Func> requestFactory, CancellationToken ct ) => await SendWithRetryAsync(requestFactory, HttpCompletionOption.ResponseHeadersRead, null, _timeouts.ShortTimeout, ct).ConfigureAwait(false); /// /// Sends an HTTP request with retry logic for transient failures. /// /// The factory to create the HTTP request /// The HTTP completion option /// A callback to determine if a request should be retried /// The timeout for the request /// The cancellation token /// >The HTTP response message public async Task SendWithRetryAsync( Func> requestFactory, HttpCompletionOption completionOption, Func? shouldRetry, TimeSpan? timeout, CancellationToken ct ) { int maxRetries = 4; var attempt = 0; var isReAuthAttempt = false; var client = await GetHttpClientAsync(ct).ConfigureAwait(false); while (true) { ct.ThrowIfCancellationRequested(); HttpResponseMessage? resp = null; try { using var request = await requestFactory(ct).ConfigureAwait(false); if (timeout.HasValue) { resp = await Utility.WithTimeout(timeout.Value, ct, c => client.SendAsync(request, completionOption, c)).ConfigureAwait(false); } else { resp = await client.SendAsync(request, completionOption, ct).ConfigureAwait(false); } } catch (TimeoutException) { // Ignore and retry } if (resp != null) { if (resp.IsSuccessStatusCode) return resp; if (shouldRetry?.Invoke(resp) == false) return resp; // Retryable status codes: // 429: TooManyRequests // 503: ServiceUnavailable // 502: BadGateway // 504: GatewayTimeout switch (resp.StatusCode) { case HttpStatusCode.TooManyRequests: case HttpStatusCode.ServiceUnavailable: case HttpStatusCode.BadGateway: case HttpStatusCode.GatewayTimeout: break; case HttpStatusCode.Unauthorized: _graphAccessToken = null; // force re-auth if (isReAuthAttempt) return resp; // already tried re-auth isReAuthAttempt = true; break; default: return resp; } } attempt++; if (attempt > maxRetries) { if (resp != null) return resp; // let EnsureOfficeApiSuccessAsync throw with details throw new TimeoutException($"Request timed out after {maxRetries} attempts"); } if (resp != null) Log.WriteRetryMessage(LOGTAG, "Office365APIRetry", null, $"Request failed with status code {(int)resp.StatusCode} {resp.StatusCode}. Retrying attempt {attempt} of {maxRetries}."); else Log.WriteRetryMessage(LOGTAG, "Office365APITimeout", null, $"Request timed out. Retrying attempt {attempt} of {maxRetries}."); // Respect Retry-After if present, else exponential backoff. TimeSpan delay; if (resp?.Headers.RetryAfter?.Delta is TimeSpan ra) { delay = ra; } else { var baseSeconds = Math.Min(60, Math.Pow(2, attempt)); // cap at 60s var jitterMs = Random.Shared.Next(0, 500); delay = TimeSpan.FromSeconds(baseSeconds) + TimeSpan.FromMilliseconds(jitterMs); } resp?.Dispose(); await Task.Delay(delay, ct).ConfigureAwait(false); } } /// /// Uploads a stream to an upload session. /// /// The upload session URL /// The content stream /// The cancellation token /// An awaitable task public async Task UploadFileToSessionAsync(string uploadUrl, Stream contentStream, CancellationToken ct) { // 320 KiB is the required multiple for Graph API const int ChunkSize = 320 * 1024 * 10; // 3.2 MB chunks var buffer = new byte[ChunkSize]; long totalLength = contentStream.Length; long position = 0; while (position < totalLength) { var bytesRead = await contentStream.ReadAsync(buffer, 0, ChunkSize, ct).ConfigureAwait(false); if (bytesRead == 0) break; var rangeHeader = $"bytes {position}-{position + bytesRead - 1}/{totalLength}"; HttpRequestMessage requestFactory(CancellationToken rct) { var req = new HttpRequestMessage(HttpMethod.Put, new NetUri(uploadUrl)); var ms = new MemoryStream(buffer, 0, bytesRead); var timeoutStream = ms.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false); req.Content = new StreamContent(timeoutStream); req.Content.Headers.ContentLength = bytesRead; req.Content.Headers.ContentRange = ContentRangeHeaderValue.Parse(rangeHeader); return req; } using var resp = await SendWithRetryAsync( ct => Task.FromResult(requestFactory(ct)), HttpCompletionOption.ResponseHeadersRead, null, null, ct).ConfigureAwait(false); await EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false); position += bytesRead; } } /// public void Dispose() { _httpClient.Dispose(); _graphAccessToken = null; } }