0

Summary

I have ASP.NET MVC 5 web app with Identity authentication and I have to develop an API with "grant_type" = "authorization_code". This API will be to provide users data to another "well-known" web service that needs a custom error responses. My IDE is Visual Studio Professional 2017. I use Postman to make requests to my Web API.

Documentation I read

In the OWIN and Katana documentation the OWIN OAuth 2.0 Authorization Server link redirects again to main OWIN and Katana page, but I think that I found the source on GitHub: OWIN OAuth 2.0 Authorization Server. I tried to follow this documentation, but there are no examples about this question.

Problem

I can create a new authorization code in my AuthorizationCodeProvider class (with Create() method) when a user authenticates and authorizes the "well-known" web service client to access user's resources. I store this code in a database. When I request a Token AuthorizationCodeProvider.Receive() method is called and the token is deserialized correctly. Then GrantAuthorizationCode() method is called, Postman receives OK response (200 status code) but without token information in body (.AspNet.ApplicationCookie is in cookies).

Detailed explanation and code

This is the Startup class:

public partial class Startup
{
    public static OAuthAuthorizationServerOptions OAuthOptions { get; private set; }

    public void ConfigureAuth(IAppBuilder app)
    {
        app.CreatePerOwinContext(ApplicationDbContext.Create);
        app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
        app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);
        app.UseCookieAuthentication(new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login"),
            Provider = new CookieAuthenticationProvider
            {
                OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
                    validateInterval: TimeSpan.FromMinutes(30),
                    regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager)),
                OnApplyRedirect = (context =>
                {
                    // This code is to return custom error response
                    string path = null;
                    if (context.Request.Path.HasValue)
                        path = context.Request.Path.Value;
                    if (!(path != null && path.Contains("/api"))) // Don't redirect to login page
                        context.Response.Redirect(context.RedirectUri);
                })
            }
        });            
        app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);
        app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5));         
        app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);

        this.ConfigureAuthorization(app);
    }
    
    private void ConfigureAuthorization(IAppBuilder app)
    {
        app.UseCors(Microsoft.Owin.Cors.CorsOptions.AllowAll);

        OAuthOptions = new OAuthAuthorizationServerOptions
        {
            AllowInsecureHttp = false,
            TokenEndpointPath = new PathString("/api/token"),
            AccessTokenExpireTimeSpan = TimeSpan.FromDays(1),
            Provider = new TokenAuthorizationServerProvider(),
            AuthorizationCodeProvider = new AuthorizationCodeProvider()
        };            
        app.Use<AuthenticationMiddleware>(); //Customize responses in Token middleware
        app.UseOAuthAuthorizationServer(OAuthOptions);
        app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions());
    }
}

ConfigureAuthorization() method configures the authorization. It uses classes implemented by me:

AuthenticationMiddleware: the well-known web service wants 401 status responses with custom error JONS instead of the usual 400 status response. It is based on the answer of the question Replace response body using owin middleware.

public class AuthenticationMiddleware : OwinMiddleware
{
    public AuthenticationMiddleware(OwinMiddleware next) : base(next) { }

    public override async Task Invoke(IOwinContext context)
    {
        var owinResponse = context.Response;
        var owinResponseStream = owinResponse.Body;
        var responseBuffer = new MemoryStream();
        owinResponse.Body = responseBuffer;

        await Next.Invoke(context);

        if (context.Response.StatusCode == (int)HttpStatusCode.BadRequest &&
            context.Response.Headers.ContainsKey(BearerConstants.CustomUnauthorizedHeaderKey))
        {
            context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;

            string headerValue = context.Response.Headers.Get(BearerConstants.CustomUnauthorizedHeaderKey);
            context.Response.Headers.Remove(BearerConstants.CustomUnauthorizedHeaderKey);

            ErrorMessage errorMessage = new ErrorMessage(headerValue);
            string json = JsonConvert.SerializeObject(errorMessage, Formatting.Indented);

            var customResponseBody = new StringContent(json);
            var customResponseStream = await customResponseBody.ReadAsStreamAsync();
            await customResponseStream.CopyToAsync(owinResponseStream);

            owinResponse.ContentType = "application/json";
            owinResponse.ContentLength = customResponseStream.Length;
            owinResponse.Body = owinResponseStream;
        }
    }
}

When ErrorMessage is serialized to JSON returns an array of errors:

{
  "errors":
  [
    "message": "the error message"
  ]
}

I set the BearerConstants.CustomUnauthorizedHeaderKey header in TokenAuthorizationServerProvider.ValidateClientAuthentication() method using a extension method:

public static void Rejected(this OAuthValidateClientAuthenticationContext context, string message)
{
    Debug.WriteLine($"\t\t{message}");
    context.SetError(message);
    context.Response.Headers.Add(BearerConstants.CustomUnauthorizedHeaderKey, new string[] { message });
    context.Rejected();
}

This is how TokenAuthorizationServerProvider is implemented:

public class TokenAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
    public override Task AuthorizeEndpoint(OAuthAuthorizeEndpointContext context)
    {
        // Only for breakpoint. Never stops.
        return base.AuthorizeEndpoint(context);
    }

    public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
    {
        // Check if grant_type is authorization_code
        string grantType = context.Parameters[BearerConstants.GrantTypeKey];
        if (string.IsNullOrEmpty(grantType) || grantType != BearerConstants.GrantTypeAuthorizationCode)
        {
            context.Rejected("Invalid grant type"); // Sets header for custom response
            return;
        }

        // Check if client_id and client_secret are in the request
        string clientId = context.Parameters[BearerConstants.ClientIdKey];
        string clientSecret = context.Parameters[BearerConstants.ClientSecretKey];
        if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
        {
            context.Rejected("Client credentials missing"); // Sets header for custom response
            return;
        }

        //Check if client_id and client_secret are valid
        ApiClient client = await (new ApiClientService()).ValidateClient(clientId, clientSecret);
        if (client != null)
        {
            // Client has been verified.
            Debug.WriteLine($"\t\tClient has been verified");
            context.OwinContext.Set<ApiClient>("oauth:client", client);
            context.Validated(clientId);
        }
        else
        {
            // Client could not be validated.
            context.Rejected("Invalid client"); // Sets header for custom response
        }
    }

    public override async Task GrantAuthorizationCode(OAuthGrantAuthorizationCodeContext context)
    {
        TokenRequestParameters parameters = await context.Request.GetBodyParameters();

        using (IUserService userService = new UserService())
        {
            ApplicationUser user = await userService.ValidateUser(parameters.Code);
            if (user == null)
            {
                context.Rejected("Invalid code");
                return;
            }
            // Initialization.  
            var claims = new List<Claim>();

            // Setting  
            claims.Add(new Claim(ClaimTypes.Name, user.UserName));

            // Setting Claim Identities for OAUTH 2 protocol.  
            ClaimsIdentity oAuthClaimIdentity = new ClaimsIdentity(claims, OAuthDefaults.AuthenticationType);
            ClaimsIdentity cookiesClaimIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationType);

            // Setting user authentication.
            IDictionary<string, string> data = new Dictionary<string, string>{ { "userName", user.UserName } };
            AuthenticationProperties properties = new AuthenticationProperties(data);
            AuthenticationTicket ticket = new AuthenticationTicket(oAuthClaimIdentity, properties);

            // Grant access to authorize user.  
            context.Validated(ticket);
            context.Request.Context.Authentication.SignIn(cookiesClaimIdentity);
        }
    }
}

ApiClientService.ValidateClient() checks on database that cliend ID and Secret are correct.

GrantAuthorizationCode() is based on the step 8 from ASP.NET MVC - OAuth 2.0 REST Web API Authorization Using Database First Approach tutorial. But this tutorial for grant_type = password and I think that something is wrong in here.

And the AuthorizationCodeProvider class:

public class AuthorizationCodeProvider : AuthenticationTokenProvider
{
    public override void Create(AuthenticationTokenCreateContext context)
    {
        AuthenticationTicket ticket = context.Ticket;            
        string serializedTicket = context.SerializeTicket();
        context.SetToken(serializedTicket);
    }

    public override void Receive(AuthenticationTokenReceiveContext context)
    {
        context.DeserializeTicket(context.Token);
        // At this point context.Ticket.Identity.IsAuthenticated is true
    }
}

I call to create method from the AuthorizationController that shows the Allow/Deny view. It is decorated with System.Web.Mvc.Authorize attribute, so if the user isn't authenticated he or she has to login using the default login page from MVC template project (/account/login):

[Authorize]
public class AuthorizationController : Controller
{
    private const string ServiceScope = "service-name";

    [HttpGet]
    public async Task<ActionResult> Index(string client_id, string response_type, string redirect_uri, string scope, string state)
    {
        AuthorizationViewModel vm = new AuthorizationViewModel()
        {
            ClientId = client_id,
            RedirectUri = redirect_uri,
            Scope = scope,
            State = state
        };

        if (scope == ServiceScope)
        {
            var authentication = HttpContext.GetOwinContext().Authentication;
            authentication.SignIn(
                new AuthenticationProperties { IsPersistent = true, RedirectUri = redirect_uri },
                new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, User.Identity.Name) },
                "Bearer"));
        }

        return View(vm);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    [MultiButton(MatchFormKey = "authorization", MatchFormValue = "Allow")]
    public async Task<ActionResult> Allow(AuthorizationViewModel vm)
    {
        if (ModelState.IsValid)
        {
            string code = await this.SetAuthorizationCode(vm.ClientId, vm.RedirectUri);
            if (vm.Scope == ServiceScope)
            {
                string url = $"{vm.RedirectUri}?code={code}&state={vm.State}";
                return Redirect(url);
            }
            else
            {
                return Redirect(vm.RedirectUri);
            }
        }
        return View(vm);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    [MultiButton(MatchFormKey = "authorization", MatchFormValue = "Deny")]
    public async Task<ActionResult> Deny(AuthorizationViewModel vm)
    {
        // Removed for brevity
        return View(vm);
    }

    private async Task<string> SetAuthorizationCode(string clientId, string redirectUri)
    {
        string userId = User.Identity.GetUserId();
        ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(clientId, OAuthDefaults.AuthenticationType));
        AuthenticationTokenCreateContext authorizeCodeContext = new AuthenticationTokenCreateContext(
            HttpContext.GetOwinContext(),
            Startup.OAuthOptions.AuthorizationCodeFormat,
            new AuthenticationTicket(
                identity,
                new AuthenticationProperties(new Dictionary<string, string>
                {
                    { "user_id", userId },
                    { "client_id", clientId },
                    { "redirect_uri", redirectUri }
                })
                {
                    IssuedUtc = DateTimeOffset.UtcNow,
                    ExpiresUtc = DateTimeOffset.UtcNow.Add(Startup.OAuthOptions.AuthorizationCodeExpireTimeSpan)
                }));

        Startup.OAuthOptions.AuthorizationCodeProvider.Create(authorizeCodeContext);
        string code = authorizeCodeContext.Token;

        IUserService userService = new UserService();
        await userService.SetAuthorization(userId, true, code); // save to database
        userService.Dispose();

        return code;
    }
}

The authorization code is created in SetAuthorizationCode() method, which is called in Allow() action. This SetAuthorizationCode() method code is based on this answer.

Questions

I now that is very long with a lot of code, but I'm stuck for some days and I didn't find the solution. I don't know the complete flow of the authorization, I think that I'm missing something.

  • What happens when I call /api/token? I mean, what are the steps in this part of the authentication/authorization flow?
  • What happens after AuthorizationCodeProvider.GrantAuthorizationCode()?
  • Why a cookie returned instead of token in the body?
Jon
  • 891
  • 13
  • 32
  • I would use a sniffer like wireshark or fiddler and check if the version of TLS is always the same. IN June Microsoft did a security push and disabled TLS 1.0/1.1 on server. Client have to be modified to use TLS 1.2/1.3. It may be you are still using TLS 1.0/1.1 when you do not have a cookie. what sometimes happens is when you use Postman a cookie is crreated and then when you work with c# it uses the same cookie. But in c# when you try to run without cookie it fails. The c# http default headers are different than Postman and you may be missing a header in c#. – jdweng Nov 23 '20 at 13:43
  • Hello jdweng, in Postman, in all responses I have a warning about TLS "Unable to verify the firts certificate", I think because I'm working in localhost, but I can see that in all responses the TLS protocol is "TLSv1.2". – Jon Nov 23 '20 at 14:37
  • One of the TLS messages is the Certificate Block that gives the names of all the certificates the server accepts.The certificate has to be loaded for both the machine and the user. When the client receives the Certificate Block it looks up the certificate in the user certificate stores.Doesn't matter if you are working with a localhost or a remove machine. The process is the same.TLS is performed before the HTTP message is sent. So if TLS fails you would not get a response like 200, or 400. If you are getting 200, or 400 it means the certificate worked. 400 means something else is wrong. – jdweng Nov 23 '20 at 14:58
  • In this case I have a 200 when I request a token – Jon Nov 23 '20 at 15:40
  • Does code work when you get a 200 OK response? – jdweng Nov 23 '20 at 15:54
  • The server responses with a cookie, but the client needs a token in the body. So my code is wrong (does not crash), but I don't know what is the mistake – Jon Nov 23 '20 at 17:38
  • Any text in the response message? – jdweng Nov 23 '20 at 19:23
  • No, it is empty – Jon Nov 24 '20 at 06:50

1 Answers1

0

I found the solution of the problem, it was the AuthenticationMiddleware. Once the body of the response is read, it remains empty and does not reach the client. So you have to rewrite the response body.

public class AuthenticationMiddleware : OwinMiddleware
{
    public AuthenticationMiddleware(OwinMiddleware next) : base(next) { }
    
    public override async Task Invoke(IOwinContext context)
    {
        var owinResponse = context.Response;
        var owinResponseStream = owinResponse.Body;
        var responseBuffer = new MemoryStream();
        owinResponse.Body = responseBuffer;
        
        await Next.Invoke(context);
        
        if (context.Response.StatusCode == (int)HttpStatusCode.BadRequest &&
            context.Response.Headers.ContainsKey(BearerConstants.CustomUnauthorizedHeaderKey))
        {
            // Customize the response
        }
        else
        {
            // Set body again with the same content
            string body = Encoding.UTF8.GetString(responseBuffer.ToArray());
            StringContent customResponseBody = new StringContent(body);
            Stream customResponseStream = await customResponseBody.ReadAsStreamAsync();
            await customResponseStream.CopyToAsync(owinResponseStream);
        }
    }
}
Jon
  • 891
  • 13
  • 32