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.
This commit is contained in:
parent
2fd799c09f
commit
512a0ef06f
9 changed files with 324 additions and 116 deletions
|
|
@ -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<TokenResponse> RequestClientCredentialsTokenAsync()
|
||||
/// <summary>
|
||||
/// Performs an interactive Authorization Code + PKCE login against the
|
||||
/// configured authority and stores the resulting access token in
|
||||
/// <see cref="BearerToken"/>. No client secret is sent; PKCE prevents
|
||||
/// authorization-code interception by relying on a per-request verifier
|
||||
/// generated locally and never leaving the device.
|
||||
/// </summary>
|
||||
/// <param name="browser">
|
||||
/// Platform-specific <see cref="IBrowser"/> implementation. On desktop
|
||||
/// pass a <c>LoopbackBrowser</c>; on Android a custom-scheme
|
||||
/// deep-link browser is required.
|
||||
/// </param>
|
||||
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<DiscoveryDocument>(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<string, string>
|
||||
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<TokenResponse>(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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue