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)