fix(auth): return 401 instead of 302 for API challenges; persist dev signing key across restarts

Root cause of the 404 on /api/admin/moysklad/test (and /api/me):
- AddIdentity<> sets DefaultChallengeScheme = IdentityConstants.ApplicationScheme
  (cookies), so unauthorized API calls got 302 → /Account/Login → 404 instead of 401.
- Ephemeral OpenIddict keys (AddEphemeralSigningKey) regenerated on every API
  restart, silently invalidating any JWT already stored in the browser.

Fixes:
- Explicitly set DefaultScheme / DefaultAuthenticateScheme / DefaultChallengeScheme
  to OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme so [Authorize]
  challenges now return 401 (axios interceptor can react + retry or redirect).
- Replace ephemeral RSA keys with a persistent dev RSA key stored in
  src/food-market.api/App_Data/openiddict-dev-key.xml (gitignored). Generated on
  first run, reused on subsequent starts. Dev tokens now survive API restarts.
  Production must register proper X509 certificates via configuration.
- .gitignore: add App_Data/, *.pem, openiddict-dev-key.xml patterns.
- Web axios: on hard 401 with failed refresh, redirect to /login rather than
  leaving the user stuck on a protected screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nurdotnet 2026-04-21 21:42:53 +05:00
parent cead88b0bc
commit b07232521b
3 changed files with 34 additions and 2 deletions

5
.gitignore vendored
View file

@ -66,10 +66,15 @@ pnpm-debug.log*
## Secrets ## Secrets
*.pfx *.pfx
*.snk *.snk
*.pem
secrets.json secrets.json
appsettings.Development.local.json appsettings.Development.local.json
appsettings.Production.local.json appsettings.Production.local.json
## OpenIddict dev keys (local only, never commit)
src/food-market.api/App_Data/
**/App_Data/openiddict-dev-key.xml
## Docker / local ## Docker / local
.docker-data/ .docker-data/
postgres-data/ postgres-data/

View file

@ -66,9 +66,25 @@
opts.AcceptAnonymousClients(); opts.AcceptAnonymousClients();
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
// Survives API restarts so issued tokens remain valid across rebuilds.
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
var rsa = System.Security.Cryptography.RSA.Create(2048);
if (File.Exists(keyPath))
{
rsa.FromXmlString(File.ReadAllText(keyPath));
}
else
{
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
}
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
opts.AddEncryptionKey(devKey);
opts.AddSigningKey(devKey);
if (builder.Environment.IsDevelopment()) if (builder.Environment.IsDevelopment())
{ {
opts.AddEphemeralEncryptionKey().AddEphemeralSigningKey();
opts.DisableAccessTokenEncryption(); opts.DisableAccessTokenEncryption();
} }
@ -87,7 +103,14 @@
opts.UseAspNetCore(); opts.UseAspNetCore();
}); });
builder.Services.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme); // Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddControllers(); builder.Services.AddControllers();

View file

@ -29,6 +29,10 @@ api.interceptors.response.use(
return api(original) return api(original)
} }
clearTokens() clearTokens()
// Redirect to login so user isn't stuck on a protected page with stale tokens.
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login'
}
} }
return Promise.reject(error) return Promise.reject(error)
}, },