Search code examples
c#asp.net-mvcoauth-2.0oauthasp.net-mvc-5

I get cookie instead of token with authorization code grant


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?

Solution

  • 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);
            }
        }
    }