aspnet / AspNetKatana

Microsoft's OWIN implementation, the Katana project
Apache License 2.0
968 stars 334 forks source link

How to handle response_type=form_post in OAuthAuthorizationServerProvider.AuthorizationEndpointResponse #467

Closed nielsedens closed 1 year ago

nielsedens commented 2 years ago

Hello,

I'm trying to implement the OAuth authorize endpoint including support for the response_type=form_post.

By default the OAuthAuthorizationServerHandler.ApplyResponseGrantAsync method sends a redirect HTTP response, with a redirect location that is the configured OAuthAuthorizationServerOptions.FormPostEndpoint. According to https://docs.microsoft.com/en-us/previous-versions/aspnet/mt180788(v=vs.113) this endpoint is responsible to send the HTML to the client/browser that will post back the authorize response data (i.e. the authorization code or access code, depending on the flow, plus additional data; https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html). The OAuthAuthorizationServerHandler.ApplyResponseGrantAsync method sends this redirect just after calling the IOAuthAuthorizationServerProvider.AuthorizationEndpointResponse.

Why is the OAuthAuthorizationServerHandler.ApplyResponseGrantAsync sending this redirect, instead of sending a form post response immediately?

I would like to prevent this redirect to the FormPostEndPoint and send the form post HTML immediately to the client/browser IOAuthAuthorizationServerProvider.AuthorizationEndpointResponse. I can write the required HTML body to OAuthAuthorizationEndpointResponseContext.Response, but the redirect is still happening. Calling the context.RequestCompleted() does not help, because OAuthAuthorizationServerHandler.ApplyResponseGrantAsync ignores this. The redirect response sent to the client does contain the body, but is still a 302 response including a location header, rather than a 200 response.

My implementation of AuthorizationEndpointResponse is currently

        public override Task AuthorizationEndpointResponse(OAuthAuthorizationEndpointResponseContext context)
        {
            NameValueCollection _returnParams = HttpUtility.ParseQueryString(string.Empty);
            foreach (KeyValuePair<string, object> _parameter in context.AdditionalResponseParameters)
            {
                _returnParams[_parameter.Key] = _parameter.Value.ToString();
            }

            _returnParams[IdentityProviderConstants.Parameters.Code] = context.AuthorizationCode;

            if (!string.IsNullOrEmpty(context.AuthorizeEndpointRequest.State))
            {
                _returnParams[IdentityProviderConstants.Parameters.State] = context.AuthorizeEndpointRequest.State;
            }

            if (context.AuthorizeEndpointRequest.IsFormPostResponseMode)
            {
                StringBuilder _inputFields = new StringBuilder();
                foreach (string _returnParam in _returnParams.AllKeys)
                {
                    foreach (string _value in _returnParams.GetValues(_returnParam))
                    {
                        _inputFields.AppendLine($@"<input type=""hidden"" name=""{_returnParam}"" value=""{_value}""/>");
                    }
                }
                StringBuilder _responseBody = new StringBuilder(
                    $@"
<html>
  <head><title>Submit This Form</title></head>
  <body onload=""javascript:document.forms[0].submit()"">
     <form method=""post"" action=""{context.AuthorizeEndpointRequest.RedirectUri}"">
{_inputFields}
     </form>
  </body>
</html>");
                context.Response.Write(_responseBody.ToString());
                context.RequestCompleted();
            }
            else
            {
                UriBuilder _redirectUri = new UriBuilder(context.AuthorizeEndpointRequest.RedirectUri);
                _redirectUri.Query = _returnParams.ToString();
                context.Response.Redirect(_redirectUri.Uri.AbsoluteUri);
                context.RequestCompleted();
            }

            return Task.CompletedTask;
        }

Any feedback is appreciated.

Tratcher commented 2 years ago

These OAuthAuthorizationServer components are outdated and we do not recommend using them anymore. There are better community implementations available like IdentityServer, or you can use a hosted solution like Azure Active Directory.

nielsedens commented 2 years ago

Thank you for your response @Tratcher.

Replacing the OAuthAuthorizationServer components at this point is not really a cost effective solution. Beside the work, a component like IdentityServer is also no longer maintained as open source / free software. Maybe OpenIddict will be a candidate when updating our software to .NET 6.0.

For now I have found a workaround using the IOwinResponse.OnSendingHeaders.

public override Task AuthorizationEndpointResponse(OAuthAuthorizationEndpointResponseContext context)
{
    Uri _uri = new Uri(context.AuthorizeEndpointRequest.RedirectUri);
    NameValueCollection _returnParams = HttpUtility.ParseQueryString(_uri.Query);
    foreach (KeyValuePair<string, object> _parameter in context.AdditionalResponseParameters)
    {
        _returnParams[_parameter.Key] = _parameter.Value.ToString();
    }

    _returnParams[IdentityProviderConstants.Parameters.Code] = context.AuthorizationCode;

    if (!string.IsNullOrEmpty(context.AuthorizeEndpointRequest.State))
    {
        _returnParams[IdentityProviderConstants.Parameters.State] = context.AuthorizeEndpointRequest.State;
    }

    // If 'response_mode=form_post' was send, handle returning the appropriate response here; see
    // https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html. We need to handle it here,
    // because the caller of this method (`OAuthAuthorizationServerHandler.ApplyResponseGrantAsync`)
    // will issue a redirect to `context.Options.FormPostEndpoint`, which we want to prevent. For
    // other response modes let the caller handle the response.
    if (context.AuthorizeEndpointRequest.IsFormPostResponseMode)
    {
        StringBuilder _inputFields = new StringBuilder();
        foreach (string _returnParam in _returnParams.AllKeys)
        {
            foreach (string _value in _returnParams.GetValues(_returnParam) ?? Array.Empty<string>())
            {
                _inputFields.AppendLine($@"<input type=""hidden"" name=""{_returnParam}"" value=""{_value}""/>");
            }
        }

        StringBuilder _responseBody = new StringBuilder(
            $@"
<html>
  <head><title>Submit This Form</title></head>
  <body onload=""javascript:document.forms[0].submit()"">
     <form method=""post"" action=""{context.AuthorizeEndpointRequest.RedirectUri}"">
{_inputFields.ToString().TrimEnd()}
     </form>
  </body>
</html>");
        context.Response.Write(_responseBody.ToString());
        context.Response.OnSendingHeaders(
            state =>
                {
                    if (state is OAuthAuthorizationEndpointResponseContext _context)
                    {
                        // Undo the 'redirect' set by the caller of `AuthorizationEndpointResponse`, i.e.
                        // `OAuthAuthorizationServerHandler.ApplyResponseGrantAsync`.
                        _context.Response.StatusCode = 200;
                        _context.Response.Headers.Remove("Location");
                    }
                },
            context);
        context.RequestCompleted();
    }

    return Task.CompletedTask;
}
kevinchalet commented 2 years ago

Maybe OpenIddict will be a candidate when updating our software to .NET 6.0.

It's worth noting OpenIddict is now natively compatible with Microsoft.Owin (support was added in 3.0 to allow OAuthAuthorizationServerMiddleware and OpenIdConnectServerMiddleware users to modernize their OAuth 2.0/OIDC implementation without being forced to migrate to ASP.NET Core).