dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.61k stars 10.06k forks source link

Personal access token support in Identity #41262

Closed vanillajonathan closed 2 years ago

vanillajonathan commented 2 years ago

Background and Motivation

Users on a website powered by ASP.NET Core Identity could also want to delegate access to third-party applications to interact with an API on the website using personal access tokens.

  1. The user goes to a page on the website and generates a personal access token.
  2. The website presents the user with the generated personal access token.
  3. The user copies the token and inserts it into an application.
  4. That application uses the provides token to interact with the website through the API which the website provides.

Proposed API

namespace Microsoft.AspNetCore.Identity;

public class UserManager<TUser> : IDisposable where TUser : class
{
+    /// <summary>
+    /// Generates a personal access token for the specified <paramref name="user"/>.
+    /// </summary>
+    /// <param name="user">The user to generate a personal access token for.</param>
+    /// <param name="scopes">The scopes which the token provides access to.</param>
+    /// <param name="expiresAt">The date which the token expires at.</param>
+    /// <returns>The <see cref="Task"/> that represents the asynchronous operation,
+    /// containing a personal access token for the specified <paramref name="user"/>.</returns>
+    public virtual Task<string> GeneratePersonalAccessTokenAsync(TUser user, IEnumerable<string> scopes, DateTimeUtc expiresAt)
}
+/// <summary>
+/// Extension methods to configure personal access token authentication.
+/// </summary>
+public static class PatBearerExtensions
+{
+    public static AuthenticationBuilder AddPatBearer(this AuthenticationBuilder builder)
+}

Usage Examples

[BindProperty]
public ICollection<string> Scopes { get; set; } // [ "write:blog_posts" ]

public async Task<IActionResult> OnPostAsync()
{
    var user = await _userManager.GetUserAsync(User.Identity);
    var expiresAt = DateTimeUtc.Today.AddDays(90);

    var token = await _userManager.GeneratePersonalAccessTokenAsync(user, Scopes, expiresAt);
    _context.Pats.Add(new Pat { Token = token, /* ... */ });
    await _context.SaveChangesAsync();

    return RedirectToPage("./Index");
}

Alternative Designs

Risks

Is your feature request related to a problem? Please describe the problem.

Users can only interact with the application through a web browser. Users can not interact with the application through other applications.

Describe the solution you'd like

For ASP.NET Core Identity to provide a way to generate personal access tokens for users.

blowdart commented 2 years ago

I'll be honest this is way beyond the scope of what identity is aimed at. This sort of thing we leave to Identity Server, OpenIddict and the various cloud platform identity services.

vanillajonathan commented 2 years ago

IdentityServer is an implementation of OpenID Connect — an unwieldy beast designed by a consortium of behemoths to cover every imaginable enterprise scenario which makes it difficult to understand, configure and setup.

IdentityServer is great as the stand-alone-hosted identity management centerpiece of an enterprise as it supports:

But it is not suitable as a smaller piece of just one application. Example an SPA or an application that provides an API alongside Razor pages with Identity.

blowdart commented 2 years ago

Rolling our own PAT implementation would be just as unwieldy, complicated and prone to error, locking people into a custom protocol and client. That's not suitable for anything modern at all.

vanillajonathan commented 2 years ago

Perhaps it could be a simple implementation with just one GeneratePersonalAccessTokenAsync method that returns a token as a string in JWT format. The method would accept a DateTimeUtc or a TimeSpan that would be used to set the exp claim. Furthermore it would add claims from the AspNetUserClaims table. The token would be compatible with the existing AddJwtBearer middleware. No custom protocol, no custom client.

blowdart commented 2 years ago

You could do that today, with your own custom code. But PATs are specialized requirements anyway, not suitable for inclusion in a general starting point framework. Identity is for web based logins, anything after that we leave to the community. If there's something blocking the implementation of a community feature we'd look at how to unblock it, but that's very different to implementing it ourselves.

vanillajonathan commented 2 years ago

True, it would look something like this.

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.IdentityModel.Tokens;

namespace Microsoft.AspNetCore.Identity;

public static class UserManagerExtensions
{
    /// <summary>
    /// Generates a personal access token for the specified <paramref name="userName"/>.
    /// </summary>
    /// <param name="userName">The user to generate a personal access token for.</param>
    /// <param name="scopes">The scopes which the token provides access to.</param>
    /// <param name="tokenDescriptor">The information which used to create a token.</param>
    /// <returns>The <see cref="Task"/> that represents the asynchronous operation,
    /// containing a personal access token for the specified <paramref name="userName"/>.</returns>
    public static async Task<string> GeneratePersonalAccessTokenAsync<TUser>(this UserManager<TUser> userManager, string userName, IEnumerable<string> scopes, SecurityTokenDescriptor tokenDescriptor)
        where TUser : class
    {
        var user = await userManager.FindByNameAsync(userName);
        var claims = await userManager.GetClaimsAsync(user);

        tokenDescriptor.Subject.AddClaims(claims);
        if (scopes.Any())
        {
            var scopeString = string.Join(", ", scopes.Select(x => x));
            tokenDescriptor.Subject.AddClaim(new Claim("scope", scopeString));
        }

        var tokenHandler = new JwtSecurityTokenHandler();
        var token = tokenHandler.CreateJwtSecurityToken(tokenDescriptor);
        var encoded = tokenHandler.WriteToken(token);

        return encoded;
    }
}

The user would have to pass in a SecurityTokenDescriptor.

Another alternative would be opaque tokens persisted to a database table.

public class PersonalAccessToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string Scope { get; set; }
    public DateTimeOffset ExpiresAt { get; set; }
}
ghost commented 2 years ago

Thank you for contacting us. Due to a lack of activity on this discussion issue we're closing it in an effort to keep our backlog clean. If you believe there is a concern related to the ASP.NET Core framework, which hasn't been addressed yet, please file a new issue.

This issue will be locked after 30 more days of inactivity. If you still wish to discuss this subject after then, please create a new issue!