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.
This commit is contained in:
parent
512a0ef06f
commit
c172d1cf9e
7 changed files with 177 additions and 6 deletions
|
|
@ -13,6 +13,7 @@
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
|
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
|
||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
|
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -16,6 +16,21 @@ namespace PostIt.Android;
|
||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||||
public class MainActivity : AvaloniaMainActivity
|
public class MainActivity : AvaloniaMainActivity
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Strongly-typed handle to the current MainActivity instance, set in
|
||||||
|
/// <see cref="OnCreate"/> and consumed by platform services such as
|
||||||
|
/// <see cref="Services.AndroidSystemBrowser"/> which need to launch
|
||||||
|
/// Chrome Custom Tabs.
|
||||||
|
/// </summary>
|
||||||
|
public static MainActivity? Current { get; private set; }
|
||||||
|
|
||||||
|
protected override void OnCreate(global::Android.OS.Bundle? savedInstanceState)
|
||||||
|
{
|
||||||
|
base.OnCreate(savedInstanceState);
|
||||||
|
PlatformBootstrap.EnsureInitialized();
|
||||||
|
Current = this;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Receives the deep-link Intent fired by the system browser after the
|
/// Receives the deep-link Intent fired by the system browser after the
|
||||||
/// user completes the OIDC login on https://yavsc.pschneider.fr. The
|
/// user completes the OIDC login on https://yavsc.pschneider.fr. The
|
||||||
|
|
|
||||||
29
src/PostIt/PostIt.Android/PlatformBootstrap.cs
Normal file
29
src/PostIt/PostIt.Android/PlatformBootstrap.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
using PostIt.Services;
|
||||||
|
using PostIt.Android.Services;
|
||||||
|
|
||||||
|
namespace PostIt.Android;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-shot platform bootstrap. Called from
|
||||||
|
/// <see cref="MainActivity.OnCreate"/> so that the shared
|
||||||
|
/// <c>LoginPageViewModel</c> sees the Android-specific redirect URI and a
|
||||||
|
/// working <c>IBrowser</c> (Chrome Custom Tabs) without referencing
|
||||||
|
/// Android APIs from the shared library.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia.Android" />
|
<PackageReference Include="Avalonia.Android" />
|
||||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
||||||
|
<PackageReference Include="Xamarin.AndroidX.Browser" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
<ProjectReference Include="..\PostIt\PostIt.csproj" />
|
||||||
|
|
|
||||||
83
src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs
Normal file
83
src/PostIt/PostIt.Android/Services/AndroidSystemBrowser.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IBrowser"/> implementation that drives Chrome Custom Tabs for
|
||||||
|
/// the OIDC Authorization Code + PKCE flow. The identity provider redirects
|
||||||
|
/// to <c>android://postit-signin?code=...&state=...</c>, which Android
|
||||||
|
/// routes back to the running PostIt instance via the activity-alias
|
||||||
|
/// declared in <c>AndroidManifest.xml</c>; the resulting Intent URI is
|
||||||
|
/// handed back through <see cref="MainActivity.AndroidOidcCallbackSink"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AndroidSystemBrowser : IBrowser
|
||||||
|
{
|
||||||
|
private readonly Activity _activity;
|
||||||
|
|
||||||
|
public AndroidSystemBrowser(Activity activity)
|
||||||
|
{
|
||||||
|
_activity = activity ?? throw new ArgumentNullException(nameof(activity));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BrowserResult> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/PostIt/PostIt/Services/Platform.cs
Normal file
28
src/PostIt/PostIt/Services/Platform.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
using IdentityModel.OidcClient.Browser;
|
||||||
|
|
||||||
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-platform access to native integration points used by the OIDC
|
||||||
|
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
|
||||||
|
/// not reference any UI framework; platform projects (PostIt.Android,
|
||||||
|
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
|
||||||
|
/// the shared <c>LoginPageViewModel</c> can drive a native browser without
|
||||||
|
/// taking a hard dependency on any specific UI toolkit.
|
||||||
|
/// </summary>
|
||||||
|
public static class Platform
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>android://postit-signin</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static string DefaultRedirectUri { get; set; } = "http://127.0.0.1:7890/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a fresh <see cref="IBrowser"/> for the running platform.
|
||||||
|
/// May return <c>null</c> if no browser is wired up; in that case
|
||||||
|
/// <c>LoginAsync</c> will surface a clear error.
|
||||||
|
/// </summary>
|
||||||
|
public static System.Func<IBrowser?>? CreateBrowser { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
|
using PostIt.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
|
@ -21,7 +22,7 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
|
|
||||||
private string _StatusMessage;
|
private string _StatusMessage;
|
||||||
public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); }
|
public string StatusMessage { get => _StatusMessage; private set => this.SetProperty(ref _StatusMessage, value); }
|
||||||
|
|
||||||
private bool _IsBusy;
|
private bool _IsBusy;
|
||||||
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
|
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
|
||||||
|
|
||||||
|
|
@ -37,10 +38,23 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Settings.Load().Wait();
|
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());
|
var loginResult = await client.LoginAsync(new LoginRequest());
|
||||||
|
|
||||||
if (loginResult.IsError)
|
if (loginResult.IsError)
|
||||||
{
|
{
|
||||||
StatusMessage = loginResult.Error;
|
StatusMessage = loginResult.Error;
|
||||||
|
|
@ -51,12 +65,12 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
|
|
||||||
this.IsBusy = false;
|
this.IsBusy = false;
|
||||||
AccessToken = loginResult.AccessToken;
|
AccessToken = loginResult.AccessToken;
|
||||||
/* TODO save or not user log and password
|
/* TODO save or not user log and password
|
||||||
await Task.Run(() =>
|
await Task.Run(() =>
|
||||||
{
|
{
|
||||||
Settings.Save().Wait();
|
Settings.Save().Wait();
|
||||||
});*/
|
});*/
|
||||||
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue