Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add API endpoints for generating identity tokens #47227

Closed
Tracked by #47288
halter73 opened this issue Mar 15, 2023 · 5 comments · Fixed by #47414
Closed
Tracked by #47288

Add API endpoints for generating identity tokens #47227

halter73 opened this issue Mar 15, 2023 · 5 comments · Fixed by #47414
Assignees
Labels
api-approved API was approved in API review, it can be implemented area-identity Includes: Identity and providers enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-token-identity Priority:0 Work that we can't release without
Milestone

Comments

@halter73
Copy link
Member

halter73 commented Mar 15, 2023

This will expose Login and Registration endpoints.

@halter73 halter73 added area-identity Includes: Identity and providers feature-token-identity labels Mar 15, 2023
@halter73 halter73 self-assigned this Mar 15, 2023
@mkArtakMSFT mkArtakMSFT added this to the 8.0-preview4 milestone Mar 15, 2023
@mkArtakMSFT mkArtakMSFT added enhancement This issue represents an ask for new feature or an enhancement to an existing one and removed untriaged labels Mar 15, 2023
@mkArtakMSFT mkArtakMSFT added the Priority:0 Work that we can't release without label Mar 17, 2023
@halter73 halter73 changed the title Add API endpoints for generating identity JWT tokens Add API endpoints for generating identity tokens Mar 25, 2023
@halter73
Copy link
Member Author

halter73 commented Mar 31, 2023

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

  • This all could be collapsed into Microsoft.AspNetCore.Identity.dll.
    • Microsoft.AspNetCore.Identity.Endpoints.dll could be renamed to something like Microsoft.AspNetCore.Identity.Apis
    • Both Apis and Endpoints are similarly vague.
  • MapIdentity could optionally take a string for the route pattern so you do not have to call app.MapGroup("/identity") or similar to prefix the endpoints with anything more than v1.
  • AddIdentityEndpoints could be renamed to AddIdentityApis or AddIdentityApiEndpoints.
  • IdentityBearerOptions could be put in a new Microsoft.AspNetCore.Identity.Bearer namespace
    • Note: We could end up adding a lot more public types related to the auth handler if we add to IdentityBearerOptions.Events.
  • IdentityBearerAuthenticationBuilderExtensions could be shortened to IdentityBearerExtensions
    • Similar extension methods like CookieExtensions.AddCookie and JwtBearerExtensions.AddJwtBearer use shorter names.
  • ExtractBearerToken could be an event similar to JwtBearerEvents.OnMessageReceived.
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.

@halter73 halter73 added the api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews label Mar 31, 2023
@ghost
Copy link

ghost commented Mar 31, 2023

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:

  • The PR contains changes to the reference-assembly that describe the API change. Or, you have included a snippet of reference-assembly-style code that illustrates the API change.
  • The PR describes the impact to users, both positive (useful new APIs) and negative (breaking changes).
  • Someone is assigned to "champion" this change in the meeting, and they understand the impact and design of the change.

@halter73 halter73 linked a pull request Mar 31, 2023 that will close this issue
@halter73
Copy link
Member Author

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 halter73 added api-suggestion Early API idea and discussion, it is NOT ready for implementation and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Mar 31, 2023
@halter73 halter73 added api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Apr 17, 2023
@halter73
Copy link
Member Author

halter73 commented Apr 24, 2023

API Review Notes:

  • Let's talk assemblies:
    • Do we like the name Microsoft.AspNetCore.Identity.Endpoints.dll for the new identity-related APIs? Could we collapse it in Microsoft.AspNetCore.Identity.dll?
    • If we want to do JWT after .NET 8 and we cannot ingest JWT into the shared framework, we'd be out of luck.
    • We have a lot of time before .NET 8 ships to pull MapIdentity out into another package.
    • We could also add extensibility points for generating the token that can be extended in packages.
    • Let's collapse it in Microsoft.AspNetCore.Identity.dll
    • What assembly should AddIdentityBearer go into?
      • Microsoft.AspNetCore.Authentication.BearerToken.dll wins the vote.
  • Should we make the IdentityBearerAuthenticationHandler public?
    • We could make it public later and unseal the options.
    • Follow conventions of existing auth handlers for which AuthenticationBuilder extension methods to add and what to name the extension method class.
  • Can we poison the response body?
    • Setting headers or status code would already fail, but it might be nice.
  • Can /login send the access token as a response header?
    • The body is (a little) less likely to be logged
    • It's slightly more discoverable in the body
    • Response headers may have a smaller size limit enforced by the client, but it has to fit in a request header regardless
    • Consider moving ExtractBearerToken to Options.Events for consistency with other handlers. Only call it in AuthenticateAsync. Never fallback in forbid.
    • Let's remove MissingBearerTokenFallbackScheme and use a composite policy scheme to fall back to cookies if necessary after the BearerTokenHandler fails to authenticate.
  • Should we add a default scheme name when not using identity? Yes. Add BearerTokenDefaults.
  • Can we remove Options.DataProtectionProvider?
    • Yes. We can added later if people don't want to new up a TicketDataFormat.
  • Let's remove the automatic "/v1/" prefix.
  • MapIdentity -> MapIdentityApi
    • MapIdentityApis? We think non-plural API could be encapsulate many endpoints as part of the overall API.
  • Do we want to take a prefix or options as a parameter to MapIdentityApi?
    • Not yet. For options, we'd likely do this on one of the Add methods anyway.

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

@halter73 halter73 added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Apr 25, 2023
@thisisnabi
Copy link

Using MapGroup in MapIdentityApi Is NOT Clear!

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

@ghost ghost locked as resolved and limited conversation to collaborators Jun 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
api-approved API was approved in API review, it can be implemented area-identity Includes: Identity and providers enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-token-identity Priority:0 Work that we can't release without
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants
@halter73 @thisisnabi @mkArtakMSFT and others