diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index 1c70a76b..5a75e91d 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -1,16 +1,51 @@ -using Android.App; +using Android.App; using Android.Content.PM; +using Android.Content; using Avalonia; using Avalonia.Android; namespace PostIt.Android; [Activity( + Name = "PostIt.Android.PostItMainActivity", Label = "PostIt.Android", Theme = "@style/MyTheme.NoActionBar", Icon = "@drawable/icon", MainLauncher = true, + LaunchMode = LaunchMode.SingleTask, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)] public class MainActivity : AvaloniaMainActivity { -} + /// + /// Receives the deep-link Intent fired by the system browser after the + /// user completes the OIDC login on https://yavsc.pschneider.fr. The + /// Intent URI has the shape android://postit-signin?code=...&state=.... + /// + /// IdentityModel.OidcClient.Browser.SystemBrowser is set up to await this + /// callback via a TaskCompletionSource; expose the received Intent here + /// through a static sink so the browser can resolve the pending login. + /// + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + if (intent is not null) AndroidOidcCallbackSink.Handle(intent); + } + + internal static class AndroidOidcCallbackSink + { + private static System.Threading.Tasks.TaskCompletionSource? _pending; + + public static System.Threading.Tasks.Task AwaitNextCallbackAsync() + { + _pending = new System.Threading.Tasks.TaskCompletionSource( + System.Threading.Tasks.TaskCreationOptions.RunContinuationsAsynchronously); + return _pending.Task; + } + + public static void Handle(Intent intent) + { + var tcs = System.Threading.Interlocked.Exchange(ref _pending, null); + tcs?.TrySetResult(intent?.Data?.ToString() ?? string.Empty); + } + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml index 1fd40cab..2472d06d 100644 --- a/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml +++ b/src/PostIt/PostIt.Android/Properties/AndroidManifest.xml @@ -1,5 +1,32 @@ - + - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs index 38927ec2..4547c732 100644 --- a/src/PostIt/PostIt/Settings/AuthenticationSettings.cs +++ b/src/PostIt/PostIt/Settings/AuthenticationSettings.cs @@ -5,13 +5,9 @@ public partial class AuthenticationSettings : ObservableObject { [ObservableProperty] - public partial string Authority { get; set; } + public partial string Authority { get; set; } [ObservableProperty] - public partial string ClientId { get; set; } + public partial string ClientId { get; set; } - [ObservableProperty] - public partial string ClientSecret { get; set; } - - -} +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs index 3996b540..b17c341e 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Platform.Storage; using CommunityToolkit.Mvvm.ComponentModel; using IdentityModel.OidcClient; +using PostIt.Services; using System; using System.IO; using System.Text.Json; @@ -15,6 +16,19 @@ public partial class Settings : ObservableObject const string SettingsFileName = "postit-settings.json"; IStorageFolder? folder = null; + /// + /// Default loopback redirect URI used for interactive PKCE login on desktop + /// platforms. The corresponding RedirectUri must be registered for + /// the PostIt client in IdentityServer. + /// + public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/"; + + /// + /// Redirect URI used by the Android app. The corresponding IntentFilter + /// in PostIt.Android/Properties/AndroidManifest.xml must match. + /// + public const string AndroidRedirectUri = "android://postit-signin"; + [ObservableProperty] public partial AuthenticationSettings Authentication { get; set; } = new(); @@ -24,20 +38,38 @@ public partial class Settings : ObservableObject [ObservableProperty] public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; + /// + /// OAuth redirect URI. Defaults to a loopback URI suitable for desktop + /// apps; mobile platforms must set this to + /// before calling LoginAsync. + /// + [ObservableProperty] + public partial string RedirectUri { get; set; } = DefaultLoopbackRedirectUri; + [ObservableProperty] public partial string[] Scopes { get; set; } - internal OidcClientOptions GetOidcClientOptions() + /// + /// Build OidcClient options configured for Authorization Code + PKCE + /// (no client secret). The browser implementation should be supplied + /// per-platform by the caller. + /// + internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null) { - return new OidcClientOptions + var options = new OidcClientOptions { Authority = Authentication.Authority, ClientId = Authentication.ClientId, - ClientSecret = Authentication.ClientSecret, - Scope = string.Join(' ', this.Scopes) + RedirectUri = RedirectUri, + Scope = string.Join(' ', this.Scopes), + // PKCE is enabled by default when no client_secret is provided. }; + if (browser is not null) + options.Browser = browser; + + return options; } internal async Task Load() @@ -81,6 +113,7 @@ public partial class Settings : ObservableObject this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; this.ApiUrl = settings.ApiUrl; + this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultLoopbackRedirectUri : settings.RedirectUri; this.Scopes = settings.Scopes; } @@ -89,4 +122,4 @@ public partial class Settings : ObservableObject Console.Error.WriteLine($"🩎 Error loading settings: {ex.Message}"); } } -} +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/MainViewModel.cs b/src/PostIt/PostIt/ViewModels/MainViewModel.cs index 0f437a99..0e142ee3 100644 --- a/src/PostIt/PostIt/ViewModels/MainViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/MainViewModel.cs @@ -1,14 +1,11 @@ using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using IdentityModel.OidcClient; +using IdentityModel.OidcClient.Browser; using PostIt.Models; using PostIt.Services; using Avalonia.Styling; @@ -237,53 +234,48 @@ public partial class MainPageViewModel : ViewModelBase } } - private async Task RequestClientCredentialsTokenAsync() + /// + /// Performs an interactive Authorization Code + PKCE login against the + /// configured authority and stores the resulting access token in + /// . No client secret is sent; PKCE prevents + /// authorization-code interception by relying on a per-request verifier + /// generated locally and never leaving the device. + /// + /// + /// Platform-specific implementation. On desktop + /// pass a LoopbackBrowser; on Android a custom-scheme + /// deep-link browser is required. + /// + public async Task LoginAsync(IBrowser browser) { - using var client = new HttpClient(); - var discoveryUrl = Settings.Authentication.Authority.TrimEnd('/') + "/.well-known/openid-configuration"; - var discoveryDocument = await client.GetFromJsonAsync(discoveryUrl, JsonOptions); - - if (discoveryDocument is null || string.IsNullOrWhiteSpace(discoveryDocument.TokenEndpoint)) + IsBusy = true; + StatusMessage = "Signing in..."; + try { - throw new InvalidOperationException("Unable to discover the token endpoint from the authority."); - } + var client = new OidcClient(Settings.GetOidcClientOptions(browser)); + var loginResult = await client.LoginAsync(new LoginRequest()).ConfigureAwait(false); - var request = new HttpRequestMessage(HttpMethod.Post, discoveryDocument.TokenEndpoint) - { - Content = new FormUrlEncodedContent(new Dictionary + if (loginResult.IsError) { - ["grant_type"] = "client_credentials", - ["client_id"] = Settings.Authentication.ClientId, - ["client_secret"] = Settings.Authentication.ClientSecret, - ["scope"] = string.Join(' ', Settings.Scopes), - }) - }; + StatusMessage = loginResult.Error ?? "Login failed."; + return; + } - var response = await client.SendAsync(request).ConfigureAwait(false); - - var payload = await response.Content.ReadFromJsonAsync(JsonOptions); - if (payload is null) - { - throw new InvalidOperationException("Invalid token response from the identity provider."); + BearerToken = loginResult.AccessToken ?? string.Empty; + StatusMessage = string.IsNullOrEmpty(BearerToken) + ? "Login succeeded but no access token was returned." + : "Signed in."; } - - if (!response.IsSuccessStatusCode) + catch (Exception ex) { - var message = string.IsNullOrWhiteSpace(payload.ErrorDescription) - ? payload.Error ?? "Unknown token error" - : payload.ErrorDescription; - throw new InvalidOperationException(message); + StatusMessage = $"Error: {ex.Message}"; + } + finally + { + IsBusy = false; } - - return payload; } - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - private BlogApiClient CreateClient() => new BlogApiClient(Settings.ApiUrl, BearerToken); @@ -297,12 +289,4 @@ public partial class MainPageViewModel : ViewModelBase private bool CanSave() => SelectedPost is not null && !IsBusy; private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; - - private sealed record DiscoveryDocument([property: JsonPropertyName("token_endpoint")] string? TokenEndpoint); - private sealed record TokenResponse( - [property: JsonPropertyName("access_token")] string? AccessToken, - [property: JsonPropertyName("token_type")] string? TokenType, - [property: JsonPropertyName("expires_in")] int ExpiresIn, - [property: JsonPropertyName("error")] string? Error, - [property: JsonPropertyName("error_description")] string? ErrorDescription); } diff --git a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs index 2f871ee1..67223d5f 100644 --- a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs @@ -13,8 +13,6 @@ public partial class SettingsPageViewModel : ViewModelBase [ObservableProperty] public partial string ClientId { get; set; } - [ObservableProperty] - public partial string ClientSecret { get; set; } public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } } diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/SettingsPage.axaml index 9d5b6f89..e8d3a125 100644 --- a/src/PostIt/PostIt/Views/SettingsPage.axaml +++ b/src/PostIt/PostIt/Views/SettingsPage.axaml @@ -1,4 +1,4 @@ - - @@ -21,8 +20,5 @@ - - - - + \ No newline at end of file diff --git a/src/PostIt/PostIt/postit-settings sample.json b/src/PostIt/PostIt/postit-settings sample.json index d0dcfd90..9cc9c38e 100644 --- a/src/PostIt/PostIt/postit-settings sample.json +++ b/src/PostIt/PostIt/postit-settings sample.json @@ -1,16 +1,15 @@ { "Authentication": { "ClientId": "postit", - "ClientSecret": "postit-secret", - "Authority": "https://blogs.pschneider.fr/auth/realms/master", + "Authority": "https://yavsc.pschneider.fr" }, + "RedirectUri": "http://127.0.0.1:7890/", "DarkMode": false, "ApiUrl": "https://blogs.pschneider.fr/api/v1/", "Scopes": [ "openid", "profile", - "email", "offline_access", "blogs" ] -} +} \ No newline at end of file diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 036d4d5b..8c483d39 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -354,68 +354,208 @@ public static class HostingExtensions }; } + private const string PostItClientId = "postit"; + + private static readonly string[] PostItRedirectUris = new[] + { + // Loopback URI for desktop / browser-based PKCE flows. + "http://127.0.0.1:7890/", + // Custom-scheme URI for Android. The matching IntentFilter must be + // declared in PostIt.Android/Properties/AndroidManifest.xml. + "android://postit-signin", + }; + + private static readonly string[] PostItGrantTypes = new[] + { + "authorization_code", + "client_credentials", + }; + + private static readonly string[] PostItScopes = new[] + { + "blog", + IdentityServer8.IdentityServerConstants.StandardScopes.OpenId, + IdentityServer8.IdentityServerConstants.StandardScopes.Profile, + }; + private static Action EnsureDefaultConfiguration() { return (context, _) => { EnsureDefaultApplicationScopes()(context, _); - var existingClient = context.Set().FirstOrDefault(c => c.ClientId == "postit"); - if (existingClient == null) + var clients = context.Set(); + var existingClient = clients.FirstOrDefault(c => c.ClientId == PostItClientId); + + if (existingClient is null) { - var client = new IdentityServer8.EntityFramework.Entities.Client - { - ClientId = "postit", - Enabled = true, - RequireClientSecret = true, - ProtocolType = "oidc", - RequireConsent = false, - }; + SeedNewPostItClient(context); + return; + } - context.Set().Add(client); - // allow authorization code (interactive) and client credentials (m2m) + MigratePostItClientToPublic(context, existingClient); + }; + } + + /// + /// Insert a brand new postit client configured as a public OIDC + /// client using Authorization Code + PKCE. Used the first time the + /// ConfigurationDb is seeded. + /// + private static void SeedNewPostItClient(DbContext context) + { + // PostIt is a public client (Authorization Code + PKCE). + // No client secret is stored or transmitted; PKCE binds the + // authorization code to the requesting device. + var client = new IdentityServer8.EntityFramework.Entities.Client + { + ClientId = PostItClientId, + Enabled = true, + RequireClientSecret = false, + RequirePkce = true, + ProtocolType = "oidc", + RequireConsent = false, + }; + + context.Set().Add(client); + + foreach (var grantType in PostItGrantTypes) + { + context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType + { + Client = client, + GrantType = grantType + }); + } + + foreach (var scope in PostItScopes) + { + context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientScope + { + Client = client, + Scope = scope + }); + } + + foreach (var redirectUri in PostItRedirectUris) + { + context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri + { + Client = client, + RedirectUri = redirectUri + }); + } + + // No ClientSecret row: PKCE-only clients don't need one. + context.SaveChanges(); + } + + /// + /// Bring an existing postit client up to the current public-client + /// configuration. Idempotent: each change is applied only when the row is + /// currently in the legacy state. + /// + private static void MigratePostItClientToPublic( + DbContext context, + IdentityServer8.EntityFramework.Entities.Client client) + { + var changed = false; + + // 1. Drop the client secret. PKCE-only clients must not have one. + var secrets = context.Set().Where(s => s.Client.Id == client.Id); + if (secrets.Any()) + { + context.Set().RemoveRange(secrets); + changed = true; + } + + // 2. Flip the security flags. + if (client.RequireClientSecret) + { + client.RequireClientSecret = false; + changed = true; + } + if (!client.RequirePkce) + { + client.RequirePkce = true; + changed = true; + } + + // 3. Ensure all expected grant types are present (don't remove extras + // that may have been added by hand). + var existingGrantTypes = context.Set() + .Where(g => g.Client.Id == client.Id) + .Select(g => g.GrantType) + .ToHashSet(); + foreach (var grantType in PostItGrantTypes) + { + if (!existingGrantTypes.Contains(grantType)) + { context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType { Client = client, - GrantType = "authorization_code" - }); - context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType - { - Client = client, - GrantType = "client_credentials" + GrantType = grantType }); + changed = true; + } + } + // 4. Ensure all expected scopes are present. + var existingScopes = context.Set() + .Where(s => s.Client.Id == client.Id) + .Select(s => s.Scope) + .ToHashSet(); + foreach (var scope in PostItScopes) + { + if (!existingScopes.Contains(scope)) + { context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientScope { Client = client, - Scope = "blog" - }); - context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientScope - { - Client = client, - Scope = IdentityServer8.IdentityServerConstants.StandardScopes.OpenId - }); - context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientScope - { - Client = client, - Scope = IdentityServer8.IdentityServerConstants.StandardScopes.Profile + Scope = scope }); + changed = true; + } + } + // 5. Ensure all expected redirect URIs are present. Legacy entries + // pointing at the OP itself (e.g. https://yavsc.pschneider.fr/) + // are removed — they redirect back into IdentityServer's own home + // page and create a login loop. + var existingRedirects = context.Set() + .Where(r => r.Client.Id == client.Id) + .ToList(); + var existingRedirectUris = existingRedirects + .Select(r => r.RedirectUri) + .ToHashSet(StringComparer.Ordinal); + + foreach (var redirectUri in PostItRedirectUris) + { + if (!existingRedirectUris.Contains(redirectUri)) + { context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri { Client = client, - RedirectUri = "http://127.0.0.1:7890/" + RedirectUri = redirectUri }); - - context.Set().Add(new IdentityServer8.EntityFramework.Entities.ClientSecret - { - Client = client, - Value = "postit-secret".ToSha256(), - }); - - context.SaveChanges(); + changed = true; } - }; + } + + var legacyRedirects = existingRedirects + .Where(r => r.RedirectUri == "https://yavsc.pschneider.fr/" + || r.RedirectUri == "yavsc://callback") + .ToList(); + if (legacyRedirects.Count > 0) + { + context.Set().RemoveRange(legacyRedirects); + changed = true; + } + + if (changed) + { + context.SaveChanges(); + } } private static void ConfigureRequestLocalization(IServiceCollection services)