postit: test LoginPageViewModel against an in-memory OIDC authority

Add a stubbed OIDC authority (discovery, jwks, /connect/token,
/connect/userinfo) and a fake IBrowser, then cover the full
authorization-code + PKCE flow in LoginPageViewModelTests.

The new LoginPageViewModel(Settings, Func<IBrowser?>) constructor
plus the BrowserFactoryOverride property keep production wiring
unchanged: the existing parameterless ctor and the platform
projects' Platform.CreateBrowser still drive runtime.
This commit is contained in:
Paul Schneider 2026-06-20 20:52:00 +01:00
commit c411445699
4 changed files with 410 additions and 3 deletions

View file

@ -0,0 +1,91 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using IdentityModel.OidcClient.Browser;
namespace PostIt.Tests;
/// <summary>
/// A minimal <see cref="IBrowser"/> for tests. Captures the authorize
/// URL emitted by OidcClient, extracts its <c>state</c>, and returns a
/// BrowserResult that mimics the OIDC redirect-with-code callback.
///
/// The paired <see cref="OidcStubAuthority"/>'s token endpoint accepts
/// any authorization code, so we don't need to mint a real one here.
/// </summary>
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<BrowserResult> 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<string, string> ParseQuery(string query)
{
var dict = new System.Collections.Generic.Dictionary<string, string>(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;
}
}
}

View file

@ -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 ?? "<null>"}");
Assert.False(
vm.StatusMessage?.StartsWith("Error") == true,
$"Login reported error: {vm.StatusMessage}");
}
}

View file

@ -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;
/// <summary>
/// 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 <see cref="FakeAuthorizingBrowser"/>:
/// the browser intercepts the authorize redirect, the server completes
/// the token exchange.
/// </summary>
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<OidcStubAuthority> 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<string, object> 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<string, object> BuildJwks()
{
var p = _rsa.ExportParameters(false);
return new Dictionary<string, object>
{
["keys"] = new[]
{
new Dictionary<string, object>
{
["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<string, object>
{
["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<string, object> claims)
{
var header = new Dictionary<string, object>
{
["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<string, string> ParseForm(string body)
{
var dict = new Dictionary<string, string>(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 { }
}
}
/// <summary>
/// Minimal base64url encoder (no padding). RFC 7515 §2.
/// </summary>
internal static class Base64UrlEncoder
{
public static string Encode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View file

@ -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()
/// <summary>
/// Optional override used by tests. When set, this factory is called
/// instead of <see cref="Platform.CreateBrowser"/> to obtain the
/// <see cref="IBrowser"/> instance.
/// </summary>
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
public LoginPageViewModel() : this(new Settings(), browserFactoryOverride: null)
{
Settings = new Settings();
}
/// <summary>
/// Test-friendly constructor: caller supplies pre-loaded <paramref name="settings"/>
/// and (optionally) a <paramref name="browserFactoryOverride"/> that bypasses
/// the static <see cref="Platform"/> indirection.
/// </summary>
public LoginPageViewModel(Settings settings, Func<IBrowser?>? 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.";