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.";