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:
parent
5bf0480906
commit
c411445699
4 changed files with 410 additions and 3 deletions
91
src/PostIt.Tests/FakeAuthorizingBrowser.cs
Normal file
91
src/PostIt.Tests/FakeAuthorizingBrowser.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/PostIt.Tests/LoginPageViewModelTests.cs
Normal file
44
src/PostIt.Tests/LoginPageViewModelTests.cs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/PostIt.Tests/OidcStubAuthority.cs
Normal file
252
src/PostIt.Tests/OidcStubAuthority.cs
Normal 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('/', '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using IdentityModel.OidcClient;
|
using IdentityModel.OidcClient;
|
||||||
|
using IdentityModel.OidcClient.Browser;
|
||||||
using PostIt.Services;
|
using PostIt.Services;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
@ -26,9 +27,26 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
private bool _IsBusy;
|
private bool _IsBusy;
|
||||||
public bool IsBusy { get=> _IsBusy; private set=> this.SetProperty(ref _IsBusy, value); }
|
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";
|
StatusMessage = "Ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +63,9 @@ public partial class LoginPageViewModel : ViewModelBase
|
||||||
? Platform.DefaultRedirectUri
|
? Platform.DefaultRedirectUri
|
||||||
: Settings.RedirectUri;
|
: Settings.RedirectUri;
|
||||||
|
|
||||||
var browser = Platform.CreateBrowser?.Invoke();
|
var browser = BrowserFactoryOverride is not null
|
||||||
|
? BrowserFactoryOverride.Invoke()
|
||||||
|
: Platform.CreateBrowser?.Invoke();
|
||||||
if (browser is null)
|
if (browser is null)
|
||||||
{
|
{
|
||||||
StatusMessage = "No browser is available on this platform.";
|
StatusMessage = "No browser is available on this platform.";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue