From c172d1cf9e8274e27efd49e859ed21945319e8b9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 20 Jun 2026 17:26:13 +0100 Subject: [PATCH] PostIt.Android: drive the PKCE flow through Chrome Custom Tabs The earlier commit removed the client_secret and wired MainActivity.OnNewIntent to AndroidOidcCallbackSink, but IdentityModel.OidcClient.LoginAsync still had no IBrowser to drive the user-agent half of the flow. Without it, the desktop / browser projects continue to fail at login with 'No browser is available'. Android now plugs in Chrome Custom Tabs: * PostIt.Android/Services/AndroidSystemBrowser.cs implements IBrowser.InvokeAsync using CustomTabsIntent.LaunchUrl and waits for MainActivity.AndroidOidcCallbackSink to deliver the deep-link Intent (android://postit-signin?code=...&state=...). * PostIt/Services/Platform.cs is a tiny static indirection the shared library uses to ask the running platform for an IBrowser and the appropriate default RedirectUri, without referencing any UI framework from the shared assembly. * LoginPageViewModel reads Platform.DefaultRedirectUri and Platform.CreateBrowser().Invoke() before calling LoginAsync. * PostIt.Android/PlatformBootstrap.cs wires the Android side at startup, and MainActivity.OnCreate calls EnsureInitialized(). * Xamarin.AndroidX.Browser 1.8.0 added to the central package versions so CustomTabsIntent resolves. --- src/PostIt/Directory.Packages.props | 1 + src/PostIt/PostIt.Android/MainActivity.cs | 15 ++++ .../PostIt.Android/PlatformBootstrap.cs | 29 +++++++ .../PostIt.Android/PostIt.Android.csproj | 1 + .../Services/AndroidSystemBrowser.cs | 83 +++++++++++++++++++ src/PostIt/PostIt/Services/Platform.cs | 28 +++++++ .../PostIt/ViewModels/LoginPageViewModel.cs | 26 ++++-- 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/PostIt/PostIt.Android/PlatformBootstrap.cs create mode 100644 src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs create mode 100644 src/PostIt/PostIt/Services/Platform.cs diff --git a/src/PostIt/Directory.Packages.props b/src/PostIt/Directory.Packages.props index 573989b6..4855cad3 100644 --- a/src/PostIt/Directory.Packages.props +++ b/src/PostIt/Directory.Packages.props @@ -13,6 +13,7 @@ + \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 5a75e91d..86ce394a 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -16,6 +16,21 @@ namespace PostIt.Android; ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { + /// + /// Strongly-typed handle to the current MainActivity instance, set in + /// and consumed by platform services such as + /// which need to launch + /// Chrome Custom Tabs. + /// + public static MainActivity? Current { get; private set; } + + protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + PlatformBootstrap.EnsureInitialized(); + Current = this; + } + /// /// Receives the deep-link Intent fired by the system browser after the /// user completes the OIDC login on https://yavsc.pschneider.fr. The diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs new file mode 100644 index 00000000..56a15fb0 --- /dev/null +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -0,0 +1,29 @@ +using PostIt.Services; +using PostIt.Android.Services; + +namespace PostIt.Android; + +/// +/// One-shot platform bootstrap. Called from +/// so that the shared +/// LoginPageViewModel sees the Android-specific redirect URI and a +/// working IBrowser (Chrome Custom Tabs) without referencing +/// Android APIs from the shared library. +/// +internal static class PlatformBootstrap +{ + private static int _initialized; + + internal static void EnsureInitialized() + { + if (System.Threading.Interlocked.Exchange(ref _initialized, 1) != 0) + return; + + Platform.DefaultRedirectUri = Settings.AndroidRedirectUri; + Platform.CreateBrowser = () => + { + var activity = MainActivity.Current; + return activity is null ? null : new AndroidSystemBrowser(activity); + }; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/PostIt.Android.csproj b/src/PostIt/PostIt.Android/PostIt.Android.csproj index 687f33e9..c7680ec7 100644 --- a/src/PostIt/PostIt.Android/PostIt.Android.csproj +++ b/src/PostIt/PostIt.Android/PostIt.Android.csproj @@ -21,6 +21,7 @@ + diff --git a/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs new file mode 100644 index 00000000..cb9c324b --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using Android.App; +using Android.Content; +using AndroidX.Browser.CustomTabs; +using IdentityModel.OidcClient.Browser; + +namespace PostIt.Android.Services; + +/// +/// implementation that drives Chrome Custom Tabs for +/// the OIDC Authorization Code + PKCE flow. The identity provider redirects +/// to android://postit-signin?code=...&state=..., which Android +/// routes back to the running PostIt instance via the activity-alias +/// declared in AndroidManifest.xml; the resulting Intent URI is +/// handed back through . +/// +public sealed class AndroidSystemBrowser : IBrowser +{ + private readonly Activity _activity; + + public AndroidSystemBrowser(Activity activity) + { + _activity = activity ?? throw new ArgumentNullException(nameof(activity)); + } + + public async Task InvokeAsync(BrowserOptions options, System.Threading.CancellationToken cancellationToken = default) + { + if (options is null) throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrWhiteSpace(options.StartUrl)) + { + return new BrowserResult + { + ResultType = BrowserResultType.UnknownError, + Error = "BrowserOptions.StartUrl is empty." + }; + } + + var uri = global::Android.Net.Uri.Parse(options.StartUrl)!; + + var callbackTask = MainActivity.AndroidOidcCallbackSink.AwaitNextCallbackAsync(); + + var tabsIntent = new CustomTabsIntent.Builder() + .SetShowTitle(true) + .Build(); + tabsIntent.LaunchUrl(_activity, uri); + + string responseUri; + try + { + responseUri = await callbackTask.WaitAsync(cancellationToken).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return new BrowserResult + { + ResultType = BrowserResultType.UserCancel + }; + } + catch (Exception ex) + { + return new BrowserResult + { + ResultType = BrowserResultType.UnknownError, + Error = $"Failed to await OIDC callback: {ex.Message}" + }; + } + + if (string.IsNullOrEmpty(responseUri)) + { + return new BrowserResult + { + ResultType = BrowserResultType.UserCancel + }; + } + + return new BrowserResult + { + ResultType = BrowserResultType.Success, + Response = responseUri + }; + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs new file mode 100644 index 00000000..3e552be4 --- /dev/null +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -0,0 +1,28 @@ +using IdentityModel.OidcClient.Browser; + +namespace PostIt.Services; + +/// +/// Per-platform access to native integration points used by the OIDC +/// Authorization Code + PKCE flow. The shared PostIt library does +/// not reference any UI framework; platform projects (PostIt.Android, +/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so +/// the shared LoginPageViewModel can drive a native browser without +/// taking a hard dependency on any specific UI toolkit. +/// +public static class Platform +{ + /// + /// Default redirect URI for the running platform. The desktop loopback + /// default is set here; platform projects override this property at + /// startup (e.g. PostIt.Android sets it to android://postit-signin). + /// + public static string DefaultRedirectUri { get; set; } = "http://127.0.0.1:7890/"; + + /// + /// Constructs a fresh for the running platform. + /// May return null if no browser is wired up; in that case + /// LoginAsync will surface a clear error. + /// + public static System.Func? CreateBrowser { get; set; } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs index 4fb25097..82bb1833 100644 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs @@ -1,5 +1,6 @@ using CommunityToolkit.Mvvm.Input; using IdentityModel.OidcClient; +using PostIt.Services; using System; using System.Threading.Tasks; @@ -21,7 +22,7 @@ public partial class LoginPageViewModel : ViewModelBase private string _StatusMessage; public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); } - + private bool _IsBusy; public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); } @@ -37,10 +38,23 @@ public partial class LoginPageViewModel : ViewModelBase try { Settings.Load().Wait(); - - var client = new OidcClient(Settings.GetOidcClientOptions()); + + // The platform project picks the right redirect URI and browser + // implementation; we don't reference any UI toolkit from here. + Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri) + ? Platform.DefaultRedirectUri + : Settings.RedirectUri; + + var browser = Platform.CreateBrowser?.Invoke(); + if (browser is null) + { + StatusMessage = "No browser is available on this platform."; + return; + } + + var client = new OidcClient(Settings.GetOidcClientOptions(browser)); var loginResult = await client.LoginAsync(new LoginRequest()); - + if (loginResult.IsError) { StatusMessage = loginResult.Error; @@ -51,12 +65,12 @@ public partial class LoginPageViewModel : ViewModelBase this.IsBusy = false; AccessToken = loginResult.AccessToken; - /* TODO save or not user log and password + /* TODO save or not user log and password await Task.Run(() => { Settings.Save().Wait(); });*/ - + } catch (Exception ex) {