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

unsupported_token_type "The specified token cannot be introspected" #347

Open
1 task done
Justincale opened this issue Dec 28, 2024 · 12 comments
Open
1 task done
Labels

Comments

@Justincale
Copy link

Justincale commented Dec 28, 2024

Confirm you've already contributed to this project or that you sponsor it

  • I confirm I'm a sponsor or a contributor

Version

V6

Question

Hi, I have been using IdentityServer4 for my open id requirements but have fallen victim to their new licensing (which i can't afford) so am looking as openiddict as an alternative. My requirement is that i have a client (or clients) that utilizes open id as a SSO solution. Each client must be able to retrieve tokens from the id server via either client_credentials, or authorization_code flows, which need to then be passed on to an external API. I have partially had some success by utilizing code from the Zirku, and Dantooine sample projects in as mush as if i retrieve a token via the client_credentials flow, i am able to pass that token on to the api.

When using the authorization_code flow, i am able to login to the client, but the access_token passed to the API gets rejected: When using introspection on the API side of things (as in the example below), i receive "unsupported_token_type" "The specified token cannot be introspected.". When attempting to use an EncryptionKey i receive "The specified token is not of the expected type."

My openiddict configuration is as follows:

Client Registration

            await manager.CreateAsync(new OpenIddictApplicationDescriptor
            {
                ConsentType = ConsentTypes.Implicit,
                ClientId = "plushtixClient",
                ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207",

                RedirectUris =
                {
                    new Uri("https://localhost:44357"),
                    new Uri("http://localhost:44357/signin-oidc"),
                    new Uri("http://localhost:44357/signin-silent-oidc"),
                },
                PostLogoutRedirectUris =
                        {
                            new Uri("http://localhost:44357/authentication/logout-callback")
                        },

                Permissions =
            {
                Permissions.Endpoints.Authorization,
                Permissions.Endpoints.EndSession,
                Permissions.Endpoints.Token,
                Permissions.GrantTypes.AuthorizationCode,
                Permissions.GrantTypes.RefreshToken,
                Permissions.GrantTypes.ClientCredentials,
                Permissions.ResponseTypes.Code,
                Permissions.Scopes.Email,
                Permissions.Scopes.Profile,
                Permissions.Scopes.Roles,

                Permissions.Prefixes.Scope + "api1",
            },
                Requirements =
            {
                Requirements.Features.ProofKeyForCodeExchange,
            },
            });

API Registration

   if (await manager.FindByClientIdAsync("resource_server_1") is null)
   {
       await manager.CreateAsync(new OpenIddictApplicationDescriptor
       {
           ClientId = "resource_server_1",
           ClientSecret = "846B62D0-DEF9-4215-A99D-86E6B8DAB342",
           Permissions =
       {
           Permissions.Endpoints.Introspection
       }
       });
   }

Scope Registration

  if (await manager.FindByNameAsync("api1") is null)
  {
      await manager.CreateAsync(new OpenIddictScopeDescriptor
      {
          Name = "api1",
          Resources =
                      {
                          "resource_server_1"
                      }
      });
  }

OPENIDDICT SERVER STARTUP (BASED ON DANTOOINE)

   public static WebApplication ConfigureServices(this WebApplicationBuilder builder)
   {

       builder.Services.AddControllersWithViews();
       builder.Services.AddRazorPages();


       builder.Services.AddDbContext<ApplicationDbContext>(options =>
         options.EnableSensitiveDataLogging(true)

         .UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))//, sql => sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
                                                                                      //.UseNetTopologySuite()
                                                                                      //.EnableRetryOnFailure())
       .UseOpenIddict());


       builder.Services.AddDatabaseDeveloperPageExceptionFilter();

       // Register the Identity services.
       builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
           .AddEntityFrameworkStores<ApplicationDbContext>()
           .AddDefaultTokenProviders()
           .AddDefaultUI();

       // OpenIddict offers native integration with Quartz.NET to perform scheduled tasks
       // (like pruning orphaned authorizations/tokens from the database) at regular intervals.
       builder.Services.AddQuartz(options =>
       {
           options.UseSimpleTypeLoader();
           options.UseInMemoryStore();
       });

       //// Register the Quartz.NET service and configure it to block shutdown until jobs are complete.
       builder.Services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true);

       builder.Services.AddOpenIddict()

           // Register the OpenIddict core components.
           .AddCore(options =>
           {
               // Configure OpenIddict to use the Entity Framework Core stores and models.
               // Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.
               options.UseEntityFrameworkCore()
                      .UseDbContext<ApplicationDbContext>();


               // Enable Quartz.NET integration.
               //options.UseQuartz();
           })

           // Register the OpenIddict server components.
           .AddServer(options =>
           {
               // Enable the authorization, logout, token and userinfo endpoints.
               options.SetAuthorizationEndpointUris("connect/authorize")
                      .SetEndSessionEndpointUris("connect/logout")
                      .SetIntrospectionEndpointUris("connect/introspect")
                      .SetTokenEndpointUris("connect/token")
                      
                      .SetUserInfoEndpointUris("connect/userinfo")
                      .SetEndUserVerificationEndpointUris("connect/verify");
               
               // Mark the "email", "profile" and "roles" scopes as supported scopes.
               options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles, Scopes.OpenId, "api1");

               options.AllowAuthorizationCodeFlow()
                      .AllowRefreshTokenFlow().AllowClientCredentialsFlow();

               options.AddEncryptionKey(new SymmetricSecurityKey(
                      Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));

               //options.IgnoreResponseTypePermissions();
               // Register the signing and encryption credentials.
               options.AddDevelopmentEncryptionCertificate()
                      .AddDevelopmentSigningCertificate();

               //options.DisableAccessTokenEncryption();

               // Register the ASP.NET Core host and configure the ASP.NET Core-specific options.
               options.UseAspNetCore()

                      .EnableAuthorizationEndpointPassthrough()
                      .EnableEndSessionEndpointPassthrough()
                      .EnableTokenEndpointPassthrough()
                      .EnableUserInfoEndpointPassthrough()

                      .EnableStatusCodePagesIntegration();

           })     // Register the OpenIddict validation components.
           .AddValidation(options =>
           {
               // Import the configuration from the local OpenIddict server instance.
               options.UseLocalServer();

               // Register the ASP.NET Core host.
               options.UseAspNetCore();
           });


       builder.Services.AddAuthorization();


       // Register the worker responsible for seeding the database.
       // Note: in a real world application, this step should be part of a setup script.
       builder.Services.AddHostedService<Worker>();

       return builder.Build();
   }

   public static WebApplication ConfigurePipeline(this WebApplication app)
   {
       if (app.Environment.IsDevelopment())
       {
           app.UseDeveloperExceptionPage();
           app.UseMigrationsEndPoint();
       }
       else
       {
           app.UseStatusCodePagesWithReExecute("~/error");
           //app.UseExceptionHandler("~/error");

           //app.UseHsts();
       }
       app.UseHttpsRedirection();
       app.UseStaticFiles();

       app.UseRouting();

       app.UseAuthentication();
       app.UseAuthorization();

       app.MapControllers();
       app.MapDefaultControllerRoute();
       app.MapRazorPages();

       return app;
   }

OPENIDDICT AUTHORIZATION CONTROLLER (authorize and exchange endpoints)

[HttpGet("/connect/authorize")]
[HttpPost("
/connect/authorize")]
[IgnoreAntiforgeryToken]
public async Task 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.
  //
  // For scenarios where the default authentication handler configured in the ASP.NET Core
  // authentication options shouldn't be used, a specific scheme can be specified here.
  var result = await HttpContext.AuthenticateAsync();
  if (result == null || !result.Succeeded || request.HasPromptValue(PromptValues.Login) ||
     (request.MaxAge != null && result.Properties?.IssuedUtc != null &&
      DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)))
  {
      // If the client application requested promptless authentication,
      // return an error indicating that the user is not logged in.
      if (request.HasPromptValue(PromptValues.None))
      {
          return Forbid(
              authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
              properties: new AuthenticationProperties(new Dictionary<string, string>
              {
                  [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.LoginRequired,
                  [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is not logged in."
              }));
      }

      // To avoid endless login -> authorization redirects, the prompt=login flag
      // is removed from the authorization request payload before redirecting the user.
      var prompt = string.Join(" ", request.GetPromptValues().Remove(PromptValues.Login));

      var parameters = Request.HasFormContentType ?
          Request.Form.Where(parameter => parameter.Key != Parameters.Prompt).ToList() :
          Request.Query.Where(parameter => parameter.Key != Parameters.Prompt).ToList();

      parameters.Add(KeyValuePair.Create(Parameters.Prompt, new StringValues(prompt)));

      // For scenarios where the default challenge handler configured in the ASP.NET Core
      // authentication options shouldn't be used, a specific scheme can be specified here.
      return Challenge(new AuthenticationProperties
      {
          RedirectUri = Request.PathBase + Request.Path + QueryString.Create(parameters)
      });
  }

  // Retrieve the profile of the logged in user.
  var user = await _userManager.GetUserAsync(result.Principal) ??
      throw new InvalidOperationException("The user details cannot be retrieved.");

  // Retrieve the application details from the database.
  var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
      throw new InvalidOperationException("Details concerning the calling client application cannot be found.");

  // Retrieve the permanent authorizations associated with the user and the calling client application.
  var authorizations = await _authorizationManager.FindAsync(
      subject: await _userManager.GetUserIdAsync(user),
      client : await _applicationManager.GetIdAsync(application),
      status : Statuses.Valid,
      type   : AuthorizationTypes.Permanent,
      scopes : request.GetScopes()).ToListAsync();

  switch (await _applicationManager.GetConsentTypeAsync(application))
  {
      // If the consent is external (e.g when authorizations are granted by a sysadmin),
      // immediately return an error if no authorization can be found in the database.
      case ConsentTypes.External when authorizations.Count is 0:
          return Forbid(
              authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
              properties: new AuthenticationProperties(new Dictionary<string, string>
              {
                  [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired,
                  [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
                      "The logged in user is not allowed to access this client application."
              }));

      // If the consent is implicit or if an authorization was found,
      // return an authorization response without displaying the consent form.
      case ConsentTypes.Implicit:
      case ConsentTypes.External when authorizations.Count is not 0:
      case ConsentTypes.Explicit when authorizations.Count is not 0 && !request.HasPromptValue(PromptValues.Consent):
          // Create the claims-based identity that will be used by OpenIddict to generate tokens.


          
          var identity = new ClaimsIdentity(
              authenticationType: TokenValidationParameters.DefaultAuthenticationType,
              nameType: Claims.Name,
              roleType: Claims.Role);


          var apiUser = await _db.ApiUsers.AsNoTracking().Where(x => x.ClientId == request.ClientId && x.ApplicationUserId == user.Id).FirstOrDefaultAsync();

          if (apiUser != null)
          {



              // Add the claims that will be persisted in the tokens.
              identity.SetClaim(Claims.Subject, await _userManager.GetUserIdAsync(user))
                      .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user))
                      .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user))
                      .SetClaim(Claims.PreferredUsername, await _userManager.GetUserNameAsync(user))
                      .SetClaim("auser_id", apiUser.Id.ToString())
                      .SetClaims(Claims.Role, [.. (await _userManager.GetRolesAsync(user))]);
          }

          // Note: in this sample, the granted scopes match the requested scope
          // but you may want to allow the user to uncheck specific scopes.
          // For that, simply restrict the list of scopes before calling SetScopes.
          identity.SetScopes(request.GetScopes());
          identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());

          // Automatically create a permanent authorization to avoid requiring explicit consent
          // for future authorization or token requests containing the same scopes.
          var authorization = authorizations.LastOrDefault();
          authorization ??= await _authorizationManager.CreateAsync(
              identity: identity,
              subject : await _userManager.GetUserIdAsync(user),
              client  : await _applicationManager.GetIdAsync(application),
              type    : AuthorizationTypes.Permanent,
              scopes  : identity.GetScopes());

          identity.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));
          identity.SetDestinations(claim => [Destinations.AccessToken]);

          return SignIn(new ClaimsPrincipal(identity), properties: null, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

      // At this point, no authorization was found in the database and an error must be returned
      // if the client application specified prompt=none in the authorization request.
      case ConsentTypes.Explicit   when request.HasPromptValue(PromptValues.None):
      case ConsentTypes.Systematic when request.HasPromptValue(PromptValues.None):
          return Forbid(
              authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
              properties: new AuthenticationProperties(new Dictionary<string, string>
              {
                  [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.ConsentRequired,
                  [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] =
                      "Interactive user consent is required."
              }));

      // In every other case, render the consent form.
      default: return View(new AuthorizeViewModel
      {
          ApplicationName = await _applicationManager.GetLocalizedDisplayNameAsync(application),
          Scope = request.Scope
      });
  }

}

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

        // Retrieve the user profile corresponding to the authorization code/refresh token.
        var user = await _userManager.FindByIdAsync(result.Principal.GetClaim(Claims.Subject));
        if (user is null)
        {
            return Forbid(
                authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
                properties: new AuthenticationProperties(new Dictionary<string, string>
                {
                    [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
                    [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The token is no longer valid."
                }));
        }

        // Ensure the user is still allowed to sign in.
        if (!await _signInManager.CanSignInAsync(user))
        {
            return Forbid(
                authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
                properties: new AuthenticationProperties(new Dictionary<string, string>
                {
                    [OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
                    [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."
                }));
        }

        var identity = new ClaimsIdentity(result.Principal.Claims,
            authenticationType: TokenValidationParameters.DefaultAuthenticationType,
            
            nameType: Claims.Name,
            roleType: Claims.Role);


        //var user = await _userManager.FindByIdAsync(context.Subject.Claims.Where(x => x.Type == "sub").FirstOrDefault().Value);
        if (user != null)
        {
            var apiUser = await _db.ApiUsers.AsNoTracking().Where(x => x.ClientId == request.ClientId && x.ApplicationUserId == user.Id).FirstOrDefaultAsync();

            if (apiUser != null)
            {
                try
                {
                    // Override the user claims present in the principal in case they
                    // changed since the authorization code/refresh token was issued.
                    identity.SetClaim(Claims.Subject, await _userManager.GetUserIdAsync(user))
                            .SetClaim(Claims.Email, await _userManager.GetEmailAsync(user))
                            .SetClaim(Claims.Name, await _userManager.GetUserNameAsync(user))
                            .SetClaim(Claims.PreferredUsername, await _userManager.GetUserNameAsync(user))
                            .SetClaim("auser_id", apiUser.Id.ToString())
                            .SetClaims(Claims.Role, [.. (await _userManager.GetRolesAsync(user))]);


                    
                }
                catch(Exception ex)
                {
                    throw new InvalidOperationException(ex.Message);
                }

                //context.IssuedClaims.Add(ApiUserIdClaim);
                //context.ValidatedRequest.ClientClaims.Add(ApiUserIdClaim);
            }
        }

        identity.SetScopes(request.GetScopes());
        identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
        identity.SetDestinations(claim => [Destinations.AccessToken, Destinations.IdentityToken]);

        // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
        return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }
    else if (request.IsClientCredentialsGrantType())
    {
        // Note: the client credentials are automatically validated by OpenIddict:
        // if client_id or client_secret are invalid, this action won't be invoked.

        var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
        if (application == null)
        {
            throw new InvalidOperationException("The application details cannot be found in the database.");
        }


        // Create the claims-based identity that will be used by OpenIddict to generate tokens.
        var identity = new ClaimsIdentity(
            authenticationType: TokenValidationParameters.DefaultAuthenticationType,
            nameType: Claims.Name,
            roleType: Claims.Role);

        // Add the claims that will be persisted in the tokens (use the client_id as the subject identifier).
        identity.SetClaim(Claims.Subject, await _applicationManager.GetClientIdAsync(application));
        identity.SetClaim(Claims.Name, await _applicationManager.GetDisplayNameAsync(application));

        // Note: In the original OAuth 2.0 specification, the client credentials grant
        // doesn't return an identity token, which is an OpenID Connect concept.
        //
        // As a non-standardized extension, OpenIddict allows returning an id_token
        // to convey information about the client application when the "openid" scope
        // is granted (i.e specified when calling principal.SetScopes()). When the "openid"
        // scope is not explicitly set, no identity token is returned to the client application.

        // Set the list of scopes granted to the client application in access_token.
        identity.SetScopes(request.GetScopes());
        identity.SetResources(await _scopeManager.ListResourcesAsync(identity.GetScopes()).ToListAsync());
        identity.SetDestinations(GetDestinations);

        return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }


    throw new InvalidOperationException("The specified grant type is not supported.");
}

API STARTUP CONFIGURATION (.AddAuthorization excluded for brevity)

builder.Services.AddDbContext(options =>
options.EnableSensitiveDataLogging(true)
.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"),
sql => sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
.MigrationsAssembly(migrationsAssembly)
.UseNetTopologySuite()
.EnableRetryOnFailure()));

// Register the OpenIddict validation components.
builder.Services.AddOpenIddict()
.AddValidation(options =>
{
// Note: the validation handler uses OpenID Connect discovery
// to retrieve the address of the introspection endpoint.
options.SetIssuer("https://localhost:44719/");
options.AddAudiences("resource_server_1", "plushtixClient");

  // Configure the validation handler to use introspection and register the client
  // credentials used when communicating with the remote introspection endpoint.
  options.UseIntrospection()
         .SetClientId("resource_server_1")
         .SetClientSecret("846B62D0-DEF9-4215-A99D-86E6B8DAB342");

  //options.AddEncryptionKey(new SymmetricSecurityKey(
  //       Convert.FromBase64String("DRjd/GnduI3Efzen9V9BvbNUfc/VKgXltV7Kbk9sMkY=")));


  // Register the System.Net.Http integration.
  options.UseSystemNetHttp();

  // Register the ASP.NET Core host.
  options.UseAspNetCore();

});

builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});

builder.Services.AddCors(options =>
{
options.AddPolicy("CorsPolicy",
builder => builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
});

Some things to note

My client is based on the Oqtane framework, which utilizes standard AspNetcore Authorization and authentication, and calls ChallengeResult to kick start the authorization_code flow.

It seems to me like the token being sent to the API is perhaps an id_token, not an access_token?

I have been down so many rabbit holes my head is spinning, but perhaps my issue is due to not using YARP? Is this a requirement in relation to my setup as I'm not sure i can implement it within the Oqtane framework.

I have always struggled with openid and oauth so please be gentle.

Any help would be much appreciated as this is driving my nuts. oh, and happy xmas. :)

@kevinchalet
Copy link
Member

kevinchalet commented Dec 28, 2024

Hey @Justincale,

Thanks for sponsoring the project, much appreciated! ❤️

I don't see any obvious mistake in the code you shared, so the issue is probably elsewhere.

I have been down so many rabbit holes my head is spinning, but perhaps my issue is due to not using YARP? Is this a requirement in relation to my setup as I'm not sure i can implement it within the Oqtane framework.

YARP is not a requirement, but it has become the de facto "standard" for implementing the backend-for-frontend pattern as most of its competitors are no longer actively developed (I can't blame them, it's hard to compete against a Microsoft-funded project 🤣). Any other proxy solution should work equally well.

It seems to me like the token being sent to the API is perhaps an id_token, not an access_token?

It's indeed one of the most common mistakes. Another reason could be that Oqtane generates its own "JWT access token" instead of sending the original access token generated by OpenIddict, which cannot work since OpenIddict requires a token with specific characteristics (e.g it must have a jwt+at token type that indicates it's an access token).

I'm not familiar with Oqtane so I'll need to spend some time taking a look at the source code. In the meantime, that would likely help if you could share the ASP.NET Core logs (make sure you lower the default log level to Trace to ensure the low-level messages logged by OpenIddict are captured).

oh, and happy xmas. :)

Merry Christmas! 🎄 🎅🏻 🎁

All the best!

@Justincale
Copy link
Author

Hi Kevin,

Thanks for the prompt response. Please find attached a trace log of a successful authorization_code flow and subsequent failed call to the API.
log20241229.txt

As per your suggestions, i have had a look at the oqtane source and can see only one place where the access_token is being set. This occurs after a successful external login via the authorization_code flow and happens here: https://github.com/oqtane/oqtane.framework/blob/d976cc6c19ee566fe4c1d6d590bdc05478591ef2/Oqtane.Server/Extensions/OqtaneSiteAuthenticationBuilderExtensions.cs#L231

If i look at the raw data returned in context.SecurityToken.RawData i can see that it does, in fact, have a type of "jwt", not "jwt+at" which is confusing as i can't see anywhere else in the Oqtane source that does anything with this token.

Some points:

  1. In the oidc settings within Oqtane i can specify the response type. At the moment this is set to "code". I have tried setting this to "code token" but when i do i receive an unsupported response type message (even when i set "rst: code token" on my application in openiddict). I believe this is due to the authorization_code flow requiring a response type of "code". I have a feeling my lack of knowledge regarding oidc may be to blame here and maybe i should be using a different flow or, perhaps, this has something to do with my configuration in openiddict?

  2. oqtane uses an Athentication Scheme of ""Identity.Application". Again, maybe a lack of knowledge on my part, but would this cause issues?

Thanks again for your help.

@kevinchalet
Copy link
Member

kevinchalet commented Dec 30, 2024

Hey Justin,

Please find attached a trace log of a successful authorization_code flow and subsequent failed call to the API.

Thanks! Unfortunately, you forgot to lower the default log level and there's no Debug/Trace logs in that file (you need to change the log level to Trace to be able to capture the tokens).

If i look at the raw data returned in context.SecurityToken.RawData i can see that it does, in fact, have a type of "jwt", not "jwt+at" which is confusing as i can't see anywhere else in the Oqtane source that does anything with this token.

OpenIddict only uses the generic JWT type for identity tokens (for backcompat' reasons) and uses specific token types for all other types (including private types like authorization codes and refresh tokens), so yeah, it's likely not a token OpenIddict produced.

I took a look and it seems Oqtane indeed produces its own tokens meant to be used by "downstream APIs":

To be honest, it's an extremely unusual approach: in the classical BFF pattern, the access token sent to the "downstream APIs" is always an access token the authorization server itself produced, not an arbitrary token minted by the BFF proxy. Since you configured your resource server/API to use introspection, the access token generated by Oqtane is sent to the authorization server, that rejects it because it didn't issue it (which is really the only acceptable outcome in this situation 😄)

I believe this is due to the authorization_code flow requiring a response type of "code". I have a feeling my lack of knowledge regarding oidc may be to blame here and maybe i should be using a different flow or, perhaps, this has something to do with my configuration in openiddict?

code token is a response type associated with the hybrid flow, so you need to enable it in the server options using options.AllowHybridFlow() if you really want to use it (in most cases, the classical authorization code is a better option, so no need to change anything, IMHO).

oqtane uses an Athentication Scheme of ""Identity.Application". Again, maybe a lack of knowledge on my part, but would this cause issues?

That scheme is used by ASP.NET Core Identity for its "main authentication cookie", which is something OpenIddict also leverages when you use it in combination with Identity (it's an extremely common case).

The root cause here is how Oqtane designed things (fun fact, I remember the author contacted me a while ago as he was interested in using OpenIddict in this project: it seems he opted for a more... creative option 😄).

I can see two options to solve that:

  • Play by Oqtane's rules by configuring your resource server/API project to accept the tokens it produces. For that, you can replace the OpenIddict validation handler with the MSFT JWT handler and configure it to use the same secret key as Oqtane. Fairly horrible approach (it's not how we're supposed to implement the BFF pattern), but it should work 😄

  • Retrieve an access token issued by OpenIddict using the standard OIDC code flow, store it in the authentication cookie and attach it to the API requests sent by the BFF proxy without relying on Oqtane (in the Dantooine sample you mentioned, we use YARP, but you can opt for a different library if you prefer). Sadly, it doesn't seem Oqtane has documentation indicating what's the best approach for that (looks like I'm not the only one struggling writing docs that cover everything 😄)

I've never used Oqtane, so it's hard for me to offer any solid advice, unfortunately. Maybe you should cross-post this thread in case its author would have more information to share?

All the best.

@Justincale
Copy link
Author

hi Kevin,

Appologies re the log file, i am using serilog which requires a log level of Verbose. Hopefully this file will shed more light on the situation.
log20241231.txt

I took a look and it seems Oqtane indeed produces its own tokens meant to be used by "downstream APIs":
AFAIK, the token generated by oqtane for "downstream api's" is used for external systems that want to access the Oqtane API instance where the token was generated. This did confuse me also and is, perhaps, poorly worded in Oqtane so I will attempt to get some clarification. The token currently getting passed to my api is not the Oqtane token, but the token retrieved from openiddict which Oqtane stores in a claim on the client side after a successful login: As can be seen on this line from the previously attached Oqtane source code:

identity.AddClaim(new Claim("access_token", context.AccessToken));

Retrieve an access token issued by OpenIddict using the standard OIDC code flow, store it in the authentication cookie and attach it to the API requests sent by the BFF proxy without relying on Oqtane (in the Dantooine sample you mentioned, we use YARP, but you can opt for a different library if you prefer)

Okay, so this is where my lack of understanding comes in. In my scenraio, am i required to use a BFF proxy? At the moment, the token i retrieve from openiddict is being attached to the authorization header of a standard HttpClient which sends the request to the API. This is how i had previously implemented Duende Identity Server.

It is worth noting that the current implementation/settings used in Oqtane are the same i was using successfully with Duende Identity Server.

@kevinchalet
Copy link
Member

Hey,

Appologies re the log file, i am using serilog which requires a log level of Verbose. Hopefully this file will shed more light on the situation.

Haha, no worries 😄

Thanks for providing the new logs, that helped a lot!
You were right, the token received by the OpenIddict validation handler is indeed an identity token (which is not legal):

2024-12-31 08:20:38.984 +08:00 [VRB] The token 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjYyMzlDQTUzNjg4MTlBMkM1Q0M5NDIxRkNCMzFFQzQ5RUMxMzU1NjEiLCJ4NXQiOiJZam5LVTJpQm1peGN5VUlmeXpIc1Nld1RWV0UiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo0NDcxOS8iLCJleHAiOjE3MzU2MDU2MzMsImlhdCI6MTczNTYwNDQzMywiYXVkIjoicGx1c2h0aXhDbGllbnQiLCJvaV9hdV9pZCI6IjI4MWMxN2NiLTc3ZGMtNDc4OC05Zjk3LTFlNjU0MDQwNDgwNCIsInN1YiI6ImM5MzY1NzkxLTRmMmEtNGVlNS1iMzA2LTFhODI1OWY4Y2M1OSIsImVtYWlsIjoianVzdGluQGlubmVydGlja2V0cy5jb20iLCJuYW1lIjoianVzdGluYyIsImdpdmVuX25hbWUiOiJKdXN0aW4iLCJmYW1pbHlfbmFtZSI6IkNhbGUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJqdXN0aW5jIiwiYXVzZXJfaWQiOjIsInBsdXNodGl4X3NlYyI6IjI6Y3J1ZHwzOmNydWR8NzpjcnVkfDEwOmNydWR8MTE6Y3J1ZHwxMjpjcnVkfDEzOmNydWR8MTg6Y3J1ZHwxOTpjcnVkfDIzOmNydWR8MjQ6Y3J1ZHwyNTpjcnVkfDI2OmNydWR8Mjc6Y3J1ZHwyOTpjcnVkfDM2OmNydWR8Mzc6Y3J1ZHwzODpjcnVkfDQwOmNydWR8NTA6Y3J1ZHw1MzpjcnVkIiwiYXpwIjoicGx1c2h0aXhDbGllbnQiLCJub25jZSI6IjYzODcxMjAxMjI0OTExODk4My5NR00xTldVM056RXRabU0wWkMwME9HWmpMV0ptTnpRdE56SmlOemRrTlRVeU56Y3dNRFkzT0dRMll6UXRabUpqWlMwME5tSXhMV0ZpT0RFdFptWXdZV1ZtTkRZelpHWmsiLCJhdF9oYXNoIjoibF81TTZDNkEzeTJQMS05OUZZOVdQUSIsIm9pX3Rrbl9pZCI6IjU3MmViMWQxLTg0YTctNDU0OC1hODA1LWE0MzUyNjNjNTdiYSJ9.eWsJPdcaxRYGLab5kMHvqYblCUSDrdsAaMjo1T81W3yXfXMSUUMCuz6Q3l_KIGKnlsX7mR7p-o5pAnhOl6K-aLiMXsa2GYBPN31ewhlSxer-ZCVrTOatZ1OwALDw_Vlg75AQXvafZBGZntVPxj-jICX-UercWzj4PsDCnguletwugWjFCIEeZLDuXFKN3Twu0lDb6DJP6qZeJl2RDbMqKoALkrupyRnsJQ1HfDQXF6H40TIKMT8JEyOHsquh0s2VBMxPkEkKie2Qr8WYymHFZnBs7YuBnpMTHUhhvCRlIXu2oe4SsWwMmsgglUI40um9gIwHj_zsZccD14kORmpIUg' was successfully validated and the following claims could be extracted: ["iss: https://localhost:44719/","exp: 1735605633","iat: 1735604433","aud: plushtixClient","oi_au_id: 281c17cb-77dc-4788-9f97-1e6540404804","sub: c9365791-4f2a-4ee5-b306-1a8259f8cc59","email: [email protected]","name: justinc","given_name: Justin","family_name: Cale","preferred_username: justinc","auser_id: 2","plushtix_sec: 2:crud|3:crud|7:crud|10:crud|11:crud|12:crud|13:crud|18:crud|19:crud|23:crud|24:crud|25:crud|26:crud|27:crud|29:crud|36:crud|37:crud|38:crud|40:crud|50:crud|53:crud","azp: plushtixClient","nonce: 638712012249118983.MGM1NWU3NzEtZmM0ZC00OGZjLWJmNzQtNzJiNzdkNTUyNzcwMDY3OGQ2YzQtZmJjZS00NmIxLWFiODEtZmYwYWVmNDYzZGZk","at_hash: l_5M6C6A3y2P1-99FY9WPQ","oi_tkn_id: 572eb1d1-84a7-4548-a805-a435263c57ba","oi_tkn_typ: id_token"].

At the moment, the token i retrieve from openiddict is being attached to the authorization header of a standard HttpClient which sends the request to the API. This is how i had previously implemented Duende Identity Server.

It's worth noting that the OpenIddict validation handler has much stricter validation rules than the MSFT JWT bearer handler that IdentityServer uses, specially regarding the type of token received: it's very likely you were already using an identity token, but the JWT bearer handler wasn't complaining because it wasn't configured to check the token type. Now that you've migrated to OpenIddict and its validation handler, using "an identity token as an access token" is no longer possible due to OpenIddict's stricter rules.

Okay, so this is where my lack of understanding comes in. In my scenraio, am i required to use a BFF proxy?

There are basically two options for using OAuth 2.0/OIDC with a SPA application:

  • Implementing OIDC at the SPA level, using a dedicated client library that will handle everything client-side and store the authentication result in the session/local storage. Blazor WASM offers an OIDC client, but it's sadly based on a deprecated OIDC JavaScript client implementation.
  • Using a backend-for-frontend/reverse proxy and implementing OIDC at the BFF-level so that all the OIDC dance is handled server-side (your SPA uses cookie authentication to communicate with its backend).

The issue with Blazor is that it heavily blurs the line between what happens server-side and client-side, depending the Blazor model you choose...

@Justincale
Copy link
Author

Thanks Kevin, if I am understanding you correctly, I need to implement bff in a blazor server configuration which will, as you say, do the dance between openiddict and the api, resolving my id token for a token the openiddict validator can accept?

If so, I don’t mind forcing my clients to use blazor server as it is the best fit for my system anyway, but I think I’ll need to start a conversation with the Oqtane dev’s and see what there thoughts are in relation to implementing bff for external api calls. I’m not sure they will want to lock the Oqtane code base into a specific BFF framework and I’m not sure I will be able to implement my own within a custom Oqtane module due to the way Oqtane registers services at startup (although I could be wrong).

Anyhow, if you could confirm re the bff dance, or let me know if you think there are any quick workarounds I could use to temporarily get past this obstacle, that would be great. And thanks, as always, for your help.

@kevinchalet
Copy link
Member

kevinchalet commented Jan 1, 2025

Hey @Justincale,

Happy New Year! 🎉

I need to implement bff in a blazor server configuration which will, as you say, do the dance between openiddict and the api, resolving my id token for a token the openiddict validator can accept?

If you decide to opt for the BFF pattern, yes, except you need to use access (and refresh) tokens, not identity tokens.
I'd recommend taking a look at the https://github.com/openiddict/openiddict-samples/tree/dev/samples/Dantooine/Dantooine.WebAssembly.Server sample to see how:

  • ... the access and refresh tokens are stored in the authentication cookie during the OIDC login dance:
    // If needed, the tokens returned by the authorization server can be stored in the authentication cookie.
    //
    // To make cookies less heavy, tokens that are not used are filtered out before creating the cookie.
    properties.StoreTokens(result.Properties.GetTokens().Where(token => token.Name is
    // Preserve the access, identity and refresh tokens returned in the token response, if available.
    //
    // The expiration date of the access token is also preserved to later determine
    // whether the access token is expired and proactively refresh tokens if necessary.
    OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
    OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessTokenExpirationDate or
    OpenIddictClientAspNetCoreConstants.Tokens.BackchannelIdentityToken or
    OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken));
  • ... access tokens are refreshed (if necessary) and attached to outgoing HTTP requests sent by the BFF proxy app: https://github.com/openiddict/openiddict-samples/blob/dev/samples/Dantooine/Dantooine.WebAssembly.Server/Helpers/TokenRefreshingDelegatingHandler.cs
  • ... everything is glued together (using YARP, in this case):
    services.AddReverseProxy()
    .LoadFromConfig(Configuration.GetSection("ReverseProxy"))
    .AddTransforms(builder =>
    {
    builder.AddRequestTransform(async context =>
    {
    // Attach the access token, access token expiration date and refresh token resolved from the authentication
    // cookie to the request options so they can later be resolved from the delegating handler and attached
    // to the request message or used to refresh the tokens if the server returned a 401 error response.
    //
    // Alternatively, the user tokens could be stored in a database or a distributed cache.
    var result = await context.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    context.ProxyRequest.Options.Set(
    key : new(Tokens.BackchannelAccessToken),
    value: result.Properties.GetTokenValue(Tokens.BackchannelAccessToken));
    context.ProxyRequest.Options.Set(
    key : new(Tokens.BackchannelAccessTokenExpirationDate),
    value: result.Properties.GetTokenValue(Tokens.BackchannelAccessTokenExpirationDate));
    context.ProxyRequest.Options.Set(
    key : new(Tokens.RefreshToken),
    value: result.Properties.GetTokenValue(Tokens.RefreshToken));
    });
    builder.AddResponseTransform(async context =>
    {
    // If tokens were refreshed during the request handling (e.g due to the stored access token being
    // expired or a 401 error response being returned by the resource server), extract and attach them
    // to the authentication cookie that will be returned to the browser: doing that is essential as
    // OpenIddict uses rolling refresh tokens: if the refresh token wasn't replaced, future refresh
    // token requests would end up being rejected as they would be treated as replayed requests.
    if (context.ProxyResponse is not TokenRefreshingHttpResponseMessage {
    RefreshTokenAuthenticationResult: RefreshTokenAuthenticationResult } response)
    {
    return;
    }
    var result = await context.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    // Override the tokens using the values returned in the token response.
    var properties = result.Properties.Clone();
    properties.UpdateTokenValue(Tokens.BackchannelAccessToken, response.RefreshTokenAuthenticationResult.AccessToken);
    properties.UpdateTokenValue(Tokens.BackchannelAccessTokenExpirationDate,
    response.RefreshTokenAuthenticationResult.AccessTokenExpirationDate?.ToString(CultureInfo.InvariantCulture));
    // Note: if no refresh token was returned, preserve the refresh token initially returned.
    if (!string.IsNullOrEmpty(response.RefreshTokenAuthenticationResult.RefreshToken))
    {
    properties.UpdateTokenValue(Tokens.RefreshToken, response.RefreshTokenAuthenticationResult.RefreshToken);
    }
    // Remove the redirect URI from the authentication properties
    // to prevent the cookies handler from genering a 302 response.
    properties.RedirectUri = null;
    // Note: this event handler can be called concurrently for the same user if multiple HTTP
    // responses are returned in parallel: in this case, the browser will always store the latest
    // cookie received and the refresh tokens stored in the other cookies will be discarded.
    await context.HttpContext.SignInAsync(result.Ticket.AuthenticationScheme, result.Principal, properties);
    });
    });
    // Replace the default HTTP client factory used by YARP by an instance able to inject the HTTP delegating
    // handler that will be used to attach the access tokens to HTTP requests or refresh tokens if necessary.
    services.Replace(ServiceDescriptor.Singleton<IForwarderHttpClientFactory, TokenRefreshingForwarderHttpClientFactory>());

If so, I don’t mind forcing my clients to use blazor server as it is the best fit for my system anyway, but I think I’ll need to start a conversation with the Oqtane dev’s and see what there thoughts are in relation to implementing bff for external api calls.

FWIW, I emailed Shaun Walker 2 days ago to let him know about this thread but I haven't received a reply yet, sadly.

Anyhow, if you could confirm re the bff dance, or let me know if you think there are any quick workarounds I could use to temporarily get past this obstacle, that would be great.

I guess the quickest workaround would simply be to update your code that sends the identity token (which isn't a legal operation) to send the access token generated by OpenIddict instead.

👇🏻

At the moment, the token i retrieve from openiddict is being attached to the authorization header of a standard HttpClient which sends the request to the API.

And thanks, as always, for your help.

My pleasure! Thanks for sponsoring the project 😃

@Justincale
Copy link
Author

Hi Kevin, I am going to close this off for now. I have created a thread over at Oqtane which i have tagged you in: oqtane/oqtane.framework#4964. I'll see what Shaun Walker comes back with as i believe this has more to do with the Oqtane implementation of oidc than openiddict. I will start another thread if needed. Thanks again for your help.

@kevinchalet
Copy link
Member

Hey @Justincale,

I will start another thread if needed.

No problem. Feel free to re-open this one if it's easier for you 👍🏻

All the best.

@Justincale
Copy link
Author

Hi @kevinchalet, I just wanted to let you know that the issue has been tracked down to Oqtane defaulting SaveTokens = false. We are in discussion to change this so a default installation has SaveTokens = true. This solves most of the issues I was having, I can work around the rest. Thanks again for everything, great work by the way. 🙂

@kevinchalet
Copy link
Member

Hey @Justincale,

Thanks for letting me know!

Hi @kevinchalet, I just wanted to let you know that the issue has been tracked down to Oqtane defaulting SaveTokens = false.

That's interesting (and a bit surprising). Now, I'm wondering: how was your code able to retrieve the identity token stored in the authentication cookie (and incorrectly used as an "access token" in API calls) if it wasn't stored in the first place? 🫨

We are in discussion to change this so a default installation has SaveTokens = true.

It's worth noting that this option will store all the tokens returned by the authorization server (including identity tokens), which will make authentication cookies much larger. Some browsers (e.g Safari) are known to enforce extremely strict per-domain limits, which may result in authentication issues if parts of the authentication cookie's chunks are discarded. Similarly, server stacks like IIS have maximal lengths allowed for request headers (which includes cookies).

Definitely something to keep in mind before making that the default value 😃

Thanks again for everything, great work by the way. 🙂

Thanks for your kind words! ❤️

All the best.

@Justincale
Copy link
Author

That's interesting (and a bit surprising). Now, I'm wondering: how was your code able to retrieve the identity token stored in the authentication cookie (and incorrectly used as an "access token" in API calls) if it wasn't stored in the first place?

This is what was confusing to me also. This is what i have pieced together:

  1. Oqtane creates an oidc event for OnTokenValidated which maps some claims and sets identity.AddClaim(new Claim("access_token", context.SecurityToken.RawData)) after a successful oidc login;

  2. Oqtane has a settings section where a host user of the Oqtane installation can set the following:

Screenshot 2025-01-05 101807

  1. The details entered above are used, in Oqtane, to Generate a new JWTToken via a call to JwtSecurityTokenHandler.CreateToken. This new token includes the user roles, and the access_token claim in point 1, and is saved to a SiteSetting named AuthorizationToken.

  2. Oqtane supplies a RemoteServiceBase class which uses the AuthorizationToken as the bearer token for downstream api calls.

So, when i started migrating my application into Oqtane, i was using duende as an ID Server. The AuthozationCode being sent to my API obviously wasn't working as it was issued by Oqtane, but i found that if i sent the "access_token" from step 1 instead, i could jig my API code to accept it.

Then, after moving from Duende to openiddict, my api calls failed due to the "access_token" not being an access_token at all.

It seems from the above, and what Shaun over at Oqtane has said, that the whole thing in Oqtane has been designed to allow a specific Oqtane client to make downstream calls to a specific API which has a relationship with the client (ie: the API should only expect tokens from a single, well know, Oqtane client).

The solution so far: Set SaveTokens = true and fire up my own HttpClient which sends the actual access_token from httpContext.GetTokenAsync("access_token") to my API. This is working, i thought, until........

It's worth noting that this option will store all the tokens returned by the authorization server (including identity tokens), which will make authentication cookies much larger. Some browsers (e.g Safari) are known to enforce extremely strict per-domain limits, which may result in authentication issues if parts of the authentication cookie's chunks are discarded. Similarly, server stacks like IIS have maximal lengths allowed for request headers (which includes cookies).

Okay, i'm starting to get the picture here re your previous post re the dantooine AuthenticationController. I think Oqtane is going to need some mod's in order to accommodate what i am trying to achieve. Let me go back to Shaun over at Oqtane and see what his thoughts are in relation to how we move forward.

Thanks @kevinchalet :)

@Justincale Justincale reopened this Jan 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants