Conditional Access – Part 2

💡Adding Terms of Service to the User Login Flow

A Terms of Service (ToS) agreement is a legal contract between the web application provider and its users. It outlines the rules, responsibilities, and guidelines for using the service. By requiring users to read and agree to the ToS before logging in, a service provider ensures that users are aware of their rights and obligations, protecting both parties from potential legal issues. This step is essential for maintaining a secure and fair environment, fostering trust, and ensuring compliance with applicable laws and regulations.

For businesses hosting their own identity provider, requiring users to agree to Terms of Service (ToS) during the login process is essential. This ensures that users acknowledge and accept the rules & guidelines before accessing a client or resource, providing several key benefits:

  1. Legal Protection: It safeguards the business by clearly defining the legal relationship between the user and the service provider, reducing the risk of disputes and liability issues.
  2. User Awareness: It guarantees that users are informed about their rights and responsibilities, including data usage, privacy policies, and acceptable use of the service.
  3. Compliance: It helps the business comply with legal and regulatory requirements, ensuring that all users are operating under the same agreed-upon terms.
  4. Security: It reinforces security measures by making users aware of the protocols and standards they must follow, helping to prevent misuse and unauthorized access.
  5. Consistency: It ensures a consistent user experience and understanding across the platform, promoting a fair and transparent environment for all users.

By integrating this check during the login process, businesses can protect their interests, maintain compliance, and foster a secure and informed user base.

🎯The End Goal

What we’ll be looking to achieve here is a simple modification to our Identity Server instance. Adding in a new Razor Page that will be displayed to our user logging in, if they haven’t already agreed to the Terms of Service, which will look like this:

Key implementation decisions I’ve taken (for demonstration and simplicity) that you may want to amend depending on your own requirements:

  • The Terms of Service text will be global and not change depending on the client being logged into.
  • There won’t be an option to “skip” – the user will either agree & continue, or disagree ending the login flow.
  • The persistence of the user agreement will be held as a specific claim type, the value of which will reflect the date & time the agreement occurred.

Your own implementation may or may not want to display different terms depending on the client. You may also want to allow the user to skip if you choose to use this as the basis for another different feature. Using claims to store the user agreement was an easy turn-key solution, you could consider using a different storage method depending on your specific use case.

🛠️Implementing the ToS Feature

Adding the new Razor Page and accompanying models

Within the Identity Server host, we’re going to add a new folder in the “Pages” directory called ToS. This folder will contain three files:

  • ViewModel.cs
  • InputModel.cs
  • Accept.cshtml (and Accept.cshtml.cs for the code-behind).

Let’s create the first file, ViewModel.cs – This will be available to the UI and allow us to customise the ToS page should we wish.

public class ViewModel
{
    public string ClientName { get; set; }
    public string ClientUrl { get; set; }
    public string ClientLogoUrl { get; set; }
}

The second file, InputModel.cs will be the bound model on the page. This gives us the Redirect URL to go to once we click a button, and the name of the button we pressed in the UI.

public class InputModel
{
    public string ReturnUrl { get; set; }
    public string Button { get; set; }
}

Lastly, we’ll add the Razor page. For those using Visual Studio, right-clicking on the “ToS” folder we created, and selecting “Add” > “Razor Page” and selecting “Razor Page – Empty” will scaffold you both the .cshtml and .cshtml.cs files in one action.

The Razor Page & Code Behind

@page
@model IdentityServer.Pages.ToS.AcceptModel

@{
    Layout = "_LayoutLogin";
}

<div class="row mb-1">
    <div class="col">
        <img src="~/logo_long.png" class="login-logo" />
    </div>
</div>

<div class="row">

    <div class="col-12 col-md-6 mb-3 mb-md-0">
        <span class="fs-2 d-block mb-1">Terms of Service</span>
        <span class="d-block">You need to read and agree to the following terms</span>
    </div>

    <div class="col-12 col-md-6">
        <div class="page-tos">

            <div class="overflow-y-scroll tos_content">
                <p>Your Own Text Goes Here...</p>
            </div>

            <form asp-page="/ToS/Accept">
                <input type="hidden" asp-for="Input.ReturnUrl" />
                <div class="my-3 mt-5">
                    <button name="Input.button" value="agree" class="btn btn-primary float-end rounded-5">Agree & Continue</button>
                    <button name="Input.button" value="disagree" class="btn float-end mx-3">Disagree</button>
                </div>
            </form>

        </div>
    </div>

</div>

The razor (.cshtml) file itself is split into two columns – the first is the page title and describes what the page is. The second is the actual terms content we want the user to read and agree to, along with the two action buttons “Disagree” and “Agree & Continue”.

For the code-behind, we’re going to add the following OnGet and OnPost methods, as well as a few private helper methods.

namespace IdentityServer.Pages.ToS
{
    public class AcceptModel(
    IIdentityServerInteractionService interaction,
    IClock clock,
    IMessageStore<ErrorMessage> errorMessageStore,
    IProfileService profileService,
    IEventService events,
    ILogger<Index> logger) : PageModel
    {
        private readonly IIdentityServerInteractionService _interaction = interaction;
        private readonly IClock _clock = clock;
        private readonly IMessageStore<ErrorMessage> _errorMessageStore = errorMessageStore;
        private readonly IEventService _events = events;
        private readonly ILogger<Index> _logger = logger;
        private readonly IUserProfileService _profileService = (IUserProfileService)profileService;

        public ViewModel View { get; set; }

        [BindProperty]
        public InputModel Input { get; set; }

        public IActionResult OnGet(string returnUrl)
        {
            Input = new InputModel
            {
                ReturnUrl = returnUrl,
            };

            return Page();
        }

        public async Task<IActionResult> OnPost()
        {
            // Get the current authorization context
            var request = await _interaction.GetAuthorizationContextAsync(Input.ReturnUrl);

            // user clicked 'yes' - store the response and redirect the user onwards
            if (Input?.Button == "agree")
            {
                // Our own custom Profile Service to add/update a user claim
                await _profileService.AddOrUpdateUserClaim(
                    subjectId: User.GetSubjectId(),
                    claimType: "tos_accepted",
                    claimValue: DateTime.Now.ToString()
                );

                return Redirect(Input.ReturnUrl);
            }

            // user clicked 'no' - send back the standard 'access_denied' response
            if (Input?.Button == "disagree")
            {
                await _interaction.DenyAuthorizationAsync(request, AuthorizationError.AccessDenied);

                return Redirect(Input.ReturnUrl);
            }

            View = await BuildViewModelAsync(Input.ReturnUrl, Input);
            return Page();
        }

        private async Task<ViewModel> BuildViewModelAsync(string returnUrl, InputModel model = null)
        {
            var request = await _interaction.GetAuthorizationContextAsync(returnUrl);
            if (request != null)
            {
                return CreateToSViewModel(model, returnUrl, request);
            }
            else
            {
                _logger.LogError("No consent request matching request: {returnUrl}", returnUrl);
            }
            return null;
        }

        private static ViewModel CreateToSViewModel(InputModel model, string returnUrl, AuthorizationRequest request)
        {
            var vm = new ViewModel
            {
                ClientName = request.Client.ClientName ?? request.Client.ClientId,
                ClientUrl = request.Client.ClientUri,
                ClientLogoUrl = request.Client.LogoUri
            };

            return vm;
        }

    }
}

The Razor Page & Code Behind

We’ve now got the UI side of things sorted and ready, but so far we haven’t covered just how we’re going to trigger this during the login flow.

To achieve that, we’ll make use of the AuthorizeInteractionResponseGenerator and create a subclass imaginatively called CustomAuthorizeInteractionResponseGenerator.

This class allows us to hook directly into the authorization flow and inject our own functionality in, such as displaying our ToS page.

Before we get into that, we need to determine if we need to display the ToS prompt to the user. So we’re going to add a helper extension that will extend the ProfileDataRequestContext class and return a bool result. So that we don’t have to keep manually resetting the claim value for every user, this extension method will require ToS if the claim is missing, of the claim value is more than an hour old.

namespace IdentityServer.Extensions.ToS;

public static class Helpers
{
    public static bool ToSRequired(this ProfileDataRequestContext profileDataRequestContext)
    {
        const int tosResetTimeInHours = 1;

        var claim = profileDataRequestContext.IssuedClaims.FirstOrDefault(c => c.Type == "tos_accepted");
        if (claim != null && !string.IsNullOrWhiteSpace(claim.Value))
        {
            if (DateTime.TryParse(claim.Value, out DateTime claimDateTime))
            {
                return DateTime.UtcNow - claimDateTime >= TimeSpan.FromHours(tosResetTimeInHours);
            }
        }
        return true;
    }
}

Now we get to implement our custom response generator. It will need to handle a couple of things:

  • Process the login and determine the current state.
  • Execute a profile data request for our custom claim type.
  • Redirect the user if our helper method above, determines it’s required.
  • Log the events out to the ILogger we have registered in Dependency Injection.
namespace IdentityServer.Extensions;

public class CustomAuthorizeInteractionResponseGenerator(
    IdentityServerOptions options,
    IClock clock,
    ILogger<CustomAuthorizeInteractionResponseGenerator> logger,
    IConsentService consent,
    IProfileService profile) : AuthorizeInteractionResponseGenerator(options, clock, logger, consent, profile)
{
    private readonly IProfileService _profileService = profile;
    private readonly ILogger<CustomAuthorizeInteractionResponseGenerator> _logger = logger;

    protected override async Task<InteractionResponse> ProcessLoginAsync(ValidatedAuthorizeRequest request)
    {
        var result = await base.ProcessLoginAsync(request);
        if (result.IsLogin || result.IsError || result.IsConsent) return result;

        _logger.LogDebug(message: "Handling custom interaction logic");

        var profileDataRequest = new ProfileDataRequestContext {
            Caller = nameof(CustomAuthorizeInteractionResponseGenerator),
            Subject = request.Subject,
            Client = request.Client,
            RequestedClaimTypes = ["tos_accepted"]
        };

        profileDataRequest.LogProfileRequest(Logger);
        await _profileService.GetProfileDataAsync(profileDataRequest);

        if (profileDataRequest.ToSRequired()) {
            _logger.LogDebug(message: "ToS confirmation is required for this interaction, redirecting to ToS prompt");
            return new InteractionResponse { RedirectUrl = "/ToS/accept" };
        }
        
        return result;
    }
}

Registering the Custom Response Generator

Now that all the pieces are ready, we need to tell Identity Server that we have a custom response generator we want to use. Helpfully, Duende provides an easy way for us to do this using the AddAuthorizeInteractionResponseGenerator extension method.

In your host setup, where you add and configure Identity Server, append the following call:

.AddAuthorizeInteractionResponseGenerator<CustomAuthorizeInteractionResponseGenerator>();

That’s it. Now each time a user logs in using our Identity Provider, we will check that they have accepted our Terms of Service and prompt them if they haven’t.

This makes for another effective conditional access policy, ensuring that users (both internal and external) are only able to access our applications having agreed to our ToS.

Scroll to Top