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();
}
}