Files
DodoSSH/tests/DodoSSH.Api.Tests/StubIdentityProvider.cs
T
jaap-jan f7c5096bc6 Keep the stub servers on loopback
Running the tests raised a Windows Firewall prompt, and raised it again from
every worktree. WireMockServer.Start() with no settings listens on 0.0.0.0
and [::], and the prompt is keyed to the binary that opened the socket — so
each test executable asks once per bin path, which a new worktree or a switch
between Debug and Release makes new again. The three suites that hold a
firewall rule on this machine are exactly the three that use WireMock; every
other listener in the repository already binds 127.0.0.1.

The stubs now say so explicitly. Port 0 is still WireMock's own free-port
search and still comes back on server.Url, which is what each stub builds its
base URL from, so the authority the API validates against and the issuer its
tokens claim follow the binding rather than being pinned to a host name.

Sampling the listening sockets of a full DodoSSH.Api.Tests run afterwards
finds one, 127.0.0.1, where there were previously three.
2026-07-31 11:06:46 +02:00

271 lines
10 KiB
C#

using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using WireMock.RequestBuilders;
using WireMock.ResponseBuilders;
using WireMock.Server;
using WireMock.Settings;
namespace DodoSSH.Api.Tests;
/// <summary>
/// A stand-in identity provider: OIDC discovery, JWKS, and token minting.
/// </summary>
/// <remarks>
/// Deliberately not a <c>TestAuthHandler</c> that short-circuits authentication. Tokens are signed
/// with a real RSA key and validated by the application's own JwtBearer pipeline, so issuer,
/// audience, signature, lifetime and claim handling are all genuinely exercised. Bypassing that
/// would hide precisely the claim-mapping mistakes that cause real authorization holes.
/// </remarks>
public sealed class StubIdentityProvider : IDisposable
{
private const string KeyId = "dodossh-test-key";
private readonly WireMockServer server;
private readonly RsaSecurityKey signingKey;
public StubIdentityProvider()
{
var rsa = RSA.Create(2048);
signingKey = new RsaSecurityKey(rsa) { KeyId = KeyId };
// Loopback explicitly: WireMock's default listens on every interface, which makes Windows Firewall
// prompt the first time each test executable runs — per binary path, so a new worktree or
// configuration asks again. Port 0 still picks a free port and reports it on server.Url, which is
// what Authority below is built from, so the issuer the tokens claim follows the binding.
server = WireMockServer.Start(new WireMockServerSettings { Urls = ["http://127.0.0.1:0"] });
Authority = server.Url!.TrimEnd('/');
StubDiscovery();
StubJwks();
}
/// <summary>Issuer URL, matching what the tokens claim.</summary>
public string Authority { get; }
/// <summary>Audience the API is configured to expect on access tokens.</summary>
public static string Audience => "dodossh-api";
/// <summary>
/// The public client identifier, and therefore the audience of an ID token.
/// </summary>
/// <remarks>
/// Distinct from <see cref="Audience"/> on purpose. An ID token is audienced to the client that
/// requested it, never to the API — validating a binding token against the API audience would
/// reject every genuine enrollment, and a test that used one audience for both would not notice.
/// </remarks>
public static string ClientId => "dodossh-desktop";
/// <summary>
/// Mints a signed access token.
/// </summary>
/// <param name="subject">The <c>sub</c> claim — the stable user identifier.</param>
/// <param name="email">Optional email claim.</param>
/// <param name="name">Optional display name claim.</param>
/// <param name="audience">Override the audience, to test rejection.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
public string MintToken(
string subject,
string? email = null,
string? name = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var claims = new List<System.Security.Claims.Claim>
{
new("sub", subject),
};
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
}
if (name is not null)
{
claims.Add(new System.Security.Claims.Claim("name", name));
}
var expiry = expires ?? now.AddMinutes(15);
// The earlier of now-1min and exp-1min. A fixed now-1min would sit after the expiry of a
// deliberately-expired test token (rejected at construction), while exp-1min alone would
// put nbf in the future for a normal token and make every valid token unauthorized.
var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);
var token = new JwtSecurityToken(
issuer: issuer ?? Authority,
audience: audience ?? Audience,
claims: claims,
notBefore: notBefore,
expires: expiry,
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>
/// Mints an ID token carrying a key-binding nonce.
/// </summary>
/// <remarks>
/// This is the artefact the whole public-key trust model rests on: an identity-provider
/// signature over the hash of a key statement. Signed with the same real RSA key the JWKS
/// endpoint publishes, so the server's verification genuinely runs.
/// </remarks>
/// <param name="subject">The <c>sub</c> claim.</param>
/// <param name="nonce">The key statement binding, from <c>KeyStatementCodec.ComputeNonce</c>.</param>
/// <param name="email">Optional email claim.</param>
/// <param name="audience">Override the audience, to test rejection. Defaults to the client id.</param>
/// <param name="issuer">Override the issuer, to test rejection.</param>
/// <param name="expires">Override expiry, to test rejection.</param>
/// <param name="omitNonce">Omit the nonce entirely, to test rejection.</param>
public string MintIdToken(
string subject,
string nonce,
string? email = null,
string? audience = null,
string? issuer = null,
DateTime? expires = null,
bool omitNonce = false)
{
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
// Integer64, so the handler writes a JSON number rather than a string. A provider that
// emitted a string here would be unusual, and the server reads auth_time as a number.
var claims = new List<System.Security.Claims.Claim>
{
new("sub", subject),
new(
"auth_time",
new DateTimeOffset(now, TimeSpan.Zero).ToUnixTimeSeconds()
.ToString(CultureInfo.InvariantCulture),
System.Security.Claims.ClaimValueTypes.Integer64),
};
if (!omitNonce)
{
claims.Add(new System.Security.Claims.Claim("nonce", nonce));
}
if (email is not null)
{
claims.Add(new System.Security.Claims.Claim("email", email));
}
var expiry = expires ?? now.AddMinutes(5);
var notBefore = expiry < now ? expiry.AddMinutes(-1) : now.AddMinutes(-1);
var token = new JwtSecurityToken(
issuer: issuer ?? Authority,
audience: audience ?? ClientId,
claims: claims,
notBefore: notBefore,
expires: expiry,
signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints an ID token signed by a key the provider does not publish.</summary>
public string MintIdTokenWithForeignKey(string subject, string nonce)
{
var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: Authority,
audience: ClientId,
claims:
[
new System.Security.Claims.Claim("sub", subject),
new System.Security.Claims.Claim("nonce", nonce),
],
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(5),
signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Mints a token signed by a different key, which must be rejected.</summary>
public string MintTokenWithForeignKey(string subject)
{
var foreign = new RsaSecurityKey(RSA.Create(2048)) { KeyId = KeyId };
var now = TimeProvider.System.GetUtcNow().UtcDateTime;
var token = new JwtSecurityToken(
issuer: Authority,
audience: Audience,
claims: [new System.Security.Claims.Claim("sub", subject)],
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(15),
signingCredentials: new SigningCredentials(foreign, SecurityAlgorithms.RsaSha256));
return new JwtSecurityTokenHandler().WriteToken(token);
}
private void StubDiscovery()
{
var document = new Dictionary<string, object>(StringComparer.Ordinal)
{
["issuer"] = Authority,
["jwks_uri"] = $"{Authority}/.well-known/jwks.json",
["authorization_endpoint"] = $"{Authority}/connect/authorize",
["token_endpoint"] = $"{Authority}/connect/token",
["response_types_supported"] = new[] { "code" },
["subject_types_supported"] = new[] { "public" },
["id_token_signing_alg_values_supported"] = new[] { "RS256" },
["code_challenge_methods_supported"] = new[] { "S256" },
};
server
.Given(Request.Create().WithPath("/.well-known/openid-configuration").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(JsonSerializer.Serialize(document)));
}
private void StubJwks()
{
var parameters = signingKey.Rsa.ExportParameters(includePrivateParameters: false);
var jwks = new
{
keys = new[]
{
new
{
kty = "RSA",
use = "sig",
kid = KeyId,
alg = "RS256",
n = Base64UrlEncoder.Encode(parameters.Modulus),
e = Base64UrlEncoder.Encode(parameters.Exponent),
},
},
};
server
.Given(Request.Create().WithPath("/.well-known/jwks.json").UsingGet())
.RespondWith(Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "application/json")
.WithBody(JsonSerializer.Serialize(jwks)));
}
/// <inheritdoc />
public void Dispose()
{
server.Stop();
server.Dispose();
signingKey.Rsa.Dispose();
}
}