using DodoSSH.Client.Auth;
using global::Android.Content;
namespace DodoSSH.Client.Android.Platform;
///
/// Opens the authorization page in the system browser.
///
///
///
/// Process.Start 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 system 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.
///
///
/// An ACTION_VIEW 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 androidx.browser package; this needs
/// nothing and satisfies the same requirement. Worth revisiting when something else pulls AndroidX in.
///
///
internal sealed class AndroidBrowserLauncher : IBrowserLauncher
{
///
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;
}
}
///
/// Receives the authorization response as an intent rather than on a socket.
///
///
///
/// Why not the loopback listener. 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.
///
///
/// A private-use scheme, and its limit is worth stating. The redirect is
/// dev.dodotech.dodossh:/oauth — 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.
///
///
/// 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.
///
///
internal sealed class AndroidRedirectCallback : IAuthorizationCallback
{
/// The scheme, which must match the intent filter on MainActivity exactly.
internal const string Scheme = "dev.dodotech.dodossh";
private readonly TaskCompletionSource completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static AndroidRedirectCallback? waiting;
/// Whether a browser was opened for a sign-in that has not answered yet.
private static volatile bool browserOpen;
///
/// The redirect path, from OidcClientOptions. Carried through so the desktop's configured value and
/// this one cannot silently differ.
///
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();
}
///
public Uri RedirectUri { get; }
/// Records that the authorization page has been handed to the browser.
///
/// One half of the cancellation story; is the other. Called by
/// 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.
///
internal static void NotifyBrowserOpened() => browserOpen = true;
/// Fails the waiting sign-in when the user comes back without a response.
///
///
/// Called from MainActivity.OnResume, 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
/// Sign in button under "Opening your browser to sign in…", which reads as a hung
/// application rather than as the cancellation it is.
///
///
/// It cannot steal a successful sign-in. Android delivers the redirect to OnNewIntent before
/// resuming the activity, so by the time this runs the completion is already settled and the attempt
/// below does nothing.
///
///
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."));
}
/// Hands a received redirect to whichever sign-in is waiting for it.
///
/// Called from MainActivity.OnNewIntent. 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.
///
internal static void Complete(Uri redirect)
{
browserOpen = false;
var target = Volatile.Read(ref waiting);
if (target is null)
{
return;
}
var parameters = new Dictionary(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));
}
///
public Task WaitForCallbackAsync(
string completionHtml,
CancellationToken cancellationToken) =>
completion.Task.WaitAsync(cancellationToken);
///
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();
}
}