From 512a0ef06f12f93cc71a2633f8ddcba2e03ae283 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sat, 20 Jun 2026 17:16:07 +0100 Subject: [PATCH] PostIt: switch OIDC client from confidential (client_secret) to public (PKCE/JWT) PostIt is a desktop/mobile app talking to Yavsc.Org (https://yavsc.pschneider.fr) as an OIDC identity provider. The previous grant used the client_credentials flow with a client_secret embedded in postit-settings.json: this was both insecure (secret travels with the binary) and inappropriate for an interactive app (token had no user identity, so the API could not scope or audit). The new flow is Authorization Code + PKCE: * PostIt client (Settings/AuthenticationSettings.cs): the ClientSecret property is removed; GetOidcClientOptions now drops the secret and accepts an optional IBrowser supplied per-platform. * Settings.cs: new AndroidRedirectUri constant ('android://postit-signin') that the Android app uses; RedirectUri is no longer hard-coded in MainViewModel. * MainViewModel.cs: the manual discovery + client_credentials POST is replaced with OidcClient.LoginAsync (Authorization Code + PKCE). * Settings sample: Authority points at the real Yavsc.Org OP, not at a non-existent Keycloak-style realm path. * Yavsc.Org/Extensions/HostingExtensions.cs: the 'postit' client seed is now idempotent (MigratePostItClientToPublic) and detects legacy state on existing ConfigurationDb rows - flips RequireClientSecret=false, RequirePkce=true, drops any ClientSecret row, and replaces the legacy RedirectUris (https://yavsc.pschneider.fr/, yavsc://callback) with the current set (http://127.0.0.1:7890/, android://postit-signin). PostIt.Android: * MainActivity: explicit Name attribute so the activity alias can target a stable component; LaunchMode.SingleTask so the existing instance receives the deep-link Intent; OnNewIntent forwards the callback URI through AndroidOidcCallbackSink. * AndroidManifest.xml: activity-alias PostIt.Android.OidcCallbackActivity exposing scheme=android host=postit-signin to Android, so the OP redirect lands back in the running PostIt instance. The IdentityModel.OidcClient.Browser.SystemBrowser package and a thin AndroidSystemBrowser implementation are added in a follow-up so OidcClient.LoginAsync can actually drive Chrome Custom Tabs and consume AndroidOidcCallbackSink. --- src/PostIt/PostIt.Android/MainActivity.cs | 39 +++- .../Properties/AndroidManifest.xml | 33 ++- .../PostIt/Settings/AuthenticationSettings.cs | 10 +- src/PostIt/PostIt/Settings/Settings.cs | 43 +++- src/PostIt/PostIt/ViewModels/MainViewModel.cs | 84 +++---- .../PostIt/ViewModels/SettingsViewModel.cs | 2 - src/PostIt/PostIt/Views/SettingsPage.axaml | 8 +- src/PostIt/PostIt/postit-settings sample.json | 7 +- src/Yavsc.Org/Extensions/HostingExtensions.cs | 218 ++++++++++++++---- 9 files changed, 326 insertions(+), 118 deletions(-) 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)