1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

CSA-2 - Require user interaction for SSO redirect (#1948)

* CSA-2 - adding validation before redirecting for SSO login

* Updating server to use generated and signed JWT for SSO redirect

* Removing erroneous file

* Removing erroneous file

* Updating for PR feedback, adding domain_hint to Login and fixing invalid domain_hint name reference

* Some code styling changes from PR feedback

* Removing unnecessary JSON serialization

* Couple small changes from PR feedback

* Fixing linting errors

* Update formatting in AccountController.cs

* Remove unused dependency

* Add token lifetime to settings

* Use tokenable directly

* Return defined models

* Revert sso proj file changes

* Check expiration validity when validating org

* Show error message with expired token

* Formatting fixes

* Add SsoTokenLifetime to Sso settings

* Fix build errors

* Fix sql warnings

Co-authored-by: Carlos J. Muentes <cmuentes@bitwarden.com>
Co-authored-by: Chad Scharf <3904944+cscharf@users.noreply.github.com>
Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Carlos J. Muentes 2022-06-01 13:23:52 -04:00 committed by GitHub
parent c27645265c
commit 14302efa2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 267 additions and 56 deletions

View File

@ -8,10 +8,12 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models; using Bit.Core.Models;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
@ -47,6 +49,7 @@ namespace Bit.Sso.Controllers
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly IGlobalSettings _globalSettings; private readonly IGlobalSettings _globalSettings;
private readonly Core.Services.IEventService _eventService; private readonly Core.Services.IEventService _eventService;
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
public AccountController( public AccountController(
IAuthenticationSchemeProvider schemeProvider, IAuthenticationSchemeProvider schemeProvider,
@ -64,7 +67,8 @@ namespace Bit.Sso.Controllers
II18nService i18nService, II18nService i18nService,
UserManager<User> userManager, UserManager<User> userManager,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
Core.Services.IEventService eventService) Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector)
{ {
_schemeProvider = schemeProvider; _schemeProvider = schemeProvider;
_clientStore = clientStore; _clientStore = clientStore;
@ -82,57 +86,47 @@ namespace Bit.Sso.Controllers
_userManager = userManager; _userManager = userManager;
_eventService = eventService; _eventService = eventService;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_dataProtector = dataProtector;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> PreValidate(string domainHint) public async Task<IActionResult> PreValidate(string domainHint)
{ {
IActionResult invalidJson(string errorMessageKey, Exception ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
{
ExceptionMessage = ex?.Message,
ExceptionStackTrace = ex?.StackTrace,
InnerExceptionMessage = ex?.InnerException?.Message,
});
}
try try
{ {
// Validate domain_hint provided // Validate domain_hint provided
if (string.IsNullOrWhiteSpace(domainHint)) if (string.IsNullOrWhiteSpace(domainHint))
{ {
return invalidJson("NoOrganizationIdentifierProvidedError"); return InvalidJson("NoOrganizationIdentifierProvidedError");
} }
// Validate organization exists from domain_hint // Validate organization exists from domain_hint
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization == null) if (organization == null)
{ {
return invalidJson("OrganizationNotFoundByIdentifierError"); return InvalidJson("OrganizationNotFoundByIdentifierError");
} }
if (!organization.UseSso) if (!organization.UseSso)
{ {
return invalidJson("SsoNotAllowedForOrganizationError"); return InvalidJson("SsoNotAllowedForOrganizationError");
} }
// Validate SsoConfig exists and is Enabled // Validate SsoConfig exists and is Enabled
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig == null) if (ssoConfig == null)
{ {
return invalidJson("SsoConfigurationNotFoundForOrganizationError"); return InvalidJson("SsoConfigurationNotFoundForOrganizationError");
} }
if (!ssoConfig.Enabled) if (!ssoConfig.Enabled)
{ {
return invalidJson("SsoNotEnabledForOrganizationError"); return InvalidJson("SsoNotEnabledForOrganizationError");
} }
// Validate Authentication Scheme exists and is loaded (cache) // Validate Authentication Scheme exists and is loaded (cache)
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme))
{ {
return invalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError");
} }
// Run scheme validation // Run scheme validation
@ -148,37 +142,60 @@ namespace Bit.Sso.Controllers
{ {
errorKey = ex.Message; errorKey = ex.Message;
} }
return invalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
} }
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
var token = _dataProtector.Protect(tokenable);
return new SsoPreValidateResponseModel(token);
} }
catch (Exception ex) catch (Exception ex)
{ {
return invalidJson("PreValidationError", ex); return InvalidJson("PreValidationError", ex);
} }
// Everything is good!
return new EmptyResult();
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Login(string returnUrl) public async Task<IActionResult> Login(string returnUrl)
{ {
var context = await _interaction.GetAuthorizationContextAsync(returnUrl); var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context.Parameters.AllKeys.Contains("domain_hint") &&
!string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) if (!context.Parameters.AllKeys.Contains("domain_hint") ||
{ string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
return RedirectToAction(nameof(ExternalChallenge), new
{
scheme = context.Parameters["domain_hint"],
returnUrl,
state = context.Parameters["state"],
userIdentifier = context.Parameters["session_state"]
});
}
else
{ {
throw new Exception(_i18nService.T("NoDomainHintProvided")); throw new Exception(_i18nService.T("NoDomainHintProvided"));
} }
var ssoToken = context.Parameters[SsoTokenable.TokenIdentifier];
if (string.IsNullOrWhiteSpace(ssoToken))
{
return Unauthorized("A valid SSO token is required to continue with SSO login");
}
var domainHint = context.Parameters["domain_hint"];
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
if (organization == null)
{
return InvalidJson("OrganizationNotFoundByIdentifierError");
}
var tokenable = _dataProtector.Unprotect(ssoToken);
if (!tokenable.TokenIsValid(organization))
{
return Unauthorized("The SSO token associated with your request is expired. A valid SSO token is required to continue.");
}
return RedirectToAction(nameof(ExternalChallenge), new
{
scheme = organization.Id.ToString(),
returnUrl,
state = context.Parameters["state"],
userIdentifier = context.Parameters["session_state"],
});
} }
[HttpGet] [HttpGet]
@ -548,6 +565,17 @@ namespace Bit.Sso.Controllers
return user; return user;
} }
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
{
ExceptionMessage = ex?.Message,
ExceptionStackTrace = ex?.StackTrace,
InnerExceptionMessage = ex?.InnerException?.Message,
});
}
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes) private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{ {
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));

View File

@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Mvc;
namespace Bit.Sso.Models
{
public class SsoPreValidateResponseModel : JsonResult
{
public SsoPreValidateResponseModel(string token) : base(new
{
token
})
{ }
}
}

View File

@ -0,0 +1,46 @@

using System;
using System.Text.Json.Serialization;
using Bit.Core.Entities;
using Bit.Core.Tokens;
namespace Bit.Core.Models.Business.Tokenables
{
public class SsoTokenable : ExpiringTokenable
{
public const string ClearTextPrefix = "BWUserPrefix_";
public const string DataProtectorPurpose = "SsoTokenDataProtector";
public const string TokenIdentifier = "ssoToken";
public Guid OrganizationId { get; set; }
public string DomainHint { get; set; }
public string Identifier { get; set; } = TokenIdentifier;
[JsonConstructor]
public SsoTokenable() { }
public SsoTokenable(Organization organization, double tokenLifetimeInSeconds) : this()
{
OrganizationId = organization?.Id ?? default;
DomainHint = organization?.Identifier;
ExpirationDate = DateTime.UtcNow.AddSeconds(tokenLifetimeInSeconds);
}
public bool TokenIsValid(Organization organization)
{
if (OrganizationId == default || DomainHint == default || organization == null || !Valid)
{
return false;
}
return organization.Identifier.Equals(DomainHint, StringComparison.InvariantCultureIgnoreCase)
&& organization.Id.Equals(OrganizationId);
}
// Validates deserialized
protected override bool TokenIsValid() =>
Identifier == TokenIdentifier
&& OrganizationId != default
&& !string.IsNullOrWhiteSpace(DomainHint);
}
}

View File

@ -679,4 +679,10 @@
<data name="IdpSingleSignOnServiceUrlInvalid" xml:space="preserve"> <data name="IdpSingleSignOnServiceUrlInvalid" xml:space="preserve">
<value>Single sign on service URL contains illegal characters.</value> <value>Single sign on service URL contains illegal characters.</value>
</data> </data>
<data name="SsoRedirectTokenValidationMissing" xml:space="preserve">
<value>Single sign on redirect token is missing from the request.</value>
</data>
<data name="InvalidSsoRedirectToken" xml:space="preserve">
<value>Single sign on redirect token is invalid or expired.</value>
</data>
</root> </root>

View File

@ -67,7 +67,7 @@ namespace Bit.Core.Settings
public virtual AmazonSettings Amazon { get; set; } = new AmazonSettings(); public virtual AmazonSettings Amazon { get; set; } = new AmazonSettings();
public virtual ServiceBusSettings ServiceBus { get; set; } = new ServiceBusSettings(); public virtual ServiceBusSettings ServiceBus { get; set; } = new ServiceBusSettings();
public virtual AppleIapSettings AppleIap { get; set; } = new AppleIapSettings(); public virtual AppleIapSettings AppleIap { get; set; } = new AppleIapSettings();
public virtual SsoSettings Sso { get; set; } = new SsoSettings(); public virtual ISsoSettings Sso { get; set; } = new SsoSettings();
public virtual StripeSettings Stripe { get; set; } = new StripeSettings(); public virtual StripeSettings Stripe { get; set; } = new StripeSettings();
public virtual ITwoFactorAuthSettings TwoFactorAuth { get; set; } = new TwoFactorAuthSettings(); public virtual ITwoFactorAuthSettings TwoFactorAuth { get; set; } = new TwoFactorAuthSettings();
@ -461,9 +461,10 @@ namespace Bit.Core.Settings
public bool AppInReview { get; set; } public bool AppInReview { get; set; }
} }
public class SsoSettings public class SsoSettings : ISsoSettings
{ {
public int CacheLifetimeInSeconds { get; set; } = 60; public int CacheLifetimeInSeconds { get; set; } = 60;
public double SsoTokenLifetimeInSeconds { get; set; } = 5;
} }
public class CaptchaSettings public class CaptchaSettings

View File

@ -14,5 +14,6 @@
IConnectionStringSettings Storage { get; set; } IConnectionStringSettings Storage { get; set; }
IBaseServiceUriSettings BaseServiceUri { get; set; } IBaseServiceUriSettings BaseServiceUri { get; set; }
ITwoFactorAuthSettings TwoFactorAuth { get; set; } ITwoFactorAuthSettings TwoFactorAuth { get; set; }
ISsoSettings Sso { get; set; }
} }
} }

View File

@ -0,0 +1,8 @@
namespace Bit.Core.Settings
{
public interface ISsoSettings
{
int CacheLifetimeInSeconds { get; set; }
double SsoTokenLifetimeInSeconds { get; set; }
}
}

View File

@ -3368,4 +3368,4 @@
} }
} }
} }
} }

View File

@ -6,6 +6,7 @@ using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Identity.Models; using Bit.Identity.Models;
using IdentityModel; using IdentityModel;
@ -59,14 +60,11 @@ namespace Bit.Identity.Controllers
var culture = requestCultureFeature.RequestCulture.Culture.Name; var culture = requestCultureFeature.RequestCulture.Culture.Name;
var requestPath = $"/Account/PreValidate?domainHint={domainHint}&culture={culture}"; var requestPath = $"/Account/PreValidate?domainHint={domainHint}&culture={culture}";
var httpClient = _clientFactory.CreateClient("InternalSso"); var httpClient = _clientFactory.CreateClient("InternalSso");
// Forward the internal SSO result
using var responseMessage = await httpClient.GetAsync(requestPath); using var responseMessage = await httpClient.GetAsync(requestPath);
if (responseMessage.IsSuccessStatusCode)
{
// All is good!
return new EmptyResult();
}
Response.StatusCode = (int)responseMessage.StatusCode;
var responseJson = await responseMessage.Content.ReadAsStringAsync(); var responseJson = await responseMessage.Content.ReadAsStringAsync();
Response.StatusCode = (int)responseMessage.StatusCode;
return Content(responseJson, "application/json"); return Content(responseJson, "application/json");
} }
catch (Exception ex) catch (Exception ex)
@ -89,6 +87,7 @@ namespace Bit.Identity.Controllers
var domainHint = context.Parameters.AllKeys.Contains("domain_hint") ? var domainHint = context.Parameters.AllKeys.Contains("domain_hint") ?
context.Parameters["domain_hint"] : null; context.Parameters["domain_hint"] : null;
var ssoToken = context.Parameters[SsoTokenable.TokenIdentifier];
if (string.IsNullOrWhiteSpace(domainHint)) if (string.IsNullOrWhiteSpace(domainHint))
{ {
@ -100,27 +99,28 @@ namespace Bit.Identity.Controllers
return RedirectToAction(nameof(ExternalChallenge), new return RedirectToAction(nameof(ExternalChallenge), new
{ {
organizationIdentifier = domainHint, domainHint = domainHint,
returnUrl, returnUrl,
userIdentifier userIdentifier,
ssoToken,
}); });
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> ExternalChallenge(string organizationIdentifier, string returnUrl, public async Task<IActionResult> ExternalChallenge(string domainHint, string returnUrl,
string userIdentifier) string userIdentifier, string ssoToken)
{ {
if (string.IsNullOrWhiteSpace(organizationIdentifier)) if (string.IsNullOrWhiteSpace(domainHint))
{ {
throw new Exception("Invalid organization reference id."); throw new Exception("Invalid organization reference id.");
} }
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(organizationIdentifier); var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
if (ssoConfig == null || !ssoConfig.Enabled) if (ssoConfig == null || !ssoConfig.Enabled)
{ {
throw new Exception("Organization not found or SSO configuration not enabled"); throw new Exception("Organization not found or SSO configuration not enabled");
} }
var domainHint = ssoConfig.OrganizationId.ToString(); var organizationId = ssoConfig.OrganizationId.ToString();
var scheme = "sso"; var scheme = "sso";
var props = new AuthenticationProperties var props = new AuthenticationProperties
@ -130,8 +130,13 @@ namespace Bit.Identity.Controllers
{ {
{ "return_url", returnUrl }, { "return_url", returnUrl },
{ "domain_hint", domainHint }, { "domain_hint", domainHint },
{ "organizationId", organizationId },
{ "scheme", scheme }, { "scheme", scheme },
}, },
Parameters =
{
{ "ssoToken", ssoToken },
}
}; };
if (!string.IsNullOrWhiteSpace(userIdentifier)) if (!string.IsNullOrWhiteSpace(userIdentifier))
@ -173,7 +178,7 @@ namespace Bit.Identity.Controllers
IsPersistent = true, IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
}; };
if (result.Properties != null && result.Properties.Items.TryGetValue("domain_hint", out var organization)) if (result.Properties != null && result.Properties.Items.TryGetValue("organizationId", out var organization))
{ {
additionalLocalClaims.Add(new Claim("organizationId", organization)); additionalLocalClaims.Add(new Claim("organizationId", organization));
} }

View File

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Bit.Core; using Bit.Core;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Identity.Utilities; using Bit.Identity.Utilities;
@ -110,10 +111,17 @@ namespace Bit.Identity
{ {
// Pass domain_hint onto the sso idp // Pass domain_hint onto the sso idp
context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"]; context.ProtocolMessage.DomainHint = context.Properties.Items["domain_hint"];
context.ProtocolMessage.Parameters.Add("organizationId", context.Properties.Items["organizationId"]);
if (context.Properties.Items.ContainsKey("user_identifier")) if (context.Properties.Items.ContainsKey("user_identifier"))
{ {
context.ProtocolMessage.SessionState = context.Properties.Items["user_identifier"]; context.ProtocolMessage.SessionState = context.Properties.Items["user_identifier"];
} }
if (context.Properties.Parameters.Count > 0 && context.Properties.Parameters.ContainsKey(SsoTokenable.TokenIdentifier))
{
var token = context.Properties.Parameters[SsoTokenable.TokenIdentifier].ToString();
context.ProtocolMessage.Parameters.Add(SsoTokenable.TokenIdentifier, token);
}
return Task.FromResult(0); return Task.FromResult(0);
} }
}; };

View File

@ -121,6 +121,11 @@ namespace Bit.SharedWeb.Utilities
HCaptchaTokenable.DataProtectorPurpose, HCaptchaTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider()) serviceProvider.GetDataProtectionProvider())
); );
services.AddSingleton<IDataProtectorTokenFactory<SsoTokenable>>(serviceProvider =>
new DataProtectorTokenFactory<SsoTokenable>(
SsoTokenable.ClearTextPrefix,
SsoTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider()));
} }
public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings)

View File

@ -3351,4 +3351,4 @@
} }
} }
} }
} }

View File

@ -11,6 +11,6 @@ BEGIN
FROM FROM
[dbo].[OrganizationSponsorship] OS [dbo].[OrganizationSponsorship] OS
WHERE WHERE
[SponsoringOrganizationUserId] = @OrganizationUserId [SponsoringOrganizationUserID] = @OrganizationUserId
END END
GO GO

View File

@ -11,6 +11,6 @@ BEGIN
FROM FROM
[dbo].[OrganizationSponsorship] OS [dbo].[OrganizationSponsorship] OS
INNER JOIN INNER JOIN
@SponsoringOrganizationUserIds I ON I.Id = OS.SponsoringOrganizationUserId @SponsoringOrganizationUserIds I ON I.Id = OS.SponsoringOrganizationUserID
END END
GO GO

View File

@ -0,0 +1,90 @@
using System;
using AutoFixture.Xunit2;
using Bit.Core.Entities;
using Bit.Core.Models.Business.Tokenables;
using Bit.Core.Tokens;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Models.Business.Tokenables
{
public class SsoTokenableTests
{
[Fact]
public void CanHandleNullOrganization()
{
var token = new SsoTokenable(null, default);
Assert.Equal(default, token.OrganizationId);
Assert.Equal(default, token.DomainHint);
}
[Fact]
public void TokenWithNullOrganizationIsInvalid()
{
var token = new SsoTokenable(null, 500)
{
ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1)
};
Assert.False(token.Valid);
}
[Theory, BitAutoData]
public void TokenValidityCheckNullOrganizationIsInvalid(Organization organization)
{
var token = new SsoTokenable(organization, 500)
{
ExpirationDate = DateTime.UtcNow + TimeSpan.FromDays(1)
};
Assert.False(token.TokenIsValid(null));
}
[Theory, AutoData]
public void SetsDataFromOrganization(Organization organization)
{
var token = new SsoTokenable(organization, default);
Assert.Equal(organization.Id, token.OrganizationId);
Assert.Equal(organization.Identifier, token.DomainHint);
}
[Fact]
public void SetsExpirationFromConstructor()
{
var expectedDateTime = DateTime.UtcNow.AddSeconds(500);
var token = new SsoTokenable(null, 500);
Assert.Equal(expectedDateTime, token.ExpirationDate, TimeSpan.FromMilliseconds(10));
}
[Theory, AutoData]
public void SerializationSetsCorrectDateTime(Organization organization)
{
var expectedDateTime = DateTime.UtcNow.AddHours(-5);
var token = new SsoTokenable(organization, default)
{
ExpirationDate = expectedDateTime
};
var result = Tokenable.FromToken<HCaptchaTokenable>(token.ToToken());
Assert.Equal(expectedDateTime, result.ExpirationDate, TimeSpan.FromMilliseconds(10));
}
[Theory, AutoData]
public void TokenIsValidFailsWhenExpired(Organization organization)
{
var expectedDateTime = DateTime.UtcNow.AddHours(-5);
var token = new SsoTokenable(organization, default)
{
ExpirationDate = expectedDateTime
};
var result = token.TokenIsValid(organization);
Assert.False(result);
}
}
}

View File

@ -3289,4 +3289,4 @@
} }
} }
} }
} }