Hi @Giorgio Sfiligoi ,
Thank you for sharing your updated implementation. Seeing how you adapted PKCEOAuthFlow helps clarify where the flow is pausing. Here is what is likely happening on Windows, along with how to connect the pieces across both platforms and continue with folder listing and file reading.
Probable cause of the Windows freeze
In your ReceiveAsync method:
using var connection = await listener.AcceptTcpClientAsync(cancellationToken);
var stream = connection.GetStream();
MemoryStream memstream = new MemoryStream();
await stream.CopyToAsync(memstream, cancellationToken);
- CopyToAsync likely waits for end-of-stream. It keeps reading until the client closes its side of the connection. After sending the GET /authorize?code=... request, browsers such as Edge or Chrome usually keep the connection open while waiting for the server's HTTP response. Since the receiver waits for the stream to end and the browser waits for a response, the connection can hang indefinitely.
- No HTTP response is sent. The browser expects a status line such as HTTP/1.1 200 OK and response headers before it considers the page loaded.
- refused to connect" after closing the app. When the app stops, nothing is listening on that port any more, so the browser page that was still waiting fails.
There is also a smaller issue: after CopyToAsync, the position of memstream is at the end, so memstream.Read(...) would return no data unless the position is reset first.
Loopback receiver for Windows
This version reads only up to the end of the HTTP headers (\r\n\r\n), extracts the callback URI, and sends a short HTML response before closing the connection. It keeps your current method signature.
The receiver only checks the callback path. The state value is validated afterwards by ProcessCodeFlowAsync during the code exchange.
using System.Net;
using System.Net.Sockets;
using System.Text;
internal static class LoopbackOAuthReceiver
{
public static async Task<Uri> ReceiveAsync(
Uri redirectUri,
Uri authorizationUri,
Func<Uri, Task<bool>> openBrowserAsync,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var listener = new TcpListener(IPAddress.Loopback, redirectUri.Port)
{
ExclusiveAddressUse = true
};
try
{
listener.Start(4);
}
catch (SocketException ex)
{
throw new InvalidOperationException(
$"Cannot listen on port {redirectUri.Port}. Close any running instance and retry.", ex);
}
if (!await openBrowserAsync(authorizationUri).ConfigureAwait(false))
throw new InvalidOperationException("Failed to launch the system browser.");
while (true)
{
using var connection = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false);
using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
requestTimeout.CancelAfter(TimeSpan.FromSeconds(5));
var stream = connection.GetStream();
Uri? callback = null;
try
{
var buffer = new byte[8192];
var received = 0;
while (received < buffer.Length)
{
var count = await stream.ReadAsync(buffer.AsMemory(received), requestTimeout.Token).ConfigureAwait(false);
if (count == 0)
break;
received += count;
if (Encoding.ASCII.GetString(buffer, 0, received)
.Contains("\r\n\r\n", StringComparison.Ordinal))
break;
}
// Example request line: GET /authorize?code=...&state=... HTTP/1.1
var request = Encoding.ASCII.GetString(buffer, 0, received);
var firstLine = request.Split("\r\n", 2)[0].Split(' ');
if (firstLine.Length == 3 &&
firstLine[0] == "GET" &&
Uri.TryCreate(redirectUri, firstLine[1], out var candidate) &&
candidate.AbsolutePath == redirectUri.AbsolutePath)
{
callback = candidate;
}
var message = callback is null
? "Invalid authorization callback. Please return to the app and try again."
: "Dropbox callback received. You can close this tab and return to the app.";
var body = Encoding.UTF8.GetBytes(
$"<!doctype html><html><body><h3>{message}</h3></body></html>");
var headers = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {(callback is null ? "400 Bad Request" : "200 OK")}\r\n" +
"Content-Type: text/html; charset=utf-8\r\n" +
$"Content-Length: {body.Length}\r\n" +
"Connection: close\r\n\r\n");
await stream.WriteAsync(headers, requestTimeout.Token).ConfigureAwait(false);
await stream.WriteAsync(body, requestTimeout.Token).ConfigureAwait(false);
await stream.FlushAsync(requestTimeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// This connection did not send a complete request; keep waiting.
}
catch (IOException)
{
// The browser closed this connection; keep waiting.
}
cancellationToken.ThrowIfCancellationRequested();
if (callback is not null)
return callback;
}
}
}
The loop also ignores unrelated browser requests, such as /favicon.ico, and keeps waiting for the real callback.
Callback router for Android
In my Android emulator test, handling the redirect through a callback Activity worked, without needing an HTTP listener inside the app. Dropbox redirects to mauidropboxgs://oauth/callback, Android opens the Activity, and the Activity passes the URI back to the pending sign-in through this shared router:
internal static class OAuthCallbackRouter
{
private static readonly object Sync = new();
private static Uri? expectedRedirect;
private static TaskCompletionSource<Uri>? pending;
public static async Task<Uri> ReceiveAsync(
Uri redirectUri,
Uri authorizationUri,
Func<Uri, Task<bool>> openBrowserAsync,
CancellationToken cancellationToken)
{
var completion = new TaskCompletionSource<Uri>(
TaskCreationOptions.RunContinuationsAsynchronously);
lock (Sync)
{
if (pending is not null)
throw new InvalidOperationException("A sign-in is already in progress.");
expectedRedirect = redirectUri;
pending = completion;
}
try
{
using var registration = cancellationToken.Register(
() => completion.TrySetCanceled(cancellationToken));
cancellationToken.ThrowIfCancellationRequested();
if (!await openBrowserAsync(authorizationUri).ConfigureAwait(false))
throw new InvalidOperationException("Could not launch the system browser.");
return await completion.Task.ConfigureAwait(false);
}
finally
{
lock (Sync)
{
if (ReferenceEquals(pending, completion))
{
pending = null;
expectedRedirect = null;
}
}
}
}
public static bool TryHandle(Uri callback)
{
lock (Sync)
{
if (pending is not null &&
expectedRedirect is not null &&
callback.Scheme == expectedRedirect.Scheme &&
callback.Host == expectedRedirect.Host &&
callback.AbsolutePath == expectedRedirect.AbsolutePath)
{
return pending.TrySetResult(callback);
}
return false;
}
}
}
Android callback Activity
Place this under Platforms/Android/. A few notes before using it:
- Register mauidropboxgs://oauth/callback under Redirect URIs in your Dropbox App Console.
- Replace TestDropbox and MainActivity if your project uses different names.
- If OAuthCallbackRouter is in a different namespace, add the corresponding using directive.
- System.Uri is fully qualified to avoid a conflict with Android.Net.Uri.
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
namespace TestDropbox; // Replace with your app's namespace
[Activity(
Exported = true,
NoHistory = true,
LaunchMode = LaunchMode.SingleTop,
Theme = "@android:style/Theme.Translucent.NoTitleBar")]
[IntentFilter(
new[] { Intent.ActionView },
Categories = new[] { Intent.CategoryDefault, Intent.CategoryBrowsable },
DataScheme = "mauidropboxgs",
DataHost = "oauth",
DataPath = "/callback")]
public sealed class DropboxCallbackActivity : Activity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
HandleCallback(Intent);
}
protected override void OnNewIntent(Intent? intent)
{
base.OnNewIntent(intent);
HandleCallback(intent);
}
private void HandleCallback(Intent? intent)
{
if (System.Uri.TryCreate(intent?.DataString, UriKind.Absolute, out var callback) &&
OAuthCallbackRouter.TryHandle(callback))
{
var returnToApp = new Intent(this, typeof(MainActivity));
returnToApp.AddFlags(ActivityFlags.ClearTop | ActivityFlags.SingleTop);
StartActivity(returnToApp);
}
Finish();
}
}
Putting the pieces together in SignInAsync
Since you are using PKCEOAuthFlow, keep the same instance for both GetAuthorizeUri and ProcessCodeFlowAsync. That instance holds the PKCE code verifier generated for the authorization request.
Also, check for an existing RefreshToken rather than AccessToken. Dropbox access tokens are short-lived, so a stored access token can expire while still present in storage; the app would then skip sign-in and fail on later API calls. The sign-in is also wrapped in a 3-minute timeout so it does not wait forever if the browser is closed mid-flow.
using System.Diagnostics;
using Dropbox.Api;
using Microsoft.Maui.ApplicationModel;
using Microsoft.Maui.Storage;
internal class DropboxAuthentication
{
private const string AppKey = "YOUR_APP_KEY"; // Enter your App key from the Dropbox App Console
private const string RedirectUrlWindows = "http://127.0.0.1:52475/authorize";
private const string RedirectUrlAndroid = "mauidropboxgs://oauth/callback";
private readonly Uri redirectUri;
private readonly string[] scopeList =
{
"files.content.read",
"files.content.write",
"files.metadata.read"
};
public DropboxAuthentication()
{
#if WINDOWS
redirectUri = new Uri(RedirectUrlWindows);
#elif ANDROID
redirectUri = new Uri(RedirectUrlAndroid);
#else
throw new PlatformNotSupportedException("Unsupported platform");
#endif
}
private static Task<bool> OpenBrowserAsync(Uri uri) =>
MainThread.InvokeOnMainThreadAsync(
() => Browser.Default.OpenAsync(uri, BrowserLaunchMode.External));
public async Task SignInAsync(CancellationToken cancellationToken = default)
{
// Check the long-lived refresh token rather than the short-lived access token.
var refreshToken = await SecureStorage.GetAsync("RefreshToken");
if (!string.IsNullOrEmpty(refreshToken))
return;
// Stop waiting if the browser is closed without completing sign-in.
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(3));
try
{
var state = Guid.NewGuid().ToString("N");
// Keep this instance: PKCEOAuthFlow stores the PKCE verifier internally.
var flow = new PKCEOAuthFlow();
var authorizeUri = flow.GetAuthorizeUri(
OAuthResponseType.Code,
AppKey,
redirectUri.ToString(),
state: state,
tokenAccessType: TokenAccessType.Offline,
scopeList: scopeList,
includeGrantedScopes: IncludeGrantedScopes.None);
Uri callbackUri;
#if WINDOWS
callbackUri = await LoopbackOAuthReceiver.ReceiveAsync(
redirectUri, authorizeUri, OpenBrowserAsync, timeout.Token);
#elif ANDROID
callbackUri = await OAuthCallbackRouter.ReceiveAsync(
redirectUri, authorizeUri, OpenBrowserAsync, timeout.Token);
#elseYOUR_APP_KEY
throw new PlatformNotSupportedException("Unsupported platform");
#endif
// ProcessCodeFlowAsync also validates that the returned state matches.
Debug.WriteLine("Exchanging code for token");
var tokenResult = await flow.ProcessCodeFlowAsync(
callbackUri,
AppKey,
redirectUri.ToString(),
state,
null);
if (!string.IsNullOrEmpty(tokenResult.RefreshToken))
await SecureStorage.SetAsync("RefreshToken", tokenResult.RefreshToken);
}
catch (Exception ex)
{
// Use string interpolation: Debug.WriteLine(string, string) treats the
// second argument as a category, not a format argument.
Debug.WriteLine($"Sign-in error: {ex.Message}");
throw;
}
}
}
The redirect URI must be identical in three places: the Dropbox App Console, GetAuthorizeUri, and ProcessCodeFlowAsync.
Performing Dropbox file operations
Once RefreshToken is stored, you do not need to write custom refresh calls. Create DropboxClient from the refresh token and App key, and the SDK refreshes the short-lived access token when needed:
var refreshToken = await SecureStorage.GetAsync("RefreshToken");
using var client = new DropboxClient(refreshToken, AppKey);
If the user later disconnects the app from their Dropbox account, the refresh token stops working and API calls will fail with an authentication error. In that case, remove RefreshToken from SecureStorage and call SignInAsync again.
Listing folders, including continuation pages:
using Dropbox.Api;
using Dropbox.Api.Files;
public async Task<(List<Metadata> Items, string? NextCursor)> ListFolderAsync(
DropboxClient client,
string folderPath = "",
string? cursor = null)
{
ListFolderResult result = cursor is null
? await client.Files.ListFolderAsync(folderPath) // "" = Dropbox root
: await client.Files.ListFolderContinueAsync(cursor);
return (result.Entries.ToList(), result.HasMore ? result.Cursor : null);
}
Reading text file content:
public async Task<string> ReadTextFileAsync(DropboxClient client, string filePathOrId)
{
using var response = await client.Files.DownloadAsync(filePathOrId);
return await response.GetContentAsStringAsync();
}
Verifying your test
When you test this on Windows, please check these points:
- After Allow, does the browser redirect to http://127.0.0.1:52475/authorize?code=...&state=... and show the "Dropbox callback received" message instead of hanging?
- In the Visual Studio / VS Code Output window, do you see the line Exchanging code for token?
- If an exception is caught, what is the exact error message printed after Sign-in error:?
Once Windows works end to end, the same SignInAsync can be tested on Android after registering the Android redirect URI.
Please let me know what you observe at these checkpoints.
Thank you.