0

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
    };
});
John
  • 113
  • 1
  • 5

1 Answers1

1

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.

Tore Nestenius
  • 16,431
  • 5
  • 30
  • 40
  • Yes, I tried that. If you look at my commented-out OpenIdConnect configuration, I do have it set to true. I feel that I'm missing something here. When you say, "issue a cookie" is that a manual process, or something built into OpenIddict or the >net core AuthenticationScheme? – John Feb 25 '23 at 14:16
  • AddCookie is in charge of creating a session cookie after the user is authenticated using the AddOpenIDConnect handler. I always recommend that you put OpenIdDict, The client (AddOpenIDConnect) and the API (AddJwtBearer) in separate ASP.NET core services/projects, otherwise it will be hard/complex to reason about what is actually going on. – Tore Nestenius Feb 25 '23 at 14:57
  • Perhaps my whole premise is incorrect. Should I be using the JwtBearer Authentication scheme at all when using OIDC flows? https://stackoverflow.com/questions/65365920/addopenidconnect-middleware-clarification Or should I strictly be using cookies for authentication? My line of thinking is that the JWT would authenticate and the OIDC would add the additional claims into the claimsprincipal. Am I off base here? – John Feb 25 '23 at 15:04
  • I've edited my post so that I have my current setup. As far as putting the addopenidconnect and addjwtbearer in separate asp.net projects: I think that you might be getting close to the source of my confusion. This is an API for an Angular Web front-end. Are you saying that the API should contain the JWTBearer authentication piece and that's it, and the Angular project should handle the addopenidconnect (cookie user info)? This is really confusing to me and is not making sense, because the client secret should not be known by the the Angular project, correct? – John Feb 25 '23 at 15:18
  • I'm starting to understand a little better. The code from the redirect LoginCallback is the client. The Client drops the cookie with the userinfo. The client also initiates the challenge to redirect to the login. The API, or Resource Server, authenticates the JWT bearer token and consumes the cookie dropped by the client. Am I getting closer? – John Feb 25 '23 at 16:14
  • You use JwtBearer in the API that you want to protect using tokens, and its job is to convert incoming requests with an access token to a User (ClaimsPrincipal) object. The API part only needs JwtBearer, notthing else. – Tore Nestenius Feb 25 '23 at 17:18
  • 1
    Then, today it is not advised to handle tokens in the browser (SPA...), instead take a look at the BFF pattern, see this video https://www.youtube.com/watch?v=lEnbi4KClVw&t=2618s and https://www.youtube.com/watch?v=UBFx3MSu1Rc – Tore Nestenius Feb 25 '23 at 17:19
  • I also recommend using a tool like https://www.telerik.com/download/fiddler to understand and explore all the requests involved. – Tore Nestenius Feb 25 '23 at 17:20
  • Also, it is a complex topic with a lot of moving parts. – Tore Nestenius Feb 25 '23 at 17:21
  • I also recomend to ask more specific questions, your current one is pretty broad. Feel free to accept my answer if it has helped you. – Tore Nestenius Feb 25 '23 at 17:21
  • This has helped and the video was great. Cleared up a bunch of things. One last question: Now that I understand this a little better, Am I correct in saying that the AddOpenIdConnect should be in the Client, and the AddJwtBearer should be in the API. This means that the additional claims will NOT be available in the API, but instead consumed directly by the Client unless I do some additional work in the API to manually go and add them? – John Feb 25 '23 at 18:18
  • 1
    Yes, the Client contains AddOpenIdConnect + AddCookie and it sends the access token to the API. The API contains AddJwtBearer only. the client uses the claims from the ID-token + UserInfo endpoint to create the user object in the client. AddJwtBearer uses the claims in the access token to create the User in the API. – Tore Nestenius Feb 25 '23 at 21:02
  • Thank you. Basically, I was going down a rabbit hole because I was conflating the Client with the API and the specific roles of each. I found this which does exactly what I need: https://joonasw.net/view/adding-custom-claims-aspnet-core-2 – John Feb 25 '23 at 21:27
  • I did blog about the JwtBearer here https://nestenius.se/2023/02/21/troubleshooting-jwtbearer-authentication-problems-in-asp-net-core/ – Tore Nestenius Feb 26 '23 at 08:36