Implement OAUTH Device Code Flow with Azure AD and ASP.NET Core

The post shows how the Device Code flow (RFC 8628) could be implemented in an ASP.NET Core web application which uses Azure AD as an identity provider. An Azure App registration is used to setup the client. This solution would be useful for input constrained devices which have a browser and need to authenticate identities.

Code: Device Code Flow with Azure AD

Posts in this series

When or why use this flow?

The OAuth Device code flow is a good solution for authentication when the client has input constraints or only a console. As an example, this solution would work really well on a game console, a TV, industrial machine PC, or a layer 7 gateway.

Create the Azure App Registration

The Azure App registration is setup in the tenant or the directory for Mobile and desktop applications. This is a public client which requires no secret. For testing, the localhost redirect url was added.

The Allow public client flows option is set to yes. The Device code flow is supported in Azure AD with this Azure App registration configuration.

In the API permissions, the required scopes are added. The standard scopes, email, openid and profile are added. These can be added from the Graph settings in the delegated scopes. Only delegated scopes are used for the public client.

Azure AD is now configured and setup to support the OAuth Device code flow, RFC 8628. The clientId and the tenantId are required to configure the client which uses this app registration.

Setup the Device Code flow client

The client can be implemented using different Nuget packages, or even completely implemented yourself. The specifications for this are an RFC and is simple to follow. I would recommend using a client Nuget package or library and not implement this yourself. This demo uses the IdentityModel Nuget package to support the flow. The client UI is implemented using an ASP.NET Core Razor page web application and some javascript packages. The Microsoft.AspNetCore.Authentication.OpenIdConnect Nuget package is also required.

<ItemGroup>
  <PackageReference 
    Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" 
    Version="5.0.2" />
  <PackageReference Include="IdentityModel" Version="5.0.0" />
</ItemGroup>

The startup class or the ASP.NET Core Razor page application uses the ConfigureServices to setup the services. A session is used as well as the specific implementation services. After the application has received an authenticated identity from Azure AD, the data for this identity is stored in a cookie. The cookie stores the claims and the tokens returned from the Azure AD identity provider.

public void ConfigureServices(IServiceCollection services)
{
	// ... 

	services.AddScoped<DeviceFlowService>();
	services.AddScoped<AuthenticationSignInService>();
	services.AddHttpClient();
	services.Configure<AzureAdConfiguration>(
	   Configuration.GetSection("AzureAd"));

	services.AddSession(options =>
	{
		options.IdleTimeout = TimeSpan.FromSeconds(60);
		options.Cookie.HttpOnly = true;
	});

	services.AddAuthentication(options =>
	{
		options.DefaultScheme = 
		   CookieAuthenticationDefaults.AuthenticationScheme;
	})
	.AddCookie();

	services.AddAuthorization();
	services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

	services.AddRazorPages();
}

The Configure method configures the middleware like any other ASP.NET Core Razor page application with authentication. The seesion middleware is also applied.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
	app.UseStaticFiles();
	app.UseCookiePolicy();
	app.UseSession();

	app.UseStaticFiles();

	app.UseRouting();

	app.UseAuthentication();
	app.UseAuthorization();

	app.UseEndpoints(endpoints =>
	{
		endpoints.MapRazorPages();
	});
}

Implement the Device code flow client

Azure AD requires an instance, tenantId and a clientId to setup the configuration for the device code client. I based this on the Microsoft.Identity.Web configurations.

{
  "AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "f81baf3d-f8f3-4976-8b5a-798ff57daab5"
  },
}

The DiscoveryDocumentRequest class from the IdentityModel nuget package is used to request the well-known endpoints of the Azure AD tenant. The endpoint validation is disabled because Azure AD uses different domains.

private readonly AzureAdConfiguration _azureAdConfiguration;
private readonly IHttpClientFactory _clientFactory;
private readonly DiscoveryDocumentRequest _discoveryDocumentRequest;

public DeviceFlowService(IOptions<AzureAdConfiguration> azureAdConfiguration, 
	IHttpClientFactory clientFactory)
{
	_azureAdConfiguration = azureAdConfiguration.Value;
	_clientFactory = clientFactory;
	var idpEndpoint = $"{_azureAdConfiguration.Instance}{_azureAdConfiguration.TenantId}/v2.0";
	_discoveryDocumentRequest = new DiscoveryDocumentRequest
	{
		Address = idpEndpoint,
		Policy = new DiscoveryPolicy
		{
			// turned off => Azure AD uses different domains.
			ValidateEndpoints = false
		}
	};
}

To begin the authorization process, like in the RFC 8628, a device code is requested using the RequestDeviceAuthorizationAsync method. This requests a device code and some other information to request the access token and id_token on a separate device where you can use strong authentication or whatever.

public async Task<DeviceAuthorizationResponse> GetDeviceCode()
{
	var client = _clientFactory.CreateClient();

	var disco = await HttpClientDiscoveryExtensions
		.GetDiscoveryDocumentAsync(client, _discoveryDocumentRequest);

	if (disco.IsError)
	{
		throw new ApplicationException($"Status code: {disco.IsError},
		  Error: {disco.Error}");
	}

	var deviceAuthorizationRequest = new DeviceAuthorizationRequest
	{
		Address = disco.DeviceAuthorizationEndpoint,
		ClientId = _azureAdConfiguration.ClientId
	};
	deviceAuthorizationRequest.Scope = "email profile openid";
	var response = await client.RequestDeviceAuthorizationAsync(deviceAuthorizationRequest);

	if (response.IsError)
	{
		throw new Exception(response.Error);
	}

	return response;
}

The PollTokenRequests method uses the device code and polls the identity provider/ secure token server until it receives a valid token or times out. While this is running, the user can open the link provided and enter the code plus the required authentication for the identity. The user can use strong authentication with MFA, FIDO2 and so on. After a successful authentication, the tokens are returned to the application.

public async Task<TokenResponse> PollTokenRequests(string deviceCode, int interval)
{
	var client = _clientFactory.CreateClient();

	var disco = await HttpClientDiscoveryExtensions
	   .GetDiscoveryDocumentAsync(client, _discoveryDocumentRequest);

	if (disco.IsError)
	{
		throw new ApplicationException($"Status code: {disco.IsError},
     		Error: {disco.Error}");
	}

	while (true)
	{
		if(!string.IsNullOrWhiteSpace(deviceCode))
		{
			var response = await client
			  .RequestDeviceTokenAsync(new DeviceTokenRequest
			{
				Address = disco.TokenEndpoint,
				ClientId = _azureAdConfiguration.ClientId,
				DeviceCode = deviceCode
			});

			if (response.IsError)
			{
				if (response.Error == "authorization_pending" 
				  || response.Error == "slow_down")
				{
					Console.WriteLine($"{response.Error}...waiting.");
					await Task.Delay(interval * 1000);
				}
				else
				{
					throw new Exception(response.Error);
				}
			}
			else
			{
				return response;
			}
		}
		else
		{
			await Task.Delay(interval * 1000);
		}
	}
}

The LoginModel Razor page uses the scoped services and provides a UI to initialize the device code flow process.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace DeviceFlowWeb.Pages
{
    public class LoginModel : PageModel
    {
        private readonly DeviceFlowService _deviceFlowService;
        private readonly AuthenticationSignInService _authenticationSignInService;

        public string AuthenticatorUri { get; set; }

        public string UserCode { get; set; }

        public LoginModel(DeviceFlowService deviceFlowService, AuthenticationSignInService authenticationSignInService)
        {
            _deviceFlowService = deviceFlowService;
            _authenticationSignInService = authenticationSignInService;
        }

        public async Task OnGetAsync()
        {
            HttpContext.Session.SetString("DeviceCode", string.Empty);

            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

            var deviceAuthorizationResponse = await _deviceFlowService.GetDeviceCode();
            AuthenticatorUri = deviceAuthorizationResponse.VerificationUri;
            UserCode = deviceAuthorizationResponse.UserCode;

            if (string.IsNullOrEmpty(HttpContext.Session.GetString("DeviceCode")))
            {
                HttpContext.Session.SetString("DeviceCode", deviceAuthorizationResponse.DeviceCode);
                HttpContext.Session.SetInt32("Interval", deviceAuthorizationResponse.Interval);
            }
        }

        public async Task<IActionResult> OnPostAsync()
        {
            var deviceCode = HttpContext.Session.GetString("DeviceCode");
            var interval = HttpContext.Session.GetInt32("Interval");

            if(interval.GetValueOrDefault() <= 0)
            {
                interval = 5;
            }

            var tokenresponse = await _deviceFlowService.PollTokenRequests(deviceCode, interval.Value);

            if (tokenresponse.IsError)
            {
                ModelState.AddModelError(string.Empty, "Invalid login attempt.");
                return Page();
            }

            await _authenticationSignInService.SignIn(HttpContext,
                tokenresponse.AccessToken, 
                tokenresponse.IdentityToken,
                tokenresponse.ExpiresIn);

            return Redirect("/Index");
        }

    }
}

The Razor page template displays the data and also a QR code of the link to enter the code. This would be useful as the user would for example use a mobile phone to complete the authentication process.

@page
@model DeviceFlowWeb.Pages.LoginModel
@{
    ViewData["Title"] = "Login";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}


Login: <p>@Model.AuthenticatorUri</p>

<br /><br />

User Code: <p>@Model.UserCode</p>
<br />
<br />

<div id="qrCode"></div>
<div id="qrCodeData" data-url="@Html.Raw(Model.AuthenticatorUri)"></div>

<br />
<br />

<form data-ajax="true"  method="post" data-ajax-method="POST">
    <button class="btn btn-secondary" 
            name="begin_token_check" 
            id="begin_token_check" type="submit" style="visibility:hidden">Get device code</button>
</form>

@section scripts {
<script src="~/js/qrcode.min.js"></script>
<script type="text/javascript">
        new QRCode(document.getElementById("qrCode"),
            {
                text: "@Html.Raw(Model.AuthenticatorUri)",
                width: 150,
                height: 150
            });

    $(document).ready(() => {
        document.getElementById('begin_token_check').click();
    });

</script>
}

After a successful authentication, the tokens are added to a cookie. The claims from the id_token are added to the claims principal and the access token is also added. The identity is then signed-in and the token is sent with each request from the client browser to the server until it expires.

public async Task SignIn(HttpContext httpContext, 
   string accessToken, string idToken, int expiresIn)
{
	var claims = GetClaims(idToken);

	var claimsIdentity = new ClaimsIdentity(
		claims,
		CookieAuthenticationDefaults.AuthenticationScheme,
		"name",
		"user");

	var authProperties = new AuthenticationProperties();
	authProperties.ExpiresUtc = DateTime.UtcNow.AddSeconds(expiresIn);

	// save the tokens in the cookie
	authProperties.StoreTokens(new List<AuthenticationToken>
	{
		new AuthenticationToken
		{
			Name = "access_token",
			Value = accessToken
		},
		new AuthenticationToken
		{
			Name = "id_token",
			Value = idToken
		}
	});

	await httpContext.SignInAsync(
		CookieAuthenticationDefaults.AuthenticationScheme,
		new ClaimsPrincipal(claimsIdentity),
		authProperties);
}

private IEnumerable<Claim> GetClaims(string token)
{
	var validJwt = new JwtSecurityToken(token);
	return validJwt.Claims;
}

Now the application can be started and everything should work. You would need to add an App registration to your tenant and change the configuration to match to run this yourself.

Implementing the device code is really simple and made easy by using the IdentityModel nuget package. Azure Microsoft also provides its own client library with support for the device code flow. Maybe in a follow up post, I will implement a client using this and add an API to acces data.

Links

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code

https://tools.ietf.org/wg/oauth/draft-ietf-oauth-device-flow/

https://damienbod.com/2019/02/20/asp-net-core-oauth-device-flow-client-with-identityserver4/

https://github.com/Azure-Samples/active-directory-dotnetcore-devicecodeflow-v2

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/cookie

https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/wiki/Device-Code-Flow

2 comments

  1. […] Implement OAUTH Device Code Flow with Azure AD and ASP.NET Core (Damien Bowden) […]

  2. […] Implement OAUTH Device Code Flow with Azure AD and ASP.NET Core – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: