From 569f1c872e86e57155d7865f4b3d627949cfcba2 Mon Sep 17 00:00:00 2001 From: Ryan Williams Date: Mon, 12 Aug 2019 16:04:38 -0600 Subject: [PATCH] Add response_mode=query support for OpenID Connect (#297) --- ...crosoft.Owin.Security.OpenIdConnect.csproj | 1 + .../AuthorizationCodeReceivedNotification.cs | 57 ++ .../TokenResponseReceivedNotification.cs | 32 ++ .../OpenIdConnectAuthenticationMiddleware.cs | 15 +- ...penIdConnectAuthenticationNotifications.cs | 6 + .../OpenIdConnectAuthenticationOptions.cs | 28 + .../OpenidConnectAuthenticationHandler.cs | 494 ++++++++++++++---- .../Resources.Designer.cs | 22 +- .../Resources.resx | 6 + .../Katana.Sandbox.WebServer.csproj | 3 + tests/Katana.Sandbox.WebServer/Startup.cs | 23 +- 11 files changed, 581 insertions(+), 106 deletions(-) create mode 100644 src/Microsoft.Owin.Security.OpenIdConnect/Notifications/TokenResponseReceivedNotification.cs diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/Microsoft.Owin.Security.OpenIdConnect.csproj b/src/Microsoft.Owin.Security.OpenIdConnect/Microsoft.Owin.Security.OpenIdConnect.csproj index d19b2403..24e5f2d2 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/Microsoft.Owin.Security.OpenIdConnect.csproj +++ b/src/Microsoft.Owin.Security.OpenIdConnect/Microsoft.Owin.Security.OpenIdConnect.csproj @@ -85,6 +85,7 @@ + diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/AuthorizationCodeReceivedNotification.cs b/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/AuthorizationCodeReceivedNotification.cs index d1e3a627..3a1bf65f 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/AuthorizationCodeReceivedNotification.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/AuthorizationCodeReceivedNotification.cs @@ -36,6 +36,11 @@ public AuthorizationCodeReceivedNotification(IOwinContext context, OpenIdConnect /// public JwtSecurityToken JwtSecurityToken { get; set; } + /// + /// The request that will be sent to the token endpoint and is available for customization. + /// + public OpenIdConnectMessage TokenEndpointRequest { get; set; } + /// /// Gets or sets the . /// @@ -47,5 +52,57 @@ public AuthorizationCodeReceivedNotification(IOwinContext context, OpenIdConnect /// This is the redirect_uri that was sent in the id_token + code OpenIdConnectRequest. [SuppressMessage("Microsoft.Design", "CA1056:UriPropertiesShouldNotBeStrings", Justification = "user controlled, not necessarily a URI")] public string RedirectUri { get; set; } + + /// + /// If the developer chooses to redeem the code themselves then they can provide the resulting tokens here. This is the + /// same as calling HandleCodeRedemption. If set then the handler will not attempt to redeem the code. An IdToken + /// is required if one had not been previously received in the authorization response. + /// + public OpenIdConnectMessage TokenEndpointResponse { get; set; } + + /// + /// Indicates if the developer choose to handle (or skip) the code redemption. If true then the handler will not attempt + /// to redeem the code. See HandleCodeRedemption and TokenEndpointResponse. + /// + public bool HandledCodeRedemption + { + get + { + return TokenEndpointResponse != null; + } + } + + /// + /// Tells the handler to skip the code redemption process. The developer may have redeemed the code themselves, or + /// decided that the redemption was not required. If tokens were retrieved that are needed for further processing then + /// call one of the overloads that allows providing tokens. An IdToken is required if one had not been previously received + /// in the authorization response. Calling this is the same as setting TokenEndpointResponse. + /// + public void HandleCodeRedemption() + { + TokenEndpointResponse = new OpenIdConnectMessage(); + } + + /// + /// Tells the handler to skip the code redemption process. The developer may have redeemed the code themselves, or + /// decided that the redemption was not required. If tokens were retrieved that are needed for further processing then + /// call one of the overloads that allows providing tokens. An IdToken is required if one had not been previously received + /// in the authorization response. Calling this is the same as setting TokenEndpointResponse. + /// + public void HandleCodeRedemption(string accessToken, string idToken) + { + TokenEndpointResponse = new OpenIdConnectMessage() { AccessToken = accessToken, IdToken = idToken }; + } + + /// + /// Tells the handler to skip the code redemption process. The developer may have redeemed the code themselves, or + /// decided that the redemption was not required. If tokens were retrieved that are needed for further processing then + /// call one of the overloads that allows providing tokens. An IdToken is required if one had not been previously received + /// in the authorization response. Calling this is the same as setting TokenEndpointResponse. + /// + public void HandleCodeRedemption(OpenIdConnectMessage tokenEndpointResponse) + { + TokenEndpointResponse = tokenEndpointResponse; + } } } \ No newline at end of file diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/TokenResponseReceivedNotification.cs b/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/TokenResponseReceivedNotification.cs new file mode 100644 index 00000000..1dd3b716 --- /dev/null +++ b/src/Microsoft.Owin.Security.OpenIdConnect/Notifications/TokenResponseReceivedNotification.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.Owin.Security.OpenIdConnect; + +namespace Microsoft.Owin.Security.Notifications +{ + /// + /// This Notification can be used to be informed when an 'AuthorizationCode' is redeemed for tokens at the token endpoint. + /// + public class TokenResponseReceivedNotification : BaseNotification + { + /// + /// Creates a + /// + public TokenResponseReceivedNotification(IOwinContext context, OpenIdConnectAuthenticationOptions options) + : base(context, options) + { + } + + /// + /// Gets or sets the that contains the code redeemed for tokens at the token endpoint. + /// + public OpenIdConnectMessage ProtocolMessage { get; set; } + + /// + /// Gets or sets the that contains the tokens received after redeeming the code at the token endpoint. + /// + public OpenIdConnectMessage TokenEndpointResponse { get; set; } + } +} diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationMiddleware.cs b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationMiddleware.cs index dbd0cb08..1a5bd163 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationMiddleware.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationMiddleware.cs @@ -67,6 +67,14 @@ public OpenIdConnectAuthenticationMiddleware(OwinMiddleware next, IAppBuilder ap Options.TokenValidationParameters.ValidAudience = Options.ClientId; } + if (Options.Backchannel == null) + { + Options.Backchannel = new HttpClient(ResolveHttpMessageHandler(Options)); + Options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core OpenIdConnect middleware"); + Options.Backchannel.Timeout = Options.BackchannelTimeout; + Options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB + } + if (Options.ConfigurationManager == null) { if (Options.Configuration != null) @@ -91,13 +99,8 @@ public OpenIdConnectAuthenticationMiddleware(OwinMiddleware next, IAppBuilder ap throw new InvalidOperationException("The MetadataAddress or Authority must use HTTPS unless disabled for development by setting RequireHttpsMetadata=false."); } - var backchannel = new HttpClient(ResolveHttpMessageHandler(Options)); - backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core OpenIdConnect middleware"); - backchannel.Timeout = Options.BackchannelTimeout; - backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB - Options.ConfigurationManager = new ConfigurationManager(Options.MetadataAddress, new OpenIdConnectConfigurationRetriever(), - new HttpDocumentRetriever(backchannel) { RequireHttps = Options.RequireHttpsMetadata }); + new HttpDocumentRetriever(Options.Backchannel) { RequireHttps = Options.RequireHttpsMetadata }); } } diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationNotifications.cs b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationNotifications.cs index f51533c6..84986069 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationNotifications.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationNotifications.cs @@ -24,6 +24,7 @@ public OpenIdConnectAuthenticationNotifications() SecurityTokenReceived = notification => Task.FromResult(0); SecurityTokenValidated = notification => Task.FromResult(0); RedirectToIdentityProvider = notification => Task.FromResult(0); + TokenResponseReceived = notification => Task.FromResult(0); } /// @@ -55,5 +56,10 @@ public OpenIdConnectAuthenticationNotifications() /// Invoked after the security token has passed validation and a ClaimsIdentity has been generated. /// public Func, Task> SecurityTokenValidated { get; set; } + + /// + /// Invoked after "authorization code" is redeemed for tokens at the token endpoint. + /// + public Func TokenResponseReceived { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationOptions.cs b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationOptions.cs index 820f51c3..1fe8fc43 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationOptions.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/OpenIdConnectAuthenticationOptions.cs @@ -41,10 +41,12 @@ public OpenIdConnectAuthenticationOptions() /// Caption: . /// ProtocolValidator: new . /// RefreshOnIssuerKeyNotFound: true + /// ResponseMode: /// ResponseType: /// Scope: . /// TokenValidationParameters: new with AuthenticationType = authenticationType. /// UseTokenLifetime: true. + /// RedeemCode: false. /// /// will be used to when creating the for the AuthenticationType property. [SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "Microsoft.Owin.Security.OpenIdConnect.OpenIdConnectAuthenticationOptions.set_Caption(System.String)", Justification = "Not a LOC field")] @@ -60,6 +62,7 @@ public OpenIdConnectAuthenticationOptions(string authenticationType) NonceLifetime = TimeSpan.FromMinutes(15) }; RefreshOnIssuerKeyNotFound = true; + ResponseMode = OpenIdConnectResponseMode.FormPost; ResponseType = OpenIdConnectResponseType.CodeIdToken; Scope = OpenIdConnectScope.OpenIdProfile; SecurityTokenValidator = new JwtSecurityTokenHandler(); @@ -67,6 +70,7 @@ public OpenIdConnectAuthenticationOptions(string authenticationType) TokenValidationParameters = new TokenValidationParameters(); UseTokenLifetime = true; CookieManager = new CookieManager(); + RedeemCode = false; } /// @@ -122,6 +126,11 @@ public TimeSpan BackchannelTimeout } } + /// + /// Used to communicate with the remote identity provider. + /// + public HttpClient Backchannel { get; set; } + /// /// Get or sets the text that the user can display on a sign in user interface. /// @@ -216,6 +225,11 @@ public OpenIdConnectProtocolValidator ProtocolValidator /// public string Resource { get; set; } + /// + /// Gets or sets the 'response_mode'. + /// + public string ResponseMode { get; set; } + /// /// Gets or sets the 'response_type'. /// @@ -290,9 +304,23 @@ public bool UseTokenLifetime set; } + /// + /// Defines whether access and refresh tokens should be stored in the + /// after a successful authorization. + /// This property is set to false by default to reduce + /// the size of the final authentication cookie. + /// + public bool SaveTokens { get; set; } + /// /// An abstraction for reading and setting cookies during the authentication process. /// public ICookieManager CookieManager { get; set; } + + /// + /// When set to true the authorization code will be redeemed for tokens at the token endpoint. + /// This property is set to false by default. + /// + public bool RedeemCode { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/OpenidConnectAuthenticationHandler.cs b/src/Microsoft.Owin.Security.OpenIdConnect/OpenidConnectAuthenticationHandler.cs index 8ee87874..bee807bd 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/OpenidConnectAuthenticationHandler.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/OpenidConnectAuthenticationHandler.cs @@ -7,6 +7,7 @@ using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; +using System.Net.Http; using System.Runtime.ExceptionServices; using System.Security.Claims; using System.Security.Cryptography; @@ -31,6 +32,14 @@ public class OpenIdConnectAuthenticationHandler : AuthenticationHandler /// Creates a new OpenIdConnectAuthenticationHandler /// @@ -145,12 +154,20 @@ protected override async Task ApplyResponseChallengeAsync() RedirectUri = Options.RedirectUri, RequestType = OpenIdConnectRequestType.Authentication, Resource = Options.Resource, - ResponseMode = OpenIdConnectResponseMode.FormPost, ResponseType = Options.ResponseType, Scope = Options.Scope, State = OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey + "=" + Uri.EscapeDataString(Options.StateDataFormat.Protect(properties)), }; + // Omitting the response_mode parameter when it already corresponds to the default + // response_mode used for the specified response_type is recommended by the specifications. + // See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes + if (!string.Equals(Options.ResponseType, OpenIdConnectResponseType.Code, StringComparison.Ordinal) || + !string.Equals(Options.ResponseMode, OpenIdConnectResponseMode.Query, StringComparison.Ordinal)) + { + openIdConnectMessage.ResponseMode = Options.ResponseMode; + } + if (Options.ProtocolValidator.RequireNonce) { AddNonceToMessage(openIdConnectMessage); @@ -189,10 +206,41 @@ protected override async Task AuthenticateCoreAsync() return null; } - OpenIdConnectMessage openIdConnectMessage = null; + OpenIdConnectMessage authorizationResponse = null; + + if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase) && Request.Query.Any()) + { + authorizationResponse = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair(pair.Key, pair.Value))); + + // response_mode=query (explicit or not) and a response_type containing id_token + // or token are not considered as a safe combination and MUST be rejected. + // See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Security + if (!string.IsNullOrEmpty(authorizationResponse.IdToken) || !string.IsNullOrEmpty(authorizationResponse.AccessToken)) + { + var invalidResponseEx = new OpenIdConnectProtocolException("An OpenID Connect response cannot contain an identity token or an access token when using response_mode=query"); + + _logger.WriteError("Exception occurred while processing message: ", invalidResponseEx); + + var authenticationFailedNotification = new AuthenticationFailedNotification(Context, Options) + { + ProtocolMessage = authorizationResponse, + Exception = invalidResponseEx + }; + await Options.Notifications.AuthenticationFailed(authenticationFailedNotification); + if (authenticationFailedNotification.HandledResponse) + { + return GetHandledResponseTicket(); + } + if (authenticationFailedNotification.Skipped) + { + return null; + } + throw invalidResponseEx; + } + } // 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) + else 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) @@ -212,10 +260,10 @@ protected override async Task AuthenticateCoreAsync() Request.Body.Seek(0, SeekOrigin.Begin); // TODO: a delegate on OpenIdConnectAuthenticationOptions would allow for users to hook their own custom message. - openIdConnectMessage = new OpenIdConnectMessage(form); + authorizationResponse = new OpenIdConnectMessage(form); } - if (openIdConnectMessage == null) + if (authorizationResponse == null) { return null; } @@ -225,7 +273,7 @@ protected override async Task AuthenticateCoreAsync() { var messageReceivedNotification = new MessageReceivedNotification(Context, Options) { - ProtocolMessage = openIdConnectMessage + ProtocolMessage = authorizationResponse }; await Options.Notifications.MessageReceived(messageReceivedNotification); if (messageReceivedNotification.HandledResponse) @@ -239,7 +287,7 @@ protected override async Task AuthenticateCoreAsync() // 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); + AuthenticationProperties properties = GetPropertiesFromState(authorizationResponse.State); if (properties == null) { _logger.WriteWarning("The state field is missing or invalid."); @@ -247,34 +295,9 @@ protected override async Task AuthenticateCoreAsync() } // devs will need to hook AuthenticationFailedNotification to avoid having 'raw' runtime errors displayed to users. - if (!string.IsNullOrWhiteSpace(openIdConnectMessage.Error)) + if (!string.IsNullOrWhiteSpace(authorizationResponse.Error)) { - throw new OpenIdConnectProtocolException( - string.Format(CultureInfo.InvariantCulture, - Resources.Exception_OpenIdConnectMessageError, - openIdConnectMessage.Error, 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 GetHandledResponseTicket(); - } - if (securityTokenReceivedNotification.Skipped) - { - return null; + throw CreateOpenIdConnectProtocolException(authorizationResponse); } if (_configuration == null) @@ -282,94 +305,97 @@ protected override async Task AuthenticateCoreAsync() _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.Request.CallCancelled); } + PopulateSessionProperties(authorizationResponse, properties); + + ClaimsPrincipal user = null; + AuthenticationTicket ticket = null; + JwtSecurityToken jwt = null; + string nonce = null; // Copy and augment to avoid cross request race conditions for updated configurations. - TokenValidationParameters tvp = Options.TokenValidationParameters.Clone(); - IEnumerable issuers = new[] { _configuration.Issuer }; - tvp.ValidIssuers = (tvp.ValidIssuers == null ? issuers : tvp.ValidIssuers.Concat(issuers)); - tvp.IssuerSigningKeys = (tvp.IssuerSigningKeys == null ? _configuration.SigningKeys : tvp.IssuerSigningKeys.Concat(_configuration.SigningKeys)); - - SecurityToken validatedToken; - ClaimsPrincipal principal = Options.SecurityTokenValidator.ValidateToken(openIdConnectMessage.IdToken, tvp, out validatedToken); - ClaimsIdentity claimsIdentity = principal.Identity as ClaimsIdentity; - - // claims principal could have changed claim values, use bits received on wire for validation. - JwtSecurityToken jwt = validatedToken as JwtSecurityToken; - AuthenticationTicket ticket = new AuthenticationTicket(claimsIdentity, properties); + var validationParameters = Options.TokenValidationParameters.Clone(); - string nonce = null; - if (Options.ProtocolValidator.RequireNonce) + // Hybrid or Implicit flow + if (!string.IsNullOrEmpty(authorizationResponse.IdToken)) { - if (String.IsNullOrWhiteSpace(openIdConnectMessage.Nonce)) + var securityTokenReceivedNotification = new SecurityTokenReceivedNotification(Context, Options) + { + ProtocolMessage = authorizationResponse, + }; + await Options.Notifications.SecurityTokenReceived(securityTokenReceivedNotification); + if (securityTokenReceivedNotification.HandledResponse) { - openIdConnectMessage.Nonce = jwt.Payload.Nonce; + return GetHandledResponseTicket(); + } + if (securityTokenReceivedNotification.Skipped) + { + return null; } - // deletes the nonce cookie - nonce = RetrieveNonce(openIdConnectMessage); - } + user = ValidateToken(authorizationResponse.IdToken, properties, validationParameters, out jwt); - // remember 'session_state' and 'check_session_iframe' - if (!string.IsNullOrWhiteSpace(openIdConnectMessage.SessionState)) - { - ticket.Properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = openIdConnectMessage.SessionState; - } + if (Options.ProtocolValidator.RequireNonce) + { + if (string.IsNullOrWhiteSpace(authorizationResponse.Nonce)) + { + authorizationResponse.Nonce = jwt.Payload.Nonce; + } - if (!string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe)) - { - ticket.Properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] = _configuration.CheckSessionIframe; - } + // deletes the nonce cookie + nonce = RetrieveNonce(authorizationResponse); + } - if (Options.UseTokenLifetime) - { - // Override any session persistence to match the token lifetime. - DateTime issued = jwt.ValidFrom; - if (issued != DateTime.MinValue) + ClaimsIdentity claimsIdentity = user.Identity as ClaimsIdentity; + ticket = new AuthenticationTicket(claimsIdentity, properties); + + var securityTokenValidatedNotification = new SecurityTokenValidatedNotification(Context, Options) { - ticket.Properties.IssuedUtc = issued.ToUniversalTime(); + AuthenticationTicket = ticket, + ProtocolMessage = authorizationResponse, + }; + await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification); + if (securityTokenValidatedNotification.HandledResponse) + { + return GetHandledResponseTicket(); } - DateTime expires = jwt.ValidTo; - if (expires != DateTime.MinValue) + if (securityTokenValidatedNotification.Skipped) { - ticket.Properties.ExpiresUtc = expires.ToUniversalTime(); + return null; } - ticket.Properties.AllowRefresh = false; - } - - var securityTokenValidatedNotification = new SecurityTokenValidatedNotification(Context, Options) - { - AuthenticationTicket = ticket, - ProtocolMessage = openIdConnectMessage, - }; - await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification); - if (securityTokenValidatedNotification.HandledResponse) - { - return GetHandledResponseTicket(); - } - if (securityTokenValidatedNotification.Skipped) - { - return null; + // Flow possible changes + ticket = securityTokenValidatedNotification.AuthenticationTicket; } - // Flow possible changes - ticket = securityTokenValidatedNotification.AuthenticationTicket; Options.ProtocolValidator.ValidateAuthenticationResponse(new OpenIdConnectProtocolValidationContext() { ClientId = Options.ClientId, - ProtocolMessage = openIdConnectMessage, + ProtocolMessage = authorizationResponse, ValidatedIdToken = jwt, Nonce = nonce }); - if (openIdConnectMessage.Code != null) + OpenIdConnectMessage tokenEndpointResponse = null; + + // Authorization Code or Hybrid flow + if (!string.IsNullOrEmpty(authorizationResponse.Code)) { + var tokenEndpointRequest = new OpenIdConnectMessage() + { + ClientId = Options.ClientId, + ClientSecret = Options.ClientSecret, + Code = authorizationResponse.Code, + GrantType = OpenIdConnectGrantTypes.AuthorizationCode, + RedirectUri = properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey] + }; + var authorizationCodeReceivedNotification = new AuthorizationCodeReceivedNotification(Context, Options) { AuthenticationTicket = ticket, - Code = openIdConnectMessage.Code, + Code = authorizationResponse.Code, JwtSecurityToken = jwt, - ProtocolMessage = openIdConnectMessage, - RedirectUri = ticket.Properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey) ? - ticket.Properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey] : string.Empty, + ProtocolMessage = authorizationResponse, + RedirectUri = properties.Dictionary.ContainsKey(OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey) ? + properties.Dictionary[OpenIdConnectAuthenticationDefaults.RedirectUriUsedForCodeKey] : string.Empty, + TokenEndpointRequest = tokenEndpointRequest }; await Options.Notifications.AuthorizationCodeReceived(authorizationCodeReceivedNotification); if (authorizationCodeReceivedNotification.HandledResponse) @@ -381,7 +407,107 @@ protected override async Task AuthenticateCoreAsync() return null; } // Flow possible changes + authorizationResponse = authorizationCodeReceivedNotification.ProtocolMessage; ticket = authorizationCodeReceivedNotification.AuthenticationTicket; + tokenEndpointRequest = authorizationCodeReceivedNotification.TokenEndpointRequest; + tokenEndpointResponse = authorizationCodeReceivedNotification.TokenEndpointResponse; + jwt = authorizationCodeReceivedNotification.JwtSecurityToken; + + if (!authorizationCodeReceivedNotification.HandledCodeRedemption && Options.RedeemCode) + { + tokenEndpointResponse = await RedeemAuthorizationCodeAsync(tokenEndpointRequest); + } + + if (tokenEndpointResponse != null) + { + var tokenResponseReceivedNotification = new TokenResponseReceivedNotification(Context, Options) + { + ProtocolMessage = authorizationResponse, + TokenEndpointResponse = tokenEndpointResponse + }; + await Options.Notifications.TokenResponseReceived(tokenResponseReceivedNotification); + if (tokenResponseReceivedNotification.HandledResponse) + { + return GetHandledResponseTicket(); + } + if (tokenResponseReceivedNotification.Skipped) + { + return null; + } + + // no need to validate signature when token is received using "code flow" as per spec + // [http://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation]. + validationParameters.RequireSignedTokens = false; + + // At least a cursory validation is required on the new IdToken, even if we've already validated the one from the authorization response. + // And we'll want to validate the new JWT in ValidateTokenResponse. + JwtSecurityToken tokenEndpointJwt = null; + var tokenEndpointUser = ValidateToken(tokenEndpointResponse.IdToken, properties, validationParameters, out tokenEndpointJwt); + + // Avoid running the event, etc, if it was already done as part of the authorization response validation. + if (user == null) + { + if (Options.ProtocolValidator.RequireNonce) + { + if (string.IsNullOrWhiteSpace(tokenEndpointResponse.Nonce)) + { + tokenEndpointResponse.Nonce = tokenEndpointJwt.Payload.Nonce; + } + + // deletes the nonce cookie + if (nonce == null) + { + nonce = RetrieveNonce(tokenEndpointResponse); + } + } + + ClaimsIdentity claimsIdentity = tokenEndpointUser.Identity as ClaimsIdentity; + ticket = new AuthenticationTicket(claimsIdentity, properties); + + var securityTokenValidatedNotification = new SecurityTokenValidatedNotification(Context, Options) + { + AuthenticationTicket = ticket, + ProtocolMessage = tokenEndpointResponse, + }; + await Options.Notifications.SecurityTokenValidated(securityTokenValidatedNotification); + if (securityTokenValidatedNotification.HandledResponse) + { + return GetHandledResponseTicket(); + } + if (securityTokenValidatedNotification.Skipped) + { + return null; + } + // Flow possible changes + ticket = securityTokenValidatedNotification.AuthenticationTicket; + } + else + { + if (!string.Equals(jwt.Subject, tokenEndpointJwt.Subject, StringComparison.Ordinal)) + { + throw new SecurityTokenException("The sub claim does not match in the id_token's from the authorization and token endpoints."); + } + } + + jwt = tokenEndpointJwt; + } + + // Validate the token response if it wasn't provided manually + if (!authorizationCodeReceivedNotification.HandledCodeRedemption && Options.RedeemCode) + { + Options.ProtocolValidator.ValidateTokenResponse(new OpenIdConnectProtocolValidationContext() + { + ClientId = Options.ClientId, + ProtocolMessage = tokenEndpointResponse, + ValidatedIdToken = jwt, + Nonce = nonce + }); + } + } + + if (Options.SaveTokens && ticket != null) + { + SaveTokens(ticket.Properties, tokenEndpointResponse ?? authorizationResponse); } return ticket; @@ -404,7 +530,7 @@ protected override async Task AuthenticateCoreAsync() var authenticationFailedNotification = new AuthenticationFailedNotification(Context, Options) { - ProtocolMessage = openIdConnectMessage, + ProtocolMessage = authorizationResponse, Exception = authFailedEx.SourceException }; await Options.Notifications.AuthenticationFailed(authenticationFailedNotification); @@ -423,6 +549,51 @@ protected override async Task AuthenticateCoreAsync() return null; } + /// + /// Redeems the authorization code for tokens at the token endpoint. + /// + /// The request that will be sent to the token endpoint and is available for customization. + /// OpenIdConnect message that has tokens inside it. + protected virtual async Task RedeemAuthorizationCodeAsync(OpenIdConnectMessage tokenEndpointRequest) + { + var requestMessage = new HttpRequestMessage(HttpMethod.Post, _configuration.TokenEndpoint); + requestMessage.Content = new FormUrlEncodedContent(tokenEndpointRequest.Parameters); + + var responseMessage = await Backchannel.SendAsync(requestMessage); + + var contentMediaType = responseMessage.Content.Headers.ContentType != null ? responseMessage.Content.Headers.ContentType.MediaType : null; + if (string.IsNullOrEmpty(contentMediaType)) + { + _logger.WriteVerbose(string.Format("Unexpected token response format. Status Code: {0}. Content-Type header is missing.", (int)responseMessage.StatusCode)); + } + else if (!string.Equals(contentMediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + { + _logger.WriteVerbose(string.Format("Unexpected token response format. Status Code: {0}. Content-Type {1}.", (int)responseMessage.StatusCode, responseMessage.Content.Headers.ContentType)); + } + + // Error handling: + // 1. If the response body can't be parsed as json, throws. + // 2. If the response's status code is not in 2XX range, throw OpenIdConnectProtocolException. If the body is correct parsed, + // pass the error information from body to the exception. + OpenIdConnectMessage message; + try + { + var responseContent = await responseMessage.Content.ReadAsStringAsync(); + message = new OpenIdConnectMessage(responseContent); + } + catch (Exception ex) + { + throw new OpenIdConnectProtocolException(string.Format("Failed to parse token response body as JSON. Status Code: {0}. Content-Type: {1}", (int)responseMessage.StatusCode, responseMessage.Content.Headers.ContentType), ex); + } + + if (!responseMessage.IsSuccessStatusCode) + { + throw CreateOpenIdConnectProtocolException(message); + } + + return message; + } + /// /// Sets to . /// @@ -543,7 +714,101 @@ protected virtual string GetNonceKey(string nonce) return OpenIdConnectAuthenticationDefaults.CookiePrefix + OpenIdConnectAuthenticationDefaults.Nonce + Convert.ToBase64String(hash.ComputeHash(Encoding.UTF8.GetBytes(nonce))); } } - + + /// + /// Save the tokens contained in the in the . + /// + /// The in which tokens are saved. + /// The OpenID Connect response. + private static void SaveTokens(AuthenticationProperties properties, OpenIdConnectMessage message) + { + if (!string.IsNullOrEmpty(message.AccessToken)) + { + properties.Dictionary[OpenIdConnectParameterNames.AccessToken] = message.AccessToken; + } + + if (!string.IsNullOrEmpty(message.IdToken)) + { + properties.Dictionary[OpenIdConnectParameterNames.IdToken] = message.IdToken; + } + + if (!string.IsNullOrEmpty(message.RefreshToken)) + { + properties.Dictionary[OpenIdConnectParameterNames.RefreshToken] = message.RefreshToken; + } + + if (!string.IsNullOrEmpty(message.TokenType)) + { + properties.Dictionary[OpenIdConnectParameterNames.TokenType] = message.TokenType; + } + + if (!string.IsNullOrEmpty(message.ExpiresIn)) + { + int value; + if (int.TryParse(message.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + var expiresAt = DateTime.UtcNow + TimeSpan.FromSeconds(value); + // https://www.w3.org/TR/xmlschema-2/#dateTime + // https://msdn.microsoft.com/en-us/library/az4se3k1(v=vs.110).aspx + properties.Dictionary["expires_at"] = expiresAt.ToString("o", CultureInfo.InvariantCulture); + } + } + } + + // Note this modifies properties if Options.UseTokenLifetime + private ClaimsPrincipal ValidateToken(string idToken, AuthenticationProperties properties, TokenValidationParameters validationParameters, out JwtSecurityToken jwt) + { + + if (!Options.SecurityTokenValidator.CanReadToken(idToken)) + { + _logger.WriteError(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateToken, idToken)); + throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateToken, idToken)); + } + + if (_configuration != null) + { + var issuer = new[] { _configuration.Issuer }; + validationParameters.ValidIssuers = validationParameters.ValidIssuers == null ? issuer : validationParameters.ValidIssuers.Concat(issuer); + + validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys == null ? _configuration.SigningKeys + : validationParameters.IssuerSigningKeys.Concat(_configuration.SigningKeys); + } + + SecurityToken validatedToken; + var principal = Options.SecurityTokenValidator.ValidateToken(idToken, validationParameters, out validatedToken); + jwt = validatedToken as JwtSecurityToken; + if (jwt == null) + { + var tokenType = validatedToken != null ? validatedToken.GetType().ToString() : null; + _logger.WriteError(string.Format(CultureInfo.InvariantCulture, Resources.ValidatedSecurityTokenNotJwt, tokenType)); + throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.ValidatedSecurityTokenNotJwt, tokenType)); + } + + if (validatedToken == null) + { + _logger.WriteError(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateToken, idToken)); + throw new SecurityTokenException(string.Format(CultureInfo.InvariantCulture, Resources.UnableToValidateToken, idToken)); + } + + if (Options.UseTokenLifetime) + { + // Override any session persistence to match the token lifetime. + DateTime issued = jwt.ValidFrom; + if (issued != DateTime.MinValue) + { + properties.IssuedUtc = issued.ToUniversalTime(); + } + DateTime expires = jwt.ValidTo; + if (expires != DateTime.MinValue) + { + properties.ExpiresUtc = expires.ToUniversalTime(); + } + properties.AllowRefresh = false; + } + + return principal; + } + private AuthenticationProperties GetPropertiesFromState(string state) { // assume a well formed query string: OpenIdConnectAuthenticationDefaults.AuthenticationPropertiesKey=kasjd;fljasldkjflksdj<&c=d> @@ -574,6 +839,41 @@ private AuthenticationProperties GetPropertiesFromState(string state) } } + private void PopulateSessionProperties(OpenIdConnectMessage message, AuthenticationProperties properties) + { + // remember 'session_state' and 'check_session_iframe' + if (!string.IsNullOrWhiteSpace(message.SessionState)) + { + properties.Dictionary[OpenIdConnectSessionProperties.SessionState] = message.SessionState; + } + + if (!string.IsNullOrWhiteSpace(_configuration.CheckSessionIframe)) + { + properties.Dictionary[OpenIdConnectSessionProperties.CheckSessionIFrame] = _configuration.CheckSessionIframe; + } + } + + private OpenIdConnectProtocolException CreateOpenIdConnectProtocolException(OpenIdConnectMessage message) + { + var description = message.ErrorDescription ?? "error_description is null"; + var errorUri = message.ErrorUri ?? "error_uri is null"; + + var errorMessage = string.Format( + CultureInfo.InvariantCulture, + Resources.Exception_OpenIdConnectMessageError, + message.Error, + description, + errorUri); + + _logger.WriteError(errorMessage); + + var ex = new OpenIdConnectProtocolException(errorMessage); + ex.Data["error"] = message.Error; + ex.Data["error_description"] = description; + ex.Data["error_uri"] = errorUri; + return ex; + } + /// /// Calls InvokeReplyPathAsync /// diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/Resources.Designer.cs b/src/Microsoft.Owin.Security.OpenIdConnect/Resources.Designer.cs index efc77447..87e5d342 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/Resources.Designer.cs +++ b/src/Microsoft.Owin.Security.OpenIdConnect/Resources.Designer.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.35317 +// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -19,7 +19,7 @@ namespace Microsoft.Owin.Security.OpenIdConnect { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { @@ -95,5 +95,23 @@ internal static string Exception_ValidatorHandlerMismatch { return ResourceManager.GetString("Exception_ValidatorHandlerMismatch", resourceCulture); } } + + /// + /// Looks up a localized string similar to Unable to validate the 'id_token', no suitable ISecurityTokenValidator was found for: '{0}'.". + /// + internal static string UnableToValidateToken { + get { + return ResourceManager.GetString("UnableToValidateToken", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Validated Security Token must be of type JwtSecurityToken, but instead its type is: '{0}'.. + /// + internal static string ValidatedSecurityTokenNotJwt { + get { + return ResourceManager.GetString("ValidatedSecurityTokenNotJwt", resourceCulture); + } + } } } diff --git a/src/Microsoft.Owin.Security.OpenIdConnect/Resources.resx b/src/Microsoft.Owin.Security.OpenIdConnect/Resources.resx index 7abad90e..a10fdaaf 100644 --- a/src/Microsoft.Owin.Security.OpenIdConnect/Resources.resx +++ b/src/Microsoft.Owin.Security.OpenIdConnect/Resources.resx @@ -129,4 +129,10 @@ An ICertificateValidator cannot be specified at the same time as an HttpMessageHandler unless it is a WebRequestHandler. + + Unable to validate the 'id_token', no suitable ISecurityTokenValidator was found for: '{0}'." + + + The Validated Security Token must be of type JwtSecurityToken, but instead its type is: '{0}'. + \ No newline at end of file diff --git a/tests/Katana.Sandbox.WebServer/Katana.Sandbox.WebServer.csproj b/tests/Katana.Sandbox.WebServer/Katana.Sandbox.WebServer.csproj index 01ef6161..62edb256 100644 --- a/tests/Katana.Sandbox.WebServer/Katana.Sandbox.WebServer.csproj +++ b/tests/Katana.Sandbox.WebServer/Katana.Sandbox.WebServer.csproj @@ -23,6 +23,7 @@ 12.0 + true @@ -51,6 +52,8 @@ ..\..\packages\Microsoft.IdentityModel.Logging.5.3.0\lib\net45\Microsoft.IdentityModel.Logging.dll True + + ..\..\packages\Microsoft.IdentityModel.Tokens.5.3.0\lib\net45\Microsoft.IdentityModel.Tokens.dll True diff --git a/tests/Katana.Sandbox.WebServer/Startup.cs b/tests/Katana.Sandbox.WebServer/Startup.cs index 2a89815d..bd771020 100644 --- a/tests/Katana.Sandbox.WebServer/Startup.cs +++ b/tests/Katana.Sandbox.WebServer/Startup.cs @@ -131,8 +131,29 @@ public void Configuration(IAppBuilder app) { Authority = Environment.GetEnvironmentVariable("oidc:authority"), ClientId = Environment.GetEnvironmentVariable("oidc:clientid"), + ClientSecret = Environment.GetEnvironmentVariable("oidc:clientsecret"), RedirectUri = "https://localhost:44318/", - CookieManager = new SystemWebCookieManager() + CookieManager = new SystemWebCookieManager(), + //ResponseType = "code", + //ResponseMode = "query", + //SaveTokens = true, + //Scope = "openid profile offline_access", + //RedeemCode = true, + //Notifications = new Microsoft.Owin.Security.OpenIdConnect.OpenIdConnectAuthenticationNotifications + //{ + // AuthorizationCodeReceived = async n => + // { + // var _configuration = await n.Options.ConfigurationManager.GetConfigurationAsync(n.OwinContext.Request.CallCancelled); + // var requestMessage = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Post, _configuration.TokenEndpoint); + // requestMessage.Content = new System.Net.Http.FormUrlEncodedContent(n.TokenEndpointRequest.Parameters); + // var responseMessage = await n.Options.Backchannel.SendAsync(requestMessage); + // responseMessage.EnsureSuccessStatusCode(); + // var responseContent = await responseMessage.Content.ReadAsStringAsync(); + // Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectMessage message = new Microsoft.IdentityModel.Protocols.OpenIdConnect.OpenIdConnectMessage(responseContent); + + // n.HandleCodeRedemption(message); + // } + //} }); /*