Overview
The following summarizes the process of creating an end-to-end OAuth2 sample using ADFS 2.1 (or Windows Azure Active Directory).
Web site setup
Use the VS.NET 2012 ASP.NET MVC 4 WebAPI project template to setup your server project.
Token handling
To process the incoming JWT token open the global.asax class and add to it the code to parse and validate the incoming JWT token (some of the commented code was used to process a token obtained from WAAD).
public class WebApiApplication : System.Web.HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); WebApiConfig.Register(GlobalConfiguration.Configuration); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); GlobalConfiguration.Configuration.MessageHandlers.Add(new TokenValidationHandler()); } enum stsTypes { ADFS, WAAD }; //const string Audience = "http://TestApp2"; //const stsTypes stsType = stsTypes.WAAD; //const string DomainName = "mrdir.onmicrosoft.com"; const string Audience = "http://TestApp2"; const stsTypes stsType = stsTypes.ADFS; const string DomainName = "idp2.corp.meraridom.com"; internal class TokenValidationHandler : DelegatingHandler { static string _issuer = string.Empty; static List<X509SecurityToken> _signingTokens = null; static DateTime _stsMetadataRetrievalTime = DateTime.MinValue;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { string jwtToken; string issuer; string stsMetadataAddress; switch(stsType) { case stsTypes.ADFS: stsMetadataAddress = string.Format(CultureInfo.InvariantCulture, "https://{0}/federationmetadata/2007-06/federationmetadata.xml", DomainName); break; case stsTypes.WAAD: stsMetadataAddress= string.Format(CultureInfo.InvariantCulture, "https://login.windows.net/{0}/federationmetadata/2007-06/federationmetadata.xml", DomainName); break; } List<X509SecurityToken> signingTokens; using (HttpResponseMessage responseMessage = new HttpResponseMessage()) { if (!TryRetrieveToken(request, out jwtToken)) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized)); } try { // Get tenant information that's used to validate incoming jwt tokens GetTenantInformation(stsMetadataAddress, out issuer, out signingTokens); } catch (WebException) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); } catch (InvalidOperationException) { return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); } JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler() { // for demo purposes certificate validation is turned off. Please note that this shouldn't be done in production code. CertificateValidator = X509CertificateValidator.None }; TokenValidationParameters validationParameters = new TokenValidationParameters { AllowedAudience = Audience, ValidIssuer = issuer, SigningTokens = signingTokens }; try { // Validate token ClaimsPrincipal claimsPrincipal = tokenHandler.ValidateToken(jwtToken, validationParameters); //foreach (var c in claimsPrincipal.Claims) //{ //} string id; var upn = claimsPrincipal.Claims.FirstOrDefault((c) => c.Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"); if (upn != null) id = upn.Value; else id = claimsPrincipal.Identity.Name; var roleIdentity = new ClaimsIdentity( new Claim[] { new Claim("UserName", id), new Claim("Role", "xyz") }, "JWT", "UserName", "Role"); claimsPrincipal = new ClaimsPrincipal(roleIdentity); //set the ClaimsPrincipal on the current thread. Thread.CurrentPrincipal = claimsPrincipal; // set the ClaimsPrincipal on HttpContext.Current if the app is running in web hosted environment. if (HttpContext.Current != null) { HttpContext.Current.User = claimsPrincipal; } return base.SendAsync(request, cancellationToken); } catch (SecurityTokenValidationException) { responseMessage.StatusCode = HttpStatusCode.Unauthorized; return Task.FromResult(responseMessage); } catch (SecurityTokenException) { responseMessage.StatusCode = HttpStatusCode.Unauthorized; return Task.FromResult(responseMessage); } catch (ArgumentException) { responseMessage.StatusCode = HttpStatusCode.Unauthorized; return Task.FromResult(responseMessage); } catch (FormatException) { responseMessage.StatusCode = HttpStatusCode.Unauthorized; return Task.FromResult(responseMessage); } } } // Reads the token from the authorization header on the incoming request static bool TryRetrieveToken(HttpRequestMessage request, out string token) { token = null; if (!request.Headers.Contains("Authorization")) { return false; } string authzHeader = request.Headers.GetValues("Authorization").First<string>(); // Verify Authorization header contains 'Bearer' scheme token = authzHeader.StartsWith("Bearer ", StringComparison.Ordinal) ? authzHeader.Split(' ')[1] : null; if (null == token) { return false; } return true; } /// <summary> /// Parses the federation metadata document and gets issuer Name and Signing Certificates /// </summary> /// <param name="metadataAddress">URL of the Federation Metadata document</param> /// <param name="issuer">Issuer Name</param> /// <param name="signingTokens">Signing Certificates in the form of X509SecurityToken</param> static void GetTenantInformation(string metadataAddress, out string issuer, out List<X509SecurityToken> signingTokens) { signingTokens = new List<X509SecurityToken>(); // The issuer and signingTokens are cached for 24 hours. They are updated if any of the conditions in the if condition is true. if (DateTime.UtcNow.Subtract(_stsMetadataRetrievalTime).TotalHours > 24 || string.IsNullOrEmpty(_issuer) || _signingTokens == null) { MetadataSerializer serializer = new MetadataSerializer() { // turning off certificate validation for demo. Don't use this in production code. CertificateValidationMode = X509CertificateValidationMode.None }; MetadataBase metadata = serializer.ReadMetadata(XmlReader.Create(metadataAddress)); EntityDescriptor entityDescriptor = (EntityDescriptor)metadata; // get the issuer name if (!string.IsNullOrWhiteSpace(entityDescriptor.EntityId.Id)) { _issuer = entityDescriptor.EntityId.Id; } // get the signing certs _signingTokens = ReadSigningCertsFromMetadata(entityDescriptor); _stsMetadataRetrievalTime = DateTime.UtcNow; } issuer = _issuer; signingTokens = _signingTokens; } static List<X509SecurityToken> ReadSigningCertsFromMetadata(EntityDescriptor entityDescriptor) { List<X509SecurityToken> stsSigningTokens = new List<X509SecurityToken>(); SecurityTokenServiceDescriptor stsd = entityDescriptor.RoleDescriptors.OfType<SecurityTokenServiceDescriptor>().First(); if (stsd != null && stsd.Keys != null) { IEnumerable<X509RawDataKeyIdentifierClause> x509DataClauses = stsd.Keys.Where(key => key.KeyInfo != null && (key.Use == KeyType.Signing || key.Use == KeyType.Unspecified)). Select(key => key.KeyInfo.OfType<X509RawDataKeyIdentifierClause>().First()); stsSigningTokens.AddRange(x509DataClauses.Select(clause => new X509SecurityToken(new X509Certificate2(clause.GetX509RawData())))); } else { throw new InvalidOperationException("There is no RoleDescriptor of type SecurityTokenServiceType in the metadata"); } return stsSigningTokens; } } }
|
Authorization
Use the ClaimsPrincipal created with the above code you can modify your ValuesController.cs as follows:
public class ValuesController : ApiController { // GET api/values [Authorize(Roles="xyz")] public IEnumerable<string> Get() { var principal = ClaimsPrincipal.Current; var list = new List<string>(); foreach (var c in principal.Claims) { list.Add(c.Type); list.Add(c.Value); } return list; }
|
ADFS 2.1 setup
Certificates
ADFS will need at least one (typically three) certificates
to sign and/or encrypt messages and to use SSL. I created my own root authority
and stored in both my client machine (where I will run my rich client), web
site machine and the machine with ADFS 2.1. The root certificate will need to
be stored in the machine’s Trusted Root Authority store on all machines using
the various certificates derived from it.
makecert -n “CN=MyRootCA” -r -sv TempCA.pfx TempCA.cer
|
Make a certificate to be used by ADFS 2.1. For simplicity, I
will use a single certificate for all three function ADFS uses certificates
for:
makecert -pe -iv TempCA.pfx -n “CN=idp2.corp.meraridom.com” -b 01/01/2013 -e 01/01/2036 -eku 1.3.6.1.5.5.7.3.1 -sky exchange -sp “Microsoft RSA SChannel Cryptographic Provider” -sy 12 -ic TempCA.cer -sr localmachine -ss My idp2.cer
|
Note the CN name must be the same as the host name you use
for ADFS.
Role setup
You will need a Windows 2012 R2 (now in preview) image to use the OAuth feature in ADFS. Enable the ADFS role using the certificate created as described above. When setting up ADFS make sure the name you give it is the same as the CN name in the certificate(s) used by that ADFS. You will need to import the certificate with its private key into the machine’s My store. The private key needs to be accessible to the ADFS service’s account.
Web site relying party setup
Add a relying party to ADFS for the web site using the ADFS UI wizard. In this example I am using http://TestApp2 for the application identifier. Otherwise, it can use all default values in the setup wizard. Define some claim rules for claims your web site needs.
Rich client setup in ADFS
Add OAuth2 client definition using Powershell, e.g.:
add-adfsclient –clientId ‘21FC8FE2-65CB-4655-B146-763482D98589’ -name ‘Sample client’ –redirectUri ‘http://TestClient’
|
For ClientId I am using a GUID (generated using VS tool) and name is a descriptive name. Both values will be used later in the client code to identify the client to ADFS.
ADFS url address
Note that since ADFS 2.1 is no longer using IIS you will NOT
be able to use the IP-based address directly. For example: https://33.0.0.3/FederationMetadata/2007-06/FederationMetadata.xml
will return ‘Page could not be displayed’ if addressed through the browser. You
will need to either use proper DNS or modify
your host file on all machines which need to reference the ADFS (client
machine and web site machine). The host entry in my case mapped 33.0.0.3 to
idp2.corp.meraridom.com.
Rich client
Project setup
Create a rich client project, e.g. WPF Windows application
and use NuGet Manager to install the latest ADAL SDK.
Code
Use the following code to request and use a JWT token using
the OAuth2 flow. (The first two commented lines were used to request the token
from WAAD).
//var ctx = new AuthenticationContext(“https://login.windows.net/mrdir.onmicrosoft.com”, false);
//var authnResult = ctx.AcquireToken(“http://TestApp2″, “43d21440-…-5f6ddd9034ff”, new Uri(“http://richclient”));
var ctx = new AuthenticationContext(“https://idp2.corp.meraridom.com/adfs/oauth2″, false);
var authnResult = ctx.AcquireToken(“http://TestApp2″, “21FC8FE2-65CB-4655-B146-763482D98589″, new Uri(“http://richclient”), @”corp\administrator”);
using (var client = new HttpClient())
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(“Bearer”, authnResult.AccessToken);
var webResponse = await client.GetAsync(“http://localhost:40464//api/values”);
if (webResponse.IsSuccessStatusCode)
_return.Text = await webResponse.Content.ReadAsStringAsync();
else
_return.Text = webResponse.ToString();
}
|
Summary
The two main issues which slowed me down when developing the above sample was the ADFS url address issue (using the IP address – see above) and not setting the certificate trusts correctly.