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:
parent
cead88b0bc
commit
b07232521b
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue