// 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.Authentication.Notifications; using Microsoft.AspNet.Http; using Microsoft.AspNet.Http.Authentication; using Microsoft.Framework.Logging; using Microsoft.IdentityModel.Protocols; namespace Microsoft.AspNet.Authentication.OpenIdConnect { /// /// A per-request authentication handler for the OpenIdConnectAuthenticationMiddleware. /// public class OpenIdConnectAuthenticationHandler : AuthenticationHandler { private const string NonceProperty = "N"; private const string UriSchemeDelimiter = "://"; private OpenIdConnectConfiguration _configuration; 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); } var message = 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.PostLogoutRedirectUri var properties = new AuthenticationProperties(signout.Properties); if (!string.IsNullOrEmpty(properties.RedirectUri)) { message.PostLogoutRedirectUri = properties.RedirectUri; } else if (!string.IsNullOrWhiteSpace(Options.PostLogoutRedirectUri)) { message.PostLogoutRedirectUri = Options.PostLogoutRedirectUri; } var notification = new RedirectToIdentityProviderNotification(Context, Options, message, properties); if (Options.Notifications.RedirectToIdentityProvider != null) { await Options.Notifications.RedirectToIdentityProvider(notification); } if (!notification.HandledResponse || !notification.Skipped) { var redirectUri = notification.ProtocolMessage.CreateLogoutRequestUrl(); if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) { Logger.LogWarning(Resources.OIDCH_0051_RedirectUriLogoutIsNotWellFormed, redirectUri); } 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. /// /// /// Uses log id's OIDCH-0026 - OIDCH-0050, next num: 37 protected override async Task ApplyResponseChallengeAsync() { Logger.LogDebug(Resources.OIDCH_0026_ApplyResponseChallengeAsync, this.GetType()); if (ShouldConvertChallengeToForbidden()) { Logger.LogDebug(Resources.OIDCH_0027_401_ConvertedTo_403); Response.StatusCode = 403; return; } if (Response.StatusCode != 401) { Logger.LogDebug(Resources.OIDCH_0028_StatusCodeNot401, Response.StatusCode); return; } // When Automatic should redirect on 401 even if there wasn't an explicit challenge. if (ChallengeContext == null && !Options.AutomaticAuthentication) { Logger.LogDebug(Resources.OIDCH_0029_ChallengeContextEqualsNull); return; } // order for local RedirectUri // 1. challenge.Properties.RedirectUri // 2. CurrentUri if Options.DefaultToCurrentUriOnRedirect is true) AuthenticationProperties properties; if (ChallengeContext == null) { properties = new AuthenticationProperties(); } else { properties = new AuthenticationProperties(ChallengeContext.Properties); } if (!string.IsNullOrWhiteSpace(properties.RedirectUri)) { Logger.LogDebug(Resources.OIDCH_0030_Using_Properties_RedirectUri, properties.RedirectUri); } else if (Options.DefaultToCurrentUriOnRedirect) { Logger.LogDebug(Resources.OIDCH_0032_UsingCurrentUriRedirectUri, CurrentUri); properties.RedirectUri = CurrentUri; } if (!string.IsNullOrWhiteSpace(Options.RedirectUri)) { Logger.LogDebug(Resources.OIDCH_0031_Using_Options_RedirectUri, Options.RedirectUri); } // When redeeming a 'code' for an AccessToken, this value is needed if (!string.IsNullOrWhiteSpace(Options.RedirectUri)) { properties.Items.Add(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey, Options.RedirectUri); } if (_configuration == null && Options.ConfigurationManager != null) { _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } var message = new OpenIdConnectMessage { ClientId = Options.ClientId, IssuerAddress = _configuration?.AuthorizationEndpoint ?? string.Empty, RedirectUri = Options.RedirectUri, // [brentschmaltz] - #215 this should be a property on RedirectToIdentityProviderNotification not on the OIDCMessage. RequestType = OpenIdConnectRequestType.AuthenticationRequest, Resource = Options.Resource, ResponseMode = Options.ResponseMode, ResponseType = Options.ResponseType, Scope = Options.Scope }; if (Options.ProtocolValidator.RequireNonce) { message.Nonce = Options.ProtocolValidator.GenerateNonce(); } var redirectToIdentityProviderNotification = new RedirectToIdentityProviderNotification(Context, Options, message, properties); await Options.Notifications.RedirectToIdentityProvider(redirectToIdentityProviderNotification); if (redirectToIdentityProviderNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0034_RedirectToIdentityProviderNotificationHandledResponse); return; } else if (redirectToIdentityProviderNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0035_RedirectToIdentityProviderNotificationSkipped); return; } if (!string.IsNullOrWhiteSpace(message.Nonce)) { if (Options.NonceCache != null) { if (!Options.NonceCache.TryAddNonce(message.Nonce)) { Logger.LogError(Resources.OIDCH_0033_TryAddNonceFailed, message.Nonce); throw new OpenIdConnectProtocolException(string.Format(CultureInfo.InvariantCulture, Resources.OIDCH_0033_TryAddNonceFailed, message.Nonce)); } } else { WriteNonceCookie(message.Nonce); } } // If 'OpenIdConnectMessage.State' is null, then the user never set it in the notification. Just set the state if (string.IsNullOrWhiteSpace(redirectToIdentityProviderNotification.ProtocolMessage.State)) { redirectToIdentityProviderNotification.ProtocolMessage.State = OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey + "=" + Options.StateDataFormat.Protect(properties); } // the user did set 'OpenIdConnectMessage.State, add a parameter to the state else { redirectToIdentityProviderNotification.ProtocolMessage.State = OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey + "=" + Options.StateDataFormat.Protect(properties) + "&userstate=" + redirectToIdentityProviderNotification.ProtocolMessage.State; } string redirectUri = redirectToIdentityProviderNotification.ProtocolMessage.CreateAuthenticationRequestUrl(); if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) { Logger.LogWarning(Resources.OIDCH_0036_UriIsNotWellFormed, redirectUri); } Logger.LogDebug(Resources.OIDCH_0037_RedirectUri, redirectUri); Response.Redirect(redirectUri); } protected override AuthenticationTicket AuthenticateCore() { return AuthenticateCoreAsync().GetAwaiter().GetResult(); } /// /// Invoked to process incoming OpenIdConnect messages. /// /// An if successful. /// Uses log id's OIDCH-0000 - OIDCH-0025 protected override async Task AuthenticateCoreAsync() { Logger.LogDebug(Resources.OIDCH_0000_AuthenticateCoreAsync, this.GetType()); // 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 message = 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) { var form = await Request.ReadFormAsync(); Request.Body.Seek(0, SeekOrigin.Begin); message = new OpenIdConnectMessage(form); } if (message == null) { return null; } try { if (Logger.IsEnabled(LogLevel.Debug)) { Logger.LogDebug(Resources.OIDCH_0001_MessageReceived, message.BuildRedirectUrl()); } var messageReceivedNotification = new MessageReceivedNotification(Context, Options) { ProtocolMessage = message }; await Options.Notifications.MessageReceived(messageReceivedNotification); if (messageReceivedNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0002_MessageReceivedNotificationHandledResponse); return messageReceivedNotification.AuthenticationTicket; } if (messageReceivedNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0003_MessageReceivedNotificationSkipped); 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. if (string.IsNullOrWhiteSpace(message.State)) { Logger.LogError(Resources.OIDCH_0004_MessageStateIsNullOrWhiteSpace); return null; } var properties = GetPropertiesFromState(message.State); if (properties == null) { Logger.LogError(Resources.OIDCH_0005_MessageStateIsInvalid); return null; } // devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users. if (!string.IsNullOrWhiteSpace(message.Error)) { Logger.LogError(Resources.OIDCH_0006_MessageErrorNotNull, message.Error); throw new OpenIdConnectProtocolException(string.Format(CultureInfo.InvariantCulture, Resources.OIDCH_0006_MessageErrorNotNull, message.Error)); } AuthenticationTicket ticket = null; JwtSecurityToken jwt = null; if (_configuration == null && Options.ConfigurationManager != null) { Logger.LogDebug(Resources.OIDCH_0007_UpdatingConfiguration); _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); } // OpenIdConnect protocol allows a Code to be received without the id_token if (!string.IsNullOrWhiteSpace(message.IdToken)) { Logger.LogDebug(Resources.OIDCH_0020_IdTokenReceived, message.IdToken); var securityTokenReceivedNotification = new SecurityTokenReceivedNotification(Context, Options) { ProtocolMessage = message }; await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification); if (securityTokenReceivedNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0008_SecurityTokenReceivedNotificationHandledResponse); return securityTokenReceivedNotification.AuthenticationTicket; } if (securityTokenReceivedNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0009_SecurityTokenReceivedNotificationSkipped); return null; } // Copy and augment to avoid cross request race conditions for updated configurations. var 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?.Concat(new[] { _configuration.Issuer }) ?? new[] { _configuration.Issuer }; } validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys) ?? _configuration.SigningKeys; } SecurityToken validatedToken = null; ClaimsPrincipal principal = null; foreach (var validator in Options.SecurityTokenValidators) { if (validator.CanReadToken(message.IdToken)) { principal = validator.ValidateToken(message.IdToken, validationParameters, out validatedToken); jwt = validatedToken as JwtSecurityToken; if (jwt == null) { Logger.LogError(Resources.OIDCH_0010_ValidatedSecurityTokenNotJwt, validatedToken?.GetType()); throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.OIDCH_0010_ValidatedSecurityTokenNotJwt, validatedToken?.GetType())); } } } if (validatedToken == null) { Logger.LogError(Resources.OIDCH_0011_UnableToValidateToken, message.IdToken); throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Resources.OIDCH_0011_UnableToValidateToken, message.IdToken)); } ticket = new AuthenticationTicket(principal, properties, Options.AuthenticationScheme); if (!string.IsNullOrWhiteSpace(message.SessionState)) { ticket.Properties.Items[OpenIdConnectSessionProperties.SessionState] = message.SessionState; } if (_configuration != null && !string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe)) { ticket.Properties.Items[OpenIdConnectSessionProperties.CheckSessionIFrame] = _configuration.CheckSessionIframe; } // Rename? if (Options.UseTokenLifetime) { var issued = validatedToken.ValidFrom; if (issued != DateTime.MinValue) { ticket.Properties.IssuedUtc = issued; } var expires = validatedToken.ValidTo; if (expires != DateTime.MinValue) { ticket.Properties.ExpiresUtc = expires; } } var securityTokenValidatedNotification = new SecurityTokenValidatedNotification(Context, Options) { AuthenticationTicket = ticket, ProtocolMessage = message }; await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification); if (securityTokenValidatedNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0012_SecurityTokenValidatedNotificationHandledResponse); return securityTokenValidatedNotification.AuthenticationTicket; } if (securityTokenValidatedNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0013_SecurityTokenValidatedNotificationSkipped); return null; } string nonce = jwt.Payload.Nonce; if (Options.NonceCache != null) { // if the nonce cannot be removed, it was used if (!Options.NonceCache.TryRemoveNonce(nonce)) { nonce = null; } } else { nonce = ReadNonceCookie(nonce); } var protocolValidationContext = new OpenIdConnectProtocolValidationContext { AuthorizationCode = message.Code, Nonce = nonce, }; Options.ProtocolValidator.Validate(jwt, protocolValidationContext); } if (message.Code != null) { Logger.LogDebug(Resources.OIDCH_0014_CodeReceived, message.Code); if (ticket == null) { ticket = new AuthenticationTicket(properties, Options.AuthenticationScheme); } var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options) { AuthenticationTicket = ticket, Code = message.Code, JwtSecurityToken = jwt, ProtocolMessage = message, RedirectUri = ticket.Properties.Items.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey) ? ticket.Properties.Items[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey] : string.Empty, }; await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification); if (authorizationCodeReceivedNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0015_CodeReceivedNotificationHandledResponse); return authorizationCodeReceivedNotification.AuthenticationTicket; } if (authorizationCodeReceivedNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0016_CodeReceivedNotificationSkipped); return null; } } return ticket; } catch (Exception exception) { Logger.LogError(Resources.OIDCH_0017_ExceptionOccurredWhileProcessingMessage, 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 = message, Exception = exception }; await Options.Notifications.AuthenticationFailed(authenticationFailedNotification); if (authenticationFailedNotification.HandledResponse) { Logger.LogInformation(Resources.OIDCH_0018_AuthenticationFailedNotificationHandledResponse); return authenticationFailedNotification.AuthenticationTicket; } if (authenticationFailedNotification.Skipped) { Logger.LogInformation(Resources.OIDCH_0019_AuthenticationFailedNotificationSkipped); 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 WriteNonceCookie(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.IsHttps }); } /// /// Searches for a matching nonce. /// /// the nonce that we are looking for. /// echos 'nonce' 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 ReadNonceCookie(string nonce) { if (nonce == null) { return null; } foreach (var nonceKey in Request.Cookies.Keys) { if (nonceKey.StartsWith(OpenIdConnectAuthenticationDefaults.CookieNoncePrefix)) { try { var nonceDecodedValue = Options.StringDataFormat.Unprotect(nonceKey.Substring(OpenIdConnectAuthenticationDefaults.CookieNoncePrefix.Length, nonceKey.Length - OpenIdConnectAuthenticationDefaults.CookieNoncePrefix.Length)); if (nonceDecodedValue == nonce) { var cookieOptions = new CookieOptions { HttpOnly = true, Secure = Request.IsHttps }; Response.Cookies.Delete(nonceKey, cookieOptions); return nonce; } } catch (Exception ex) { Logger.LogWarning("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> var startIndex = 0; if (string.IsNullOrWhiteSpace(state) || (startIndex = state.IndexOf(OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey, StringComparison.Ordinal)) == -1) { return null; } var authenticationIndex = startIndex + OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey.Length; if (authenticationIndex == -1 || authenticationIndex == state.Length || state[authenticationIndex] != '=') { return null; } // scan rest of string looking for '&' authenticationIndex++; var 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() { var ticket = await AuthenticateAsync(); if (ticket != null) { if (ticket.Principal != null) { Request.HttpContext.Authentication.SignIn(Options.SignInScheme, ticket.Principal, ticket.Properties); } // Redirect back to the original secured resource, if any. if (!string.IsNullOrWhiteSpace(ticket.Properties.RedirectUri)) { Response.Redirect(ticket.Properties.RedirectUri); return true; } } return false; } } }