Azure AD Access Token Lifetime Policy Management in ASP.NET Core

This article shows how the lifespan of access tokens can be set and managed in Azure AD using ASP.NET Core Razor pages with Microsoft Graph API and token lifetime policies. A TokenLifetimePolicy can be created for the whole tenant or used for specific Azure App Registrations.

Code: Azure AD Token Management

Posts in this series

App Registrations and Token Lifetime Policies

When creating Azure applications, you sometimes need to reduce the access token lifespan or increase this depending on your requirements. Normally this is set per Azure App registration and application. In Azure, token lifetime policies can be created for this purpose and applied or assigned to the different applications.

Only Azure App registrations with a SignInAudience of AzureADMyOrg or AzureADMultipleOrgs can be assigned a policy. An Azure App registration can only be assigned a single policy. If your application has a SignInAudience with the value AzureADandPersonalMicrosoftAccount, a policy cannot be assigned.

Setting up the Azure App registration

A private Azure App registration was created to manage and create the token policies. A secret is required to use this application. It would be good to require MFA for this type of application. The application requires three scopes from the delegated Graph API:

  • Policy.Read.All
  • Policy.ReadWrite.ApplicationConfiguration
  • Application.ReadWrite.All

Three Nuget packages are used to implement the Azure AD auth; Microsoft.Identity.Web, Microsoft.Identity.Web.MicrosoftGraphBeta and Microsoft.Identity.Web.UI.

The ConfigureServices uses the AddMicrosoftIdentityWebAppAuthentication method to authenticate with Azure AD and uses the Azure App registration setup for this application. The secret is added to the user secrets for the development and the rest in in the appsettings.json file.

public void ConfigureServices(IServiceCollection services)
{
	services.AddTransient<TokenLifetimePolicyGraphApiService>();
	services.AddHttpClient();

	services.AddOptions();

	services.AddMicrosoftIdentityWebAppAuthentication(Configuration)
		.EnableTokenAcquisitionToCallDownstreamApi()
		.AddInMemoryTokenCaches();

	services.AddRazorPages().AddMvcOptions(options =>
	{
		var policy = new AuthorizationPolicyBuilder()
			.RequireAuthenticatedUser()
			.Build();
		options.Filters.Add(new AuthorizeFilter(policy));
	}).AddMicrosoftIdentityUI();
}

The TokenLifetimePolicyGraphApiService service is used to access the Microsoft Graph API. In this example, the beta version is used. The ITokenAcquisition and the IHttpClientFactory interfaces are used to setup the service. The ITokenAcquisition is provided and setup in the Microsoft.Identity.Web package and is implemented like any other downstream API. The IHttpClientFactory is used to manage the HttpClient instances which is created for the API access in .NET.

public class TokenLifetimePolicyGraphApiService
{
	private readonly string graphUrl = 
		"https://graph.microsoft.com/beta";

	private readonly string[] scopesPolicy = new string[] {
			"Policy.Read.All", 
			"Policy.ReadWrite.ApplicationConfiguration" };

	private readonly string[] scopesApplications = new string[] {
			"Policy.Read.All", 
			"Policy.ReadWrite.ApplicationConfiguration", 
			"Application.ReadWrite.All" };


	private readonly ITokenAcquisition _tokenAcquisition;
	private readonly IHttpClientFactory _clientFactory;

	public TokenLifetimePolicyGraphApiService(
		ITokenAcquisition tokenAcquisition,
		IHttpClientFactory clientFactory)
	{
		_clientFactory = clientFactory;
		_tokenAcquisition = tokenAcquisition;
	}

The GetGraphClient method gets an access token for the defined scopes using the ITokenAcquisition interface and adds the access token to the GraphServiceClient instance using the DelegateAuthenticationProvider.

private async Task<GraphServiceClient> GetGraphClient(string[] scopes)
{
	var token = await _tokenAcquisition.GetAccessTokenForUserAsync(
	 scopes).ConfigureAwait(false);

	var client = _clientFactory.CreateClient();
	client.BaseAddress = new Uri(graphUrl);
	client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

	GraphServiceClient graphClient = new GraphServiceClient(client)
	{
		AuthenticationProvider = new DelegateAuthenticationProvider(async (requestMessage) =>
		{
			requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", token);
		})
	};

	graphClient.BaseUrl = graphUrl;
	return graphClient;
}

CRUD access token lifetime policies

The Microsoft Graph API can be used to get, create, update and delete the policies. The Graph API .NET implementation provides classes to request most Azure resources. The TokenLifetimePolicies is used to get (Http GET request) the policies or a single policy using an Id.

public async Task<IPolicyRootTokenLifetimePoliciesCollectionPage> GetPolicies()
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	return await graphclient
		.Policies
		.TokenLifetimePolicies
		.Request()
		.GetAsync()
		.ConfigureAwait(false);
}

public async Task<TokenLifetimePolicy> GetPolicy(string id)
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	return await graphclient.Policies
		.TokenLifetimePolicies[id]
		.Request()
		.GetAsync()
		.ConfigureAwait(false);
}

The CreatePolicy method creates a new TokenLifetimePolicy. The Definition takes a list of strings and multiple definitions can be added. We will only create a single definition for AccessTokenLifetime types. The definition is a Json string. If the IsOrganizationDefault is true, this is a policy for the Azure AD tenant or directory.

public async Task<TokenLifetimePolicy> CreatePolicy(TokenLifetimePolicy tokenLifetimePolicy)
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	//var tokenLifetimePolicy = new TokenLifetimePolicy
	//{
	//    Definition = new List<string>()
	//    {
	//        "{\"TokenLifetimePolicy\":{\"Version\":1,\"AccessTokenLifetime\":\"05:30:00\"}}"
	//    },
	//    DisplayName = "AppAccessTokenLifetimePolicy",
	//    IsOrganizationDefault = false
	//};

	return await graphclient
		.Policies
		.TokenLifetimePolicies
		.Request()
		.AddAsync(tokenLifetimePolicy)
		.ConfigureAwait(false);
}

The UpdatePolicy method uses the Id of the TokenLifetimePolicy and updates the values of the policy.

public async Task<TokenLifetimePolicy> UpdatePolicy(TokenLifetimePolicy tokenLifetimePolicy)
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	return await graphclient
		.Policies
		.TokenLifetimePolicies[tokenLifetimePolicy.Id]
		.Request()
		.UpdateAsync(tokenLifetimePolicy)
		.ConfigureAwait(false);
}

The DeletePolicy method deletes the policy.

public async Task DeletePolicy(string policyId)
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	await graphclient
		.Policies
		.TokenLifetimePolicies[policyId]
		.Request()
		.DeleteAsync()
		.ConfigureAwait(false);
}

Now the Razor pages can be implemented to use the Graph API service methods.

The Index Razor page uses the TokenLifetimePolicyGraphApiService to get all the policies and display these using a view DTO.

public class IndexModel : PageModel
{
	private readonly TokenLifetimePolicyGraphApiService _tokenLifetimePolicyGraphApiService;

	public IndexModel(TokenLifetimePolicyGraphApiService tokenLifetimePolicyGraphApiService)
	{
		_tokenLifetimePolicyGraphApiService = tokenLifetimePolicyGraphApiService;
	}

	public List<TokenLifetimePolicyDto> TokenLifetimePolicyDto { get; set; }

	public async Task OnGetAsync()
	{
		var policies = await _tokenLifetimePolicyGraphApiService.GetPolicies();
		TokenLifetimePolicyDto = policies.CurrentPage.Select(policy => new TokenLifetimePolicyDto
		{
			Definition = policy.Definition.FirstOrDefault(),
			DisplayName = policy.DisplayName,
			IsOrganizationDefault = policy.IsOrganizationDefault.GetValueOrDefault(),
			Id = policy.Id
		}).ToList();
	}
}

The policies are displayed using a HTML table and bootstrap 4.

@page
@model TokenManagement.Pages.AadTokenPolicies.IndexModel

@{
    ViewData["Title"] = "Index";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}

<p>
    <a asp-page="Create"><i class="far fa-plus-square"></i> Create new TokenLifetimePolicy </a>
</p>
<table class="table">
    <thead class="thead-light">
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto[0].Definition)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto[0].DisplayName)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto[0].IsOrganizationDefault)
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.TokenLifetimePolicyDto)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Definition)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.DisplayName)
                </td>
                <td>
                    <input asp-for="@item.IsOrganizationDefault" disabled class="big_checkbox" />
                </td>
                <td>
                    <div class="form-group" style="width:140px">
                        <a asp-page="./Edit" asp-route-id="@item.Id"><i class="far fa-edit fa-2x"></i></a>
                        <a asp-page="./Details" asp-route-id="@item.Id"><i class="far fa-folder-open fa-2x"></i></a>
                        <a asp-page="./Delete" asp-route-id="@item.Id"><i class="far fa-trash-alt fa-2x"></i></a>
                    </div>
                </td>
            </tr>
        }
    </tbody>
</table>
	

The table uses font awesome icons to update, delete, create or view the details of a policy.

The Edit policy is implemented in a standard Razor page. The get method selects the policy using the Id and requests the data using the Graph API service. The Post method requests a policy post update.

public class EditModel : PageModel
    {
        private readonly TokenLifetimePolicyGraphApiService _tokenLifetimePolicyGraphApiService;

        public EditModel(TokenLifetimePolicyGraphApiService tokenLifetimePolicyGraphApiService)
        {
            _tokenLifetimePolicyGraphApiService = tokenLifetimePolicyGraphApiService;
        }

        [BindProperty]
        public TokenLifetimePolicyDto TokenLifetimePolicyDto { get; set; }

        public async Task<IActionResult> OnGetAsync(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var policy = await _tokenLifetimePolicyGraphApiService.GetPolicy(id);
            TokenLifetimePolicyDto = new TokenLifetimePolicyDto
            {
                Definition = policy.Definition.FirstOrDefault(),
                DisplayName = policy.DisplayName,
                IsOrganizationDefault = policy.IsOrganizationDefault.GetValueOrDefault(),
                Id = policy.Id
            };

            if (TokenLifetimePolicyDto == null)
            {
                return NotFound();
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync()
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            // get existing
            var policy = await _tokenLifetimePolicyGraphApiService
				.GetPolicy(TokenLifetimePolicyDto.Id);
				
            var tokenLifetimePolicy = new TokenLifetimePolicy
            {
                Id = TokenLifetimePolicyDto.Id,
                Definition = new List<string>()
                {
                    TokenLifetimePolicyDto.Definition
                },
                DisplayName = TokenLifetimePolicyDto.DisplayName,
                IsOrganizationDefault = TokenLifetimePolicyDto.IsOrganizationDefault,
            };


            await _tokenLifetimePolicyGraphApiService.UpdatePolicy(tokenLifetimePolicy);

            return RedirectToPage("./Index");
        }
    }

The Edit Razor page can process the html form and update the policy.

Assigning Azure App registrations to policies

Now that a TokenLifetimePolicy can be managed for access tokens, the policies need to be assigned to Azure App registrations. An Azure App registration can only be assigned one policy and has to have a SignInAudience with a AzureADMyOrg or AzureADMultipleOrgs value.

The PolicyAppliesTo method is used to find all Azure App registrations where the policy has been assigned.

public async Task<IStsPolicyAppliesToCollectionWithReferencesPage> PolicyAppliesTo(string tokenLifetimePolicyId)
{
	var graphclient = await GetGraphClient(scopesPolicy).ConfigureAwait(false);

	var appliesTo = await graphclient
		.Policies
		.TokenLifetimePolicies[tokenLifetimePolicyId]
		.AppliesTo
		.Request()
		.GetAsync()
		.ConfigureAwait(false);

	return appliesTo;
}

The AssignTokenPolicyToApplicationUsingGraphId method uses the TokenLifetimePolicy Graph API id and assigns this policy to the application using the Azure App registration Graph Id.

public async Task AssignTokenPolicyToApplicationUsingGraphId(string applicationGraphId, string tokenLifetimePolicyId)
{
	var graphclient = await GetGraphClient(scopesApplications).ConfigureAwait(false);

	var policy = await GetPolicy(tokenLifetimePolicyId);

	await graphclient
		.Applications[applicationGraphId]
		.TokenLifetimePolicies
		.References
		.Request()
		.AddAsync(policy)
		.ConfigureAwait(false);
}

The RemovePolicyFromApplication method uses the Azure App registration AppId and removes the policy from the application. The AppId is the Id you would find in the Azure portal. The Microsoft Graph API Id for the application could also be used and would be better because it would save a HTTP request. If you were to adapt this service, you might be dealing with AppIds.

public async Task RemovePolicyFromApplication(string appId, string tokenLifetimePolicyId)
{
	var graphclient = await GetGraphClient(scopesApplications).ConfigureAwait(false);

	var app2 = await graphclient
		.Applications
		.Request()
		.Filter($"appId eq '{appId}'")
		.GetAsync()
		.ConfigureAwait(false);

	var id = app2[0].Id;

	await graphclient
		.Applications[id]
		.TokenLifetimePolicies[tokenLifetimePolicyId]
		.Reference
		.Request()
		.DeleteAsync()
		.ConfigureAwait(false);
}

The GetApplicationsSingleOrMultipleOrg method returns all the Azure App registrations in the tenant which have been or can be assigned a policy. The TokenLifetimePolicies is also returned.

public async Task<IGraphServiceApplicationsCollectionPage> GetApplicationsSingleOrMultipleOrg()
{
	var graphclient = await GetGraphClient(scopesApplications).ConfigureAwait(false);

	// AzureADMyOrg and AzureADMultipleOrgs
	return await graphclient
		.Applications
		.Request()
		.Expand("TokenLifetimePolicies")
		.Filter($"signInAudience eq 'AzureADMyOrg' or signInAudience eq 'AzureADMultipleOrgs'")
		.GetAsync()
		.ConfigureAwait(false);
}

The Details Razor Page uses the methods to remove a policy from an Azure App registration or view the applications which have been assigned the selected policy. From this page, new applications can be assigned to the policy.

public class DetailsModel : PageModel
{
	private readonly TokenLifetimePolicyGraphApiService _tokenLifetimePolicyGraphApiService;

	public DetailsModel(TokenLifetimePolicyGraphApiService tokenLifetimePolicyGraphApiService)
	{
		_tokenLifetimePolicyGraphApiService = tokenLifetimePolicyGraphApiService;
	}

	public TokenLifetimePolicyDto TokenLifetimePolicyDto { get; set; }

	public List<PolicyAssignedApplicationsDto> PolicyAssignedApplications { get; set; }

	public async Task<IActionResult> OnGetAsync(string id)
	{
		if (id == null)
		{
			return NotFound();
		}

		var policy = await _tokenLifetimePolicyGraphApiService.GetPolicy(id);
		TokenLifetimePolicyDto = new TokenLifetimePolicyDto
		{
			Definition = policy.Definition.FirstOrDefault(),
			DisplayName = policy.DisplayName,
			IsOrganizationDefault = policy.IsOrganizationDefault.GetValueOrDefault(),
			Id = policy.Id
		};

		if (TokenLifetimePolicyDto == null)
		{
			return NotFound();
		}

		var applications = await _tokenLifetimePolicyGraphApiService.PolicyAppliesTo(id);
		PolicyAssignedApplications = applications.CurrentPage.Select(app => new PolicyAssignedApplicationsDto
		{
			Id = app.Id,
			DisplayName = (app as Microsoft.Graph.Application).DisplayName,
			AppId = (app as Microsoft.Graph.Application).AppId,
			SignInAudience = (app as Microsoft.Graph.Application).SignInAudience

		}).ToList();
		return Page();
	}

	public async Task<IActionResult> OnPostAsync()
	{
		var appId = Request.Form["item.AppId"];
		var policyId = Request.Form["TokenLifetimePolicyDto.Id"];
		if (!ModelState.IsValid)
		{
			return Page();
		}

		await _tokenLifetimePolicyGraphApiService
			.RemovePolicyFromApplication(appId, policyId);

		return Redirect($"./Details?id={policyId}");
	}
}

The AssignNewApplicationToPolicyModel Razor page is used to assign new applications to the selected policy. A HTML bootstrap 4 drop down is created and only Azure App registrations which can be assigned a new policy are added to the list. The post method uses the AssignTokenPolicyToApplicationUsingGraphId service to assign the policy to the application.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace TokenManagement.Pages
{
    public class AssignNewApplicationToPolicyModel : PageModel
    {
        private readonly TokenLifetimePolicyGraphApiService _tokenLifetimePolicyGraphApiService;

        public AssignNewApplicationToPolicyModel(TokenLifetimePolicyGraphApiService tokenLifetimePolicyGraphApiService)
        {
            _tokenLifetimePolicyGraphApiService = tokenLifetimePolicyGraphApiService;
        }

        public TokenLifetimePolicyDto TokenLifetimePolicyDto { get; set; }

        public string ApplicationGraphId { get; set; }
        public List<SelectListItem> ApplicationOptions { get; set; }

        public async Task<IActionResult> OnGetAsync(string id)
        {
            if (id == null)
            {
                return NotFound();
            }

            var policy = await _tokenLifetimePolicyGraphApiService.GetPolicy(id);
            TokenLifetimePolicyDto = new TokenLifetimePolicyDto
            {
                Definition = policy.Definition.FirstOrDefault(),
                DisplayName = policy.DisplayName,
                IsOrganizationDefault = policy.IsOrganizationDefault.GetValueOrDefault(),
                Id = policy.Id
            };

            var singleAndMultipleOrgApplications = await _tokenLifetimePolicyGraphApiService.GetApplicationsSingleOrMultipleOrg();
            
            ApplicationOptions = singleAndMultipleOrgApplications.CurrentPage
                .Where(app => app.TokenLifetimePolicies != null && app.TokenLifetimePolicies.Count <=0)
                .Select(a =>
                    new SelectListItem
                    {
                        Value = a.Id,
                        Text = $"{a.DisplayName}" // AppId: {a.AppId}, 
                    }).ToList();

            if (TokenLifetimePolicyDto == null)
            {
                return NotFound();
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync()
        {
            var applicationGraphId = Request.Form["ApplicationGraphId"];
            var policyId = Request.Form["TokenLifetimePolicyDto.Id"];
            if (!ModelState.IsValid)
            {
                return Page();
            }

            await _tokenLifetimePolicyGraphApiService
                .AssignTokenPolicyToApplicationUsingGraphId(applicationGraphId, policyId);

            return Redirect($"./Details?id={policyId}");
        }
    }
}

The view of the Razor page shows the details of the policy and the form with the drop down to assign a new application.

@page
@model TokenManagement.Pages.AssignNewApplicationToPolicyModel
@{
}

<div class="card bg-light mb-3">
    <div class="card-header">Token Lifetime Policy</div>
    <div class="card-body">
        <dl class="row">
            <dt class="col-sm-4">
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto.Definition)
            </dt>
            <dd class="col-sm-8">
                @Html.DisplayFor(model => model.TokenLifetimePolicyDto.Definition)
            </dd>
            <dt class="col-sm-4">
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto.DisplayName)
            </dt>
            <dd class="col-sm-8">
                @Html.DisplayFor(model => model.TokenLifetimePolicyDto.DisplayName)
            </dd>
            <dt class="col-sm-4">
                @Html.DisplayNameFor(model => model.TokenLifetimePolicyDto.IsOrganizationDefault)
            <dd class="col-sm-8">
                <input asp-for="TokenLifetimePolicyDto.IsOrganizationDefault" disabled class="big_checkbox" />
            </dd>
        </dl>
    </div>
</div>

<br />

<div class="card">
    <div class="card-body">
        <div class="alert alert-info" role="alert">
            Only <b>AzureADMyOrg</b> and <b>AzureADMultipleOrgs</b> Azure App registrations can be assigned a policy. An application can only be assigned a single policy.
            If your application is not in the drop down, it is already assigned, or is an <b>AzureADandPersonalMicrosoftAccount</b> Azure App registration.
            Check the <a asp-area="" asp-page="/AzureAppRegistrations">AAD App registrations</a> to see all.
        </div>
        <div class="row">
            <div class="col-md-12">
                <form method="post">
                    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
                    <input type="hidden" asp-for="TokenLifetimePolicyDto.Id" />
                    <div class="form-group">
                        <label class="control-label">Select App registration</label>
                        <select asp-for="ApplicationGraphId" asp-items="Model.ApplicationOptions"
                                class="form-control">
                        </select>
                    </div>

                    <br />

                    <div class="form-group">
                        <button type="submit" class="btn btn-primary"><i class="fas fa-link"></i> Assign TokenLifetimePolicy to selected App registration</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

The Razor page can then be used to assign new applications to the access token lifetime policy.

Testing

Now the policies and the applications can be tested. A policy of 30 mins is assigned to one of the Azure App registrations. The ASP.NET Core API application which uses this App registration is started and the Microsoft.Identity.Web Nuget package is used to get the access token.

var accessToken = await _tokenAcquisition
       .GetAccessTokenForUserAsync(new[] { scope });

The access token can be copied and viewed at jwt.ms as long as it’s not decrypted. The token has a lifespan of 35 minutes. The 30 minutes we set in the policy and 5 mins which azure AD adds itself to all tokens issued.

Now using this, the access tokens lifespan can be controlled for you Azure AD applications. You want to keep the access tokens as short as possible for example when using SPA applications like Angular, react or Blazor.

Links

https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-configurable-token-lifetimes#configurable-token-lifetime-properties

https://stackoverflow.com/questions/65278010/how-to-set-the-access-token-lifetime-for-an-app-using-the-microsoft-graph-api

https://docs.microsoft.com/en-us/graph/api/tokenlifetimepolicy-post-tokenlifetimepolicies?view=graph-rest-1.0&tabs=http

https://docs.microsoft.com/en-us/graph/api/serviceprincipal-post-claimsmappingpolicies?view=graph-rest-1.0&tabs=http

https://github.com/AzureAD/microsoft-identity-web

3 comments

  1. […] Azure AD Access Token Lifetime Policy Management in ASP.NET Core – Damien Bowden […]

  2. PostedInJanuary · · Reply

    this isn’t possible anymore

    1. Thanks, I will validate and update the post

      Greetings Damien

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.