From c802d3f793dc53a82c2ee6f7b25f02566f2e30d6 Mon Sep 17 00:00:00 2001 From: Scott Grosch Date: Thu, 11 Jul 2024 22:34:18 -0700 Subject: [PATCH] feat: allow DateTimeOffset to date claims --- src/Paseto/Builder/PasetoBuilderExtensions.cs | 238 ++++++++------ src/Paseto/Validators/DateValidator.cs | 145 ++++----- tests/Paseto.Tests/PasetoValidatorTests.cs | 302 +++++++++--------- 3 files changed, 368 insertions(+), 317 deletions(-) diff --git a/src/Paseto/Builder/PasetoBuilderExtensions.cs b/src/Paseto/Builder/PasetoBuilderExtensions.cs index 5c4e5ef..f35573f 100644 --- a/src/Paseto/Builder/PasetoBuilderExtensions.cs +++ b/src/Paseto/Builder/PasetoBuilderExtensions.cs @@ -1,93 +1,145 @@ -namespace Paseto.Builder; - -using System; - -public static class PasetoBuilderExtensions -{ - //public const string DateTimeISO8601Format = "yyyy-MM-ddTHH:mm:sszzz"; // The default format used by Json.NET is the ISO 8601 standard - - /// - /// Adds an issuer claim to the Paseto. - /// - /// The PasetoBuilder instance. - /// The issuer. - /// Current builder instance - public static PasetoBuilder Issuer(this PasetoBuilder builder, string issuer) => builder.AddClaim(RegisteredClaims.Issuer, issuer); - - /// - /// Adds a subject claim to the Paseto. - /// - /// The PasetoBuilder instance. - /// The subject. - /// Current builder instance - public static PasetoBuilder Subject(this PasetoBuilder builder, string subject) => builder.AddClaim(RegisteredClaims.Subject, subject); - - /// - /// Adds an audience claim to the Paseto. - /// - /// The PasetoBuilder instance. - /// The audience. - /// Current builder instance - public static PasetoBuilder Audience(this PasetoBuilder builder, string audience) => builder.AddClaim(RegisteredClaims.Audience, audience); - - /// - /// Adds an expiration claim to the Paseto. - /// The Utc time will be converted to Unix time. - /// - /// This method behaves the same as . - /// - /// The PasetoBuilder instance. - /// The Utc time. - /// Current builder instance - public static PasetoBuilder Expiration(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.ExpirationTime, time); - - /// - /// Adds a not before claim to the Paseto. - /// The Utc time will be converted to Unix time. - /// - /// This method behaves the same as . - /// - /// The PasetoBuilder instance. - /// The Utc time. - /// Current builder instance - public static PasetoBuilder NotBefore(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.NotBefore, time); - - /// - /// Adds a not before claim to the Paseto. - /// The Utc time will be converted to Unix time. - /// - /// This method behaves the same as . - /// - /// The PasetoBuilder instance. - /// The Utc time. - /// Current builder instance - public static PasetoBuilder ValidFrom(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.NotBefore, time); - - /// - /// Adds an expiration claim to the Paseto. - /// The Utc time will be converted to Unix time. - /// - /// This method behaves the same as . - /// - /// The PasetoBuilder instance. - /// The Utc time. - /// Current builder instance - public static PasetoBuilder ValidTo(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.ExpirationTime, time); - - /// - /// Adds an issued claim to the Paseto. - /// The Utc time will be converted to Unix time. - /// - /// The PasetoBuilder instance. - /// The Utc time. - /// Current builder instance - public static PasetoBuilder IssuedAt(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.IssuedAt, time); - - /// - /// Adds a token identifier or jti claim to the Paseto. - /// - /// The PasetoBuilder instance. - /// The token identifier. - /// Current builder instance - public static PasetoBuilder TokenIdentifier(this PasetoBuilder builder, string jti) => builder.AddClaim(RegisteredClaims.TokenIdentifier, jti); -} +namespace Paseto.Builder; + +using System; + +public static class PasetoBuilderExtensions +{ + //public const string DateTimeISO8601Format = "yyyy-MM-ddTHH:mm:sszzz"; // The default format used by Json.NET is the ISO 8601 standard + + /// + /// Adds an issuer claim to the Paseto. + /// + /// The PasetoBuilder instance. + /// The issuer. + /// Current builder instance + public static PasetoBuilder Issuer(this PasetoBuilder builder, string issuer) => builder.AddClaim(RegisteredClaims.Issuer, issuer); + + /// + /// Adds a subject claim to the Paseto. + /// + /// The PasetoBuilder instance. + /// The subject. + /// Current builder instance + public static PasetoBuilder Subject(this PasetoBuilder builder, string subject) => builder.AddClaim(RegisteredClaims.Subject, subject); + + /// + /// Adds an audience claim to the Paseto. + /// + /// The PasetoBuilder instance. + /// The audience. + /// Current builder instance + public static PasetoBuilder Audience(this PasetoBuilder builder, string audience) => builder.AddClaim(RegisteredClaims.Audience, audience); + + /// + /// Adds an expiration claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc time. + /// Current builder instance + public static PasetoBuilder Expiration(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.ExpirationTime, time); + + /// + /// Adds an expiration claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc offset. + /// Current builder instance + public static PasetoBuilder Expiration(this PasetoBuilder builder, DateTimeOffset offset) => builder.AddClaim(RegisteredClaims.ExpirationTime, offset); + + /// + /// Adds a not before claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc time. + /// Current builder instance + public static PasetoBuilder NotBefore(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.NotBefore, time); + + /// + /// Adds a not before claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc offset. + /// Current builder instance + public static PasetoBuilder NotBefore(this PasetoBuilder builder, DateTimeOffset offset) => builder.AddClaim(RegisteredClaims.NotBefore, offset); + + /// + /// Adds a not before claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc time. + /// Current builder instance + public static PasetoBuilder ValidFrom(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.NotBefore, time); + + /// + /// Adds a not before claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc offset. + /// Current builder instance + public static PasetoBuilder ValidFrom(this PasetoBuilder builder, DateTimeOffset offset) => builder.AddClaim(RegisteredClaims.NotBefore, offset); + + /// + /// Adds an expiration claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc time. + /// Current builder instance + public static PasetoBuilder ValidTo(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.ExpirationTime, time); + + /// + /// Adds an expiration claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// This method behaves the same as . + /// + /// The PasetoBuilder instance. + /// The Utc offset. + /// Current builder instance + public static PasetoBuilder ValidTo(this PasetoBuilder builder, DateTimeOffset offset) => builder.AddClaim(RegisteredClaims.ExpirationTime, offset); + + /// + /// Adds an issued claim to the Paseto. + /// The Utc time will be converted to Unix time. + /// + /// The PasetoBuilder instance. + /// The Utc time. + /// Current builder instance + public static PasetoBuilder IssuedAt(this PasetoBuilder builder, DateTime time) => builder.AddClaim(RegisteredClaims.IssuedAt, time); + + /// + /// Adds an issued claim to the Paseto. + /// + /// The PasetoBuilder instance. + /// The Utc offset. + /// Current builder instance + public static PasetoBuilder IssuedAt(this PasetoBuilder builder, DateTimeOffset offset) => builder.AddClaim(RegisteredClaims.IssuedAt, offset); + + /// + /// Adds a token identifier or jti claim to the Paseto. + /// + /// The PasetoBuilder instance. + /// The token identifier. + /// Current builder instance + public static PasetoBuilder TokenIdentifier(this PasetoBuilder builder, string jti) => builder.AddClaim(RegisteredClaims.TokenIdentifier, jti); +} diff --git a/src/Paseto/Validators/DateValidator.cs b/src/Paseto/Validators/DateValidator.cs index 39bd8fd..3317dba 100644 --- a/src/Paseto/Validators/DateValidator.cs +++ b/src/Paseto/Validators/DateValidator.cs @@ -1,76 +1,71 @@ -namespace Paseto.Validators; - -using System; -using System.Text.Json; -using Paseto.Validators.Internal; - -/// -/// The Base Date Validator. -/// -/// -public abstract class DateValidator : BaseValidator -{ - /// - /// Initializes a new instance of the class. - /// - /// The payload. - public DateValidator(PasetoPayload payload) : base(payload) { } - - /// - /// Validates the input value against the provided optional expected value. Throws an exception if not valid. - /// - /// The input value to validate. - /// The optional expected value. - public abstract void ValidateDate(IComparable value, IComparable expected = null); - - /// - /// Validates the payload against the provided optional expected value. Throws an exception if not valid. - /// - /// The optional expected value. - /// - /// Token has expired. - /// - public override void Validate(IComparable expected = null) - { - if (!Payload.TryGetValue(ClaimName, out var value)) - throw new PasetoTokenValidationException($"Claim '{ClaimName}' not found"); - - DateTime exp; - try - { - if (value is JsonElement json) - value = GetValueFromJsonElement(json); - - if (value is string s) - exp = DateTimeOffset.Parse(s).UtcDateTime; - else - exp = Convert.ToDateTime(value); - } - catch (Exception) - { - throw new PasetoTokenValidationException($"Claim '{ClaimName}' must be a DateTime"); - } - - expected ??= DateTime.UtcNow; - - ValidateDate(exp, expected); - } - - /// - /// Validates the payload against the provided optional expected value. - /// - /// The optional expected value. - /// true if the specified value is valid; otherwise, false. - public override bool IsValid(IComparable expected = null) - { - try - { - Validate(expected); - return true; - } - catch (Exception) - { - return false; - } - } +namespace Paseto.Validators; + +using System; +using System.Text.Json; +using Paseto.Validators.Internal; + +/// +/// The Base Date Validator. +/// +/// +public abstract class DateValidator : BaseValidator +{ + /// + /// Initializes a new instance of the class. + /// + /// The payload. + public DateValidator(PasetoPayload payload) : base(payload) { } + + /// + /// Validates the input value against the provided optional expected value. Throws an exception if not valid. + /// + /// The input value to validate. + /// The optional expected value. + public abstract void ValidateDate(IComparable value, IComparable expected = null); + + /// + /// Validates the payload against the provided optional expected value. Throws an exception if not valid. + /// + /// The optional expected value. + /// + /// Token has expired. + /// + public override void Validate(IComparable expected = null) + { + if (!Payload.TryGetValue(ClaimName, out var value)) + throw new PasetoTokenValidationException($"Claim '{ClaimName}' not found"); + + var exp = value switch + { + JsonElement { ValueKind: JsonValueKind.String } str when DateTimeOffset.TryParse(str.GetString(), out var dto) => dto.UtcDateTime, + DateTimeOffset offset => offset.UtcDateTime, + DateTime dt => dt, + _ => throw new PasetoTokenValidationException($"Claim '{ClaimName}' must be a DateTime") + }; + + if (expected is DateTimeOffset o) + expected = o.UtcDateTime; + else + expected ??= DateTime.UtcNow; + + ValidateDate(exp, expected); + } + + /// + /// Validates the payload against the provided optional expected value. + /// + /// The optional expected value. + /// true if the specified value is valid; otherwise, false. + public override bool IsValid(IComparable expected = null) + { + try + { + Validate(expected); + return true; + } + catch (Exception) + { + return false; + } + } } \ No newline at end of file diff --git a/tests/Paseto.Tests/PasetoValidatorTests.cs b/tests/Paseto.Tests/PasetoValidatorTests.cs index 8471b56..3ba7883 100644 --- a/tests/Paseto.Tests/PasetoValidatorTests.cs +++ b/tests/Paseto.Tests/PasetoValidatorTests.cs @@ -1,149 +1,153 @@ -namespace Paseto.Tests; - -using System; - -using FluentAssertions; -using Xunit; - -using Paseto.Builder; -using Paseto.Extensions; - -public class PasetoValidatorTests -{ - private const string HelloPaseto = "Hello Paseto!"; - private const string IssuedBy = "Paragon Initiative Enterprises"; - - [Fact] - public void PayloadIssuedAtNextDayValidationFails() - { - var iat = new Validators.IssuedAtValidator(new PasetoPayload - { - { RegisteredClaims.IssuedAt.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(24) } - }); - - Action act = () => iat.Validate(DateTime.UtcNow); - act.Should().Throw().WithMessage("Token is not yet valid"); - } - - [Fact] - public void PayloadIssuedAtPreviousDayValidationSucceeds() - { - var iat = new Validators.IssuedAtValidator(new PasetoPayload - { - { RegisteredClaims.IssuedAt.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(-24) } - }); - - Action act = () => iat.Validate(DateTime.UtcNow); - act.Should().NotThrow(); - } - - [Fact] - public void PayloadIssuedAtSameDayValidationSucceeds() - { - var now = DateTime.UtcNow; - - var iat = new Validators.IssuedAtValidator(new PasetoPayload - { - { RegisteredClaims.IssuedAt.GetRegisteredClaimName(), now } - }); - - Action act = () => iat.Validate(now); - act.Should().NotThrow(); - } - - [Fact] - public void PayloadNotBeforeNextDayValidationFails() - { - var nbf = new Validators.NotBeforeValidator(new PasetoPayload - { - { RegisteredClaims.NotBefore.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(24) } - }); - - Action act = () => nbf.Validate(DateTime.UtcNow); - act.Should().Throw().WithMessage("Token is not yet valid"); - } - - [Fact] - public void PayloadNotBeforeDayValidationSucceeds() - { - var nbf = new Validators.NotBeforeValidator(new PasetoPayload - { - { RegisteredClaims.NotBefore.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(-24) } - }); - - Action act = () => nbf.Validate(DateTime.UtcNow); - act.Should().NotThrow(); - } - - [Fact] - public void PayloadExpirationTimeYesterdayValidationFails() - { - var exp = new Validators.ExpirationTimeValidator(new PasetoPayload - { - { RegisteredClaims.ExpirationTime.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(-24) } - }); - - Action act = () => exp.Validate(DateTime.UtcNow); - act.Should().Throw().WithMessage("Token has expired"); - } - - [Fact] - public void PayloadExpirationNextDayTimeValidationSucceeds() - { - var exp = new Validators.ExpirationTimeValidator(new PasetoPayload - { - { RegisteredClaims.ExpirationTime.GetRegisteredClaimName(), DateTime.UtcNow.AddHours(24) } - }); - - Action act = () => exp.Validate(DateTime.UtcNow); - act.Should().NotThrow(); - } - - [Fact] - public void PayloadEqualValidationNonEqualFails() - { - var val = new Validators.EqualValidator(new PasetoPayload - { - { RegisteredClaims.Issuer.GetRegisteredClaimName(), IssuedBy } - }, RegisteredClaims.Issuer.GetRegisteredClaimName()); - - Action act = () => val.Validate(IssuedBy + "."); - act.Should().Throw(); - } - - [Fact] - public void PayloadEqualValidationTest() - { - var val = new Validators.EqualValidator(new PasetoPayload - { - { RegisteredClaims.Issuer.GetRegisteredClaimName(), IssuedBy } - }, RegisteredClaims.Issuer.GetRegisteredClaimName()); - - Action act = () => val.Validate(IssuedBy); - act.Should().NotThrow(); - } - - [Fact] - public void PayloadCustomValidationNonEqualFails() - { - var val = new Validators.EqualValidator(new PasetoPayload - { - { "example", HelloPaseto } - }, "example"); - - Action act = () => val.Validate(HelloPaseto + "!"); - act.Should().Throw(); - } - - [Fact] - public void PayloadCustomValidationTest() - { - var val = new Validators.EqualValidator(new PasetoPayload - { - { "example", HelloPaseto } - }, "example"); - - Action act = () => val.Validate(HelloPaseto); - act.Should().NotThrow(); - } -} +namespace Paseto.Tests; + +using System; +using FluentAssertions; +using Xunit; +using Builder; +using Paseto.Extensions; + +public class PasetoValidatorTests +{ + private const string HelloPaseto = "Hello Paseto!"; + private const string IssuedBy = "Paragon Initiative Enterprises"; + + [Theory] + [MemberData(nameof(FutureTimes))] + public void PayloadIssuedAtNextDayValidationFails(IComparable when, IComparable compareTo) + { + var iat = new Validators.IssuedAtValidator(CreateDateValidatorPayload(RegisteredClaims.IssuedAt, when)); + + Action act = () => iat.Validate(compareTo); + act.Should().Throw().WithMessage("Token is not yet valid"); + } + + [Theory] + [MemberData(nameof(PastTimes))] + public void PayloadIssuedAtPreviousDayValidationSucceeds(IComparable when, IComparable compareTo) + { + var iat = new Validators.IssuedAtValidator(CreateDateValidatorPayload(RegisteredClaims.IssuedAt, when)); + + Action act = () => iat.Validate(compareTo); + act.Should().NotThrow(); + } + + [Theory] + [MemberData(nameof(NowTimes))] + public void PayloadIssuedAtSameDayValidationSucceeds(IComparable when) + { + var iat = new Validators.IssuedAtValidator(CreateDateValidatorPayload(RegisteredClaims.IssuedAt, when)); + + Action act = () => iat.Validate(when); + act.Should().NotThrow(); + } + + [Theory] + [MemberData(nameof(FutureTimes))] + public void PayloadNotBeforeNextDayValidationFails(IComparable when, IComparable compareTo) + { + var nbf = new Validators.NotBeforeValidator(CreateDateValidatorPayload(RegisteredClaims.NotBefore, when)); + + Action act = () => nbf.Validate(compareTo); + act.Should().Throw().WithMessage("Token is not yet valid"); + } + + [Theory] + [MemberData(nameof(PastTimes))] + public void PayloadNotBeforeDayValidationSucceeds(IComparable when, IComparable compareTo) + { + var nbf = new Validators.NotBeforeValidator(CreateDateValidatorPayload(RegisteredClaims.NotBefore, when)); + + Action act = () => nbf.Validate(compareTo); + act.Should().NotThrow(); + } + + [Theory] + [MemberData(nameof(PastTimes))] + public void PayloadExpirationTimeYesterdayValidationFails(IComparable when, IComparable compareTo) + { + var exp = new Validators.ExpirationTimeValidator(CreateDateValidatorPayload(RegisteredClaims.ExpirationTime, when)); + Action act = () => exp.Validate(compareTo); + act.Should().Throw().WithMessage("Token has expired"); + } + + [Theory] + [MemberData(nameof(FutureTimes))] + public void PayloadExpirationNextDayTimeValidationSucceeds(IComparable when, IComparable compareTo) + { + var exp = new Validators.ExpirationTimeValidator(CreateDateValidatorPayload(RegisteredClaims.ExpirationTime, when)); + + Action act = () => exp.Validate(compareTo); + act.Should().NotThrow(); + } + + [Fact] + public void PayloadEqualValidationNonEqualFails() + { + var val = new Validators.EqualValidator(new PasetoPayload + { + { RegisteredClaims.Issuer.GetRegisteredClaimName(), IssuedBy } + }, RegisteredClaims.Issuer.GetRegisteredClaimName()); + + Action act = () => val.Validate(IssuedBy + "."); + act.Should().Throw(); + } + + [Fact] + public void PayloadEqualValidationTest() + { + var val = new Validators.EqualValidator(new PasetoPayload + { + { RegisteredClaims.Issuer.GetRegisteredClaimName(), IssuedBy } + }, RegisteredClaims.Issuer.GetRegisteredClaimName()); + + Action act = () => val.Validate(IssuedBy); + act.Should().NotThrow(); + } + + [Fact] + public void PayloadCustomValidationNonEqualFails() + { + var val = new Validators.EqualValidator(new PasetoPayload + { + { "example", HelloPaseto } + }, "example"); + + Action act = () => val.Validate(HelloPaseto + "!"); + act.Should().Throw(); + } + + [Fact] + public void PayloadCustomValidationTest() + { + var val = new Validators.EqualValidator(new PasetoPayload + { + { "example", HelloPaseto } + }, "example"); + + Action act = () => val.Validate(HelloPaseto); + act.Should().NotThrow(); + } + + public static TheoryData FutureTimes => new() + { + { DateTime.UtcNow.AddHours(24), DateTime.UtcNow }, + { DateTimeOffset.UtcNow.AddHours(24), DateTimeOffset.UtcNow } + }; + + public static TheoryData PastTimes => new() + { + { DateTime.UtcNow.AddHours(-24), DateTime.UtcNow }, + { DateTimeOffset.UtcNow.AddHours(-24), DateTimeOffset.UtcNow } + }; + + public static TheoryData NowTimes = new() + { + { DateTime.UtcNow }, + { DateTimeOffset.UtcNow } + }; + + private static PasetoPayload CreateDateValidatorPayload(RegisteredClaims claim, IComparable when) => new() + { + { claim.ToDescription(), when } + }; +} \ No newline at end of file