Complete record of the Stage 3 build session · Created 2026-05-01
Implement the to do works in stage2_conversation.html's Stage 3 Notes / Extension Points section. Test suites and documented details requests are the same as stage 1 & 2.
Stage 3 extension points (all 10 from Stage 2 notes):
Building all 10 extension points:
PersistedChatMessage entity, updated SupportHubNew NuGet packages added: CsvHelper 33.0.1, Microsoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4.
| Component | File | Change |
|---|---|---|
| New entity | Models/PersistedChatMessage.cs | Id, SessionUserId, From, Text, SentAt, IsAdmin |
| DbSet | Data/ApplicationDbContext.cs | DbSet<PersistedChatMessage> ChatMessages |
| Hub | Hubs/SupportHub.cs | Injected ApplicationDbContext; UserSend and AdminReply both call PersistAsync before broadcasting; OnConnectedAsync replays history from DB instead of static dict |
| Admin inbox | BuildSessionSummariesAsync | Session list now queries DB grouped by SessionUserId |
The in-memory _activeSessions dictionary is kept as a lightweight index of currently-online users. The DB is the source of truth for message history. Chat history survives server restarts.
| Component | File | Detail |
|---|---|---|
| Model | Models/ProductReview.cs | Id, ProductId, UserId, Rating (1–5), Title?, Body, CreatedAt |
| Unique index | ApplicationDbContext | HasIndex(r => new { r.ProductId, r.UserId }).IsUnique() - one review per user per product |
| Controller | Controllers/ReviewController.cs | POST Submit - blocks Viewers; checks for duplicate; POST Delete - owner or admin only |
| Shop detail | Controllers/ShopController.Detail | Includes Reviews.User and Variants; computes AverageRating, ReviewCount |
| View | Views/Shop/Detail.cshtml | Star display (★☆), average badge, review list with delete link, write-review form (radio 1–5) |
| ViewModels | ProductViewModels.cs | ProductReviewViewModel, SubmitReviewViewModel added to ShopProductViewModel |
| Component | File | Detail |
|---|---|---|
| Model | Models/DiscountCode.cs | Code (unique), DiscountPercent, MaxUses?, UsedCount, IsActive, ExpiresAt?. IsValid(now) helper checks all three conditions. |
| Admin controller | Areas/Admin/Controllers/DiscountsController.cs | Index, Create, Edit, Delete. Code is uppercased on save. Duplicate code check on create. |
| Admin views | Areas/Admin/Views/Discounts/ | Index table (code badge, uses, expiry, active badge, edit/delete), Create form, Edit form. |
| Cart | Controllers/CartController.cs | POST ApplyCoupon stores validated code in session; POST RemoveCoupon clears it. Checkout GET/POST compute discount via ComputeDiscountAsync. |
| Order model | Models/Order.cs | Added CouponCode? and DiscountAmount fields. Migration includes these columns. |
| Checkout view | Views/Cart/Checkout.cshtml | Coupon card (apply form or applied badge + remove link), discount row in order summary. |
| Sidebar | _AdminLayout.cshtml | New "Promotions" section with 🏷️ Discount Codes link. |
On successful checkout, UsedCount is incremented atomically in the same SaveChangesAsync call as the order and stock deduction.
| Component | File | Detail |
|---|---|---|
| Model | Models/ProductVariant.cs | Id, ProductId, Name (e.g. "Red / L"), StockQuantity, PriceAdjustment |
| Admin controller | ProductsController.Variants, AddVariant, DeleteVariant | Variants page lists existing variants; add form creates new ones; delete removes them. |
| Admin view | Areas/Admin/Views/Products/Variants.cshtml | Two-column layout: existing variants table (with delete) + add-variant form. |
| Products list | Areas/Admin/Views/Products/Index.cshtml | Added Variants button next to Edit on each product row. |
| Shop detail | Views/Shop/Detail.cshtml | Variant dropdown (only shows in-stock variants) next to quantity input. Price delta shown in option text. |
| Cart | CartItem.cs, CartController.Add | CartItem gained VariantId? and VariantName?. Add action looks up variant, adjusts unit price accordingly. |
| Order item | OrderItem.cs | Added VariantName? stored with the order for historical accuracy. |
| Component | File | Detail |
|---|---|---|
| Controller | Areas/Admin/Controllers/ImportController.cs | GET /Admin/Import shows upload form; POST ImportCsv parses file, validates each row, creates orders and deducts stock in one SaveChangesAsync. |
| View | Areas/Admin/Views/Import/Index.cshtml | File upload card + CSV format reference table. Shows success count and per-row error list after import. |
| Row class | ImportController.OrderCsvRow | Matches CSV columns: PoNumber, Company*, Address, City, State, Zip, ContactName, ContactPhone, ProductName*, Quantity (default 1). |
| Sidebar | _AdminLayout.cshtml | 📥 Bulk Import link in Promotions section. |
Skipped rows (missing Company, unknown product, insufficient stock) are reported individually. Valid rows are committed even when some rows fail.
| Component | File | Detail |
|---|---|---|
| Controller | Areas/Admin/Controllers/ReportsController.cs | Queries: daily revenue (30 days), top-5 products by revenue, order status breakdown, MTD vs prior month totals. Cancelled orders excluded from revenue. |
| View | Areas/Admin/Views/Reports/Index.cshtml | MTD/prior-month cards; daily revenue line chart; status doughnut chart; top-5 products horizontal bar chart. Chart.js loaded from CDN. |
| Sidebar | _AdminLayout.cshtml | 📈 Sales Reports link in new "Analytics" sidebar section. |
Charts use the active theme's --gs-primary CSS variable for consistent colour across themes.
| Component | File | Detail |
|---|---|---|
| Controller | AccountController.TwoFactor GET | Shows setup page if disabled (key + QR code) or disable button if enabled. |
| Enable | AccountController.EnableTwoFactor POST | Calls VerifyTwoFactorTokenAsync then SetTwoFactorEnabledAsync(true). |
| Disable | AccountController.DisableTwoFactor POST | Calls SetTwoFactorEnabledAsync(false). |
| 2FA login step | AccountController.TwoFactorLogin GET/POST | Reached automatically when PasswordSignInAsync returns RequiresTwoFactor. Calls TwoFactorAuthenticatorSignInAsync. |
| Views | Views/Account/TwoFactor.cshtml | QR code via api.qrserver.com; manual key display; verification form. |
Views/Account/TwoFactorLogin.cshtml | Single code input shown after password step when 2FA is required. | |
| Profile link | Views/Account/Profile.cshtml | "Two-Factor Auth" button added to Profile action bar. |
| QR URI format | AccountController.GenerateQrCodeUri | otpauth://totp/GuyStore:{email}?secret={key}&issuer=GuyStore&digits=6 |
Token provider: AddDefaultTokenProviders() (already present in Program.cs) includes the AuthenticatorTokenProvider required for TOTP. No additional package needed.
| Component | File | Detail |
|---|---|---|
| Model | Models/ShippingAddress.cs | Id, UserId, Label, Company, Address, City, State, Zip, ContactName, ContactPhone, IsDefault, CreatedAt |
| Controller | Controllers/AddressController.cs | Index, Create, Edit, Delete, SetDefault. ClearDefaultAsync ensures only one default per user. |
| Views | Views/Address/Index.cshtml | Card grid; default address highlighted in primary colour; Set Default / Delete buttons. |
Views/Address/Create.cshtml, Edit.cshtml | Both use _AddressForm.cshtml partial. | |
| Checkout GET | CartController.Checkout | Loads user's saved addresses; pre-fills form from the default address if one exists. |
| Checkout view | Views/Cart/Checkout.cshtml | Address button strip at the top of the form. Clicking a button fills all fields via inline JavaScript (dataset.* attributes). |
| Profile | Views/Account/Profile.cshtml | "My Addresses" button added to Profile action bar. |
| Component | Detail |
|---|---|
| Threshold | CartController.LowStockThreshold = 10 (configurable constant). Also added "StockAlertThreshold": 10 in appsettings.json for reference. |
| Trigger point | After stock deduction in CartController.Checkout POST - collects all products whose new stock falls below threshold, then pushes one LowStockAlert SignalR message per product. |
| Hub | IHubContext<NotificationHub> injected into CartController. Clients.All.SendAsync("LowStockAlert", productSummary) broadcasts to all connected clients. |
| Client | Admin dashboard already listens to NotificationHub. Frontend can add a connection.on("LowStockAlert", ...) handler in notifications.js to display a toast. |
| Component | File | Detail |
|---|---|---|
| Packages | GuyStore.csproj | Microsoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4 |
| Registration | Program.cs | Reads Authentication:Google:ClientId / Authentication:Microsoft:ClientId. Only calls AddGoogle / AddMicrosoftAccount when the key is non-empty. |
| Config keys | appsettings.json | Added Authentication.Google.{ClientId,ClientSecret} and Authentication.Microsoft.{ClientId,ClientSecret} - both blank by default. |
| Controller | AccountController.ExternalLogin POST | Issues Challenge(provider) with redirect to ExternalLoginCallback. |
AccountController.ExternalLoginCallback GET | Signs in existing linked account; or creates a new ApplicationUser (auto-approved, Registered role) from the OAuth claims (email, given name, surname). | |
| Login view | Views/Account/Login.cshtml | Only renders OAuth buttons when ViewBag.ExternalSchemes is non-empty (i.e., ClientId is configured). |
To go live with Google: create a project in Google Cloud Console, enable Google Sign-In, set Authentication:Google:ClientId and Authentication:Google:ClientSecret in appsettings.json (or via environment variables / secrets). Redirect URI: https://yourdomain/Account/ExternalLoginCallback.
| Project | Type | Total tests | New in Stage 3 |
|---|---|---|---|
GuyStore.Tests.Unit | Unit (xUnit) | 53 | +15 (DiscountCode ×6, ProductReview ×3, ProductVariant ×3, other ×3) |
GuyStore.Tests.Integration | Integration (HTTP) | 76 | +16 (reports, discounts, coupon apply, addresses, reviews, variants, import, 2FA, chat, inventory, OAuth) |
GuyStore.Tests.E2E | E2E (Playwright) | 37 | +8 (reports, discounts, addresses, reviews, variants, import, 2FA, checkout with address) |
Stage3IntegrationTests.cs)Stage3Tests.cs)1 - Unit tests (xUnit)
cd "C:\Guy\Notes\Claud Projects\Guy"
dotnet test GuyStore.Tests.Unit\GuyStore.Tests.Unit.csproj
2 - Integration tests (xUnit + WebApplicationFactory)
dotnet test GuyStore.Tests.Integration\GuyStore.Tests.Integration.csproj
3 - E2E tests (Playwright + headless Chromium)
One-time browser install (only needed once per machine):
cd "C:\Guy\Notes\Claud Projects\Guy"
dotnet build GuyStore.Tests.E2E\GuyStore.Tests.E2E.csproj
pwsh GuyStore.Tests.E2E\bin\Debug\net9.0\playwright.ps1 install chromium
dotnet test GuyStore.Tests.E2E\GuyStore.Tests.E2E.csproj
Run all C# suites in one pass
cd "C:\Guy\Notes\Claud Projects\Guy"
dotnet test GuyStore.sln
Run a single test by name
dotnet test GuyStore.Tests.Unit\GuyStore.Tests.Unit.csproj --filter "DiscountCode_IsValid_Inactive_ReturnsFalse"
dotnet test GuyStore.Tests.Integration\GuyStore.Tests.Integration.csproj --filter "Reports_AdminCanAccess"
dotnet test GuyStore.Tests.E2E\GuyStore.Tests.E2E.csproj --filter "SalesReports_AdminCanAccess"
DB-persisted chat with injected DbContext in Hub
SupportHub now has a constructor dependency on ApplicationDbContext. SignalR hubs are created per-connection (scoped), which matches the default EF Core DbContext lifetime - no lifetime mismatch issue. The static _activeSessions dictionary remains only as an online-user tracker.
Coupon state in session (not cookie)
The active coupon code is stored in HttpContext.Session so it is server-side and cannot be tampered with by the client. It is cleared from the session after a successful checkout.
OAuth optional registration pattern
The same pattern as SMTP: check config key for non-empty value before calling AddGoogle/AddMicrosoftAccount. This means the app builds and runs with no OAuth configuration - OAuth buttons simply do not appear on the login page.
TOTP QR code via external API
QR codes are rendered by api.qrserver.com to avoid adding a QR code generation library. This requires internet access on the 2FA setup page. For offline/intranet environments, add a library such as QRCoder and serve the image as a data URI.
Inventory alert to all clients (not admins group)
The low-stock push uses Clients.All on NotificationHub so that any connected frontend can react (e.g., an admin dashboard tab). In production, scope this to the "admins" group in SupportHub or a dedicated admin group in NotificationHub.
| Item | Value |
|---|---|
| Project location | C:\Guy\Notes\Claud Projects\Guy\GuyStore\ |
| Run command | dotnet run --launch-profile http |
| Admin account | [email protected] / Admin@123 |
| AccountMgr account | [email protected] / Acct@123 |
| Build result | 0 errors, 2 warnings (QuestPDF version resolution - harmless) |
| Unit tests (C#) | 53 passed (38 Stage 1+2 + 15 new) |
| Integration tests | 76 passed (60 Stage 1+2 + 16 new) |
| E2E tests | 37 (29 Stage 1+2 + 8 new) - Playwright browser required |
| New packages | CsvHelper 33.0.1, Microsoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4 |
| New DB tables | ChatMessages, ProductReviews, DiscountCodes, ProductVariants, ShippingAddresses |
| New columns | Orders.CouponCode, Orders.DiscountAmount, OrderItems.VariantName |
| New config keys | Authentication.Google.{ClientId,ClientSecret}, Authentication.Microsoft.{ClientId,ClientSecret}, StockAlertThreshold |
| New admin sections | Promotions (Discount Codes, Bulk Import), Analytics (Sales Reports) |
How to get the Two-factor authentication (TOTP / SMS) work after stage 3?
Everything is wired up in Stage 3:
| Component | Detail |
|---|---|
AccountController.TwoFactor GET | Setup page with QR code at /Account/TwoFactor |
AccountController.EnableTwoFactor POST | Verifies the 6-digit code and activates 2FA |
AccountController.TwoFactorLogin GET/POST | Handles the login challenge step |
Profile.cshtml | "Two-Factor Auth" button links directly to the setup page |
Login POST | Already handles result.RequiresTwoFactor and redirects automatically |
To test it right now:
Views/Account/TwoFactorLogin.cshtml:11 has
style="display:none" hardcoded on the validation summary,
so error messages after a wrong code are invisible. Fix:
<!-- current - errors never show -->
<div asp-validation-summary="All" class="alert alert-danger" style="display:none"></div>
<!-- fix - remove the inline style -->
<div asp-validation-summary="All" class="alert alert-danger"></div>
SMS requires these additions:
1 - Implement an SMS sender service
// Services/SmsSenderService.cs
public interface ISmsSender
{
Task SendSmsAsync(string number, string message);
}
// Stub for demo (no-op); swap for Twilio in production
public class SmsSenderService : ISmsSender
{
public Task SendSmsAsync(string number, string message)
{
// Log only for demo - real: TwilioClient.MessageResource.CreateAsync(...)
Console.WriteLine($"[SMS] To {number}: {message}");
return Task.CompletedTask;
}
}
Register in Program.cs: builder.Services.AddScoped<ISmsSender, SmsSenderService>();
2 - Add SMS send and verify actions to AccountController
// Send code via SMS
[Authorize, HttpPost]
public async Task<IActionResult> SendSmsCode()
{
var user = await _userManager.GetUserAsync(User);
var code = await _userManager.GenerateTwoFactorTokenAsync(user!, "Phone");
await _smsSender.SendSmsAsync(user!.PhoneNumber!, $"GuyStore verification code: {code}");
return RedirectToAction("TwoFactorLoginSms");
}
// Verify SMS code at login
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> TwoFactorLoginSms(string code, string? returnUrl, bool rememberMe)
{
var result = await _signInManager.TwoFactorSignInAsync("Phone", code, rememberMe, rememberMe);
if (result.Succeeded) return RedirectToLocal(returnUrl);
ModelState.AddModelError("", "Invalid code.");
return View();
}
3 - Route the login challenge by provider
Update the Login POST to check which provider the user has and route accordingly:
if (result.RequiresTwoFactor)
{
var twoFaUser = await _signInManager.GetTwoFactorAuthenticationUserAsync();
var providers = await _userManager.GetValidTwoFactorProvidersAsync(twoFaUser!);
if (providers.Contains("Phone"))
return RedirectToAction("TwoFactorLoginSms", new { returnUrl, rememberMe });
return RedirectToAction("TwoFactorLogin", new { returnUrl, rememberMe });
}
4 - Phone number UI
IdentityUser already has a PhoneNumber property.
Add a phone number input and verification flow to the Profile page so users can save and confirm their number before enabling SMS 2FA.
| Feature | Status |
|---|---|
| TOTP setup + login | ✅ Done - fix hidden validation summary |
| SMS sender stub | Need to add ISmsSender service |
| SMS phone setup UI | Need profile page additions |
| SMS login challenge | Need TwoFactorLoginSms action + view |
| Provider routing at login | Need update to Login POST |
Do the same for External OAuth Login (Google, Microsoft) feature and also check the corresponding implemented codes have bugs or not.
To go live with Google:
https://yourdomain/Account/ExternalLoginCallback.Authentication:Google:ClientId and Authentication:Google:ClientSecret in appsettings.json (or user secrets / environment variables).To go live with Microsoft:
https://yourdomain/Account/ExternalLoginCallback.Authentication:Microsoft:ClientId and Authentication:Microsoft:ClientSecret in appsettings.json.New user behaviour: First OAuth sign-in auto-creates an account with IsApproved = true and role Registered. No admin approval required for OAuth users.
Returning user behaviour: If the OAuth email matches an existing local account, the OAuth provider is linked to that account on first use, then used for sign-in on subsequent visits.
Four bugs were identified and fixed in Controllers/AccountController.cs.
| # | Severity | Location | Bug | Fix |
|---|---|---|---|---|
| 1 | Code Quality | Login GET |
GetExternalAuthenticationSchemesAsync().Result blocks the thread - bad practice (potential deadlock in older hosting models, unnecessary thread-pool pressure in .NET Core). |
Made the action async Task<IActionResult> and replaced .Result with await. |
| 2 | Security | ExternalLoginCallback |
When ExternalLoginSignInAsync succeeds, the custom IsApproved field is never checked. A locally-registered but unapproved user who links an OAuth provider can sign in via OAuth and bypass the approval gate entirely. |
After result.Succeeded, fetch the linked user with FindByLoginAsync, check IsApproved, and call SignOutAsync + redirect with error if not approved. |
| 3 | Security | ExternalLoginCallback |
result.RequiresTwoFactor was never checked. A user with TOTP enabled who signs in via OAuth is not routed to the 2FA challenge - the callback falls through to the account-creation code and calls SignInAsync directly, silently bypassing 2FA. |
Added if (result.RequiresTwoFactor) return RedirectToAction("TwoFactorLogin", ...) before the account-creation fallthrough. |
| 4 | Security | ExternalLoginCallback - existing unlinked user path |
When an OAuth email matches an existing local account (first OAuth use), IsApproved is not checked before calling SignInAsync. Also, the result of AddLoginAsync was discarded - if linking fails (e.g. the provider key is already linked to another account), the user is still signed in. |
Added else if (!user.IsApproved) guard before SignInAsync. Changed AddLoginAsync call to capture and check its result before proceeding. |
public async Task<IActionResult> ExternalLoginCallback(string? returnUrl = null)
{
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info is null) return RedirectToAction("Login");
var result = await _signInManager.ExternalLoginSignInAsync(
info.LoginProvider, info.ProviderKey, isPersistent: false);
if (result.Succeeded)
{
// ExternalLoginSignInAsync bypasses the custom IsApproved field - check it explicitly
var linkedUser = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (linkedUser != null && !linkedUser.IsApproved)
{
await _signInManager.SignOutAsync();
TempData["Error"] = "Your account is pending approval.";
return RedirectToAction("Login");
}
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction("Index", "Home");
}
// Route to 2FA challenge when the linked user has TOTP enabled
if (result.RequiresTwoFactor)
return RedirectToAction("TwoFactorLogin", new { returnUrl, rememberMe = false });
// First OAuth login - auto-create or link to existing account
var email = info.Principal.FindFirst(ClaimTypes.Email)?.Value;
if (string.IsNullOrEmpty(email))
{
TempData["Error"] = "Could not retrieve email from external provider.";
return RedirectToAction("Login");
}
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
user = new ApplicationUser { ... IsApproved = true, ... };
var createResult = await _userManager.CreateAsync(user);
if (!createResult.Succeeded) { ... return RedirectToAction("Login"); }
await _userManager.AddToRoleAsync(user, "Registered");
}
else if (!user.IsApproved)
{
// Existing local-registered user not yet approved - block OAuth linkage too
TempData["Error"] = "Your account is pending approval.";
return RedirectToAction("Login");
}
var addResult = await _userManager.AddLoginAsync(user, info);
if (!addResult.Succeeded)
{
TempData["Error"] = "Could not link external login: "
+ string.Join("; ", addResult.Errors.Select(e => e.Description));
return RedirectToAction("Login");
}
await _signInManager.SignInAsync(user, isPersistent: false);
...
}
| Item | Status |
|---|---|
| Google OAuth sign-in (new user) | ✅ Works |
| Microsoft OAuth sign-in (new user) | ✅ Works |
| Optional no-op when ClientId blank | ✅ Works |
Login GET blocking .Result call | ✅ Fixed - now async/await |
IsApproved bypass for linked OAuth users | ✅ Fixed |
| TOTP bypass via OAuth | ✅ Fixed - RequiresTwoFactor handled |
IsApproved bypass + unhandled AddLoginAsync failure | ✅ Fixed |
Is it possible to use localhost:7091 for Google's https://yourdomain/Account/ExternalLoginCallback and same for Microsoft?
Yes - both Google and Microsoft explicitly support localhost redirect URIs for development. Port 7091 is already the HTTPS profile in this project's Properties/launchSettings.json:
"https": {
"applicationUrl": "https://localhost:7091;http://localhost:5090",
...
}
Run on that port with:
dotnet run --launch-profile https
If your browser shows a certificate warning the first time, trust the ASP.NET Core dev certificate once:
dotnet dev-certs https --trust
https://localhost:7091/Account/ExternalLoginCallback
appsettings.json:
"Authentication": {
"Google": {
"ClientId": "123456789-xxxx.apps.googleusercontent.com",
"ClientSecret": "GOCSPX-xxxx"
}
}
Tip: Google also allows plain http://localhost:5090/Account/ExternalLoginCallback (HTTP, no HTTPS required for localhost) - useful if you are running the http launch profile. You can register both URIs at the same time.
https://localhost:7091/Account/ExternalLoginCallback
appsettings.json:
"Authentication": {
"Microsoft": {
"ClientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"ClientSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
Tip: Microsoft also supports http://localhost:5090/Account/ExternalLoginCallback for localhost development - add it as a second redirect URI under the same platform entry if needed.
Add the production URI alongside the localhost one - do not remove localhost, as it lets you continue testing locally. Both providers support multiple redirect URIs on the same credential/registration.
| Environment | Redirect URI to register |
|---|---|
| Local HTTPS dev | https://localhost:7091/Account/ExternalLoginCallback |
| Local HTTP dev (optional) | http://localhost:5090/Account/ExternalLoginCallback |
| Production | https://yourdomain.com/Account/ExternalLoginCallback |
Never commit real ClientId / ClientSecret values into appsettings.json in a shared or public repository.
Use ASP.NET Core User Secrets for local development instead:
dotnet user-secrets set "Authentication:Google:ClientId" "your-client-id"
dotnet user-secrets set "Authentication:Google:ClientSecret" "your-client-secret"
dotnet user-secrets set "Authentication:Microsoft:ClientId" "your-client-id"
dotnet user-secrets set "Authentication:Microsoft:ClientSecret" "your-client-secret"
User secrets are stored outside the project folder and are never included in git commits. The app reads them automatically in the Development environment.