The behavior you’re seeing is expected:
OpenIdConnectOptions is registered as a singleton per scheme.ctx.Options.ClientId / ctx.Options.Authority inside an event (e.g. OnRedirectToIdentityProvider), you are mutating that singleton.To make ClientId and Authority per-request and per-tenant:
ctx.Options.* in the event.ctx.ProtocolMessage, which is specific to the current redirect.Example:
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using Volo.Abp.MultiTenancy;
public class TenantOpenIdConnectEvents : IConfigureNamedOptions<OpenIdConnectOptions>
{
private readonly IConfiguration _configuration;
private readonly ICurrentTenant _currentTenant;
public TenantOpenIdConnectEvents(
IConfiguration configuration,
ICurrentTenant currentTenant)
{
_configuration = configuration;
_currentTenant = currentTenant;
}
public void Configure(string name, OpenIdConnectOptions options)
{
if (name != "oidc") return; // your scheme name
options.Events ??= new OpenIdConnectEvents();
var existingRedirect = options.Events.OnRedirectToIdentityProvider;
options.Events.OnRedirectToIdentityProvider = async context =>
{
// Determine tenant (you said URL-based tenant resolution already works)
var tenantName = _currentTenant.Name; // or from Host header if needed
// Get per-tenant settings from configuration (or any store) –
// no DB needed on the Blazor server, just a mapping.
// appsettings.json:
// "TenantAuth": {
// "tenantA": { "Authority": "https://auth-a.example.com", "ClientId": "ClientA" },
// "tenantB": { "Authority": "https://auth-b.example.com", "ClientId": "ClientB" }
// }
var section = _configuration.GetSection("TenantAuth");
if (!tenantName.IsNullOrWhiteSpace())
{
var tenantSection = section.GetSection(tenantName!);
var authority = tenantSection["Authority"];
var clientId = tenantSection["ClientId"];
if (!string.IsNullOrEmpty(authority))
{
// Set per-request issuer address for this redirect
context.ProtocolMessage.IssuerAddress =
authority.TrimEnd('/') + "/connect/authorize";
}
if (!string.IsNullOrEmpty(clientId))
{
// Set per-request client id – DO NOT touch options.ClientId here
context.ProtocolMessage.ClientId = clientId;
}
}
if (existingRedirect != null)
{
await existingRedirect(context);
}
};
}
public void Configure(OpenIdConnectOptions options)
=> Configure(Options.DefaultName, options);
}
Register:
services.AddSingleton<IConfigureOptions<OpenIdConnectOptions>, TenantOpenIdConnectEvents>();
Key points:
options.ClientId / options.Authority stay as a default (e.g. host or fallback).context.ProtocolMessage.ClientIdcontext.ProtocolMessage.IssuerAddressThis pattern works fine even when the Blazor server has no DB access; just keep a configuration-based map from tenant (or domain) →
Authority/ClientId.
Relevant docs for how ABP wires OIDC (for reference of where these events fit):
A few important clarifications:
AbpController / ApplicationService base classes.[Authorize] works on any controller; only the authentication configuration and attributes matter.AddAuthentication / AddJwtBearer or OpenIddict validation.2.1. Ensure the same authentication scheme is used
Check:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// your issuer / audience settings
options.RequireHttpsMetadata = true;
// Custom issuer validator – MUST be here
options.TokenValidationParameters.ValidAudience = "your-api";
options.TokenValidationParameters.IssuerValidator = (issuer, token, parameters) =>
{
// your custom logic, e.g. per-tenant issuers
// return a valid issuer string or throw
return issuer;
};
});
[Authorize] // IMPORTANT: do NOT override the scheme unless you really need to
public class ThirdPartyController : ThirdPartyBaseController
{
// ...
}
Common pitfalls:
[Authorize(AuthenticationSchemes = "Bearer")] while you configured the custom IssuerValidator only on the default scheme (JwtBearerDefaults.AuthenticationScheme), or vice versa.AddJwtBearer(...) calls with different scheme names and only configuring the IssuerValidator on one.Make sure:
[Authorize] on your custom controller is the one where you attached the custom IssuerValidator.2.2. YARP forwarding and Invalid_issuer from the downstream service
From your description:
401, 302, sometimes Invalid_issuer.This means:
Invalid_issuer occurs.Checklist:
Authorization header as-is:services.AddReverseProxy()
.LoadFromConfig(Configuration.GetSection("ReverseProxy"));
// default YARP preserves Authorization; only custom transforms may strip it
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = true;
options.TokenValidationParameters.ValidateIssuer = true;
// Either allow all tenant issuers:
options.TokenValidationParameters.ValidIssuers = new[]
{
"https://auth-tenantA.example.com/",
"https://auth-tenantB.example.com/",
// ...
};
// Or use the same custom validator you use in your ABP host:
options.TokenValidationParameters.IssuerValidator = (issuer, token, parameters) =>
{
// same logic as ABP host, e.g. look up tenant by issuer
return issuer;
};
});
If you’re using OpenIddict validation instead of pure JwtBearer, configure it similarly on that service.
AddJwtBearer("SomeScheme", ...)) while callers send a token to the default Bearer scheme without the same IssuerValidator.2.3. Why the custom validator is “never called” for the custom controller
There are only a few reasons this can happen inside the same host:
[Authorize] / default scheme).[AllowAnonymous] (not your case) or uses a policy mapped to a different scheme.Action steps:
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(...);
[Authorize]
[ApiController]
[Route("api/thirdparty")]
public class ThirdPartyController : ThirdPartyBaseController
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ThirdPartyController(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
[HttpGet("debug-auth")]
public IActionResult DebugAuth()
{
var user = _httpContextAccessor.HttpContext!.User;
// Inspect claims/principal to confirm which scheme authenticated it
return Ok(new { authenticated = user.Identity?.IsAuthenticated, name = user.Identity?.Name });
}
}
If IsAuthenticated is false here while it’s true on app services for the same token, that confirms the scheme mismatch problem.
Per-tenant OIDC config in Blazor Web App:
ctx.Options.ClientId or .Authority inside events; that changes them globally.ctx.ProtocolMessage.ClientIdctx.ProtocolMessage.IssuerAddressICurrentTenant or domain, and keep a config mapping for each tenant’s Authority / ClientId.Custom controller + YARP + Invalid_issuer:
AbpController vs 3rd-party base) is irrelevant to authentication.Invalid_issuer you see is thrown by the downstream tenant-specific service; configure the same issuer validation (ValidIssuers or IssuerValidator) there.Authorization header and that the downstream API’s auth configuration matches the expectations of the token’s issuer.Sources (for patterns used above):
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
MvcCachedApplicationConfigurationClientHelper.CreateCacheKey(CurrentUser) builds a cache key per user (tenant + user id + culture).MvcCurrentApplicationConfigurationCacheResetEventHandler.HandleEventAsync does:public virtual async Task HandleEventAsync(CurrentApplicationConfigurationCacheResetEventData eventData)
{
await Cache.RemoveAsync(CreateCacheKey());
}
protected virtual string CreateCacheKey()
{
return MvcCachedApplicationConfigurationClientHelper.CreateCacheKey(CurrentUser);
}
CurrentUser here is the user who is executing the current request (the one who publishes CurrentApplicationConfigurationCacheResetEventData).So when your admin user changes settings/permissions and triggers the reset event, the handler removes only the admin’s cache entry, because CurrentUser is admin.
Other users’ cache keys remain in Redis and expire only when the cache timeout (5 minutes) is reached. That matches exactly what you’re seeing: “deleted is always admin, other users can’t be deleted, only wait 5 minutes timeout”.
This is by design: CurrentApplicationConfigurationCacheResetEventData is meant to reset the current user’s application configuration, not all users.
/api/abp/application-configuration again and refreshes its cache, orChoose depending on your requirement:
If it is acceptable that other users see updated permissions after at most 5 minutes (or after they refresh / re-login), then you don’t need any change. This is ABP’s default behavior.
You can lower the cache duration if 5 minutes is too long by customizing distributed cache options for ApplicationConfigurationDto, but that increases load:
Configure<AbpDistributedCacheOptions>(options =>
{
options.CacheConfigurators.Add(cacheName =>
{
if (cacheName == typeof(ApplicationConfigurationDto).FullName)
{
return entryOpts =>
{
// e.g. 1 minute
entryOpts.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
};
}
return null;
});
});
If you prefer always fresh config and can accept more backend traffic:
Configure<AbpAspNetCoreMvcClientOptions>(options =>
{
options.CacheApplicationConfiguration = false;
});
Then MvcCurrentApplicationConfigurationCacheResetEventHandler effectively becomes unnecessary because nothing is cached.
If you really need “when admin changes something, all users must see it immediately”:
Keep using distributed cache, but:
That would require:
MvcCachedApplicationConfigurationClient (or helper) to use your own cache key strategy.This is not provided out-of-the-box; you must implement and test it yourself.
MvcCurrentApplicationConfigurationCacheResetEventHandler.HandleEventAsync always uses the CurrentUser of the request that fired the event.admin’s cache entries are removed.If you need immediate reflection for other users, use option B (disable cache) or implement a custom global invalidation strategy as in option C.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
[Authorize(ChildProjectPermissions.Something)] on ChildProject endpoints…as long as both API hosts validate the same access token correctly and the permissions are defined/seeded consistently.
Your 401 means the ChildProject is not accepting the token (authentication fails) before it even reaches the ABP permission system.
In ChildProject.HttpApi.Host:
appsettings.json
"AuthServer": {
"Authority": "https://MAIN-PROJECT-AUTH-URL", // MainProject Auth server URL
"RequireHttpsMetadata": "false", // true in production with HTTPS
"SwaggerClientId": "ChildProject_Swagger" // optional, for swagger auth
},
"Jwt": {
"Authority": "https://MAIN-PROJECT-AUTH-URL",
"Audience": "ChildProject" // see step 3
}
(or follow the same AuthServer section structure you already use in MainProject HttpApi.Host, just changing the Audience if needed.)
HttpApiHostModule – ConfigureServices
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetRequiredService<IConfiguration>();
ConfigureAuthentication(context, configuration);
}
private void ConfigureAuthentication(ServiceConfigurationContext context, IConfiguration configuration)
{
context.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = configuration["AuthServer:Authority"];
options.RequireHttpsMetadata =
Convert.ToBoolean(configuration["AuthServer:RequireHttpsMetadata"]);
options.Audience = "ChildProject"; // must match the audience of the token
// (temporarily you can disable audience validation to test)
// options.TokenValidationParameters.ValidateAudience = false;
});
}
Startup pipeline (OnApplicationInitialization)
Make sure the middleware order includes both authentication and authorization:
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
app.UseCorrelationId();
app.UseRouting();
app.UseCors();
app.UseAuthentication(); // BEFORE multi-tenancy & authorization
app.UseAbpRequestLocalization();
app.UseMultiTenancy();
app.UseAuthorization();
app.UseSwagger();
app.UseAbpSwaggerUI(options => { ... });
app.UseAuditing();
app.UseConfiguredEndpoints();
}
If UseAuthentication() is missing or in wrong order, you will also constantly get 401.
The token you get from MainProject must be accepted by ChildProject:
In MainProject Auth Server (OpenIddict data seed / identity seed contributor), you must define an API scope / resource that represents ChildProject, for example "ChildProject".
Add that scope to the client that obtains the token (Angular, Swagger, Postman client, etc.).
When you request a token, request that scope:
ChildProject in the scopes.scope=... ChildProject.The resulting token must either:
aud = "ChildProject", and you set options.Audience = "ChildProject", orValidateAudience = false (for testing only) so that any token from the same authority is accepted.If the aud in the token doesn’t match what ChildProject expects, you will see 401 from the JWT middleware, even though the same token works on the MainProject API Host.
To be able to use:
[Authorize(xxxWebAPIPermissions.WCFService.Default)]
public class MyChildController : AbpController
{
...
}
do the following:
Permission definition provider
Put your xxxWebAPIPermissions and the corresponding PermissionDefinitionProvider in a shared application contracts project that is referenced by:
Module dependency
In ChildProjectApplicationContractsModule (or HttpApi module), depend on the module that contains the permission provider:
[DependsOn(
typeof(AbpAuthorizationAbstractionsModule),
typeof(MySharedPermissionsModule) // where your PermissionDefinitionProvider lives
)]
public class ChildProjectApplicationContractsModule : AbpModule
{
}
If authentication is correct and the user really has the permission, then failing [Authorize(…)] would give 403 Forbidden, not 401.
Since you get 401, it’s almost certainly token validation (authentication) in ChildProject, not the permission policy itself.
On ChildProject HttpApi.Host:
UseAuthentication() is present and before UseAuthorization().AddAuthentication().AddJwtBearer() is configured to use MainProject AuthServer Authority.Audience and token’s aud (or ValidateAudience = false) are aligned.Once these are done, you can:
Authorization: Bearer <token>.[Authorize(xxxWebAPIPermissions.WCFService.Default)] will work as expected.This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Verify you run a single-origin Blazor Server host
Don’t add the “access_token to Authorization” middleware
Ensure ABP antiforgery defaults aren’t overridden
Confirm ChatSignalRModule is active in the same host
using Volo.Abp.Modularity;
using Volo.Chat.Blazor.Server;
[DependsOn(typeof(ChatBlazorServerModule))]
public class YourAppBlazorModule : AbpModule
{
}
Check app startup logs to see endpoint mapping for /signalr-hubs/chat.
Check SameSite and HTTPS
Known ASP.NET Core SignalR option on Blazor Server
Configure<Microsoft.AspNetCore.SignalR.HubOptions>(options =>
{
options.DisableImplicitFromServicesParameters = true;
});
This is a general ASP.NET Core quirk; not normally required for the Chat module, but harmless if added.
If you align to a single Blazor Server host (no gateway), leave cookie auth only, and keep ABP antiforgery defaults, the negotiate POST will succeed and the chat toolbar component will connect without the antiforgery 400.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Your error indicates the antiforgery protection blocked the SignalR negotiate request. In ABP, antiforgery is automatically enforced for browser-originated POSTs, and the client sends the RequestVerificationToken header automatically when using ABP’s HTTP pipeline. However, SignalR’s negotiate POST (from the chat client component) must also include the antiforgery token. In Blazor Server, this works when:
Do the following checks/fixes:
Ensure correct module dependencies in your Blazor Server host (single host)
Keep cookie auth only (no bearer conversion)
Do not disable ABP antiforgery
Same-site and domain consistency
Verify hub mapping and startup
Permissions and Feature are already set (you did this), but confirm the logged-in user stays authenticated during the negotiate call (no redirect/401). If your layout triggers a redirect for unauthenticated negotiate, the antiforgery error can follow.
After aligning the above, the negotiate POST should include antiforgery automatically in Blazor Server and succeed with cookie auth, and the chat toolbar component will connect without the 400.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Install the correct packages into the Blazor Server host
Add module dependencies
using Volo.Abp.Modularity;
using Volo.Chat.Blazor;
using Volo.Chat.SignalR;
[DependsOn(
typeof(ChatBlazorModule),
typeof(ChatSignalRModule)
)]
public class YourAppBlazorModule : AbpModule
{
}
using Volo.Abp.Modularity;
using Volo.Chat;
using Volo.Chat.SignalR;
[DependsOn(
typeof(ChatHttpApiModule),
typeof(ChatApplicationModule),
typeof(ChatSignalRModule)
)]
public class YourAppHttpApiHostModule : AbpModule
{
}
Enable the Chat feature (tenant/host)
Grant Chat permissions to the user/role
Ensure SignalR hub is reachable
app.Use(async (ctx, next) =>
{
var accessToken = ctx.Request.Query["access_token"];
if (!string.IsNullOrEmpty(accessToken) &&
ctx.Request.Path.StartsWithSegments("/signalr-hubs/chat"))
{
ctx.Request.Headers["Authorization"] = "Bearer " + accessToken;
}
await next();
});
Blazor Server toolbar visibility
Database/migration
Common pitfalls
If you complete the steps above, the chat icon should appear in the top toolbar and the API calls to /api/chat/... and SignalR to /signalr-hubs/chat should succeed.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
A) Remove the tenant switch UI on the Account layout (server-side) for LeptonX
B) Add a Tenant Name textbox to the Angular login component and send it with the login request
Example steps:
Create a form control and UI for tenant input:
Set the tenant header before login:
Example (illustrative, place inside your custom login component):
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { RestService } from '@abp/ng.core';
import { AccountService } from '@abp/ng.account/public/config'; // or your own auth call
@Component({
selector: 'app-custom-login',
template: `
<form [formGroup]="form" (ngSubmit)="login()">
<label>Tenant Name</label>
<input formControlName="tenantName" type="text" autocomplete="organization" />
<label>Username</label>
<input formControlName="username" type="text" autocomplete="username" />
<label>Password</label>
<input formControlName="password" type="password" autocomplete="current-password" />
<button type="submit" [disabled]="form.invalid || submitting">Login</button>
</form>
`
})
export class CustomLoginComponent {
private fb = inject(FormBuilder);
private rest = inject(RestService);
private account = inject(AccountService); // if you use the ABP Account endpoints
submitting = false;
form = this.fb.group({
tenantName: [''],
username: ['', Validators.required],
password: ['', Validators.required],
rememberMe: [false],
});
private setTenantCookie(name: string | null | undefined) {
// When empty, clear cookie; when not empty, set cookie.
const cookieName = 'abp.tenant';
const encoded = name ? encodeURIComponent(name) : '';
const expires = new Date();
expires.setDate(expires.getDate() + 30);
if (encoded) {
document.cookie = `${cookieName}=${encoded}; path=/; expires=${expires.toUTCString()}; SameSite=Lax`;
} else {
// clear
document.cookie = `${cookieName}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax`;
}
}
async login() {
if (this.form.invalid) return;
this.submitting = true;
try {
const tenantName = (this.form.value.tenantName || '').trim();
// Ensure the backend resolves the request under this tenant:
this.setTenantCookie(tenantName || null);
// If you call login via a custom HTTP request, you can also set __tenant header:
// Example:
// await this.rest.request({
// method: 'POST',
// url: '/api/account/login',
// body: { userName: this.form.value.username, password: this.form.value.password },
// headers: tenantName ? { __tenant: tenantName } : {}
// }).toPromise();
// If using ABP AccountService (OIDC flow), just trigger sign-in flow after setting cookie
await this.account.navigateToLogin(); // or start the flow used in your app
} finally {
this.submitting = false;
}
}
}
Notes:
C) Ensure tenant resolution is configured to accept cookie/header
Example to ensure defaults (in your HttpApiHostModule or AuthServer):
using Volo.Abp.MultiTenancy;
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpTenantResolveOptions>(options =>
{
// Keep default resolvers, or ensure header/cookie are present
// options.TenantResolvers.Clear();
// options.TenantResolvers.Add(new HeaderTenantResolveContributor());
// options.TenantResolvers.Add(new CookieTenantResolveContributor());
// options.TenantResolvers.Add(new QueryStringTenantResolveContributor());
});
}
Outcome
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
What they do
Why ABP moved from CheckTokenExpiration to IntrospectAccessToken for Blazor Server templates
When to use which
How to configure in Blazor Server (ABP 8.x)
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, options =>
{
// Preferred in v8.x:
options.IntrospectAccessToken(oidcAuthenticationScheme: OpenIdConnectDefaults.AuthenticationScheme);
// If you cannot introspect, fallback:
// options.CheckTokenExpiration(); // optional: pass advance/validationInterval
});
}
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
[Collection(MyAppTestConsts.CollectionDefinitionName)]
public class EfCore_ProductAppService_Tests
: ProductAppService_Tests<MyAppEntityFrameworkCoreTestModule>
{
}
public static class MyAppTestConsts
{
public const string CollectionDefinitionName = "MyApp collection";
}
[CollectionDefinition(MyAppTestConsts.CollectionDefinitionName)]
public class MyAppCollection : ICollectionFixture<MyAppEntityFrameworkCoreFixture>
{
}
public class MyAppEntityFrameworkCoreFixture : IDisposable
{
public SqliteConnection Connection { get; }
public MyAppEntityFrameworkCoreFixture()
{
Connection = new SqliteConnection("Data Source=:memory:");
Connection.Open();
}
public void Dispose() => Connection.Dispose();
}
[DependsOn(typeof(MyAppApplicationTestModule), typeof(MyAppEntityFrameworkCoreModule), typeof(AbpEntityFrameworkCoreSqliteModule))]
public class MyAppEntityFrameworkCoreTestModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var conn = context.Services.GetRequiredService<MyAppEntityFrameworkCoreFixture>().Connection;
Configure<AbpDbContextOptions>(options =>
{
options.Configure(cfg => cfg.DbContextOptions.UseSqlite(conn));
});
}
}
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
Checklist to fix:
// Mobile (React Native) client
await CreateClientAsync(
name: "MyApp_Mobile",
scopes: new[] { "openid", "profile", "email", "offline_access", "MyService" },
grantTypes: new[] { "authorization_code" },
secret: null, // PKCE, public client
requireClientSecret: false,
redirectUri: "com.myapp.mobile://callback",
postLogoutRedirectUri: "com.myapp.mobile://signout-callback",
corsOrigins: new[] { "https://your-api-host", "https://your-auth-host" }
);
"App": {
"SelfUrl": "https://api.staging.myproject.io",
"CorsOrigins": "https://*.myproject.io,http://localhost:19006,exp://127.0.0.1:19000",
"RedirectAllowedUrls": "com.myapp.mobile://callback,com.myapp.mobile://signout-callback"
},
"AuthServer": {
"Authority": "https://auth.staging.myproject.io",
"RequireHttpsMetadata": "true"
}
Why 400 appears:
Quick minimal set you can copy:
await CreateClientAsync(
name: "MyApp_Mobile",
scopes: new[] { "openid", "profile", "email", "offline_access", "MyService" },
grantTypes: new[] { "authorization_code" },
requireClientSecret: false,
redirectUri: "com.myapp.mobile://callback",
postLogoutRedirectUri: "com.myapp.mobile://signout-callback",
corsOrigins: new[] { "https://auth.staging.myproject.io", "https://api.staging.myproject.io" }
);
"App": {
"CorsOrigins": "https://*.myproject.io,http://localhost:19006,exp://127.0.0.1:19000",
"RedirectAllowedUrls": "com.myapp.mobile://callback,com.myapp.mobile://signout-callback"
}
export const oAuthConfig = {
issuer: 'https://auth.staging.myproject.io',
clientId: 'MyApp_Mobile',
redirectUri: 'com.myapp.mobile://callback',
scopes: ['openid', 'profile', 'email', 'offline_access', 'MyService'],
};
If you apply the above, the React Native login should complete and the token exchange should return 200 with tokens instead of 400.
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.