Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add TimeProvider support to token validation #2573

Closed
wants to merge 2 commits into from
Closed

Add TimeProvider support to token validation #2573

wants to merge 2 commits into from

Conversation

alexmurari
Copy link

Add TimeProvider support to token validation

TimeProvider abstracts time and enables deterministic tests.

Description

Added a new parameter to the TokenValidationParameters class to hold the TimeProvider instance.
The default behavior's still the use of DateTime class to obtain the current time.

Fixes #2572

The TimeProvider class abstracts time, facilitating deterministic tests by removing reliance on ambient context for obtaining the current time.
If not set, validators will fall back to using the DateTime class to obtain the current time.
@alexmurari alexmurari requested a review from a team as a code owner April 28, 2024 23:27
@alexmurari
Copy link
Author

@microsoft-github-policy-service agree

@@ -23,6 +23,10 @@
<DefineConstants>$(DefineConstants);HAVE_ADO_NET;HAVE_APP_DOMAIN;HAVE_ASYNC;HAVE_ASYNC_DISPOSABLE;HAVE_BIG_INTEGER;HAVE_BINARY_FORMATTER;HAVE_BINARY_SERIALIZATION;HAVE_BINARY_EXCEPTION_SERIALIZATION;HAVE_CHAR_TO_LOWER_WITH_CULTURE;HAVE_CHAR_TO_STRING_WITH_CULTURE;HAVE_COM_ATTRIBUTES;HAVE_COMPONENT_MODEL;HAVE_CONCURRENT_COLLECTIONS;HAVE_COVARIANT_GENERICS;HAVE_DATA_CONTRACTS;HAVE_DATE_TIME_OFFSET;HAVE_DB_NULL_TYPE_CODE;HAVE_DYNAMIC;HAVE_EMPTY_TYPES;HAVE_ENTITY_FRAMEWORK;HAVE_EXPRESSIONS;HAVE_FAST_REVERSE;HAVE_FSHARP_TYPES;HAVE_FULL_REFLECTION;HAVE_GUID_TRY_PARSE;HAVE_HASH_SET;HAVE_ICLONEABLE;HAVE_ICONVERTIBLE;HAVE_IGNORE_DATA_MEMBER_ATTRIBUTE;HAVE_INOTIFY_COLLECTION_CHANGED;HAVE_INOTIFY_PROPERTY_CHANGING;HAVE_ISET;HAVE_LINQ;HAVE_MEMORY_BARRIER;HAVE_METHOD_IMPL_ATTRIBUTE;HAVE_NON_SERIALIZED_ATTRIBUTE;HAVE_READ_ONLY_COLLECTIONS;HAVE_REFLECTION_EMIT;HAVE_REGEX_TIMEOUTS;HAVE_SECURITY_SAFE_CRITICAL_ATTRIBUTE;HAVE_SERIALIZATION_BINDER_BIND_TO_NAME;HAVE_STREAM_READER_WRITER_CLOSE;HAVE_STRING_JOIN_WITH_ENUMERABLE;HAVE_TIME_SPAN_PARSE_WITH_CULTURE;HAVE_TIME_SPAN_TO_STRING_WITH_CULTURE;HAVE_TIME_ZONE_INFO;HAVE_TRACE_WRITER;HAVE_TYPE_DESCRIPTOR;HAVE_UNICODE_SURROGATE_DETECTION;HAVE_VARIANT_TYPE_PARAMETERS;HAVE_VERSION_TRY_PARSE;HAVE_XLINQ;HAVE_XML_DOCUMENT;HAVE_XML_DOCUMENT_TYPE;HAVE_CONCURRENT_DICTIONARY;HAVE_INDEXOF_STRING_COMPARISON;HAVE_REPLACE_STRING_COMPARISON;HAVE_REPLACE_STRING_COMPARISON;HAVE_GETHASHCODE_STRING_COMPARISON;HAVE_NULLABLE_ATTRIBUTES;HAVE_DYNAMIC_CODE_COMPILED;HAS_ARRAY_EMPTY;HAVE_DATE_ONLY;$(AdditionalConstants)</DefineConstants>
</PropertyGroup>

<PropertyGroup>
<FrameworksWithoutTimeProvider>|net461|net462|net472|netstandard2.0|net6.0|</FrameworksWithoutTimeProvider>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TimeProvider is included by default in .NET 8 onwards. For every other target, we must eat the Microsoft.Bcl.TimeProvider nuget.

@@ -24,6 +24,10 @@
<PackageReference Include="Microsoft.Azure.KeyVault.Cryptography" Version="$(MicrosoftAzureKeyVaultCryptographyVersion)" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' != 'net461'">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every test target, except .NET FWK 4.6.1 supports the Microsoft.Extensions.TimeProvider.Testing.

@@ -55,6 +55,7 @@
</PropertyGroup>

<PropertyGroup>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
Copy link
Author

@alexmurari alexmurari Apr 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppress "Microsoft.Bcl.TimeProvider doesn't support .NET Framework 4.6.1, consider upgrading [...]" warnings. (Altough it's compatible with that framework version).

@@ -25,8 +25,10 @@
</PropertyGroup>

<PropertyGroup>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same.

<NoWarn>$(NoWarn);SYSLIB0050</NoWarn>
<NoWarn>$(NoWarn);SYSLIB0051</NoWarn>
<NoWarn>$(NoWarn);NU1701</NoWarn>
Copy link
Author

@alexmurari alexmurari Apr 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppresses the "This package may not be fully compatible with your project" warning. The cause is that Microsoft.Extensions.TimeProvider.Testing package is not compatible with .NET FWK 4.6.1.

@pmaytak
Copy link
Contributor

pmaytak commented May 2, 2024

@alexmurari Thanks for the proposal.

After team discussion, there is more design and work needed here. (Our PR process is described in the contributing guide, making sure a design is discussed before time is spent making changes.)

  • We're careful with adding new dependencies (MS.Bcl.TimeProvider) as it can create conflicts higher in the stack.
    • So we can conditionally use TimeProvider only in .NET 8.
  • We need to make sure adding TimeProvider property to TokenValidationParameters is the right thing to do as it's a commonly used class.

Proposal could be:

  • Create an internal time provider abstraction, e.g. IInternalTimeProvider.
  • On .NET 8 it will use .NET's TimeProvider, on other platforms - the current DateTime class.
  • Update all DateTime related code to use this abstraction, including tests.
  • This will allow us more easily to add MS.Bcl.TimeProvider package in the future, if it's the right thing to do.

@alexmurari
Copy link
Author

@pmaytak Thanks for the review and the modified proposal.

I agree to those points.

Client code would still need to provide a TimeProvider instance. Since DateTime usage is ubiquitous, we have two possibilities:

a. (Constructor/Method injection) Every class/method that needs a TimeProvider will have new parameters to receive it. (This kinda defeats the IInternalTimeProvider purpose).

- OR -

b. (Property-injection) We create an options class (e.g. TimeProviderOptions) that has an TimeProvider property. It's suitable for property-injection because it has a good local default: TimeProvider.System. It's basically an extension point.

public class TimeProviderOptions
{
    private TimeProvider _timeProvider = TimeProvider.System; // Local default

    public TimeProvider TimeProvider
    {
        get
        {
            return _timeProvider;
        }
        set
        {
            if (value == null) 
               throw new ArgumentNullException("value");

            _timeProvider = value;
        }
    }
}

We just need somewhere to set an instance of this options class, then the IInternalTimeProvider would use it to obtain the TimeProvider instance.

What do you think?

@trejjam
Copy link

trejjam commented May 23, 2024

We used a similar path with TimeProvider in OpenIddict:
https://github.com/openiddict/openiddict-core/pull/2071/files#diff-63fa24a3f983e94df2b11646deac927651bd5a5836f02d3d1de6dbd30f61e5baR1092

One of the constraints was also not to use the Bcl package. So there is #SUPPORTS_TIME_PROVIDER used instead

@trejjam
Copy link

trejjam commented May 23, 2024

This way, the PR becomes simple and minimalistic. I am willing to prepare PR using this approach.

@trejjam
Copy link

trejjam commented May 23, 2024

A workaround can look like this:

private const string TimeProviderName = "TimeProviderName";

/// <summary>
/// Validates the lifetime of a <see cref="SecurityToken"/>.
/// </summary>
internal static bool ValidateLifetime(DateTime? notBefore, DateTime? expires, SecurityToken securityToken, TokenValidationParameters validationParameters)
{
    if (!expires.HasValue && validationParameters.RequireExpirationTime)
    {
        throw LogHelper.LogExceptionMessage(new SecurityTokenNoExpirationException(
            LogHelper.FormatInvariant("IDX10225", LogHelper.MarkAsNonPII(securityToken.GetType().ToString()))
        ));
    }

    if (notBefore.HasValue && expires.HasValue && notBefore.Value > expires.Value)
    {
        throw LogHelper.LogExceptionMessage(new SecurityTokenInvalidLifetimeException(
            LogHelper.FormatInvariant("IDX10224", LogHelper.MarkAsNonPII(notBefore.Value), LogHelper.MarkAsNonPII(expires.Value))
        )
        {
            NotBefore = notBefore,
            Expires = expires,
        });
    }

    var timeProvider = (TimeProvider) validationParameters.InstancePropertyBag[TimeProviderName];

    var utcNow = timeProvider.GetUtcNow().UtcDateTime;
    if (notBefore.HasValue && notBefore.Value > DateTimeUtil.Add(utcNow, validationParameters.ClockSkew))
    {
        throw LogHelper.LogExceptionMessage(new SecurityTokenNotYetValidException(
            LogHelper.FormatInvariant("IDX10222", LogHelper.MarkAsNonPII(notBefore.Value), LogHelper.MarkAsNonPII(utcNow))
        )
        {
            NotBefore = notBefore.Value,
        });
    }

    if (expires.HasValue && expires.Value < DateTimeUtil.Add(utcNow, validationParameters.ClockSkew.Negate()))
    {
        throw LogHelper.LogExceptionMessage(new SecurityTokenExpiredException(
            LogHelper.FormatInvariant("IDX10223", LogHelper.MarkAsNonPII(expires.Value), LogHelper.MarkAsNonPII(utcNow))
        )
        {
            Expires = expires.Value,
        });
    }

    // if it reaches here, that means lifetime of the token is valid
    LogHelper.LogInformation("IDX10239");
    return true;
}

var timeProvider = TimeProvider.System;

var validationParameters = new TokenValidationParameters
{
  LifetimeValidator = ValidateLifetime,
}
validationParameters.InstancePropertyBag.Add(TimeProviderName, timeProvider);

@alexmurari
Copy link
Author

@trejjam That's a great approach! The changes to OpenIddict were minimal.

Fine by me a new PR with this approach. I'm just not sure where client code will configure the desired TimeProvider instance.

@trejjam
Copy link

trejjam commented May 27, 2024

Hi my PR is here: #2612

@alexmurari
Copy link
Author

Closing in favor of #2612

@alexmurari alexmurari closed this May 27, 2024
@alexmurari alexmurari deleted the feature/support-timeprovider-token-validation branch May 27, 2024 13:34
@alexmurari alexmurari mentioned this pull request Jul 26, 2024
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Use TimeProvider instead of DateTime ambient context
3 participants