Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#

ROPC and Refresh Token with ASP.NET Core Identity

5.00/5 (2 votes)
12 Jul 2024CPOL4 min read 6.2K  
Implement Resource Owner Password Credentials Grant and Refreshing Token with ASP.NET Core Identity through strongly typed API
Implement section 4.3 and 6 of The OAuth 2.0 Authorization Framework (RFC6749)

Introduction

The scaffolding codes of ASP.NET Identity and ASP.NET Core Identity have provided a basic framework for user/name password login as well as interfacing with 3rd authentication providers like Google, Facebook and Apple etc. This article introduce:

  1. Single API endpoint for both ROPC and refreshing token, conforming to section 4.3 and section 6 of  the OAuth 2.0 Authorization Framework (RFC6749)
  2. The token API is strongly typed.

The authorization server is "Core3WebApi", and in particular, the auth endpoint is "AuthController.cs".

+----------+
| Resource |
|  Owner   |
|          |
+----------+
     v
     |    Resource Owner
    (A) Password Credentials
     |
     v
+---------+                                  +---------------+
|         |>--(B)---- Resource Owner ------->|               |
|         |         Password Credentials     | Authorization |
| Client  |                                  |     Server    |
|         |<--(C)---- Access Token ---------<|               |
|         |    (w/ Optional Refresh Token)   |               |
+---------+                                  +---------------+

Remarks:

  • This article covers only "public client" of "user-agent-based application".

Background

This article is to follow up the articles below:

  1. Decouple ASP.NET Core Identity, Authentication and Database Engines
  2. Share Identity Bearer Tokens among ASP.NET Core Web APIs

Using the code

In the payload examples of RFC6749 and many implementations of OAuth2, the token payloads are all through a single endpoint "/token".

When crafting ASP.NET Core Web API, technically you may just use a weak and dynamic type "object" when crafting your ASP.NET Core Web API, as you do in JavaScript programing. And this article introduce "polymorphic model binding" to be used in the token endpoint, with the "application/x-www-form-urlencoded" request.

Token Request Models

As what described in Section 4.3.2 and Section 6:

C#
[DataContract]
public class RequestBase
{
    [Required]
    [JsonPropertyName("grant_type")]
    [JsonPropertyOrder(-10)]
    [DataMember(Name = "grant_type")]
    public string grant_type { get; protected set; }
}

/// <summary>
/// Section 4.3 and 4.3.2.
/// GrantType must be Value MUST be set to "password".
/// </summary>
[DataContract]
public class ROPCRequst : RequestBase
{
    public ROPCRequst()
    {
        grant_type = "password";
    }

    [Required]
    [DataMember]
    public string Username { get; set; }

    [Required]
    [DataMember]
    public string Password { get; set; }

    [DataMember]
    public string Scope { get; set; }

}

/// <summary>
/// Section 6
/// Grant type MUST be set to "refresh_token".
/// </summary>
[DataContract]
public class RefreshAccessTokenRequest : RequestBase
{
    public RefreshAccessTokenRequest()
    {
        grant_type = "refresh_token";
    }

    [Required]
    [JsonPropertyName("refresh_token")]
    [DataMember(Name = "refresh_token")]
    public string refresh_token { get; set; }

    [DataMember]
    public string Scope { get; set; }
}

Token Endpoint

AuthController.cs:

C#
[AllowAnonymous]
[Consumes("application/x-www-form-urlencoded")] // redundant generally because of FromForm below
[HttpPost]
public async Task<ActionResult<TokenResponseModel>> Authenticate([FromForm] RequestBase model)
{
    if (model is ROPCRequst)
    {
        ROPCRequst ropcRequest = model as ROPCRequst;
        ApplicationUser user = await UserManager.FindByNameAsync(ropcRequest.Username);
        if (user == null)
        {
            return Unauthorized(new { message = "Username or password is invalid" });
        }

        bool passwordIsCorrect = await UserManager.CheckPasswordAsync(user, ropcRequest.Password);
        if (!passwordIsCorrect)
        {
            return Unauthorized(new { message = "Username or password is incorrect" });
        }

        var tokenHelper = new UserTokenHelper(UserManager, symmetricSecurityKey, authSettings);
        return await tokenHelper.GenerateJwtToken(user, ropcRequest.Username, Guid.Empty); //todo: some apps may need to deal with scope
    }
    else if (model is RefreshAccessTokenRequest refreshAccessTokenRequest)
    {
        if (AuthenticationHeaderValue.TryParse(Request.Headers.Authorization, out var headerValue)){
            var scehma = headerValue.Scheme;
            Debug.Assert("bearer".Equals(scehma, StringComparison.OrdinalIgnoreCase));
            var accessToken = headerValue.Parameter;
            var jwtSecurityToken = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
            var uniqueNameClaim = jwtSecurityToken.Claims.Single(d => d.Type == "unique_name");
            var username = uniqueNameClaim.Value;
            var user = await UserManager.FindByNameAsync(username);

            if (user == null)
            {
                return BadRequest(new { message = "Username or password is invalid" });
            }

            var tokenHelper = new UserTokenHelper(UserManager, symmetricSecurityKey, authSettings);
            var tokenTextExisting = await tokenHelper.MatchToken(user, "RefreshToken", refreshAccessTokenRequest.refresh_token, Guid.Empty);
            if (!tokenTextExisting)
            {
                return StatusCode(401, new { message = "Invalid to retrieve token through refreshToken" }); // message may be omitted in prod build, to avoid exposing implementation details.
            }

            return await tokenHelper.GenerateJwtToken(user, username, Guid.Empty);
        }
    }

    throw new NotSupportedException();
}

Polymorphic Model Binding

The technical detail of polymorphic model binding is out of the scope of this article, please google "ASP.NET Core polymorphic model binding" for good articles.

OAuth2RequestBinderProvider.cs:

C#
public class OAuth2RequestBinderProvider : Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType != typeof(RequestBase))
        {
            return null;
        }

        var subclasses = new[] { typeof(ROPCRequst), typeof(RefreshAccessTokenRequest), };

        var binders = new Dictionary<Type, (ModelMetadata, IModelBinder)>();
        foreach (var type in subclasses)
        {
            var modelMetadata = context.MetadataProvider.GetMetadataForType(type);
            binders[type] = (modelMetadata, context.CreateBinder(modelMetadata));
        }

        return new RequestModelBinder(binders);
    }
}

public class RequestModelBinder : IModelBinder
{
    private Dictionary<Type, (ModelMetadata, IModelBinder)> binders;

    public RequestModelBinder(Dictionary<Type, (ModelMetadata, IModelBinder)> binders)
    {
        this.binders = binders;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext.HttpContext.Request.ContentType.Contains("application/json"))
        {
            return;
        }

        var modelKindName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, "grant_type");
        var modelTypeValue = bindingContext.ValueProvider.GetValue(modelKindName).FirstValue;

        IModelBinder modelBinder;
        ModelMetadata modelMetadata;
        if (modelTypeValue == "password")
        {
            (modelMetadata, modelBinder) = binders[typeof(ROPCRequst)];
        }
        else if (modelTypeValue == "refresh_token")
        {
            (modelMetadata, modelBinder) = binders[typeof(RefreshAccessTokenRequest)];
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        var newBindingContext = DefaultModelBindingContext.CreateBindingContext(
            bindingContext.ActionContext,
            bindingContext.ValueProvider,
            modelMetadata,
            bindingInfo: null,
            bindingContext.ModelName);

        await modelBinder.BindModelAsync(newBindingContext);
        bindingContext.Result = newBindingContext.Result;

        if (newBindingContext.Result.IsModelSet)
        {
            bindingContext.ValidationState[newBindingContext.Result.Model] = new ValidationStateEntry
            {
                Metadata = modelMetadata,
            };
        }
    }
}

And then register the provider in the startup codes:

C#
builder.Services.AddControllers(configure =>
{
    configure.ModelBinderProviders.Insert(0, new OAuth2RequestBinderProvider());
})

Client API Codes to Request Tokens

AuthClient.cs:

C#
public class AuthClient
{
    private System.Net.Http.HttpClient client;

    private JsonSerializerOptions jsonSerializerSettings;

    public AuthClient(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings = null)
    {
        if (client == null)
            throw new ArgumentNullException(nameof(client), "Null HttpClient.");

        if (client.BaseAddress == null)
            throw new ArgumentNullException(nameof(client), "HttpClient has no BaseAddress");

        this.client = client;
        this.jsonSerializerSettings = jsonSerializerSettings;
    }

    public async Task<Fonlow.Auth.Models.Client.AccessTokenResponse> PostRopcTokenRequestAsFormDataToAuthAsync(Fonlow.Auth.Models.Client.ROPCRequst model, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "token";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri);
        var pairs = new KeyValuePair<string, string>[]
                    {
                        new KeyValuePair<string, string>( "grant_type", model.grant_type ),
                        new KeyValuePair<string, string>( "username", model.Username ),
                        new KeyValuePair<string, string> ( "password", model.Password )
                    };
        var content = new FormUrlEncodedContent(pairs);
        httpRequestMessage.Content = content;
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        using var responseMessage = await client.SendAsync(httpRequestMessage);
        responseMessage.EnsureSuccessStatusCodeEx();
        var stream = await responseMessage.Content.ReadAsStreamAsync();
        return JsonSerializer.Deserialize<Fonlow.Auth.Models.Client.AccessTokenResponse>(stream, jsonSerializerSettings);
    }

    public async Task<Fonlow.Auth.Models.Client.AccessTokenResponse> PostRefreshTokenRequestAsFormDataToAuthAsync(Fonlow.Auth.Models.Client.RefreshAccessTokenRequest model, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
    {
        var requestUri = "token";
        using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri);
        var pairs = new KeyValuePair<string, string>[]
                    {
                        new KeyValuePair<string, string>( "grant_type", model.grant_type ),
                        new KeyValuePair<string, string>( "refresh_token", model.refresh_token ),
                        new KeyValuePair<string, string> ( "scope", model.Scope )
                    };
        var content = new FormUrlEncodedContent(pairs);
        httpRequestMessage.Content = content;
        handleHeaders?.Invoke(httpRequestMessage.Headers);
        using var responseMessage = await client.SendAsync(httpRequestMessage);
        responseMessage.EnsureSuccessStatusCodeEx();
        var stream = await responseMessage.Content.ReadAsStreamAsync();
        return JsonSerializer.Deserialize<Fonlow.Auth.Models.Client.AccessTokenResponse>(stream, jsonSerializerSettings);
    }
}

Hints:

C#
/// <summary>
/// Section 4.3 and 4.3.2.
/// GrantType must be Value MUST be set to "password".
/// </summary>
[System.Runtime.Serialization.DataContract(Namespace="http://demoapp.client/2024")]
public class ROPCRequst : Fonlow.Auth.Models.Client.RequestBase
{
    
    /// <summary>
    /// Required
    /// </summary>
    [System.ComponentModel.DataAnnotations.Required()]
    [System.Runtime.Serialization.DataMember()]
    public string Password { get; set; }
    
    [System.Runtime.Serialization.DataMember()]
    public string Scope { get; set; }
    
    /// <summary>
    /// Required
    /// </summary>
    [System.ComponentModel.DataAnnotations.Required()]
    [System.Runtime.Serialization.DataMember()]
    public string Username { get; set; }
}

Integration Testing

This test gets the access token first, then uses an authenticated client to refresh token.

C#
[Fact]
public async Task TestPostRefreshTokenRequestAsFormDataToAuthAsync()
{
    var ra = await api.PostRopcTokenRequestAsFormDataToAuthAsync(new ROPCRequst
    {
        grant_type = "password",
        Username = "admin",
        Password = "Pppppp*8"
    });

    HttpClient client = new HttpClient();
    client.BaseAddress = new Uri(baseUrl);
    client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ra.access_token);
    AuthClient authClient = new AuthClient(client);
    var r = await authClient.PostRefreshTokenRequestAsFormDataToAuthAsync(new RefreshAccessTokenRequest
    {
        grant_type = "refresh_token",
        refresh_token = ra.refresh_token
    });

    Assert.Equal("bearer", r.token_type, true);
    Assert.NotNull(r.access_token);
    Assert.NotNull(r.refresh_token);
    Assert.True(r.expires_in > 0);
}

Points of Interest

Polymorphic Binding and Conformation to C# Property Naming Convention

You might be curious about why certain property names in the token request models don’t adhere to the standard C# property naming convention. The issue is, the encoding of the request payload is "application/x-www-form-urlencoded", and the request model binding of ASP.NET Core won't respect what declared in JsonPropertyNameAttribute , and the runtime can at best transform TitleCase to camelCase.

It appears that there isn’t a built-in mechanism in ASP.NET Core specifically designed to handle an “access_token” associated with a property named “AccessToken.” If you happen to discover one or have insights, feel free to share your findings in the comments section.

Alternative Auth Services

As a .NET developer, you check out this article and its repository probably because:

  • You don't want to use commercial auth services like Okta, Auth0, Microsoft Azure AD / Entra, at least not yet, due to various reasons.
  • You want to conform to RFC6749 as much as possible for basic bar of security and easy migration to more comprehensive auth service, commercial or free.

There was a once popular free auth server "Identity Server", which became discontinued in 2022. However, DuendeSoftware has taken over. You may want to check this out , when considering a more comprehensive auth server which will be integrated into your business applications.

ROPC To Be Deprecated

Like it or not, ROPC is to be deprecated, though it could be handy in small applications in some contexts, for examples, your target users don't like 2FA/MFA for some reasons. Also, it may be a part of the transition solution migrating from OAuth2 provider A to OAuth2 provider B.

References:

Do You Agree ROPC Should Be Deprecated?

I am not a security expert. As far as I understand, typical MFA requires extra devices like SMS, smartphone apps, or stick in addition to the primary info access device like PC. Some groups of people simply cannot handle the procedures of MFA.

In fact, in Australia, for online banking some major banks require only customer ID + password for online banking on a Web browser. No SMS code needed even for the first login on a new PC. I am sure the security behind the scene on both frontend and backend check many things, however, at least from the consumer point of view, they just need username + password. And only the first payment to a new recipient requires SMS verification, however, this is of authorization, not the sign-in authentication. The balance between convenience and literal security is somewhere for real security and real value for the users.

Can you think of some more scenarios that ROPC is still good old tech that is legitimate for certain contexts?

I can think of 2 scenarios:

  1. Large portion of retirees who could barely use smartphone, or easily get confused by doggy apps.
  2. Many school children and most pre-school school children who have no smartphone.

How to improve security in addition to ROPC is well another big subject.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)