Files
DodoSSH/src/DodoSSH.Client.Android/Platform/AndroidAuthorization.cs
T
jaap-janandClaude Opus 5 1db8bed872 Let a cancelled sign-in end the sign-in rather than the timeout
Backing out of the login page on Android left the shell showing "Opening your browser to
sign in…" with the button disabled for five minutes. Nothing was wrong except that nobody
told it: the redirect callback only ever completed when an intent arrived, so a user who
pressed back was waiting on OidcClient's browser timeout to expire before the flow failed
and the button came back.

There is no cancel event to subscribe to on this platform. Pressing back, dismissing the
browser and closing a provider's error page are indistinguishable from here — the browser
goes away and this application is foreground again with nothing delivered — so being
resumed while a sign-in is still waiting is the signal, and the only one there is. The
launcher records that a browser took the intent, OnResume fails the wait, and the guard
means the resumes that have nothing to do with signing in (a launch, recents, the
keystore's fingerprint prompt) go through untouched.

An exception rather than a cancellation, because OidcClient reads a cancelled wait as its
own timeout expiring and would report five minutes passing to somebody who waited two
seconds. It cannot steal a successful sign-in either: Android delivers the redirect to
OnNewIntent before resuming the activity, so the completion is already settled and the
attempt does nothing.

The enrollment key-binding trip through the browser is covered by the same change, since it
waits on the same callback.

The OnNewIntent remark had been sitting above OnResume, describing a method two below it.
Moved back, since the new remark wanted the space and the old one was wrong where it was.

Verified by building the head in Debug and Release. The behaviour itself is unverified for
the reason docs/android-port.md gives about this whole head: nothing has been run on a
device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:10:37 +02:00

206 lines
9.6 KiB
C#

using DodoSSH.Client.Auth;
using global::Android.Content;
namespace DodoSSH.Client.Android.Platform;
/// <summary>
/// Opens the authorization page in the system browser.
/// </summary>
/// <remarks>
/// <para>
/// <c>Process.Start</c> does not exist on this platform, so the desktop head's launcher cannot be reused —
/// but the rule it enforces is the same one and is enforced here too: the <em>system</em> browser, never an
/// embedded WebView. RFC 8252 §8.12 — an embedded user-agent can read the user's credentials as they are
/// typed, hides the real address bar, and cannot reuse an existing single-sign-on session.
/// </para>
/// <para>
/// An <c>ACTION_VIEW</c> intent rather than a Custom Tab. A Custom Tab is the nicer surface and shares the
/// browser's cookie jar just as this does, but it needs the <c>androidx.browser</c> package; this needs
/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
/// </para>
/// </remarks>
internal sealed class AndroidBrowserLauncher : IBrowserLauncher
{
/// <inheritdoc />
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(url);
cancellationToken.ThrowIfCancellationRequested();
// Asserted rather than assumed, exactly as the desktop launcher does: an ACTION_VIEW on some other
// scheme is a request to open whatever app claims it, and this only ever opens an authorize URL.
if (!string.Equals(url.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
&& !string.Equals(url.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
{
throw new ArgumentException($"Refusing to open a '{url.Scheme}' URL.", nameof(url));
}
var intent = new Intent(Intent.ActionView, global::Android.Net.Uri.Parse(url.AbsoluteUri));
// NewTask because the launch comes from an application context rather than an activity one.
intent.AddFlags(ActivityFlags.NewTask);
PhoneEnvironment.Require().StartActivity(intent);
// Only now, and only if the browser actually took the intent: this is what lets a return to this
// application with no response be read as a cancellation. See AndroidRedirectCallback.Abandon.
AndroidRedirectCallback.NotifyBrowserOpened();
return Task.CompletedTask;
}
}
/// <summary>
/// Receives the authorization response as an intent rather than on a socket.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why not the loopback listener.</b> RFC 8252 §7.3's loopback redirect assumes a machine where the
/// application's own port is the application's own. On a phone it is not: any installed application can
/// bind a loopback port and, on a race, take the authorization code. §8.3 names this, and the platform's
/// answer is to have the system route the redirect to the registered application instead.
/// </para>
/// <para>
/// <b>A private-use scheme, and its limit is worth stating.</b> The redirect is
/// <c>dev.dodotech.dodossh:/oauth</c> — the reversed package name, which RFC 8252 §7.1 recommends because
/// it is a namespace the application demonstrably controls. It is *not* an Android App Link, and the
/// difference is real: another application can also declare this scheme, and Android will offer the user a
/// chooser rather than refusing. What stops that being a compromise is PKCE — the code is useless without
/// the verifier, which never leaves this process — plus the state check. An App Link would close the gap
/// properly, at the cost of hosting an assetlinks.json on the DodoSSH server's own domain; that is the
/// upgrade, and it is a server change rather than a client one.
/// </para>
/// <para>
/// The completion HTML is ignored. There is no tab of this application's own to write it into: the system
/// hands the intent over and closes the browser itself.
/// </para>
/// </remarks>
internal sealed class AndroidRedirectCallback : IAuthorizationCallback
{
/// <summary>The scheme, which must match the intent filter on MainActivity exactly.</summary>
internal const string Scheme = "dev.dodotech.dodossh";
private readonly TaskCompletionSource<CallbackResult> completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static AndroidRedirectCallback? waiting;
/// <summary>Whether a browser was opened for a sign-in that has not answered yet.</summary>
private static volatile bool browserOpen;
/// <param name="path">
/// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
/// this one cannot silently differ.
/// </param>
public AndroidRedirectCallback(string path)
{
RedirectUri = new Uri($"{Scheme}:{(path.StartsWith('/') ? path : "/" + path)}");
// Exactly one sign-in can be outstanding, because exactly one activity receives the intent. A
// second overlapping attempt would leave the first waiting forever on a response the second
// consumed, so the earlier one is failed rather than orphaned.
Interlocked.Exchange(ref waiting, this)?.completion.TrySetCanceled();
}
/// <inheritdoc />
public Uri RedirectUri { get; }
/// <summary>Records that the authorization page has been handed to the browser.</summary>
/// <remarks>
/// One half of the cancellation story; <see cref="Abandon"/> is the other. Called by
/// <see cref="AndroidBrowserLauncher"/> rather than set when the wait starts, so that only a return
/// from a browser this application actually opened counts as an answer that never came.
/// </remarks>
internal static void NotifyBrowserOpened() => browserOpen = true;
/// <summary>Fails the waiting sign-in when the user comes back without a response.</summary>
/// <remarks>
/// <para>
/// Called from <c>MainActivity.OnResume</c>, because this platform has no cancel event to subscribe
/// to. Pressing back out of the login page, dismissing the browser, or closing a provider error page
/// all look identical from here: this application is foreground again and no intent ever arrived.
/// Left alone the flow sits on the five-minute browser timeout — that long with a disabled
/// <em>Sign in</em> button under "Opening your browser to sign in…", which reads as a hung
/// application rather than as the cancellation it is.
/// </para>
/// <para>
/// It cannot steal a successful sign-in. Android delivers the redirect to <c>OnNewIntent</c> before
/// resuming the activity, so by the time this runs the completion is already settled and the attempt
/// below does nothing.
/// </para>
/// </remarks>
internal static void Abandon()
{
// Guarded rather than unconditional, because most resumes have nothing to do with signing in:
// launching the application, coming back from recents, returning from the keystore's fingerprint
// prompt. Only a resume that follows a browser this class opened is an answer that never came.
if (!browserOpen)
{
return;
}
browserOpen = false;
// An exception rather than a cancellation, because OidcClient reads a cancelled wait as its own
// timeout expiring and would report five minutes passing. This says what actually happened.
Volatile.Read(ref waiting)?.completion.TrySetException(
new OidcException("Sign-in was cancelled: the browser closed without signing you in."));
}
/// <summary>Hands a received redirect to whichever sign-in is waiting for it.</summary>
/// <remarks>
/// Called from <c>MainActivity.OnNewIntent</c>. Returns quietly when nothing is waiting: a redirect
/// can arrive after the app was killed and relaunched, and that is a stale response rather than an
/// error worth showing anybody.
/// </remarks>
internal static void Complete(Uri redirect)
{
browserOpen = false;
var target = Volatile.Read(ref waiting);
if (target is null)
{
return;
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal);
// The query, and the fragment if the provider used one. Response parameters arrive in the query
// for a code flow; reading both costs nothing and means a provider that answers errors in the
// fragment does not look like a silent hang.
foreach (var part in new[] { redirect.Query, redirect.Fragment })
{
foreach (var pair in part.TrimStart('?', '#').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var split = pair.Split('=', 2);
parameters[Uri.UnescapeDataString(split[0])] =
split.Length == 2 ? Uri.UnescapeDataString(split[1]) : string.Empty;
}
}
target.completion.TrySetResult(new CallbackResult(parameters));
}
/// <inheritdoc />
public Task<CallbackResult> WaitForCallbackAsync(
string completionHtml,
CancellationToken cancellationToken) =>
completion.Task.WaitAsync(cancellationToken);
/// <inheritdoc />
public void Dispose()
{
// Only if it is still this one. A later sign-in has already replaced it, and clearing the slot
// then would silently break the attempt that is actually in flight — including its claim on the
// browser, which is why the flag is cleared under the same condition and not beside it.
if (ReferenceEquals(Interlocked.CompareExchange(ref waiting, null, this), this))
{
browserOpen = false;
}
completion.TrySetCanceled();
}
}