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.38k stars 10k forks source link

Add API endpoints for generating identity tokens #47227

Closed halter73 closed 1 year ago

halter73 commented 1 year ago

This will expose Login and Registration endpoints.

halter73 commented 1 year ago

Background and Motivation

To provide self-hosted identity auth endpoints suitable for SPA apps and non-browser apps.

Proposed API

// Microsoft.AspNetCore.Identity.dll (In the ASP.NET Core shared framework)
namespace Microsoft.AspNetCore.Identity;

public class IdentityConstants
{
    private const string IdentityPrefix = "Identity"

    public static readonly string ApplicationScheme = IdentityPrefix + ".Application";
+   public static readonly string BearerScheme = IdentityPrefix + ".Bearer";
    // ...
}
// Microsoft.AspNetCore.Identity.Endpoints.dll (Proposed as NuGet package)
// Could be moved to the shared framework and/or merged with Microsoft.AspNetCore.Identity.dll
namespace Microsoft.AspNetCore.Identity;

public sealed class IdentityBearerOptions : AuthenticationSchemeOptions
{
    public TimeSpan BearerTokenExpiration { get; set; } = TimeSpan.FromHours(1);

    public ISecureDataFormat<AuthenticationTicket>? BearerTokenProtector { get; set; }

    public IDataProtectionProvider? DataProtectionProvider { get; set; }

    public string? MissingBearerTokenFallbackScheme { get; set; }

    public Func<HttpContext, ValueTask<string?>>? ExtractBearerToken { get; set; }
}

namespace Microsoft.Extensions.DependencyInjection;

// M.E.D.I namespace matches AddCookie, AddJwtBearer, etc... despite AuthenticationBuilder living elsewhere.
// The most consistent thing would be to call this IdentityBearerExtensions though.
public static class IdentityBearerAuthenticationBuilderExtensions
{
    public static AuthenticationBuilder AddIdentityBearer(this AuthenticationBuilder builder, Action<IdentityBearerOptions>? configure);
}

public static class IdentityEndpointsServiceCollectionExtensions
{
    // Adds everything required for MapIdentity including both the identity bearer and cookie auth handlers
    public static IdentityBuilder AddIdentityEndpoints<TUser>(this IServiceCollection services)
        where TUser : class, new();
    public static IdentityBuilder AddIdentityEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
        where TUser : class, new();

    // Adds everything erquired for MapIdenity except the auth handlers.
    // AddIdentityCookie and/or AddIdentityBearer need to be called seperately.
    public static IdentityBuilder AddIdentityEndpointsCore<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
        where TUser : class, new();
}

namespace Microsoft.AspNetCore.Routing;

public static class IdentityEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapIdentity<TUser>(this IEndpointRouteBuilder endpoints) where TUser : class, new();
}

Usage Examples

Server

Bearer token and cookies enabled

using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddDbContext<ApplicationDbContext>(
    options => options.UseSqlite(builder.Configuration["ConnectionString"]));

builder.Services.AddIdentityEndpoints<IdentityUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

var app = builder.Build();

app.MapGroup("/identity").MapIdentity<IdentityUser>();

app.MapGet("/requires-auth", (ClaimsPrincipal user) => $"Hello, {user.Identity?.Name}!").RequireAuthorization();

app.Run();

public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
    }
}

Bearer token only

// Same usings as above

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(IdentityConstants.BearerScheme)
    .AddIdentityBearer(options => { });
builder.Services.AddAuthorization();

builder.Services.AddDbContext<ApplicationDbContext>(
    options => options.UseSqlite(builder.Configuration["ConnectionString"]));

builder.Services.AddIdentityEndpointsCore<IdentityUser>(options => { })
    .AddEntityFrameworkStores<ApplicationDbContext>();

var app = builder.Build();

// Same as above

Cookie only

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthorization();

builder.Services.AddDbContext<ApplicationDbContext>(
    options => options.UseSqlite(builder.Configuration["ConnectionString"]));

builder.Services.AddIdentity<IdentityUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

var app = builder.Build();

OR

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme)
    .AddIdentityCookies();
builder.Services.AddAuthorization();

builder.Services.AddDbContext<ApplicationDbContext>(
    options => options.UseSqlite(builder.Configuration["ConnectionString"]));

builder.Services.AddIdentityEndpointsCore<IdentityUser>(options => { })
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddSignInManager();

var app = builder.Build();

Client

Assume httpClient, username and password are already initialized.

Register

// Email confirmation will be added later.
// The request body is: { "username": "<username>", "password": "<password>" }
await httpClient.PostAsJsonAsync("/identity/v1/register", new { username, password });

Login (Bearer token)

// 2fa flow will be added later.
// The request body is: { "username": "<username>", "password": "<password>" }
var loginResponse = await httpClient.PostAsJsonAsync("/identity/v1/login", new { username, password });

// loginResponse is similar to the "Access Token Response" defined in the OAuth 2 spec
// {
//   "token_type": "Bearer",
//   "access_token": "...",
//   "expires_in": 3600
// }
// refresh token is likely to be added later
var loginContent = await loginResponse.Content.ReadFromJsonAsync<JsonElement>();
var accessToken = loginContent.GetProperty("access_token").GetString();

httpClient.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
Console.WriteLine(await httpClient.GetStringAsync("/requires-auth"));

Login (Cookie)

// HttpClientHandler.UseCookies is true by default on supported platforms.
// The request body is: { "username": "<username>", "password": "<password>", "cookieMode": true }
await httpClient.PostAsJsonAsync("/identity/v1/login", new { username, password, cookieMode = true });

Console.WriteLine(await cookieClient.GetStringAsync("/requires-auth"));

Alternative Designs

authBuilder.AddJwtBearer(options =>
 {
      // Events is marked non-nullable but is appears to be null here.
      options.Events = new JwtBearerEvents
      {
          OnMessageReceived = context =>
          {
              var accessToken = context.HttpContext.Request.Query["access_token"];

              // If the request is for our hub...
              var path = context.HttpContext.Request.Path;
              if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/chat"))
              {
                  // Read the token out of the query string
                  context.Token = accessToken;
              }
              return Task.CompletedTask;
          }
      };
  });

vs:

// or services.Configure<IdentityBearerOptions>(IdentityConstants.BearerScheme, options =>
authBuilder.AddIdentityBearer(options =>
{
    options.ExtractBearerToken = httpContext =>
    {
          var accessToken = httpContext.Request.Query["access_token"];

          // If the request is for our hub...
          var path = httpContext.Request.Path;
          if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/chat"))
          {
              return new(accessToken);
          }

          return default;
       }
    };
})

Risks

This is low risk since it's entirely new API. And all of it is in a new NuGet package aside from IdentityConstants.BearerScheme.

ghost commented 1 year ago

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

halter73 commented 1 year ago

I'm temporarily removing api-ready-for-review, since I'll be OOF for the next two weeks. I want to be there for the review.

I think the API proposal is complete though, and would appreciate any feedback people have prior to the official API review.

halter73 commented 1 year ago

API Review Notes:

API Approved!

// Microsoft.AspNetCore.Identity.dll (In the ASP.NET Core shared framework)
namespace Microsoft.AspNetCore.Identity;

public class IdentityConstants
{
    private const string IdentityPrefix = "Identity"

    public static readonly string ApplicationScheme = IdentityPrefix + ".Application";
+   public static readonly string BearerScheme = IdentityPrefix + ".Bearer";
    // ...
}
// Microsoft.AspNetCore.Authentication.BearerToken.dll (Shared framework)
namespace Microsoft.AspNetCore.Authentication.BearerToken;

public static class BearerTokenDefaults
{
    // JwtBearer's default "AuthenticationScheme" is "Bearer" :(
    public const string AuthenticationScheme = "BearerToken";
}

public sealed class BearerTokenOptions : AuthenticationSchemeOptions
{
    public TimeSpan BearerTokenExpiration { get; set; } = TimeSpan.FromHours(1);

    public ISecureDataFormat<AuthenticationTicket>? BearerTokenProtector { get; set; }

    public Func<HttpContext, ValueTask<string?>>? ExtractBearerToken { get; set; }
}

namespace Microsoft.Extensions.DependencyInjection;

public static class BearerTokenExtensions
{
    public static AuthenticationBuilder AddBearerToken(this AuthenticationBuilder builder);
    public static AuthenticationBuilder AddBearerToken(this AuthenticationBuilder builder, string authenticationScheme);
    public static AuthenticationBuilder AddBearerToken(this AuthenticationBuilder builder, Action<BearerTokenOptions>? configure);
    public static AuthenticationBuilder AddBearerToken(this AuthenticationBuilder builder, string authenticationScheme, Action<BearerTokenOptions>? configure);
}
// Microsoft.AspNetCore.Identity.dll (Shared framework)
namespace Microsoft.Extensions.DependencyInjection;

public static class IdentityApiEndpointsServiceCollectionExtensions
{
    // Adds everything required for MapIdentity including both the identity bearer and cookie auth handlers
    public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services)
        where TUser : class, new();
    public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
        where TUser : class, new();
}

namespace Microsoft.AspNetCore.Identity;

public static class IdentityApiEndpointsIdentityBuilderExtensions
{
    // Adds everything required for MapIdentity except the auth handlers.
    // AddIdentityCookies and/or AddIdentityBearer need to be called separately.
    public static IdentityBuilder AddApiEndpoints(this IdentityBuilder builder);
}

namespace Microsoft.AspNetCore.Routing;

public static class IdentityApiEndpointRouteBuilderExtensions
{
    public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRouteBuilder endpoints) where TUser : class, new();
}
thisisnabi commented 1 year ago

Using MapGroup in MapIdentityApi Is NOT Clear!

app.MapIdentityApi<IdentityUser>(basePath: "/identity");