diff --git a/src/PostIt.Tests/FakeAuthorizingBrowser.cs b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
new file mode 100644
index 00000000..fca1726c
--- /dev/null
+++ b/src/PostIt.Tests/FakeAuthorizingBrowser.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using IdentityModel.OidcClient.Browser;
+
+namespace PostIt.Tests;
+
+///
+/// A minimal for tests. Captures the authorize
+/// URL emitted by OidcClient, extracts its state, and returns a
+/// BrowserResult that mimics the OIDC redirect-with-code callback.
+///
+/// The paired 's token endpoint accepts
+/// any authorization code, so we don't need to mint a real one here.
+///
+public sealed class FakeAuthorizingBrowser
+{
+ private readonly string _loopbackRedirectUri;
+ private readonly HttpClient _http = new();
+
+ public FakeAuthorizingBrowser(string loopbackRedirectUri)
+ {
+ _loopbackRedirectUri = loopbackRedirectUri;
+ }
+
+ public IdentityModel.OidcClient.Browser.IBrowser CreateBrowser() => new Impl(_loopbackRedirectUri, _http);
+
+ private sealed class Impl : IdentityModel.OidcClient.Browser.IBrowser
+ {
+ private readonly string _loopbackRedirectUri;
+ private readonly HttpClient _http;
+
+ public Impl(string loopbackRedirectUri, HttpClient http)
+ {
+ _loopbackRedirectUri = loopbackRedirectUri;
+ _http = http;
+ }
+
+ public async Task InvokeAsync(BrowserOptions options, System.Threading.CancellationToken cancellationToken = default)
+ {
+ // Touch the authorize URL so any 4xx/5xx surfaces; we don't
+ // actually need its response body because we synthesize the
+ // redirect below from the original URL's query string.
+ var startUri = new Uri(options.StartUrl);
+ try
+ {
+ using var resp = await _http.GetAsync(startUri, cancellationToken);
+ // Ignore the status: the stub has no real /connect/authorize.
+ }
+ catch
+ {
+ // Network errors are expected against the stub; continue.
+ }
+
+ // Pull `state` from the authorize URL so the OidcClient can
+ // verify it against its own nonces.
+ var state = ParseQuery(startUri.Query).GetValueOrDefault("state");
+ if (string.IsNullOrEmpty(state))
+ {
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.UserCancel,
+ ErrorDescription = "no state in authorize URL"
+ };
+ }
+
+ var redirectUri =
+ $"{_loopbackRedirectUri.TrimEnd('/')}/?code=test-auth-code&state={Uri.EscapeDataString(state)}";
+
+ return new BrowserResult
+ {
+ ResultType = BrowserResultType.Success,
+ Response = redirectUri
+ };
+ }
+
+ private static System.Collections.Generic.Dictionary ParseQuery(string query)
+ {
+ var dict = new System.Collections.Generic.Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrEmpty(query)) return dict;
+ if (query.StartsWith("?")) query = query[1..];
+ foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var eq = pair.IndexOf('=');
+ if (eq < 0) { dict[pair] = ""; continue; }
+ dict[pair[..eq]] = Uri.UnescapeDataString(pair[(eq + 1)..]);
+ }
+ return dict;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs
new file mode 100644
index 00000000..42390121
--- /dev/null
+++ b/src/PostIt.Tests/LoginPageViewModelTests.cs
@@ -0,0 +1,44 @@
+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 ?? ""}");
+ Assert.False(
+ vm.StatusMessage?.StartsWith("Error") == true,
+ $"Login reported error: {vm.StatusMessage}");
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt.Tests/OidcStubAuthority.cs b/src/PostIt.Tests/OidcStubAuthority.cs
new file mode 100644
index 00000000..9c6948cd
--- /dev/null
+++ b/src/PostIt.Tests/OidcStubAuthority.cs
@@ -0,0 +1,252 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PostIt.Tests;
+
+///
+/// Minimal in-process OIDC authority used by LoginPageViewModelTests.
+/// It serves the discovery document, jwks, and a token endpoint that
+/// accepts any authorization code and returns a signed RS256 JWT.
+///
+/// Designed to be used together with :
+/// the browser intercepts the authorize redirect, the server completes
+/// the token exchange.
+///
+public sealed class OidcStubAuthority : IAsyncDisposable, IDisposable
+{
+ private readonly HttpListener _listener;
+ private readonly RSA _rsa;
+ private readonly string _kid;
+ private readonly CancellationTokenSource _cts = new();
+
+ public string Issuer { get; }
+ public string LoopbackRedirectUri { get; }
+
+ private OidcStubAuthority(HttpListener listener, RSA rsa, string kid, string issuer, string loopback)
+ {
+ _listener = listener;
+ _rsa = rsa;
+ _kid = kid;
+ Issuer = issuer;
+ LoopbackRedirectUri = loopback;
+ }
+
+ public static async Task StartAsync()
+ {
+ // Pick a free loopback port.
+ var port = GetFreePort();
+ var prefix = $"http://127.0.0.1:{port}/";
+ var loopback = "http://127.0.0.1:7890/"; // matches PostIt.Settings.DefaultLoopbackRedirectUri
+
+ var listener = new HttpListener();
+ listener.Prefixes.Add(prefix);
+ listener.Start();
+
+ var rsa = RSA.Create(2048);
+ var kid = "test-key-1";
+
+ var authority = new OidcStubAuthority(listener, rsa, kid, prefix.TrimEnd('/'), loopback);
+ _ = Task.Run(() => authority.AcceptLoopAsync(authority._cts.Token));
+ return authority;
+ }
+
+ private async Task AcceptLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ HttpListenerContext ctx;
+ try { ctx = await _listener.GetContextAsync().WaitAsync(ct); }
+ catch (OperationCanceledException) { return; }
+ catch (HttpListenerException) { return; }
+
+ try { await DispatchAsync(ctx); }
+ catch { /* swallow per-request */ }
+ }
+ }
+
+ private async Task DispatchAsync(HttpListenerContext ctx)
+ {
+ var path = ctx.Request.Url?.AbsolutePath ?? "/";
+ switch (path)
+ {
+ case "/.well-known/openid-configuration":
+ await WriteJsonAsync(ctx.Response, BuildDiscovery());
+ break;
+ case "/.well-known/jwks":
+ await WriteJsonAsync(ctx.Response, BuildJwks());
+ break;
+ case "/connect/token":
+ await HandleTokenAsync(ctx);
+ break;
+ case "/connect/userinfo":
+ await WriteJsonAsync(ctx.Response, new { sub = "test-user" });
+ break;
+ default:
+ ctx.Response.StatusCode = 404;
+ ctx.Response.Close();
+ break;
+ }
+ }
+
+ private Dictionary BuildDiscovery() => new()
+ {
+ ["issuer"] = Issuer,
+ ["authorization_endpoint"] = $"{Issuer}/connect/authorize",
+ ["token_endpoint"] = $"{Issuer}/connect/token",
+ ["userinfo_endpoint"] = $"{Issuer}/connect/userinfo",
+ ["jwks_uri"] = $"{Issuer}/.well-known/jwks",
+ ["response_types_supported"] = new[] { "code" },
+ ["subject_types_supported"] = new[] { "public" },
+ ["id_token_signing_alg_values_supported"] = new[] { "RS256" },
+ ["grant_types_supported"] = new[] { "authorization_code" },
+ ["code_challenge_methods_supported"] = new[] { "S256" },
+ };
+
+ private Dictionary BuildJwks()
+ {
+ var p = _rsa.ExportParameters(false);
+ return new Dictionary
+ {
+ ["keys"] = new[]
+ {
+ new Dictionary
+ {
+ ["kty"] = "RSA",
+ ["use"] = "sig",
+ ["alg"] = "RS256",
+ ["kid"] = _kid,
+ ["n"] = Base64UrlEncoder.Encode(p.Modulus!),
+ ["e"] = Base64UrlEncoder.Encode(p.Exponent!),
+ }
+ }
+ };
+ }
+
+ private async Task HandleTokenAsync(HttpListenerContext ctx)
+ {
+ // Read form-encoded body.
+ string body;
+ using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8))
+ body = await reader.ReadToEndAsync();
+
+ var form = ParseForm(body);
+ // We accept any code and don't validate PKCE on the stub side;
+ // the OidcClient itself validates the redirect_uri match.
+ var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var claims = new Dictionary
+ {
+ ["iss"] = Issuer,
+ ["sub"] = "test-user",
+ ["aud"] = form.TryGetValue("client_id", out var cid) ? cid : "postit-tests",
+ ["exp"] = now + 600,
+ ["iat"] = now,
+ };
+
+ var accessToken = SignJwt(claims);
+ var response = new
+ {
+ access_token = accessToken,
+ id_token = accessToken,
+ token_type = "Bearer",
+ expires_in = 600,
+ scope = form.TryGetValue("scope", out var s) ? s : "openid",
+ };
+ await WriteJsonAsync(ctx.Response, response);
+ }
+
+ private string SignJwt(Dictionary claims)
+ {
+ var header = new Dictionary
+ {
+ ["alg"] = "RS256",
+ ["typ"] = "JWT",
+ ["kid"] = _kid,
+ };
+ var headerJson = JsonSerializer.Serialize(header);
+ var payloadJson = JsonSerializer.Serialize(claims);
+ var headerB64 = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(headerJson));
+ var payloadB64 = Base64UrlEncoder.Encode(Encoding.UTF8.GetBytes(payloadJson));
+ var signingInput = $"{headerB64}.{payloadB64}";
+ var signature = _rsa.SignData(
+ Encoding.UTF8.GetBytes(signingInput),
+ HashAlgorithmName.SHA256,
+ RSASignaturePadding.Pkcs1);
+ return $"{signingInput}.{Base64UrlEncoder.Encode(signature)}";
+ }
+
+ private static Dictionary ParseForm(string body)
+ {
+ var dict = new Dictionary(StringComparer.Ordinal);
+ foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var eq = pair.IndexOf('=');
+ if (eq < 0) continue;
+ var key = Uri.UnescapeDataString(pair[..eq]);
+ var val = Uri.UnescapeDataString(pair[(eq + 1)..]);
+ dict[key] = val;
+ }
+ return dict;
+ }
+
+ private static async Task WriteJsonAsync(HttpListenerResponse response, object payload)
+ {
+ response.ContentType = "application/json";
+ response.StatusCode = 200;
+ var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
+ await response.OutputStream.WriteAsync(bytes);
+ response.Close();
+ }
+
+ private static int GetFreePort()
+ {
+ var l = new TcpListener(IPAddress.Loopback, 0);
+ l.Start();
+ var port = ((IPEndPoint)l.LocalEndpoint).Port;
+ l.Stop();
+ return port;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ _cts.Cancel();
+ try { _listener.Stop(); } catch { }
+ _listener.Close();
+ _rsa.Dispose();
+ _cts.Dispose();
+ await Task.CompletedTask;
+ }
+
+ public void Dispose()
+ {
+ // Synchronous dispose: cancels the accept loop and tears down
+ // resources. The accept task will exit on its own once the
+ // listener is closed.
+ try { _cts.Cancel(); } catch { }
+ try { _listener.Stop(); } catch { }
+ try { _listener.Close(); } catch { }
+ try { _rsa.Dispose(); } catch { }
+ try { _cts.Dispose(); } catch { }
+ }
+}
+
+///
+/// Minimal base64url encoder (no padding). RFC 7515 ยง2.
+///
+internal static class Base64UrlEncoder
+{
+ public static string Encode(byte[] data)
+ {
+ return Convert.ToBase64String(data)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
index 82bb1833..f7fb25bc 100644
--- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs
@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient;
+using IdentityModel.OidcClient.Browser;
using PostIt.Services;
using System;
using System.Threading.Tasks;
@@ -26,9 +27,26 @@ public partial class LoginPageViewModel : ViewModelBase
private bool _IsBusy;
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
- public LoginPageViewModel()
+ ///
+ /// Optional override used by tests. When set, this factory is called
+ /// instead of to obtain the
+ /// instance.
+ ///
+ public Func? BrowserFactoryOverride { get; set; }
+
+ public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
{
- Settings = new Settings();
+ }
+
+ ///
+ /// Test-friendly constructor: caller supplies pre-loaded
+ /// and (optionally) a that bypasses
+ /// the static indirection.
+ ///
+ public LoginPageViewModel(Settings settings, Func? browserFactoryOverride = null)
+ {
+ Settings = settings;
+ BrowserFactoryOverride = browserFactoryOverride;
StatusMessage = "Ready";
}
@@ -45,7 +63,9 @@ public partial class LoginPageViewModel : ViewModelBase
? Platform.DefaultRedirectUri
: Settings.RedirectUri;
- var browser = Platform.CreateBrowser?.Invoke();
+ var browser = BrowserFactoryOverride is not null
+ ? BrowserFactoryOverride.Invoke()
+ : Platform.CreateBrowser?.Invoke();
if (browser is null)
{
StatusMessage = "No browser is available on this platform.";