Udostępnij za pośrednictwem


Working with signed JWTs (OAuth with certificates)

How do I use certificates for authenticating against an ADFS server while using OAuth as a trusted client? Simple question right? Yes, but unfortunately it still took me a little work to land on the relevant pages in an Internet search, and subsequently getting it all to work. (Actually most things here apply to Azure Active Directory as well, but I worked it from the ADFS angle.)

There is actually a decent explanation here of how to set up the certificate-based authentication on the ADFS server:
https://blogs.technet.microsoft.com/cloudpfe/2017/10/16/oauth-2-0-confidential-clients-and-active-directory-federation-services-on-windows-server-2016/

As explained in that article the certificates aren't used for establishing the SSL/TLS connection itself, but rather using it for the payload. Which coincidentially solves a lot of issues involved in getting a web server to "speak" the client certs language.

The client code shown can seem a bit daunting however, and can be made much easier if you don't want to go the low-level route. Actually you can make it work pretty much the same as if you are working with Azure AD on the client-side.

Digging further there is a fairly thorough explanation of certificates with Azure AD apps here:
https://github.com/Azure-Samples/active-directory-dotnetcore-daemon-certificate-credential

Now, if we could only combine the two…

First things first, to authenticate with a certificate we need to have a certificate to present to the identity provider. Since I happen to have Visual Studio installed I open up the "Developer Command Prompt" to use the makecert utility. This is to generate certificates in files rather than using the certificate store in Windows. (Making it suitable both for running containerized on Linux, and for something like Azure Key Vault should you want to put your code in the cloud.)

 
makecert -r -pe -n "CN=adfs.contoso.com" -sky exchange -sv adfs.contoso.com.pvk adfs.contoso.com.cer

pvk2pfx -pvk adfs.contoso.com.pvk -spc adfs.contoso.com.cer -pfx adfs.contoso.com.pfx

You will also be prompted to specify a password for the certificate. I skipped this to make the lab exercise simpler.

With a certificate on the client we should also be able to use it for acquiring a token. Before doing so make sure that the certificate you just generated is trusted on your ADFS Server. (Assumption being that you have created the basic app group setup on ADFS.)

Let's keep the token stuff as simple as possible. I generate a dotnet console app on the command line, and then fire up Visual Studio Code:

You need ADAL so throw that into SignedJWT.csproj:

 
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>   
    <PackageReference Include="Microsoft.IdentityModel.Clients.ActiveDirectory" Version="4.4.0" />
    <PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
  </ItemGroup>

</Project>

Why not MSAL which seems to be the general recommendation for authentication libraries from Microsoft? Well, MSAL doesn't support ADFS yet so for now we will use ADAL.

The main code can also be kept simple:

 
Program.cs

using System;
using System.Security.Cryptography.X509Certificates;
using Microsoft.IdentityModel.Clients.ActiveDirectory;

namespace SignedJwt
{
  class Program
  {
    static void Main(string[] args)
    {
      string adfsInstance = "https://contoso.com/adfs/";
      string ResourceId = "https://contoso.com/api";
      string clientId = "copy-from-adfs-server";
      string clientSecret = "copy-from-adfs-server";            
            
      ClientAssertionCertificate certCred = null;
      AuthenticationContext authContext = null;            
      var authority = $"{adfsInstance}";

      authContext = new AuthenticationContext(authority, false);

      X509Certificate2 cert = new X509Certificate2("adfs.contoso.com.pfx");

      var token = "";
      AuthenticationResult result = null;
      try
      {                
        ClientCredential clientCred = new ClientCredential(clientId, clientSecret);
        certCred = new ClientAssertionCertificate(clientId, cert);                
        result = authContext.AcquireTokenAsync(ResourceId, certCred).Result;
        token = result.AccessToken;
      }
      catch (Exception x)
      {               
        Console.WriteLine($"Error: {x.Message}");                               
      }
      Console.WriteLine($"Token: {token}");
    }
  }
}

Hopping back to the command line you can execute the app with dotnet run:

That's all there is to it really when it comes to acquiring a token based on a certificate. And in case you're using the ADFS Web Application Proxy the code works with that too.

How do we know this actually worked, aside from the fact that we get a token in return? Can we validate the token somehow? So happy that you asked!

Let's create a simple API for this purpose:

Let's start with the task of just doing a raw dump of the certificate and return a parsed output of the claims:

 
[HttpGet]
[Route("Parse")]
public ActionResult<IEnumerable<string>> Parse()
{
  var token = string.Empty;

  //The token can be passed either via query string or headers
  if (HttpContext.Request.QueryString.Value.Contains("token"))
  {
    token = HttpContext.Request.Query["token"].ToString();
  }
  if (HttpContext.Request.Headers["Authorization"].ToString() != null)
  {
    token = HttpContext.Request.Headers["Authorization"];

    //Remove "Bearer " from string
    token = token.Substring(7);
  }
  //No token equals bad request
  else
  {
    return BadRequest("Missing something?");
  }

  //Let's try to treat it like a token
  var jwtHandler = new JwtSecurityTokenHandler();
  var jwtInput = token;

  //Check if readable token (string is in a JWT format)
  var readableToken = jwtHandler.CanReadToken(jwtInput);

  if (readableToken == true)
  {
    var jwtoken = jwtHandler.ReadJwtToken(jwtInput);

    var header = jwtoken.RawHeader;
    byte[] hData = Convert.FromBase64String(header);
    string hDecodedString = Encoding.UTF8.GetString(hData);

    //.NET needs some padding to Base64 decode
    var payload = jwtoken.RawPayload + "==";
    byte[] pData = Convert.FromBase64String(payload);
    string pDecodedString = Encoding.UTF8.GetString(pData);

    return Content("[" + hDecodedString + "," +
      pDecodedString + "]",
      "application/json");
  }
  if (readableToken != true)
  {
    //The token doesn't seem to be in a proper JWT format.
    //Assume it's a combo token and break it apart
    string decodedString = string.Empty;
    try
    {
      byte[] data = Convert.FromBase64String(token + "=");
      decodedString = Encoding.UTF8.GetString(data);
    }
    catch (Exception)
    {
      //If this fails we'll just assume bogus input
      return BadRequest("Not able to figure out this token");
    }

    //The tokens are separated with a comma
    var tokens = decodedString.Split(',');

    //Sort out the proxy token first
    var proxyToken = tokens[0];
    proxyToken = proxyToken.Substring(16);
    proxyToken = proxyToken.Substring(0, proxyToken.Length - 1);

    var pToken = jwtHandler.ReadJwtToken(proxyToken);

    var ptHeader = pToken.RawHeader;
    byte[] ptHeaderData = Convert.FromBase64String(ptHeader);
    string ptHDecodedString = Encoding.UTF8.GetString(ptHeaderData);

    var ptPayload = pToken.RawPayload;
    //.NET needs extra padding to do Base64 decode
    byte[] ptPayloadData = Convert.FromBase64String(ptPayload + "==");
    string ptTDecodedString = Encoding.UTF8.GetString(ptPayloadData);

    //Figure out the access token
    var accessToken = tokens[1];
    accessToken = accessToken.Substring(16);
    accessToken = accessToken.Substring(0, accessToken.Length - 2);

    var aToken = jwtHandler.ReadJwtToken(accessToken);

    var atHeader = aToken.RawHeader;
    byte[] atHeaderData = Convert.FromBase64String(atHeader);
    string atHDecodedString = Encoding.UTF8.GetString(atHeaderData);

    var atPayload = aToken.RawPayload;
    //.NET needs extra padding to do Base64 decode
    byte[] atPayloadData = Convert.FromBase64String(atPayload + "==");
    string atTDecodedString = Encoding.UTF8.GetString(atPayloadData);

    return Content("[" + ptHDecodedString + "," +
        ptTDecodedString + "," +
        atHDecodedString + "," +
        atTDecodedString + "]",
        "application/json");
  }

  return new string[] { "How did you end up here?" };
}

Since the console app we created to acquire the token didn't actually do anything but print out to the command line I chose to call this "API" through Fiddler, but you can of course extend the token acquisition app to do a subsequent HTTP call instead. A slightly modified output (readability and removal of ids) looks like this:

 
[{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "..."
},{
  "aud": "urn:AppProxy:com",
  "iss": "https://adfs.contoso.com/adfs/services/trust",
  "iat": 1543769125,
  "exp": 1543772725,
  "relyingpartytrustid": "...",
  "clientreqid": "...",
  "authmethod": 
    ["https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/tlsclient", 
    "https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/x509"],
  "auth_time": "2018-12-02T16:45:25.055Z",
  "ver": "1.0"
},{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "..."
},{
  "aud": "https://contoso.com/api",
  "iss": "https://adfs.contoso.com/adfs/services/trust",
  "iat": 1543769125,
  "exp": 1543772725,
  "authmethod": 
    ["https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/tlsclient", 
    "https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/x509"],
  "apptype": "Confidential",
  "appid": "...",
  "auth_time": "2018-12-02T16:45:25.055Z",
  "ver": "1.0"
}]

Sure, it could have been formatted in a prettier way, but that's not the main point here. The takeaways here are that it says that the authentication was done with a certificate (x509), and that in this case we have a combo token. There's one cert for the ADFS proxy, and one regular access token.

Ok, perhaps this requires a little bit explanation in case you're not familiar with the proxy. The purpose of the proxy is (usually) to add a pre-authentication step so that you don't get to talk to the app unless you have been pre-approved. The way ADFS implementes this is basically having the proxy generate one token as stamp of approval, and letting the traffic through to the backend ADFS server letting it add another token so you have a net of two tokens that are bundled together.

As evidenced by the above code handling the combo token is sort of messy. From the client developer's perspective this should not be noticeable. You request a token, you get something back, you present that to the API you are calling. You should not take a dependency on parsing access tokens. Parsing identity tokens is a different matter, but the access token is something you should perceive as an opaque base64 string.

The API accepting the token might have a different perspective, which brings us to the next step.

This wasn't exactly validation of the token though - we just accepted an input and returned some text. What if we want to actually validate that the token is ok, and do an actual authorization?

Let's do that by modifying Startup.cs which contains the auth middlewares.

 
public void ConfigureServices(IServiceCollection services)
{
  services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(options =>
  {                
    options.MetadataAddress = "https://adfs.contoso.com/adfs/.well-known/openid-configuration";
    options.Validate();                

    options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
    {
      ValidIssuer = "https://adfs.contoso.com/adfs/services/trust",
      ValidateAudience = true,
      ValidateIssuerSigningKey = true,
      ValidateLifetime = true,
      ValidAudience = "https://contoso.com/api",
      RequireSignedTokens = true,
      ValidateActor = true,                 
    };
  });                       

  services.AddAuthorization(options =>
  {
    options.AddPolicy("Certificate", policy =>
      policy.RequireAssertion(context =>
      context.User.HasClaim(c =>
        (c.Type == System.Security.Claims.ClaimTypes.AuthenticationMethod &&
        c.Value == "https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/tlsclient" ||
        c.Value == "https://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/x509" ))));
  });

  services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }
  else
  {
    app.UseHsts();
  }

  app.UseHttpsRedirection();
  app.UseAuthentication();            
  app.UseMvc();
}

There are a couple of things to note here.
We're using the JwtBearer middleware since this is an API and not an interactive flow which mean we're not redirecting to a web page for signin.

Even though we pull metadata from the openid-configuration endpoint we still need to set the valid issuer in the validation parameters. This is a quirk of ADFS where the metadata contains issuer=https://adfs.contoso.com/adfs and access_token_issuer=https://adfs.contoso.com/adfs/services/trust so we need to accept specifically for access tokens here.

You will also notice that we create a policy for authentication method. The token is validated and can be found ok regardless of how you acquired the token. However since part of the point of our exercise is to use certificates we use that as an authorization parameter enabling us to reject password-based authentication attempts. (You can choose per API endpoint or controller if you want to use the policy so it doesn't mean you're blocked from also accepting other authentication mechanisms.)

To use the policy we attach it to an API controller:

 
[HttpGet]
[Authorize(Policy = "Certificate")]
[Route("Validate")]
public ActionResult<IEnumerable<string>> Validate()
{
  var token = "{";
  foreach (var claim in User.Claims)
  {
    //Datetimes are already escaped
    if (claim.Type.ToString().Contains("time"))
    {
      token += $"\"{claim.Type}\":{claim.Value},";
    }
    else
    {
      //Let's not care about the authentication method here
      //since that requires an array for building valid json
      if (claim.Type == ClaimTypes.AuthenticationMethod)
      { }
      else
      {
        token += $"\"{claim.Type}\":\"{claim.Value}\",";
      }
    }
  }
  //Remove the surplus comma
  token = token.Substring(0, token.Length - 1);
  token += "}";

  return Content(token, "application/json");
}

Yes, this is really pretty code for parsing the token, I know :) (You're not likely to actually work with the token like this in real life though.)

The output is still the contents of the token, but this time you're not allowed to see the claims until you have authenticated yourself properly. (There's nothing inherently secret about tokens, but it looks better than returning "Hello World".)

If you try to pass the same combo token to this API endpoint you will get an error in return - "invalid token". So, the middleware doesn't seem to be created for dealing with this scenario. If you set up your API behind the ADFS proxy, (and publish through the corresponding wizard), it isn't going to be a problem either because the proxy takes care of it for you. Whether this is the right choice for you depends on other factors as well so it's hard to say which path you should take. You could of course take care of it yourself, but apart from the initial auth that means the subsequent requests are not pre-authenticated. Or maybe you have a different proxy product you pipe your traffic to before hitting ADFS. Either way; this means that this part of the code only works with "pure" tokens from ADFS, but it should be possible to build the rest as an exercise if you like.

The complete code sample can be found here:
https://github.com/ahelland/AADGuide-CodeSamples/tree/master/CoreWebAPISignedJWTs

Comments

  • Anonymous
    December 10, 2018
    Thanks Andreas! you have saved me a heap of time; much appreciated.
    • Anonymous
      December 13, 2018
      Well, it's a fun job, but someone has to do it :) If it saves someone else from having headaches it's a plus.
  • Anonymous
    December 11, 2018
    Really a great post!
  • Anonymous
    December 13, 2018
    Thanks Andreas!