97 lines
3.4 KiB
C#
97 lines
3.4 KiB
C#
|
|
using System.IdentityModel.Tokens.Jwt;
|
||
|
|
using System.Security.Claims;
|
||
|
|
using System.Text;
|
||
|
|
using Microsoft.IdentityModel.Tokens;
|
||
|
|
using w4c_workflows.Services;
|
||
|
|
using Xunit;
|
||
|
|
|
||
|
|
namespace w4c_workflows.Tests;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// P1-14: the shared symmetric key alone is weak, so issuer/audience must be
|
||
|
|
/// enforced whenever the deployment declares them, and left optional otherwise
|
||
|
|
/// (w4c-auth may not stamp them). A token signed with the wrong key, from the
|
||
|
|
/// wrong issuer or for the wrong audience must never validate.
|
||
|
|
/// </summary>
|
||
|
|
public class JwtValidatorTests
|
||
|
|
{
|
||
|
|
private const string SigningKey = "unit-test-signing-key-that-is-long-enough-1234567890";
|
||
|
|
private const string OtherKey = "another-signing-key-that-is-long-enough-0987654321";
|
||
|
|
|
||
|
|
private static string Token(
|
||
|
|
string key,
|
||
|
|
string? issuer = "w4c-auth",
|
||
|
|
string? audience = "w4c",
|
||
|
|
DateTime? expires = null,
|
||
|
|
DateTime? notBefore = null)
|
||
|
|
{
|
||
|
|
var credentials = new SigningCredentials(
|
||
|
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
|
||
|
|
SecurityAlgorithms.HmacSha256);
|
||
|
|
|
||
|
|
var token = new JwtSecurityToken(
|
||
|
|
issuer: issuer,
|
||
|
|
audience: audience,
|
||
|
|
claims: new[] { new Claim("tenant_id", "tenant-42"), new Claim("sub", "user-1") },
|
||
|
|
notBefore: notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
||
|
|
expires: expires ?? DateTime.UtcNow.AddMinutes(5),
|
||
|
|
signingCredentials: credentials);
|
||
|
|
|
||
|
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Valid_token_is_accepted_and_extracts_the_tenant()
|
||
|
|
{
|
||
|
|
var principal = JwtValidator.Validate($"Bearer {Token(SigningKey)}", SigningKey, "w4c-auth", "w4c");
|
||
|
|
|
||
|
|
Assert.NotNull(principal);
|
||
|
|
Assert.Equal("tenant-42", principal!.FindFirst("tenant_id")?.Value);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Issuer_and_audience_are_enforced_when_configured()
|
||
|
|
{
|
||
|
|
var bearer = $"Bearer {Token(SigningKey, issuer: "attacker", audience: "w4c")}";
|
||
|
|
Assert.Null(JwtValidator.Validate(bearer, SigningKey, "w4c-auth", "w4c"));
|
||
|
|
|
||
|
|
bearer = $"Bearer {Token(SigningKey, issuer: "w4c-auth", audience: "someone-else")}";
|
||
|
|
Assert.Null(JwtValidator.Validate(bearer, SigningKey, "w4c-auth", "w4c"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Issuer_and_audience_are_optional_when_the_deployment_does_not_declare_them()
|
||
|
|
{
|
||
|
|
// A token that carries an issuer/audience still validates when the server
|
||
|
|
// is not configured to check them (backward compatibility with w4c-auth).
|
||
|
|
var principal = JwtValidator.Validate($"Bearer {Token(SigningKey)}", SigningKey);
|
||
|
|
|
||
|
|
Assert.NotNull(principal);
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Token_signed_with_another_key_is_rejected()
|
||
|
|
{
|
||
|
|
Assert.Null(JwtValidator.Validate($"Bearer {Token(OtherKey)}", SigningKey, "w4c-auth", "w4c"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Fact]
|
||
|
|
public void Expired_token_is_rejected()
|
||
|
|
{
|
||
|
|
var expired = Token(
|
||
|
|
SigningKey,
|
||
|
|
notBefore: DateTime.UtcNow.AddHours(-1),
|
||
|
|
expires: DateTime.UtcNow.AddMinutes(-5));
|
||
|
|
Assert.Null(JwtValidator.Validate($"Bearer {expired}", SigningKey, "w4c-auth", "w4c"));
|
||
|
|
}
|
||
|
|
|
||
|
|
[Theory]
|
||
|
|
[InlineData("")]
|
||
|
|
[InlineData("not-a-bearer")]
|
||
|
|
[InlineData("Bearer ")]
|
||
|
|
public void Missing_or_malformed_header_is_rejected(string header)
|
||
|
|
{
|
||
|
|
Assert.Null(JwtValidator.Validate(header, SigningKey, "w4c-auth", "w4c"));
|
||
|
|
}
|
||
|
|
}
|