// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; using System.Globalization; using System.IdentityModel.Tokens; using System.IO; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNet.Http; using Microsoft.AspNet.Http.Security; using Microsoft.AspNet.Security.Infrastructure; using Microsoft.AspNet.Security.Notifications; using Microsoft.Framework.Logging; using Microsoft.IdentityModel.Protocols; namespace Microsoft.AspNet.Security.OpenIdConnect { /// /// A per-request authentication handler for the OpenIdConnectAuthenticationMiddleware. /// public class OpenIdConnectAuthenticationHandler : AuthenticationHandler { private const string NonceProperty = "N"; private const string UriSchemeDelimiter = "://"; private readonly ILogger _logger; private OpenIdConnectConfiguration _configuration; /// /// Creates a new OpenIdConnectAuthenticationHandler /// /// public OpenIdConnectAuthenticationHandler(ILogger logger) { _logger = logger; } private string CurrentUri { get { return Request.Scheme + UriSchemeDelimiter + Request.Host + Request.PathBase + Request.Path + Request.QueryString; } } protected override void ApplyResponseGrant() { ApplyResponseGrantAsync().GetAwaiter().GetResult(); } /// /// Handles Signout /// /// protected override async Task ApplyResponseGrantAsync() { var signout = SignOutContext; if (signout != null) { if (_configuration == null && Options.ConfigurationManager != null) { _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } OpenIdConnectMessage openIdConnectMessage = new OpenIdConnectMessage() { IssuerAddress = _configuration == null ? string.Empty : (_configuration.EndSessionEndpoint ?? string.Empty), RequestType = OpenIdConnectRequestType.LogoutRequest, }; // Set End_Session_Endpoint in order: // 1. properties.Redirect // 2. Options.Wreply AuthenticationProperties properties = new AuthenticationProperties(); if (properties != null && !string.IsNullOrEmpty(properties.RedirectUri)) { openIdConnectMessage.PostLogoutRedirectUri = properties.RedirectUri; } else if (!string.IsNullOrWhiteSpace(Options.PostLogoutRedirectUri)) { openIdConnectMessage.PostLogoutRedirectUri = Options.PostLogoutRedirectUri; } var notification = new RedirectToIdentityProviderNotification(Context, Options) { ProtocolMessage = openIdConnectMessage }; await Options.Notifications.RedirectToIdentityProvider(notification); if (!notification.HandledResponse) { string redirectUri = notification.ProtocolMessage.CreateLogoutRequestUrl(); if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) { _logger.WriteWarning("The logout redirect URI is malformed: {0}", (redirectUri ?? "null")); } Response.Redirect(redirectUri); } } } protected override void ApplyResponseChallenge() { ApplyResponseChallengeAsync().GetAwaiter().GetResult(); } /// /// Responds to a 401 Challenge. Sends an OpenIdConnect message to the 'identity authority' to obtain an identity. /// /// protected override async Task ApplyResponseChallengeAsync() { if (Response.StatusCode != 401) { return; } // Active middleware should redirect on 401 even if there wasn't an explicit challenge. if (ChallengeContext == null && Options.AuthenticationMode == AuthenticationMode.Passive) { return; } // order for redirect_uri // 1. challenge.Properties.RedirectUri // 2. CurrentUri AuthenticationProperties properties; if (ChallengeContext == null) { properties = new AuthenticationProperties(); } else { properties = new AuthenticationProperties(ChallengeContext.Properties); } if (string.IsNullOrEmpty(properties.RedirectUri)) { properties.RedirectUri = CurrentUri; } // this value will be passed to the AuthorizationCodeReceivedNotification if (!string.IsNullOrWhiteSpace(Options.RedirectUri)) { properties.Dictionary.Add(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey, Options.RedirectUri); } if (_configuration == null && Options.ConfigurationManager != null) { _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } OpenIdConnectMessage openIdConnectMessage = new OpenIdConnectMessage { ClientId = Options.ClientId, IssuerAddress = _configuration == null ? string.Empty : (_configuration.AuthorizationEndpoint ?? string.Empty), RedirectUri = Options.RedirectUri, RequestType = OpenIdConnectRequestType.AuthenticationRequest, Resource = Options.Resource, ResponseMode = OpenIdConnectResponseModes.FormPost, ResponseType = Options.ResponseType, Scope = Options.Scope, State = OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey + "=" + Uri.EscapeDataString(Options.StateDataFormat.Protect(properties)) }; if (Options.ProtocolValidator.RequireNonce) { openIdConnectMessage.Nonce = Options.ProtocolValidator.GenerateNonce(); if (Options.NonceCache != null) { Options.NonceCache.AddNonce(openIdConnectMessage.Nonce); } else { RememberNonce(openIdConnectMessage.Nonce); } } var notification = new RedirectToIdentityProviderNotification(Context, Options) { ProtocolMessage = openIdConnectMessage }; await Options.Notifications.RedirectToIdentityProvider(notification); if (!notification.HandledResponse) { string redirectUri = notification.ProtocolMessage.CreateAuthenticationRequestUrl(); if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) { _logger.WriteWarning("Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute) returned 'false', redirectUri is: {0}", (redirectUri ?? "null")); } Response.Redirect(redirectUri); } } protected override AuthenticationTicket AuthenticateCore() { return AuthenticateCoreAsync().GetAwaiter().GetResult(); } /// /// Invoked to process incoming OpenIdConnect messages. /// /// An if successful. protected override async Task AuthenticateCoreAsync() { // Allow login to be constrained to a specific path. Need to make this runtime configurable. if (Options.CallbackPath.HasValue && Options.CallbackPath != (Request.PathBase + Request.Path)) { return null; } OpenIdConnectMessage openIdConnectMessage = null; // assumption: if the ContentType is "application/x-www-form-urlencoded" it should be safe to read as it is small. if (string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(Request.ContentType) // May have media/type; charset=utf-8, allow partial match. && Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) && Request.Body.CanRead) { IFormCollection form = await Request.ReadFormAsync(); Request.Body.Seek(0, SeekOrigin.Begin); openIdConnectMessage = new OpenIdConnectMessage(form); } if (openIdConnectMessage == null) { return null; } try { var messageReceivedNotification = new MessageReceivedNotification(Context, Options) { ProtocolMessage = openIdConnectMessage }; await Options.Notifications.MessageReceived(messageReceivedNotification); if (messageReceivedNotification.HandledResponse) { return messageReceivedNotification.AuthenticationTicket; } if (messageReceivedNotification.Skipped) { return null; } // runtime always adds state, if we don't find it OR we failed to 'unprotect' it this is not a message we // should process. AuthenticationProperties properties = GetPropertiesFromState(openIdConnectMessage.State); if (properties == null) { _logger.WriteWarning("The state field is missing or invalid."); return null; } // devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users. if (!string.IsNullOrWhiteSpace(openIdConnectMessage.Error)) { throw new OpenIdConnectProtocolException( string.Format(CultureInfo.InvariantCulture, openIdConnectMessage.Error, Resources.Exception_OpenIdConnectMessageError, openIdConnectMessage.ErrorDescription ?? string.Empty, openIdConnectMessage.ErrorUri ?? string.Empty)); } // code is only accepted with id_token, in this version, hence check for code is inside this if // OpenIdConnect protocol allows a Code to be received without the id_token if (string.IsNullOrWhiteSpace(openIdConnectMessage.IdToken)) { _logger.WriteWarning("The id_token is missing."); return null; } var securityTokenReceivedNotification = new SecurityTokenReceivedNotification(Context, Options) { ProtocolMessage = openIdConnectMessage }; await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification); if (securityTokenReceivedNotification.HandledResponse) { return securityTokenReceivedNotification.AuthenticationTicket; } if (securityTokenReceivedNotification.Skipped) { return null; } if (_configuration == null && Options.ConfigurationManager != null) { _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } // Copy and augment to avoid cross request race conditions for updated configurations. TokenValidationParameters validationParameters = Options.TokenValidationParameters.Clone(); if (_configuration != null) { if (string.IsNullOrWhiteSpace(validationParameters.ValidIssuer)) { validationParameters.ValidIssuer = _configuration.Issuer; } else if (!string.IsNullOrWhiteSpace(_configuration.Issuer)) { validationParameters.ValidIssuers = (validationParameters.ValidIssuers == null ? new[] { _configuration.Issuer } : validationParameters.ValidIssuers.Concat(new[] { _configuration.Issuer })); } validationParameters.IssuerSigningKeys = (validationParameters.IssuerSigningKeys == null ? _configuration.SigningKeys : validationParameters.IssuerSigningKeys.Concat(_configuration.SigningKeys)); } AuthenticationTicket ticket; SecurityToken validatedToken = null; ClaimsPrincipal principal = null; JwtSecurityToken jwt = null; foreach (var validator in Options.SecurityTokenValidators) { if (validator.CanReadToken(openIdConnectMessage.IdToken)) { principal = validator.ValidateToken(openIdConnectMessage.IdToken, validationParameters, out validatedToken); jwt = validatedToken as JwtSecurityToken; if (jwt == null) { throw new InvalidOperationException("Validated Security Token must be a JwtSecurityToken was: " + (validatedToken == null ? "null" : validatedToken.GetType().ToString())); } } } if (validatedToken == null) { throw new InvalidOperationException("No SecurityTokenValidator found for token: " + openIdConnectMessage.IdToken); } ticket = new AuthenticationTicket(principal, properties, Options.AuthenticationType); if (!string.IsNullOrWhiteSpace(openIdConnectMessage.SessionState)) { ticket.Properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = openIdConnectMessage.SessionState; } if (_configuration != null && !string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe)) { ticket.Properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] = _configuration.CheckSessionIframe; } if (Options.UseTokenLifetime) { // Override any session persistence to match the token lifetime. DateTime issued = validatedToken.ValidFrom; if (issued != DateTime.MinValue) { ticket.Properties.IssuedUtc = issued; } DateTime expires = validatedToken.ValidTo; if (expires != DateTime.MinValue) { ticket.Properties.ExpiresUtc = expires; } ticket.Properties.AllowRefresh = false; } var securityTokenValidatedNotification = new SecurityTokenValidatedNotification(Context, Options) { AuthenticationTicket = ticket, ProtocolMessage = openIdConnectMessage }; await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification); if (securityTokenValidatedNotification.HandledResponse) { return securityTokenValidatedNotification.AuthenticationTicket; } if (securityTokenValidatedNotification.Skipped) { return null; } var protocolValidationContext = new OpenIdConnectProtocolValidationContext { AuthorizationCode = openIdConnectMessage.Code, Nonce = RetrieveNonce(jwt.Payload.Nonce), }; Options.ProtocolValidator.Validate(jwt, protocolValidationContext); if (openIdConnectMessage.Code != null) { var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options) { AuthenticationTicket = ticket, Code = openIdConnectMessage.Code, JwtSecurityToken = jwt, ProtocolMessage = openIdConnectMessage, RedirectUri = ticket.Properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey) ? ticket.Properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey] : string.Empty, }; await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification); if (authorizationCodeReceivedNotification.HandledResponse) { return authorizationCodeReceivedNotification.AuthenticationTicket; } if (authorizationCodeReceivedNotification.Skipped) { return null; } } return ticket; } catch (Exception exception) { _logger.WriteError("Exception occurred while processing message", exception); // Refresh the configuration for exceptions that may be caused by key rollovers. The user can also request a refresh in the notification. if (Options.RefreshOnIssuerKeyNotFound && exception.GetType().Equals(typeof(SecurityTokenSignatureKeyNotFoundException))) { Options.ConfigurationManager.RequestRefresh(); } var authenticationFailedNotification = new AuthenticationFailedNotification(Context, Options) { ProtocolMessage = openIdConnectMessage, Exception = exception }; await Options.Notifications.AuthenticationFailed(authenticationFailedNotification); if (authenticationFailedNotification.HandledResponse) { return authenticationFailedNotification.AuthenticationTicket; } if (authenticationFailedNotification.Skipped) { return null; } throw; } } /// /// Adds the nonce to . /// /// the nonce to remember. /// is called to add a cookie with the name: 'OpenIdConnectAuthenticationDefaults.Nonce + (nonce)'. /// The value of the cookie is: "N". private void RememberNonce(string nonce) { if (string.IsNullOrWhiteSpace(nonce)) { throw new ArgumentNullException("nonce"); } Response.Cookies.Append( OpenIdConnectAuthenticationDefaults.CookieNoncePrefix + Options.StringDataFormat.Protect(nonce), NonceProperty, new CookieOptions { HttpOnly = true, Secure = Request.IsSecure }); } /// /// Searches for a matching nonce. /// /// the nonce that was found in the jwt token. /// 'nonceExpectedValue' if a cookie is found that matches, null otherwise. /// Examine that start with the prefix: 'OpenIdConnectAuthenticationDefaults.Nonce'. /// is used to obtain the actual 'nonce'. If the nonce is found, then is called. private string RetrieveNonce(string nonceExpectedValue) { if (nonceExpectedValue == null) { return null; } foreach (var nonceKey in Request.Cookies.Keys) { if (nonceKey.StartsWith(OpenIdConnectAuthenticationDefaults.CookieNoncePrefix)) { try { string nonceDecodedValue = Options.StringDataFormat.Unprotect(nonceKey.Substring(OpenIdConnectAuthenticationDefaults.CookieNoncePrefix.Length, nonceKey.Length - OpenIdConnectAuthenticationDefaults.CookieNoncePrefix.Length)); if (nonceDecodedValue == nonceExpectedValue) { var cookieOptions = new CookieOptions { HttpOnly = true, Secure = Request.IsSecure }; Response.Cookies.Delete(nonceKey, cookieOptions); return nonceExpectedValue; } } catch (Exception ex) { _logger.WriteWarning("Failed to un-protect the nonce cookie.", ex); } } } return null; } private AuthenticationProperties GetPropertiesFromState(string state) { // assume a well formed query string: OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey=kasjd;fljasldkjflksdj<&c=d> int startIndex = 0; if (string.IsNullOrWhiteSpace(state) || (startIndex = state.IndexOf(OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey, StringComparison.Ordinal)) == -1) { return null; } int authenticationIndex = startIndex + OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey.Length; if (authenticationIndex == -1 || authenticationIndex == state.Length || state[authenticationIndex] != '=') { return null; } // scan rest of string looking for '&' authenticationIndex++; int endIndex = state.Substring(authenticationIndex, state.Length - authenticationIndex).IndexOf("&", StringComparison.Ordinal); // -1 => no other parameters are after the AuthenticationPropertiesKey if (endIndex == -1) { return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex).Replace('+', ' '))); } else { return Options.StateDataFormat.Unprotect(Uri.UnescapeDataString(state.Substring(authenticationIndex, endIndex).Replace('+', ' '))); } } /// /// Calls InvokeReplyPathAsync /// /// True if the request was handled, false if the next middleware should be invoked. public override Task InvokeAsync() { return InvokeReplyPathAsync(); } private async Task InvokeReplyPathAsync() { AuthenticationTicket ticket = await AuthenticateAsync(); if (ticket != null) { if (ticket.Principal != null) { Request.HttpContext.Response.SignIn(ticket.Properties, ticket.Principal.Identities); } // Redirect back to the original secured resource, if any. if (!string.IsNullOrWhiteSpace(ticket.Properties.RedirectUri)) { Response.Redirect(ticket.Properties.RedirectUri); return true; } } return false; } } }