Compare commits
No commits in common. "0adc60d5ed0e63e382ddf8a2d0609cb25e50616a" and "0480e3e3e8a80e58c8e33f320d1f6f840d05398d" have entirely different histories.
0adc60d5ed
...
0480e3e3e8
25 changed files with 383 additions and 756 deletions
|
|
@ -1,16 +0,0 @@
|
||||||
info:
|
|
||||||
name: Get Posts
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: GET
|
|
||||||
url: https://jsonplaceholder.typicode.com/users
|
|
||||||
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
||||||
docs: This request retrieves a list of users from the JSONPlaceholder API.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
info:
|
|
||||||
name: Untitled
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: GET
|
|
||||||
url: ""
|
|
||||||
auth: inherit
|
|
||||||
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
info:
|
|
||||||
name: blogs
|
|
||||||
type: http
|
|
||||||
seq: 1
|
|
||||||
|
|
||||||
http:
|
|
||||||
method: GET
|
|
||||||
url: "{{Blogs}}/api/v1/blog"
|
|
||||||
auth: inherit
|
|
||||||
|
|
||||||
settings:
|
|
||||||
encodeUrl: true
|
|
||||||
timeout: 0
|
|
||||||
followRedirects: true
|
|
||||||
maxRedirects: 5
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
name: Development
|
|
||||||
variables:
|
|
||||||
- name: Blogs
|
|
||||||
value: https://localhost:5003
|
|
||||||
- name: Authority
|
|
||||||
value: https://localhost:5001
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
name: Production
|
|
||||||
variables:
|
|
||||||
- name: Authority
|
|
||||||
value: https://yavsc.pschneider.fr
|
|
||||||
- name: Blogs
|
|
||||||
value: https://blogs.pschneider.fr
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
opencollection: 1.0.0
|
|
||||||
|
|
||||||
info:
|
|
||||||
name: blogs
|
|
||||||
config:
|
|
||||||
proxy:
|
|
||||||
inherit: true
|
|
||||||
config:
|
|
||||||
protocol: http
|
|
||||||
hostname: ""
|
|
||||||
port: ""
|
|
||||||
auth:
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
bypassProxy: ""
|
|
||||||
|
|
||||||
request:
|
|
||||||
auth:
|
|
||||||
type: oauth2
|
|
||||||
flow: authorization_code
|
|
||||||
authorizationUrl: "{{Authority}}/connect/authorize"
|
|
||||||
accessTokenUrl: "{{Authority}}/connect/token"
|
|
||||||
callbackUrl: "{{Authority}}"
|
|
||||||
credentials:
|
|
||||||
clientId: postit
|
|
||||||
placement: basic_auth_header
|
|
||||||
scope: openid blogs
|
|
||||||
pkce: {}
|
|
||||||
tokenConfig:
|
|
||||||
id: credentials
|
|
||||||
placement:
|
|
||||||
header: Bearer
|
|
||||||
source: access_token
|
|
||||||
settings:
|
|
||||||
autoFetchToken: true
|
|
||||||
autoRefreshToken: false
|
|
||||||
bundled: false
|
|
||||||
extensions:
|
|
||||||
bruno:
|
|
||||||
ignore:
|
|
||||||
- node_modules
|
|
||||||
- .git
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using PostIt.Services;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Diagnostic coverage for the 401 we're seeing in production when
|
|
||||||
/// PostIt talks to <c>Yavsc.Blogs</c>. The hypothesis this file
|
|
||||||
/// isolates: "the access token sent on the wire is missing the
|
|
||||||
/// <c>blogs</c> scope that <c>Yavsc.Blogs</c>'s <c>BlogScope</c>
|
|
||||||
/// policy requires". The policy lives in
|
|
||||||
/// <c>Yavsc.Blogs/Program.cs</c> as
|
|
||||||
/// <c>RequireClaim(JwtClaimTypes.Scope, "blogs")</c>.
|
|
||||||
///
|
|
||||||
/// <para>
|
|
||||||
/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
|
|
||||||
/// any network listener. The test fakes a single
|
|
||||||
/// <see cref="HttpMessageHandler"/> that captures the outbound
|
|
||||||
/// request, deserialises the bearer JWT, and asserts the
|
|
||||||
/// <c>scope</c> claim contains the segment the policy needs. This
|
|
||||||
/// pins the client side of the contract so a future regression in
|
|
||||||
/// <see cref="YavscApiClient"/> or <see cref="Settings"/> (e.g. a
|
|
||||||
/// silently dropped scope, a wrong merge order, a scope string
|
|
||||||
/// that no longer matches the server policy) trips the test before
|
|
||||||
/// it reaches production.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public class BearerScopeTests
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Hard-coded <c>blogs</c> scope string. Mirrors the value in
|
|
||||||
/// <c>Yavsc.Blogs/Program.cs</c>'s <c>BlogScope</c> policy; if
|
|
||||||
/// the server ever moves to <c>"blog.read"</c> or similar this
|
|
||||||
/// constant should be updated to match.
|
|
||||||
/// </summary>
|
|
||||||
private const string RequiredScope = "blogs";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt()
|
|
||||||
{
|
|
||||||
// Build the exact scope list a user would have in
|
|
||||||
// postit-settings.json. MergeScopes (called inside
|
|
||||||
// YavscApiClient when issuing the authorize request) would
|
|
||||||
// have appended "openid profile offline_access", so the
|
|
||||||
// access token in real life carries all of them. The test
|
|
||||||
// pins that the scope the *server* needs survived the
|
|
||||||
// round trip from settings.json to the access_token.
|
|
||||||
var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope };
|
|
||||||
var scopeInAccessToken = string.Join(' ', userScopes);
|
|
||||||
|
|
||||||
// Mint a fake access token whose only payload claim is
|
|
||||||
// "scope". No signature: the client never verifies, and the
|
|
||||||
// production server doesn't see this token (we mock the
|
|
||||||
// HttpMessageHandler, so the message never leaves the
|
|
||||||
// process).
|
|
||||||
var accessToken = MintUnsignedJwt(scopeInAccessToken);
|
|
||||||
|
|
||||||
var settings = new PostIt.ViewModels.Settings
|
|
||||||
{
|
|
||||||
Authentication = new AuthenticationSettings
|
|
||||||
{
|
|
||||||
Authority = "https://example.invalid",
|
|
||||||
ClientId = "postit-tests",
|
|
||||||
Scopes = userScopes,
|
|
||||||
RedirectUri = "postit://callback",
|
|
||||||
},
|
|
||||||
BusinessApiUrl = "https://example.invalid/api/v1/",
|
|
||||||
};
|
|
||||||
|
|
||||||
var tokensPath = Path.Combine(
|
|
||||||
Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Pre-seed the token store so YavscApiClient believes
|
|
||||||
// it has a valid session and CallAsync does not refuse
|
|
||||||
// to send.
|
|
||||||
var store = new TokenStore(tokensPath);
|
|
||||||
store.Save(new RefreshTokenRecord(
|
|
||||||
AccessToken: accessToken,
|
|
||||||
RefreshToken: "irrelevant-for-this-test",
|
|
||||||
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
|
|
||||||
IdToken: null));
|
|
||||||
|
|
||||||
// CapturingHttpHandler is the assertion point. It
|
|
||||||
// records the first request's Authorization header and
|
|
||||||
// returns 200 with an empty array (BlogApiClient
|
|
||||||
// deserialises to List<BlogPost>).
|
|
||||||
var captured = new CapturingHttpHandler();
|
|
||||||
var client = new YavscApiClient(
|
|
||||||
settings,
|
|
||||||
store,
|
|
||||||
// Bypass OidcClient construction (it would try to
|
|
||||||
// resolve an Authority we don't have a real IdP
|
|
||||||
// for). The handler we inject below is what the
|
|
||||||
// bearer attaches the token to; refresh paths are
|
|
||||||
// not exercised in this test.
|
|
||||||
oidc: null!);
|
|
||||||
|
|
||||||
// YavscApiClient builds its own HttpClient around a
|
|
||||||
// BearerTokenHandler(new HttpClientHandler()) in its
|
|
||||||
// constructor; the handler is not exposed for
|
|
||||||
// replacement. The seam we use: CallAsync is virtual,
|
|
||||||
// so a subclass that talks to a caller-supplied
|
|
||||||
// HttpMessageHandler lets us assert on the outbound
|
|
||||||
// request without standing up any server.
|
|
||||||
var subClient = new TestableYavscApiClient(
|
|
||||||
settings, store, captured, accessToken);
|
|
||||||
|
|
||||||
// Resolve a BlogApiClient on top. We don't need real
|
|
||||||
// posts; we just need the outbound HTTP request to be
|
|
||||||
// the one we capture.
|
|
||||||
var blog = new BlogApiClient(subClient);
|
|
||||||
|
|
||||||
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
// The test only makes sense if we did capture
|
|
||||||
// something. If we got here with an empty capture, the
|
|
||||||
// BlogApiClient chose a non-HTTP path and this whole
|
|
||||||
// setup is wrong.
|
|
||||||
Assert.NotNull(captured.Authorization);
|
|
||||||
Assert.StartsWith("Bearer ", captured.Authorization);
|
|
||||||
|
|
||||||
var jwt = captured.Authorization.Substring("Bearer ".Length).Trim();
|
|
||||||
var scopes = ExtractScopes(jwt);
|
|
||||||
|
|
||||||
Assert.Contains(RequiredScope, scopes);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (File.Exists(tokensPath)) File.Delete(tokensPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers -------------------------------------------------------
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Build an unsigned JWT carrying a single <c>scope</c> claim.
|
|
||||||
/// Mirrors the read-only fallback in
|
|
||||||
/// <see cref="YavscApiClient.ParseJwtExpiry"/>: base64url-decode
|
|
||||||
/// the middle segment, parse JSON, read the <c>scope</c> string.
|
|
||||||
/// The header and signature are placeholders — nobody in the
|
|
||||||
/// test path verifies the signature.
|
|
||||||
/// </summary>
|
|
||||||
private static string MintUnsignedJwt(string scope)
|
|
||||||
{
|
|
||||||
var header = Base64Url("""{"alg":"none","typ":"JWT"}""");
|
|
||||||
var payload = Base64Url(JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
sub = "test-user",
|
|
||||||
iss = "https://example.invalid",
|
|
||||||
aud = "postit",
|
|
||||||
exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(),
|
|
||||||
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
||||||
scope,
|
|
||||||
}));
|
|
||||||
return $"{header}.{payload}.";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Base64Url(string s)
|
|
||||||
{
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(s);
|
|
||||||
return Convert.ToBase64String(bytes)
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Pull the <c>scope</c> claim out of a (possibly unsigned) JWT
|
|
||||||
/// and split on whitespace, the canonical encoding per RFC 8693
|
|
||||||
/// §4.2 and OpenID Connect Core 1.0 §5.1.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyCollection<string> ExtractScopes(string jwt)
|
|
||||||
{
|
|
||||||
var parts = jwt.Split('.');
|
|
||||||
Assert.True(parts.Length >= 2, "JWT must have a payload segment");
|
|
||||||
|
|
||||||
var payload = parts[1].Replace('-', '+').Replace('_', '/');
|
|
||||||
switch (payload.Length % 4)
|
|
||||||
{
|
|
||||||
case 2: payload += "=="; break;
|
|
||||||
case 3: payload += "="; break;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
|
|
||||||
if (!doc.RootElement.TryGetProperty("scope", out var scopeEl))
|
|
||||||
{
|
|
||||||
return Array.Empty<string>();
|
|
||||||
}
|
|
||||||
var raw = scopeEl.GetString() ?? string.Empty;
|
|
||||||
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Minimal <see cref="HttpMessageHandler"/> that records the
|
|
||||||
/// first request's <c>Authorization</c> header and replies 200
|
|
||||||
/// with an empty JSON array. Anything beyond the first request
|
|
||||||
/// is a regression in the test setup, not the production code
|
|
||||||
/// path under test.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class CapturingHttpHandler : HttpMessageHandler
|
|
||||||
{
|
|
||||||
public string? Authorization { get; private set; }
|
|
||||||
public Uri? RequestUri { get; private set; }
|
|
||||||
|
|
||||||
protected override Task<HttpResponseMessage> SendAsync(
|
|
||||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
Authorization = request.Headers.Authorization?.ToString();
|
|
||||||
RequestUri = request.RequestUri;
|
|
||||||
|
|
||||||
var response = new HttpResponseMessage(HttpStatusCode.OK)
|
|
||||||
{
|
|
||||||
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
return Task.FromResult(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
|
|
||||||
/// traffic through a caller-supplied
|
|
||||||
/// <see cref="HttpMessageHandler"/>. The base ctor wires
|
|
||||||
/// <c>Http</c> as <c>new HttpClient(BearerTokenHandler(...))</c>;
|
|
||||||
/// we don't replace that — we override the public call seam
|
|
||||||
/// <see cref="YavscApiClient.CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>
|
|
||||||
/// (declared <c>virtual</c>) and talk to our own HttpClient
|
|
||||||
/// from there. The <c>EnsureFreshToken</c> / 401-retry path
|
|
||||||
/// is intentionally not exercised here — that lives in
|
|
||||||
/// <c>YavscApiClientTests</c>; isolating the bearer
|
|
||||||
/// attachment is the whole point of this test.
|
|
||||||
/// </summary>
|
|
||||||
private sealed class TestableYavscApiClient : YavscApiClient
|
|
||||||
{
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly string _accessToken;
|
|
||||||
|
|
||||||
public TestableYavscApiClient(
|
|
||||||
PostIt.ViewModels.Settings settings,
|
|
||||||
TokenStore store,
|
|
||||||
HttpMessageHandler handler,
|
|
||||||
string accessToken)
|
|
||||||
: base(settings, store, oidc: null!)
|
|
||||||
{
|
|
||||||
_http = new HttpClient(handler, disposeHandler: false);
|
|
||||||
_accessToken = accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<T> CallAsync<T>(
|
|
||||||
HttpMethod method, string path, object? body = null,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Reproduce just enough of the production request
|
|
||||||
// shape: a real HttpRequestMessage with the bearer
|
|
||||||
// attached, so the assertion in the test is faithful.
|
|
||||||
// We skip the EnsureFreshToken/401-retry machinery on
|
|
||||||
// purpose — that path is already covered by
|
|
||||||
// YavscApiClientTests, and isolating the bearer
|
|
||||||
// attachment is exactly what this test exists for.
|
|
||||||
//
|
|
||||||
// The base YavscApiClient relies on HttpClient.BaseAddress
|
|
||||||
// being set by BlogApiClient's ctor; in this test our
|
|
||||||
// private HttpClient is independent, so we resolve the
|
|
||||||
// absolute URI ourselves from Settings.BusinessApiUrl —
|
|
||||||
// the same URL BlogApiClient would have set as BaseAddress.
|
|
||||||
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
|
||||||
using var req = new HttpRequestMessage(method, absolute);
|
|
||||||
req.Headers.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
|
||||||
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
|
||||||
resp.EnsureSuccessStatusCode();
|
|
||||||
using var stream = resp.Content.ReadAsStream();
|
|
||||||
var dto = JsonSerializer.Deserialize<T>(stream,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
||||||
return Task.FromResult(dto!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
257
src/PostIt.Tests/LoginPageViewModelTests.cs
Normal file
257
src/PostIt.Tests/LoginPageViewModelTests.cs
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PostIt.ViewModels;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PostIt.Tests;
|
||||||
|
|
||||||
|
public class LoginPageViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority()
|
||||||
|
{
|
||||||
|
// Arrange: spin up a stub OIDC authority and a fake browser that
|
||||||
|
// short-circuits the system browser. The authority signs its
|
||||||
|
// access_token with RS256; the fake browser captures the redirect
|
||||||
|
// URI so the authority can complete the token exchange.
|
||||||
|
using var authority = await OIDCStubAuthority.StartAsync();
|
||||||
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
||||||
|
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = authority.Issuer,
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
|
Scopes = new[] { "openid", "profile", "blog" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
// Assert: the ViewModel surfaced a token, not an error.
|
||||||
|
Assert.True(
|
||||||
|
!string.IsNullOrEmpty(vm.AccessToken),
|
||||||
|
$"Login did not produce a token. StatusMessage={vm.StatusMessage ?? "<null>"}");
|
||||||
|
Assert.False(
|
||||||
|
vm.StatusMessage?.StartsWith("Error") == true,
|
||||||
|
$"Login reported error: {vm.StatusMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
|
||||||
|
{
|
||||||
|
// Regression: when no user settings file exists and the embedded
|
||||||
|
// default somehow fails to load (e.g. resource stripped at publish
|
||||||
|
// time), the ViewModel must NOT hand a blank Authority to
|
||||||
|
// OidcClient — IdentityModel would build a bogus authorize URL
|
||||||
|
// like "http://127.0.0.1:1/" which the browser rejects with a
|
||||||
|
// confusing error. Surface a clear, actionable message instead.
|
||||||
|
//
|
||||||
|
// SettingsLoadOverride is set to a no-op so the test fixture's
|
||||||
|
// pre-loaded Settings object survives the call to LoginAsync.
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "",
|
||||||
|
ClientId = "postit-tests",
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var browserInvoked = false;
|
||||||
|
var vm = new LoginPageViewModel(settings, () =>
|
||||||
|
{
|
||||||
|
browserInvoked = true;
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
// Skip the disk / embedded read so the Authority stays empty.
|
||||||
|
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.False(
|
||||||
|
browserInvoked,
|
||||||
|
"Browser factory was invoked even though Authority was empty.");
|
||||||
|
Assert.NotNull(vm.StatusMessage);
|
||||||
|
Assert.Contains("Configuration manquante", vm.StatusMessage);
|
||||||
|
Assert.Contains("postit-settings.json", vm.StatusMessage);
|
||||||
|
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_works_when_authority_has_trailing_slash()
|
||||||
|
{
|
||||||
|
// Regression: with Authority ending in "/" (the production
|
||||||
|
// postit-settings.json shape for https://yavsc.pschneider.fr/),
|
||||||
|
// the discovery URL OidcClient computes must NOT contain a
|
||||||
|
// double slash before /.well-known/openid-configuration. The
|
||||||
|
// stub advertises itself without the trailing slash; OidcClient
|
||||||
|
// must bridge.
|
||||||
|
using var authority = await OIDCStubAuthority.StartAsync();
|
||||||
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
||||||
|
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = authority.Issuer + "/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
!string.IsNullOrEmpty(vm.AccessToken),
|
||||||
|
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
|
||||||
|
// Trailing slash on Authority is normalised away.
|
||||||
|
Assert.Equal(
|
||||||
|
"https://yavsc.example.com/Account/Register",
|
||||||
|
vm.RegisterUrl);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://yavsc.example.com/Account/ForgotPassword",
|
||||||
|
vm.ForgotPasswordUrl);
|
||||||
|
Assert.True(vm.HasRegisterUrl);
|
||||||
|
Assert.True(vm.HasForgotPasswordUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterUrl_is_empty_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.Equal(string.Empty, vm.RegisterUrl);
|
||||||
|
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
|
||||||
|
Assert.False(vm.HasRegisterUrl);
|
||||||
|
Assert.False(vm.HasForgotPasswordUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigMissing_is_true_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.True(vm.ConfigMissing);
|
||||||
|
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConfigMissing_is_false_when_authority_is_set()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
Assert.False(vm.ConfigMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
|
||||||
|
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
|
||||||
|
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
|
||||||
|
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings { Authority = authority }
|
||||||
|
};
|
||||||
|
var vm = new LoginPageViewModel(settings);
|
||||||
|
Assert.Equal(expected, vm.DiscoveryUrl);
|
||||||
|
// ExternalUrl is the slash-normalised form of Authority.
|
||||||
|
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DiscoveryUrl_is_empty_when_authority_is_unset()
|
||||||
|
{
|
||||||
|
var vm = new LoginPageViewModel(new PostIt.Settings());
|
||||||
|
Assert.Equal(string.Empty, vm.DiscoveryUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_failure_message_includes_discovery_url()
|
||||||
|
{
|
||||||
|
// Arrange: settings point at an unreachable authority; the test
|
||||||
|
// browser throws synchronously to guarantee the catch branch runs.
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://does-not-exist.invalid/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
// Assert: the surfaced error mentions the canonical discovery URL,
|
||||||
|
// so it can be copy-pasted into a browser to diagnose reachability.
|
||||||
|
Assert.NotNull(vm.StatusMessage);
|
||||||
|
Assert.StartsWith("Error:", vm.StatusMessage);
|
||||||
|
Assert.Contains(
|
||||||
|
"https://does-not-exist.invalid/.well-known/openid-configuration",
|
||||||
|
vm.StatusMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
|
||||||
|
{
|
||||||
|
var settings = new PostIt.Settings
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationSettings
|
||||||
|
{
|
||||||
|
Authority = "https://yavsc.example.com/",
|
||||||
|
ClientId = "postit-tests"
|
||||||
|
},
|
||||||
|
RedirectUri = "http://127.0.0.1:7890/",
|
||||||
|
Scopes = new[] { "openid" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var vm = new LoginPageViewModel(settings, () => null);
|
||||||
|
|
||||||
|
await vm.LoginAsync();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
"https://yavsc.example.com/.well-known/openid-configuration",
|
||||||
|
vm.StatusMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,11 +60,11 @@ public class PostItViewModelTests
|
||||||
public ThrowingYavscApiClient() : base(
|
public ThrowingYavscApiClient() : base(
|
||||||
new Settings
|
new Settings
|
||||||
{
|
{
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://stub.invalid",
|
Authority = "https://stub.invalid",
|
||||||
ClientId = "stub",
|
ClientId = "stub",
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||||
|
|
@ -81,11 +81,11 @@ public class PostItViewModelTests
|
||||||
: base(
|
: base(
|
||||||
new Settings
|
new Settings
|
||||||
{
|
{
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://stub.invalid",
|
Authority = "https://stub.invalid",
|
||||||
ClientId = "stub",
|
ClientId = "stub",
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new TokenStore(System.IO.Path.GetTempFileName()))
|
new TokenStore(System.IO.Path.GetTempFileName()))
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ public class SettingsLoadTests
|
||||||
return; // nothing to assert: user file wins.
|
return; // nothing to assert: user file wins.
|
||||||
}
|
}
|
||||||
|
|
||||||
var settings = new PostIt.ViewModels.Settings();
|
var settings = new PostIt.Settings();
|
||||||
settings.Load();
|
settings.Load();
|
||||||
|
|
||||||
// The bundled postit-settings.json points at yavsc.pschneider.fr.
|
// The bundled postit-settings.json points at yavsc.pschneider.fr.
|
||||||
|
|
@ -48,7 +48,7 @@ public class SettingsLoadTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
|
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
|
||||||
{
|
{
|
||||||
var settings = new PostIt.ViewModels.Settings();
|
var settings = new PostIt.Settings();
|
||||||
|
|
||||||
// First load pre-populates Authentication.Authority so the
|
// First load pre-populates Authentication.Authority so the
|
||||||
// early-return path in Load() runs (we don't want file I/O
|
// early-return path in Load() runs (we don't want file I/O
|
||||||
|
|
@ -58,9 +58,9 @@ public class SettingsLoadTests
|
||||||
settings.Authentication = new AuthenticationSettings
|
settings.Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://example.test/",
|
Authority = "https://example.test/",
|
||||||
ClientId = "postit-tests",
|
ClientId = "postit-tests"
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
};
|
};
|
||||||
|
settings.Scopes = new[] { "openid" };
|
||||||
// Load() takes the early-return path because Authority is
|
// Load() takes the early-return path because Authority is
|
||||||
// already populated; flips Loaded=true under the gate.
|
// already populated; flips Loaded=true under the gate.
|
||||||
settings.Load();
|
settings.Load();
|
||||||
|
|
@ -90,10 +90,10 @@ public class SettingsLoadTests
|
||||||
{
|
{
|
||||||
bool flip = ((workerId + i) & 1) == 0;
|
bool flip = ((workerId + i) & 1) == 0;
|
||||||
settings.DarkMode = flip;
|
settings.DarkMode = flip;
|
||||||
settings.Authentication.RedirectUri =
|
settings.RedirectUri = flip
|
||||||
global::AuthenticationSettings.DefaultDesktopRedirectUri;
|
? PostIt.Settings.DefaultDesktopRedirectUri
|
||||||
|
: PostIt.Settings.DefaultLoopbackRedirectUri;
|
||||||
settings.BusinessApiUrl = flip
|
settings.ApiUrl = flip
|
||||||
? "https://a.example.test/api/v1/"
|
? "https://a.example.test/api/v1/"
|
||||||
: "https://b.example.test/api/v1/";
|
: "https://b.example.test/api/v1/";
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ public class SettingsLoadTests
|
||||||
// invariants that the gate protects.
|
// invariants that the gate protects.
|
||||||
Assert.True(settings.Loaded);
|
Assert.True(settings.Loaded);
|
||||||
Assert.NotNull(settings.Authentication);
|
Assert.NotNull(settings.Authentication);
|
||||||
Assert.NotNull(settings.Authentication.Scopes);
|
Assert.NotNull(settings.Scopes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -131,7 +131,7 @@ public class SettingsLoadTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_is_idempotent_under_concurrent_calls()
|
public void Load_is_idempotent_under_concurrent_calls()
|
||||||
{
|
{
|
||||||
var settings = new PostIt.ViewModels.Settings
|
var settings = new PostIt.Settings
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ using System.Threading.Tasks;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using IdentityModel.OidcClient.Browser;
|
using IdentityModel.OidcClient.Browser;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using PostIt.ViewModels;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace PostIt.Tests;
|
namespace PostIt.Tests;
|
||||||
|
|
@ -62,12 +61,6 @@ public class YavscApiClientTests
|
||||||
|
|
||||||
// Reload — YavscApiClient constructor reads the store.
|
// Reload — YavscApiClient constructor reads the store.
|
||||||
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
|
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
|
||||||
// Same BaseAddress dance as LoginAndPersistAsync: a fresh
|
|
||||||
// YavscApiClient starts with no BaseAddress, and the test
|
|
||||||
// calls CallAsync("posts", ...) directly (bypassing
|
|
||||||
// BlogApiClient, which is the only thing that would set
|
|
||||||
// it in production). Mirror prod here.
|
|
||||||
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
|
||||||
|
|
||||||
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
||||||
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
||||||
|
|
@ -118,16 +111,16 @@ public class YavscApiClientTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
|
public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
|
||||||
{
|
{
|
||||||
var settings = new Settings
|
var settings = new PostIt.Settings
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = "https://127.0.0.1:5001",
|
Authority = "https://127.0.0.1:5001",
|
||||||
ClientId = "postit-tests",
|
ClientId = "postit-tests",
|
||||||
RedirectUri = "postit://callback",
|
|
||||||
Scopes = new[] { "openid" },
|
|
||||||
},
|
},
|
||||||
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
|
RedirectUri = "postit://callback",
|
||||||
|
Scopes = new[] { "openid" },
|
||||||
|
ApiUrl = "https://127.0.0.1:5003/api/v1",
|
||||||
};
|
};
|
||||||
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
||||||
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
||||||
|
|
@ -162,30 +155,24 @@ public class YavscApiClientTests
|
||||||
|
|
||||||
// --- helpers --------------------------------------------------------
|
// --- helpers --------------------------------------------------------
|
||||||
|
|
||||||
private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
|
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
|
||||||
{
|
{
|
||||||
Authentication = new AuthenticationSettings
|
Authentication = new AuthenticationSettings
|
||||||
{
|
{
|
||||||
Authority = authority.Issuer,
|
Authority = authority.Issuer,
|
||||||
ClientId = "postit-tests",
|
ClientId = "postit-tests",
|
||||||
RedirectUri = authority.LoopbackRedirectUri,
|
|
||||||
Scopes = new[] { "openid", "profile", "blog" }
|
|
||||||
},
|
},
|
||||||
BusinessApiUrl = apiBaseUrl
|
RedirectUri = authority.LoopbackRedirectUri,
|
||||||
|
Scopes = new[] { "openid", "profile", "blog" },
|
||||||
|
ApiUrl = apiBaseUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
||||||
Settings settings, OIDCStubAuthority authority, string tokensPath)
|
PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath)
|
||||||
{
|
{
|
||||||
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
|
||||||
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
var client = new YavscApiClient(settings, new TokenStore(tokensPath));
|
||||||
|
|
||||||
// The two integration tests that call CallAsync("posts", ...)
|
|
||||||
// directly (bypassing BlogApiClient) rely on the same
|
|
||||||
// BaseAddress the production chain sets in BlogApiClient's
|
|
||||||
// ctor. Mirror that here so "posts" resolves to the stub.
|
|
||||||
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
|
||||||
|
|
||||||
// Force the API client to use the test browser by routing the
|
// Force the API client to use the test browser by routing the
|
||||||
// LoginInteractiveAsync call through a small wrapper.
|
// LoginInteractiveAsync call through a small wrapper.
|
||||||
await LoginWithBrowserAsync(client, browser.CreateBrowser());
|
await LoginWithBrowserAsync(client, browser.CreateBrowser());
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ internal static class PlatformBootstrap
|
||||||
// Use the custom-scheme redirect on Desktop. Loopback is only
|
// Use the custom-scheme redirect on Desktop. Loopback is only
|
||||||
// a fallback for platforms that cannot register postit://
|
// a fallback for platforms that cannot register postit://
|
||||||
// (see Settings.DefaultLoopbackRedirectUri for that path).
|
// (see Settings.DefaultLoopbackRedirectUri for that path).
|
||||||
Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
|
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri;
|
||||||
Platform.CustomScheme = "postit";
|
Platform.CustomScheme = "postit";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ public partial class App : Application
|
||||||
services.AddSingleton(api);
|
services.AddSingleton(api);
|
||||||
services.AddSingleton(client);
|
services.AddSingleton(client);
|
||||||
services.AddTransient<MainPageViewModel>();
|
services.AddTransient<MainPageViewModel>();
|
||||||
services.AddTransient<Settings>();
|
services.AddTransient<SettingsPageViewModel>();
|
||||||
services.AddTransient<HomePageViewModel>();
|
services.AddTransient<HomePageViewModel>();
|
||||||
services.AddTransient<SignaturePageViewModel>();
|
services.AddTransient<SignaturePageViewModel>();
|
||||||
|
|
||||||
|
|
@ -130,22 +130,6 @@ public partial class App : Application
|
||||||
_ = PushMainPageAsync(provider, w);
|
_ = PushMainPageAsync(provider, w);
|
||||||
};
|
};
|
||||||
|
|
||||||
// When the user clicks the "Paramètres" button on the
|
|
||||||
// session banner, push the SettingsPage on top of the
|
|
||||||
// current navigation stack. Resolved from DI so the
|
|
||||||
// ViewLocator + service-locator dance stays out of the
|
|
||||||
// VM, and bound to the same Settings singleton the rest
|
|
||||||
// of the app is using (the one we Load()'d at startup).
|
|
||||||
// Two-way bindings on the page mutate that singleton
|
|
||||||
// in place; callers re-read on next access.
|
|
||||||
sessionStatus.OpenSettingsRequested += () =>
|
|
||||||
{
|
|
||||||
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
|
|
||||||
var settingsPage = provider.GetRequiredService<SettingsPage>();
|
|
||||||
settingsPage.DataContext = provider.GetRequiredService<Settings>();
|
|
||||||
_ = w.NavRoot.PushAsync(settingsPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.Opened += async (_, _) => await BootAsync(provider, api, window);
|
window.Opened += async (_, _) => await BootAsync(provider, api, window);
|
||||||
}
|
}
|
||||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,6 @@ public sealed class BlogApiClient
|
||||||
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
|
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
|
||||||
{
|
{
|
||||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||||
|
|
||||||
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
|
|
||||||
// trailing slash so relative paths ("posts") resolve correctly.
|
|
||||||
api.Http.BaseAddress = new Uri(api.Settings.BusinessApiUrl);
|
|
||||||
|
|
||||||
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
|
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using PostIt.ViewModels;
|
|
||||||
|
|
||||||
namespace PostIt.Services;
|
namespace PostIt.Services;
|
||||||
|
|
||||||
|
|
@ -30,10 +29,10 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
// network latency + JWT validation on the server side.
|
// network latency + JWT validation on the server side.
|
||||||
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
|
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
public Settings Settings { get; }
|
private readonly Settings _settings;
|
||||||
private readonly OidcClient _oidc;
|
private readonly OidcClient _oidc;
|
||||||
private readonly TokenStore _store;
|
private readonly TokenStore _store;
|
||||||
public HttpClient Http { get; }
|
private readonly HttpClient _http;
|
||||||
private readonly BearerTokenHandler _bearer;
|
private readonly BearerTokenHandler _bearer;
|
||||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||||
|
|
||||||
|
|
@ -41,12 +40,17 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
|
|
||||||
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
|
||||||
{
|
{
|
||||||
Settings = settings;
|
_settings = settings;
|
||||||
_store = store;
|
_store = store;
|
||||||
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
|
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
|
||||||
|
|
||||||
_bearer = new BearerTokenHandler(this);
|
_bearer = new BearerTokenHandler(this);
|
||||||
Http = new HttpClient(_bearer, disposeHandler: true);
|
_http = new HttpClient(_bearer, disposeHandler: true)
|
||||||
|
{
|
||||||
|
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
|
||||||
|
// trailing slash so relative paths ("posts") resolve correctly.
|
||||||
|
BaseAddress = new Uri(settings.ApiUrl)
|
||||||
|
};
|
||||||
|
|
||||||
_tokens = store.Load();
|
_tokens = store.Load();
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +101,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
throw new InvalidOperationException("No browser is available on this platform.");
|
throw new InvalidOperationException("No browser is available on this platform.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var client = new OidcClient(Settings.GetOidcClientOptions(browser));
|
var client = new OidcClient(_settings.GetOidcClientOptions(browser));
|
||||||
|
|
||||||
// OidcClient.LoginAsync builds the authorize URL, calls
|
// OidcClient.LoginAsync builds the authorize URL, calls
|
||||||
// IBrowser.InvokeAsync (which on desktop hands the user off
|
// IBrowser.InvokeAsync (which on desktop hands the user off
|
||||||
|
|
@ -241,7 +245,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
using var req = new HttpRequestMessage(method, path);
|
using var req = new HttpRequestMessage(method, path);
|
||||||
if (body is not null)
|
if (body is not null)
|
||||||
req.Content = JsonContent.Create(body);
|
req.Content = JsonContent.Create(body);
|
||||||
var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
|
var response = await _http.SendAsync(req, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
{
|
{
|
||||||
|
|
@ -253,7 +257,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
using var retry = new HttpRequestMessage(method, path);
|
using var retry = new HttpRequestMessage(method, path);
|
||||||
if (body is not null)
|
if (body is not null)
|
||||||
retry.Content = JsonContent.Create(body);
|
retry.Content = JsonContent.Create(body);
|
||||||
response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
|
response = await _http.SendAsync(retry, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -320,7 +324,7 @@ public class YavscApiClient : IAsyncDisposable
|
||||||
|
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
Http.Dispose();
|
_http.Dispose();
|
||||||
_refreshGate.Dispose();
|
_refreshGate.Dispose();
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,11 @@ using System;
|
||||||
|
|
||||||
public partial class AuthenticationSettings : ObservableObject
|
public partial class AuthenticationSettings : ObservableObject
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Default custom-scheme redirect URI on Desktop. The OS routes the
|
|
||||||
/// callback to the running PostIt instance via the named-pipe
|
|
||||||
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
|
|
||||||
/// (RFC 8252 §7.1). Production Desktop builds use this.
|
|
||||||
/// </summary>
|
|
||||||
public const string DefaultDesktopRedirectUri = "postit://callback";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Redirect URI used by the Android app. The corresponding IntentFilter
|
|
||||||
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
|
|
||||||
/// </summary>
|
|
||||||
public const string AndroidRedirectUri = "android://postit-signin";
|
|
||||||
|
|
||||||
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
|
|
||||||
|
|
||||||
public static string DefaultClientId { get; internal set; } = "postit";
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Authority { get; set; }
|
public partial string Authority { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string ClientId { get; set; }
|
public partial string ClientId { get; set; }
|
||||||
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial string[] Scopes { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
|
|
||||||
/// (custom URI scheme) which is the right answer for desktop
|
|
||||||
/// production builds. Mobile platforms must set this to
|
|
||||||
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,29 +1,48 @@
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PostIt.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("PostIt.Tests")]
|
[assembly: InternalsVisibleTo("PostIt.Tests")]
|
||||||
|
|
||||||
namespace PostIt.ViewModels;
|
namespace PostIt;
|
||||||
|
|
||||||
public partial class Settings : ViewModelBase
|
public partial class Settings : ObservableObject
|
||||||
{
|
{
|
||||||
const string SettingsFileName = "postit-settings.json";
|
const string SettingsFileName = "postit-settings.json";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy loopback redirect URI. The post-2026.6 production flow
|
||||||
|
/// uses the custom URI scheme (<see cref="DefaultDesktopRedirectUri"/>
|
||||||
|
/// on desktop, <see cref="AndroidRedirectUri"/> on Android) so the
|
||||||
|
/// OS hands the callback to the running instance without a TCP
|
||||||
|
/// listener. The loopback constant stays here so test fixtures
|
||||||
|
/// (which spin up an in-process OidcStubAuthority) keep working,
|
||||||
|
/// but it is no longer used as a default anywhere in production.
|
||||||
|
/// If you are still pointing your production <c>postit-settings.json</c>
|
||||||
|
/// at this URI, switch to <c>postit://callback</c> and remove the
|
||||||
|
/// matching entry from the Yavsc.Org server's allowed redirect URIs.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Redirect URI used by the Android app. The corresponding IntentFilter
|
/// Redirect URI used by the Android app. The corresponding IntentFilter
|
||||||
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
|
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string AndroidRedirectUri = "android://postit-signin";
|
public const string AndroidRedirectUri = "android://postit-signin";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default custom-scheme redirect URI on Desktop. The OS routes the
|
||||||
|
/// callback to the running PostIt instance via the named-pipe
|
||||||
|
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
|
||||||
|
/// (RFC 8252 §7.1). Production Desktop builds use this.
|
||||||
|
/// </summary>
|
||||||
|
public const string DefaultDesktopRedirectUri = "postit://callback";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process-wide canonical <see cref="Settings"/> instance, wired up
|
/// Process-wide canonical <see cref="Settings"/> instance, wired up
|
||||||
|
|
@ -88,62 +107,22 @@ public partial class Settings : ViewModelBase
|
||||||
public partial bool DarkMode { get; set; } = false;
|
public partial bool DarkMode { get; set; } = false;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
|
||||||
|
/// (custom URI scheme) which is the right answer for desktop
|
||||||
|
/// production builds. Mobile platforms must set this to
|
||||||
|
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
|
||||||
|
/// </summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
|
||||||
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
|
public partial string[] Scopes { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Catch top-level mutations: the four ObservableProperty
|
|
||||||
/// setters above all funnel through here, and we flip
|
|
||||||
/// <see cref="IsDirty"/> in lock-step. Sub-property mutations
|
|
||||||
/// (e.g. <c>Authentication.Authority</c>) are caught by the
|
|
||||||
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
|
|
||||||
/// below. <see cref="ApplyJson"/> disables the flag during bulk
|
|
||||||
/// hydration so the disk load itself does not count as a user
|
|
||||||
/// edit.
|
|
||||||
/// </summary>
|
|
||||||
private void MarkDirty() => IsDirty = true;
|
|
||||||
|
|
||||||
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
|
||||||
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
|
||||||
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Authentication can be reassigned wholesale by
|
|
||||||
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
|
|
||||||
/// <c>PropertyChanged</c> listener so sub-property edits
|
|
||||||
/// (Authority, ClientId, RedirectUri, Scopes) are picked up
|
|
||||||
/// by the dirty tracker. We don't filter on PropertyName: any
|
|
||||||
/// nested setter is treated as a user edit, which matches the
|
|
||||||
/// user's mental model ("I typed in a field, the page is now
|
|
||||||
/// dirty").
|
|
||||||
/// </summary>
|
|
||||||
partial void OnAuthenticationChanged(AuthenticationSettings value)
|
|
||||||
{
|
|
||||||
if (value is not null)
|
|
||||||
{
|
|
||||||
value.PropertyChanged += (_, _) => MarkDirty();
|
|
||||||
}
|
|
||||||
MarkDirty();
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool Loaded { get; private set; } = false;
|
public bool Loaded { get; private set; } = false;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when the in-memory state has drifted from the last
|
|
||||||
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
|
|
||||||
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
|
|
||||||
/// this flag, so it only enables when the user has actually
|
|
||||||
/// touched something since the last load / save. Cleared by
|
|
||||||
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
|
|
||||||
/// every successful setter on the four top-level mutable
|
|
||||||
/// properties and on the sub-properties of
|
|
||||||
/// <see cref="Authentication"/>.
|
|
||||||
/// </summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
public partial bool IsDirty { get; private set; } = false;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
|
||||||
/// generates setters that call <c>SetProperty(...)</c> which fires
|
/// generates setters that call <c>SetProperty(...)</c> which fires
|
||||||
|
|
@ -175,8 +154,8 @@ public partial class Settings : ViewModelBase
|
||||||
{
|
{
|
||||||
Authority = Authentication.Authority,
|
Authority = Authentication.Authority,
|
||||||
ClientId = Authentication.ClientId,
|
ClientId = Authentication.ClientId,
|
||||||
RedirectUri = Authentication.RedirectUri,
|
RedirectUri = RedirectUri,
|
||||||
Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
|
Scope = string.Join(' ', this.Scopes),
|
||||||
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
|
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
|
||||||
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
|
PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
|
||||||
// PKCE is enabled by default when no client_secret is provided.
|
// PKCE is enabled by default when no client_secret is provided.
|
||||||
|
|
@ -189,48 +168,6 @@ public partial class Settings : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scopes the PostIt client always requires from the OIDC provider,
|
|
||||||
/// regardless of what the user has in their settings file.
|
|
||||||
///
|
|
||||||
/// <para>PostIt calls into the Blog API (and any other Yavsc API
|
|
||||||
/// gated by an <c>[Authorize("…Scope")]</c> policy) and is silent
|
|
||||||
/// about the contract: a missing scope here surfaces as a 401
|
|
||||||
/// on the very first API call after login, with no obvious link
|
|
||||||
/// to the settings. The "feature" scopes the user must opt into
|
|
||||||
/// (e.g. <c>blogs</c>) are still their choice — we only force the
|
|
||||||
/// structural ones that OIDC itself needs.</para>
|
|
||||||
/// </summary>
|
|
||||||
private static readonly string[] BuiltInScopes = new[]
|
|
||||||
{
|
|
||||||
"openid", // OIDC: required for the id_token
|
|
||||||
"profile", // OIDC: standard profile claims
|
|
||||||
"offline_access" // OIDC: required to receive a refresh_token
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Merge user-configured scopes with the built-in ones. User scopes
|
|
||||||
/// come first (preserves author intent), then the built-ins, with
|
|
||||||
/// duplicates removed case-sensitively. <c>null</c> or empty input
|
|
||||||
/// is fine — we still emit the built-ins.
|
|
||||||
/// </summary>
|
|
||||||
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
|
|
||||||
{
|
|
||||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
||||||
if (userScopes is not null)
|
|
||||||
{
|
|
||||||
foreach (var s in userScopes)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(s)) continue;
|
|
||||||
if (seen.Add(s)) yield return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (var s in BuiltInScopes)
|
|
||||||
{
|
|
||||||
if (seen.Add(s)) yield return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void Load()
|
internal void Load()
|
||||||
{
|
{
|
||||||
if (Loaded) return;
|
if (Loaded) return;
|
||||||
|
|
@ -343,28 +280,10 @@ public partial class Settings : ViewModelBase
|
||||||
{
|
{
|
||||||
this.Authentication = settings.Authentication;
|
this.Authentication = settings.Authentication;
|
||||||
this.DarkMode = settings.DarkMode;
|
this.DarkMode = settings.DarkMode;
|
||||||
if (!(settings.Authentication is null))
|
this.ApiUrl = settings.ApiUrl;
|
||||||
{
|
this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri;
|
||||||
this.Authentication = new AuthenticationSettings();
|
this.Scopes = settings.Scopes;
|
||||||
this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ?
|
|
||||||
AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority;
|
|
||||||
this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ?
|
|
||||||
AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId;
|
|
||||||
this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ?
|
|
||||||
AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri;
|
|
||||||
this.Authentication.Scopes = settings.Authentication.Scopes;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// A disk load (or an embedded-resource fallback) is the
|
|
||||||
// baseline, not a user edit. Clear the dirty flag last
|
|
||||||
// so the OnAuthenticationChanged / sub-property fan-out
|
|
||||||
// triggered by the assignments above doesn't leave it
|
|
||||||
// stuck at true.
|
|
||||||
IsDirty = false;
|
|
||||||
// Re-notify the command in case the button was bound
|
|
||||||
// before Load finished and the CanExecute cache is
|
|
||||||
// stale.
|
|
||||||
SaveCommand.NotifyCanExecuteChanged();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -373,63 +292,30 @@ public partial class Settings : ViewModelBase
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persist the current in-memory state to
|
/// Marshals every <see cref="ObservableObject.PropertyChanged"/>
|
||||||
/// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
|
/// notification onto the Avalonia UI thread before it leaves this
|
||||||
/// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
|
/// instance. Without this, a background worker (OIDC discovery
|
||||||
/// (Windows). Symmetrical to <see cref="Load"/>: same path,
|
/// running on a Task, the file I/O continuation in <see cref="Load"/>,
|
||||||
/// same directory creation, same <c>0600</c> file mode (POSIX)
|
/// any HTTP callback) would raise <c>PropertyChanged</c> from a
|
||||||
/// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
|
/// thread-pool thread and Avalonia's binding sink would then reach
|
||||||
/// on success.
|
/// into <c>DataValidationErrors.SetErrors</c> from off-thread,
|
||||||
///
|
/// blowing up with <c>InvalidOperationException: The calling thread
|
||||||
/// <para>Synchronous on purpose: matches <see cref="Load"/>'s
|
/// cannot access this object because a different thread owns it</c>.
|
||||||
/// contract (the file is a few KiB at most, and the Avalonia
|
/// We keep the mutation lock separate (above) and let the property
|
||||||
/// UI thread cannot await here without risking the same
|
/// setters do their work synchronously — only the notification
|
||||||
/// deadlock <see cref="Load"/>'s docstring describes).
|
/// fan-out is bounced to the UI thread.
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
public void Save()
|
|
||||||
{
|
{
|
||||||
var configDir = Path.Combine(
|
if (UiDispatcher.IsOnUiThread)
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
||||||
"PostIt");
|
|
||||||
Directory.CreateDirectory(configDir);
|
|
||||||
var configPath = Path.Combine(configDir, SettingsFileName);
|
|
||||||
|
|
||||||
lock (_mutationGate)
|
|
||||||
{
|
{
|
||||||
try
|
base.OnPropertyChanged(e);
|
||||||
{
|
return;
|
||||||
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
});
|
|
||||||
File.WriteAllText(configPath, json);
|
|
||||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
|
||||||
File.SetUnixFileMode(configPath,
|
|
||||||
UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
|
||||||
IsDirty = false;
|
|
||||||
Console.WriteLine($"💾 Settings saved to {configPath}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Capture by value: the args object is mutable in some binding
|
||||||
|
// sinks, and we don't want a background thread to keep mutating
|
||||||
|
// it after we hand it to the dispatcher.
|
||||||
|
var snapshot = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName);
|
||||||
|
UiDispatcher.Post(() => base.OnPropertyChanged(snapshot));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanSave() => IsDirty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Re-notify the <c>SaveCommand</c> (generated by
|
|
||||||
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
|
|
||||||
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
|
|
||||||
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
|
|
||||||
/// / <see cref="ApplyJson"/>).
|
|
||||||
/// </summary>
|
|
||||||
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
|
|
||||||
|
|
||||||
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
|
|
||||||
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
|
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ public class ViewLocator : IDataTemplate
|
||||||
return data switch
|
return data switch
|
||||||
{
|
{
|
||||||
MainPageViewModel => _services.GetRequiredService<MainPage>(),
|
MainPageViewModel => _services.GetRequiredService<MainPage>(),
|
||||||
Settings => _services.GetRequiredService<SettingsPage>(),
|
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(),
|
||||||
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
HomePageViewModel => _services.GetRequiredService<HomePage>(),
|
||||||
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
|
||||||
null => new TextBlock { Text = "No view for <null>" },
|
null => new TextBlock { Text = "No view for <null>" },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
|
|
@ -18,7 +19,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ViewModelBase? CurrentViewModel { get; set; }
|
public partial ViewModelBase? CurrentViewModel { get; set; }
|
||||||
|
|
||||||
public Settings SettingsModel { get; }
|
public SettingsPageViewModel SettingsModel { get; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string StatusMessage { get; set; }
|
public partial string StatusMessage { get; set; }
|
||||||
|
|
@ -59,7 +60,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
public MainPageViewModel()
|
public MainPageViewModel()
|
||||||
{
|
{
|
||||||
Init(null);
|
Init(null);
|
||||||
SettingsModel = new Settings();
|
SettingsModel = new SettingsPageViewModel();
|
||||||
BlogClient = null;
|
BlogClient = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,7 +94,7 @@ public partial class MainPageViewModel : ViewModelBase
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
|
||||||
{
|
{
|
||||||
SettingsModel = new Settings();
|
SettingsModel = new SettingsPageViewModel();
|
||||||
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
|
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
|
||||||
|
|
||||||
Init(settings);
|
Init(settings);
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,6 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
|
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
|
||||||
public event System.Action? LoginSucceeded;
|
public event System.Action? LoginSucceeded;
|
||||||
|
|
||||||
/// <summary>Raised when the user clicks the "Paramètres" button on
|
|
||||||
/// the session banner. <c>App.axaml.cs</c> listens and pushes
|
|
||||||
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
|
|
||||||
/// <c>Settings</c> singleton) on top of the current navigation
|
|
||||||
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
|
|
||||||
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
|
|
||||||
/// <c>NavigationPage</c> / window lifetime.</summary>
|
|
||||||
public event System.Action? OpenSettingsRequested;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsLoggedIn { get; private set; }
|
public partial bool IsLoggedIn { get; private set; }
|
||||||
|
|
||||||
|
|
@ -142,11 +133,4 @@ public partial class SessionStatusViewModel : ViewModelBase
|
||||||
Refresh();
|
Refresh();
|
||||||
LogoutCompleted?.Invoke();
|
LogoutCompleted?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
public async System.Threading.Tasks.Task OpenSettingsCommand()
|
|
||||||
{
|
|
||||||
OpenSettingsRequested?.Invoke();
|
|
||||||
await System.Threading.Tasks.Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
src/PostIt/PostIt/ViewModels/SettingsViewModel.cs
Normal file
18
src/PostIt/PostIt/ViewModels/SettingsViewModel.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
|
namespace PostIt.ViewModels;
|
||||||
|
|
||||||
|
public partial class SettingsPageViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool DarkMode { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string Authority { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial string ClientId { 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(); }
|
||||||
|
}
|
||||||
|
|
@ -21,9 +21,6 @@
|
||||||
Command="{Binding LoginCommand}"
|
Command="{Binding LoginCommand}"
|
||||||
IsVisible="{Binding IsLoggedOut}"
|
IsVisible="{Binding IsLoggedOut}"
|
||||||
DockPanel.Dock="Right"/>
|
DockPanel.Dock="Right"/>
|
||||||
<Button Content="Paramètres"
|
|
||||||
Command="{Binding OpenSettingsCommand}"
|
|
||||||
DockPanel.Dock="Right"/>
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
xmlns:controls="cl:avalonia.Controls"
|
xmlns:controls="cl:avalonia.Controls"
|
||||||
x:Class="PostIt.Views.SettingsPage"
|
x:Class="PostIt.Views.SettingsPage"
|
||||||
xmlns:vm="using:PostIt.ViewModels"
|
xmlns:vm="using:PostIt.ViewModels"
|
||||||
x:DataType="vm:Settings"
|
x:DataType="vm:SettingsPageViewModel"
|
||||||
Width="400"
|
Width="400"
|
||||||
Height="300">
|
Height="300">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|
@ -13,42 +13,12 @@
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<TextBlock Grid.Row="0" Text="Authority"/>
|
<TextBlock Grid.Row="0" Text="Authority"/>
|
||||||
<TextBox Grid.Row="1" x:Name="AuthorityTextBox"
|
<TextBox Grid.Row="1" x:Name="AuthorityTextBox" Text="{Binding Authority, Mode=TwoWay}"/>
|
||||||
Text="{Binding Authentication.Authority, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="2" Text="ClientId"/>
|
<TextBlock Grid.Row="2" Text="ClientId"/>
|
||||||
<TextBox Grid.Row="3" x:Name="ClientIdTextBox"
|
<TextBox Grid.Row="3" x:Name="ClientIdTextBox" Text="{Binding ClientId, Mode=TwoWay}"/>
|
||||||
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="4" Text="Blogs API URL"/>
|
|
||||||
<TextBox Grid.Row="5" x:Name="BlogsApiUrlTextBox"
|
|
||||||
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="6" Text="Business API URL"/>
|
|
||||||
<TextBox Grid.Row="7" x:Name="BusinessApiUrlTextBox"
|
|
||||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<TextBlock Grid.Row="8" Text="Dark mode"/>
|
|
||||||
<CheckBox Grid.Row="9" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
|
||||||
|
|
||||||
<!-- Sauver: bound to the SaveCommand on the Settings VM, with
|
|
||||||
IsEnabled driven by the inverse of IsDirty so the button
|
|
||||||
auto-disables when there's nothing to persist. -->
|
|
||||||
<Button Grid.Row="10" Content="Sauver"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
Margin="0,12,0,0"
|
|
||||||
Command="{Binding SaveCommand}"
|
|
||||||
IsEnabled="{Binding !IsDirty}"/>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
@ -8,26 +8,12 @@
|
||||||
"https://localhost:5005"
|
"https://localhost:5005"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
|
||||||
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
|
|
||||||
},
|
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft": "Warning",
|
"Microsoft": "Warning",
|
||||||
"Microsoft.Hosting.Lifetime": "Information",
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
"Microsoft.AspNetCore.Authentication": "Debug"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*"
|
||||||
"Kestrel": {
|
|
||||||
"Endpoints": {
|
|
||||||
"Http": {
|
|
||||||
"Url": "http://localhost:5002"
|
|
||||||
},
|
|
||||||
"Https": {
|
|
||||||
"Url": "https://localhost:5003"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,30 +91,6 @@ public static class ServiceExtensions
|
||||||
RoleClaimType = YavscConstants.RoleClaimType
|
RoleClaimType = YavscConstants.RoleClaimType
|
||||||
};
|
};
|
||||||
options.MapInboundClaims = true;
|
options.MapInboundClaims = true;
|
||||||
|
|
||||||
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
|
|
||||||
// Yavsc.Org itself) validates JWTs against the OP that runs
|
|
||||||
// on https://localhost:5001 with a self-signed dev cert.
|
|
||||||
// The default .NET HttpClient rejects self-signed certs, so
|
|
||||||
// JwtBearer's backchannel silently fails to fetch the OIDC
|
|
||||||
// discovery + JWKS. With an empty ValidIssuer, every token
|
|
||||||
// is rejected with IDX10204 ("ValidIssuer is null or
|
|
||||||
// whitespace"). Telling the backchannel to skip TLS
|
|
||||||
// validation unblocks discovery in dev. Production uses a
|
|
||||||
// real CA-signed cert and the default validation path; the
|
|
||||||
// override is gated on HostingEnvironment == Development
|
|
||||||
// and only fires when the consumer opt-in via the
|
|
||||||
// 'Yavsc:Dev:TlsInsecure' configuration flag (default
|
|
||||||
// false), so a misconfigured production environment cannot
|
|
||||||
// silently downgrade TLS.
|
|
||||||
if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development")
|
|
||||||
{
|
|
||||||
options.BackchannelHttpHandler = new HttpClientHandler
|
|
||||||
{
|
|
||||||
ServerCertificateCustomValidationCallback =
|
|
||||||
(_, _, _, _) => true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
configure?.Invoke(options);
|
configure?.Invoke(options);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue