Search code examples
jwtopenidopeniddict

OpenIdDict Velusia Example: What triggers UserInfo to be hit?


I modelled off strongly off of the Velusia OpenIddict sample (Authorization Code Flow):

In the client, the first step to authorization is to go to the login redirect:

    [HttpGet("~/login")]
    public ActionResult LogIn(string returnUrl)
    {
        var properties = new AuthenticationProperties(new Dictionary<string, string>
        {
            // Note: when only one client is registered in the client options,
            // setting the issuer property is not required and can be omitted.
            [OpenIddictClientAspNetCoreConstants.Properties.Issuer] = "https://localhost:44313/"
        })
        {
            // Only allow local return URLs to prevent open redirect attacks.
            RedirectUri = Url.IsLocalUrl(returnUrl) ? returnUrl : "/"
        };

        // Ask the OpenIddict client middleware to redirect the user agent to the identity provider.
        return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);
    }

Note that it redirects through Challenge to a login page on the Authorization server:

enter image description here

After a successful login, code travels to the server /Authorize

  [HttpGet("~/connect/authorize")]
    [HttpPost("~/connect/authorize")]
    [IgnoreAntiforgeryToken]
    public async Task<IActionResult> Authorize()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
            throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

        // Try to retrieve the user principal stored in the authentication cookie and redirect
        // the user agent to the login page (or to an external provider) in the following cases:
        //
        //  - If the user principal can't be extracted or the cookie is too old.
        //  - If prompt=login was specified by the client application.
        //  - If a max_age parameter was provided and the authentication cookie is not considered "fresh" enough.
        var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme);
        if (result == null || !result.Succeeded || request.HasPrompt(Prompts.Login) ||
           (request.MaxAge != null && result.Properties?.IssuedUtc != null &&
            DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)))

...

Then, since I am using implicit consent, it transports itself immediately to Exchange:

   [HttpPost("~/connect/token"), IgnoreAntiforgeryToken, Produces("application/json")]
    public async Task<IActionResult> Exchange()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
            throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

        if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
        {
            // Retrieve the claims principal stored in the authorization code/refresh token.
            var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

Then, magically(!), it goes straight to UserInfo (my implementation):

        [Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
        [HttpGet("~/connect/userinfo")]
        public async Task<IActionResult> Userinfo()
        {
            var request = HttpContext.GetOpenIddictServerRequest() ??  throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
            var claimsPrincipal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
            var user = await _userManager.FindByIdAsync(claimsPrincipal?.GetClaim(Claims.Subject) ?? throw new Exception("Principal cannot be found!"));

Then it goes to back to the client specified by the redirect LoginCallback

  // Note: this controller uses the same callback action for all providers
    // but for users who prefer using a different action per provider,
    // the following action can be split into separate actions.
    [HttpGet("~/callback/login/{provider}"), HttpPost("~/callback/login/{provider}"), IgnoreAntiforgeryToken]
    public async Task<ActionResult> LogInCallback()
    {
        // Retrieve the authorization data validated by OpenIddict as part of the callback handling.
        var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);

        // Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons:
        //
        //   * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable
        //     for applications that don't need a long-term access to the user's resources or don't want to store
        //     access/refresh tokens in a database or in an authentication cookie (which has security implications).
        //     It is also suitable for applications that don't need to authenticate users but only need to perform
    
...


 return SignIn(new ClaimsPrincipal(identity), properties, CookieAuthenticationDefaults.AuthenticationScheme);

whereupon all the claims are gathered and stored in a cookie.

The result is that when I go to my protected controller, all my claims that are specified with the destination of Destinations.IdentityToken appear!

enter image description here

This is perfect and exactly what I want! Except, that the example uses cookie authentication. I need to use JWT authentication.

I can get the JWT authentication to work fine EXCEPT I cannot get my claims loaded in my protected controller.

So a couple questions:

  1. What triggers the UserInfo to be executed in the first example? Strangely, when I don't call the login page through the Challenge (first code block) I cannot get UserInfo to execute. I've matched all the query params that they seem the same.
  2. Shouldn't the id_token (which I am getting) contain all the relevant information so that the UserInfo endpoint should not be needed?
  3. In this scenario, is it appropriate to store the user claims information in a cookie? I can't see any other good way to persist this info. What's the best way of doing this under this scenario so that my claims principal will have all the claims automatically loaded once I enter my protected controller?

In my client application in my program.cs (.net 6)

builder.Services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseEntityFrameworkCore().UseDbContext<OpenIddictContext>();
    })
    .AddClient(options =>
    {
        options.AllowAuthorizationCodeFlow();
        options.AddDevelopmentEncryptionCertificate().AddDevelopmentSigningCertificate();
        options.UseAspNetCore()
            .EnableStatusCodePagesIntegration()
            .EnableRedirectionEndpointPassthrough()
            .EnablePostLogoutRedirectionEndpointPassthrough();
        options.UseSystemNetHttp();
        options.AddRegistration(new OpenIddict.Client.OpenIddictClientRegistration
        {
            Issuer = new Uri(configuration?["OpenIddict:Issuer"] ?? throw new Exception("Configuration.Issuer is null for AddOpenIddict")),
            ClientId = configuration["OpenIddict:ClientId"],
            ClientSecret = configuration["OpenIddict:ClientSecret"],
            Scopes = { Scopes.OpenId, Scopes.OfflineAccess, "api" },
            RedirectUri = new Uri("callback/login/local", UriKind.Relative), //Use this when going directly to the login
            //RedirectUri=new Uri("swagger/oauth2-redirect.html", UriKind.Relative),  //Use this when using Swagger to JWT authenticate
            PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative)
        });
    })
    .AddValidation(option =>
    {
        option.SetIssuer(configuration?["OpenIddict:Issuer"] ?? throw new Exception("Configuration.Issuer is null for AddOpenIddict"));
        option.AddAudiences(configuration?["OpenIddict:Audience"] ?? throw new Exception("Configuration is missing!"));
        option.UseSystemNetHttp();
        option.UseAspNetCore();
    });

and I changed this (cookie authentication)

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options =>
 {
     options.LoginPath = "/login";
     options.LogoutPath = "/logout";
     options.ExpireTimeSpan = TimeSpan.FromMinutes(50);
     options.SlidingExpiration = false;
 });

to this:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
//.AddCookie(p =>
//{
//    p.SlidingExpiration = true;
//    p.Events.OnSigningIn = (context) =>
//    {
//        context.CookieOptions.Expires = DateTimeOffset.UtcNow.AddHours(14);
//        return Task.CompletedTask;
//    };
//})
//.AddOpenIdConnect(options =>
//{
//    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
//    options.RequireHttpsMetadata = true;
//    options.Authority = configuration?["OpenIddict:Issuer"];
//    options.ClientId = configuration?["OpenIddict:ClientId"];
//    options.ClientSecret = configuration?["OpenIddict:ClientSecret"];
//    options.ResponseType = OpenIdConnectResponseType.Code;
//    options.Scope.Add("openid");
//    options.Scope.Add("profile");
//    options.Scope.Add("offline_access");
//    options.Scope.Add("api");
//    options.GetClaimsFromUserInfoEndpoint = true;
//    options.SaveTokens = true;
//    //options.TokenValidationParameters = new TokenValidationParameters
//    //{
//    //    NameClaimType = "name",
//    //    RoleClaimType = "role"
//    //};
//});
.AddJwtBearer(options =>
{
    options.Authority = configuration?["OpenIddict:Issuer"];
    options.Audience = configuration?["OpenIddict:Audience"];
    options.IncludeErrorDetails = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidIssuer = configuration?["OpenIddict:Issuer"],
        ValidAudience = configuration?["OpenIddict:Audience"],
        ValidateIssuerSigningKey = true,
        ClockSkew = TimeSpan.Zero
    };
});

Note that I attempted a number of configurations based on the .NET OpenIdConnect to no avail.

My current configuration is based on the idea that I need JWTBearer for authentication and AddOpenIdConnect for the additional claims. This setup authenticates just fine, but does not add the additional claims:

    builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Policy1", policy => policy.RequireClaim("Claim1", "0"));
    options.AddPolicy("Policy2", policy => policy.RequireClaim("Claim2", "1"));
    options.AddPolicy("Policy3", policy => policy.RequireClaim("Claim3", "1"));
    options.AddPolicy("DefaultPolicy", policy => policy.RequireAuthenticatedUser().RequireClaim("HasAccess"));
    //var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(
    //            JwtBearerDefaults.AuthenticationScheme);
    //defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
    //options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
});


builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(p =>
{
    p.SlidingExpiration = true;
    p.Events.OnSigningIn = (context) =>
    {
        context.CookieOptions.Expires = DateTimeOffset.UtcNow.AddHours(14);
        return Task.CompletedTask;
    };
})
.AddOpenIdConnect(options =>
{
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.RequireHttpsMetadata = true;
    options.Authority = configuration?["OpenIddict:Issuer"];
    options.ClientId = configuration?["OpenIddict:ClientId"];
    options.ClientSecret = configuration?["OpenIddict:ClientSecret"];
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("offline_access");
    options.Scope.Add("api");
    //options.Scope.Add("openid");
    //options.Scope.Add("offline_access");
    //options.Scope.Add("api");
    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;
    //options.TokenValidationParameters = new TokenValidationParameters
    //{
    //    NameClaimType = "name",
    //    RoleClaimType = "role"
    //};
})
.AddJwtBearer(options =>
{
    options.Authority = configuration?["OpenIddict:Issuer"];
    options.Audience = configuration?["OpenIddict:Audience"];
    options.IncludeErrorDetails = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidIssuer = configuration?["OpenIddict:Issuer"],
        ValidAudience = configuration?["OpenIddict:Audience"],
        ValidateIssuerSigningKey = true,
        ClockSkew = TimeSpan.Zero
    };
});

Solution

  • You tell the OpenIDConnect handler with this setting that it should also do a separate call to the UserInfo endpoint to get additional claims about the user.

    options.GetClaimsFromUserInfoEndpoint = true;
    

    AddJwtBearer will never call the UserInfo endpoint.

    The reason for the extra call is many; one reason is that you can reduce the size of the ID-token.

    You typically always issue a cookie after login, and it is secure to store the tokens inside the cookie, as the cookie is encrypted by the Data Protection API in ASP.NET Core. However, the cookie might become pretty big, and if that is an issue, then there are ways to reduce the cookie size using a custom SessionStore.

    I did write a blog post, talking more about the purpose of the session store and how it can improve security at: Improving ASP.NET Core Security By Putting Your Cookies On A Diet