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; namespace DodoSSH.Api.Tests; /// /// A stand-in identity provider: OIDC discovery, JWKS, and token minting. /// /// /// Deliberately not a TestAuthHandler 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. /// 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 }; server = WireMockServer.Start(); Authority = server.Url!.TrimEnd('/'); StubDiscovery(); StubJwks(); } /// Issuer URL, matching what the tokens claim. public string Authority { get; } /// Audience the API is configured to expect on access tokens. public static string Audience => "dodossh-api"; /// /// The public client identifier, and therefore the audience of an ID token. /// /// /// Distinct from 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. /// public static string ClientId => "dodossh-desktop"; /// /// Mints a signed access token. /// /// The sub claim — the stable user identifier. /// Optional email claim. /// Optional display name claim. /// Override the audience, to test rejection. /// Override the issuer, to test rejection. /// Override expiry, to test rejection. 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 { 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); } /// /// Mints an ID token carrying a key-binding nonce. /// /// /// 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. /// /// The sub claim. /// The key statement binding, from KeyStatementCodec.ComputeNonce. /// Optional email claim. /// Override the audience, to test rejection. Defaults to the client id. /// Override the issuer, to test rejection. /// Override expiry, to test rejection. /// Omit the nonce entirely, to test rejection. 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 { 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); } /// Mints an ID token signed by a key the provider does not publish. 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); } /// Mints a token signed by a different key, which must be rejected. 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(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))); } /// public void Dispose() { server.Stop(); server.Dispose(); signingKey.Rsa.Dispose(); } }