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(); } }