GuyStore - Stage 3 Build Conversation

Complete record of the Stage 3 build session · Created 2026-05-01

Table of Contents

  1. User Requirements
  2. Build Plan
  3. Persist Support Chat to Database
  4. Product Reviews & Ratings
  5. Discount Codes / Coupon System
  6. Product Variants
  7. Bulk Order Import (CSV)
  8. Sales Report Dashboard
  9. Two-Factor Authentication (TOTP)
  10. Shipping Address Book
  11. Real-Time Inventory Alerts
  12. External OAuth Login (Google, Microsoft)
  13. Stage 3 Test Suite
  14. Final Result
  15. 2FA How-To Guide (Post-Stage 3 Q&A)
  16. OAuth Bug Review & How-To Guide (Post-Stage 3 Q&A)
  17. OAuth with localhost:7091 (Post-Stage 3 Q&A)
👤
User · 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):

  1. Persist support chat messages to database
  2. Product reviews and ratings by registered users
  3. Discount codes / coupon system
  4. Product variants (size, colour) with separate stock per variant
  5. Bulk order import via CSV/Excel
  6. Sales report dashboard (revenue by date, top products, order trends)
  7. Two-factor authentication (TOTP / SMS)
  8. Shipping address book (save multiple addresses per user)
  9. Real-time inventory alerts (SignalR push when stock drops below threshold)
  10. External OAuth login (Google, Microsoft)
🤖
Claude · Build Plan

Building all 10 extension points:

  1. ✓ Done Persist support chat to DB - PersistedChatMessage entity, updated SupportHub
  2. ✓ Done Product reviews & ratings - star UI on detail page, one-review-per-user constraint
  3. ✓ Done Discount codes - admin CRUD, session-based application at checkout, coupon usage tracking
  4. ✓ Done Product variants - admin variant manager, variant selector on shop detail, cart & order item support
  5. ✓ Done Bulk CSV import - CsvHelper, validates product existence & stock, per-row error reporting
  6. ✓ Done Sales report dashboard - Chart.js (daily revenue line, status doughnut, top-5 bar), MTD vs prior month
  7. ✓ Done Two-factor authentication - Identity TOTP, QR code via qrserver.com API, enable/disable flow, 2FA login step
  8. ✓ Done Shipping address book - CRUD, default address, auto-fill checkout with JS, address selector buttons
  9. ✓ Done Real-time inventory alerts - SignalR push to all clients after checkout stock deduction below threshold (10)
  10. ✓ Done External OAuth - Google + Microsoft, optional (no-op when ClientId blank), auto-creates approved account
  11. ✓ Done Stage 3 test suite (unit · integration · E2E)
  12. ✓ Done stage3_conversation.html documentation

New NuGet packages added: CsvHelper 33.0.1, Microsoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4.


1 - Persist Support Chat to Database

🤖
Stage 2 used a static in-memory dictionary; Stage 3 persists every message to SQL Server.
ComponentFileChange
New entityModels/PersistedChatMessage.csId, SessionUserId, From, Text, SentAt, IsAdmin
DbSetData/ApplicationDbContext.csDbSet<PersistedChatMessage> ChatMessages
HubHubs/SupportHub.csInjected ApplicationDbContext; UserSend and AdminReply both call PersistAsync before broadcasting; OnConnectedAsync replays history from DB instead of static dict
Admin inboxBuildSessionSummariesAsyncSession 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.


2 - Product Reviews & Ratings

🤖
Star ratings + review form on product detail page
ComponentFileDetail
ModelModels/ProductReview.csId, ProductId, UserId, Rating (1–5), Title?, Body, CreatedAt
Unique indexApplicationDbContextHasIndex(r => new { r.ProductId, r.UserId }).IsUnique() - one review per user per product
ControllerControllers/ReviewController.csPOST Submit - blocks Viewers; checks for duplicate; POST Delete - owner or admin only
Shop detailControllers/ShopController.DetailIncludes Reviews.User and Variants; computes AverageRating, ReviewCount
ViewViews/Shop/Detail.cshtmlStar display (★☆), average badge, review list with delete link, write-review form (radio 1–5)
ViewModelsProductViewModels.csProductReviewViewModel, SubmitReviewViewModel added to ShopProductViewModel

3 - Discount Codes / Coupon System

🤖
Admin CRUD + session-based application at checkout
ComponentFileDetail
ModelModels/DiscountCode.csCode (unique), DiscountPercent, MaxUses?, UsedCount, IsActive, ExpiresAt?. IsValid(now) helper checks all three conditions.
Admin controllerAreas/Admin/Controllers/DiscountsController.csIndex, Create, Edit, Delete. Code is uppercased on save. Duplicate code check on create.
Admin viewsAreas/Admin/Views/Discounts/Index table (code badge, uses, expiry, active badge, edit/delete), Create form, Edit form.
CartControllers/CartController.csPOST ApplyCoupon stores validated code in session; POST RemoveCoupon clears it. Checkout GET/POST compute discount via ComputeDiscountAsync.
Order modelModels/Order.csAdded CouponCode? and DiscountAmount fields. Migration includes these columns.
Checkout viewViews/Cart/Checkout.cshtmlCoupon card (apply form or applied badge + remove link), discount row in order summary.
Sidebar_AdminLayout.cshtmlNew "Promotions" section with 🏷️ Discount Codes link.

On successful checkout, UsedCount is incremented atomically in the same SaveChangesAsync call as the order and stock deduction.


4 - Product Variants

🤖
Size/colour variants with individual stock and price adjustment
ComponentFileDetail
ModelModels/ProductVariant.csId, ProductId, Name (e.g. "Red / L"), StockQuantity, PriceAdjustment
Admin controllerProductsController.Variants, AddVariant, DeleteVariantVariants page lists existing variants; add form creates new ones; delete removes them.
Admin viewAreas/Admin/Views/Products/Variants.cshtmlTwo-column layout: existing variants table (with delete) + add-variant form.
Products listAreas/Admin/Views/Products/Index.cshtmlAdded Variants button next to Edit on each product row.
Shop detailViews/Shop/Detail.cshtmlVariant dropdown (only shows in-stock variants) next to quantity input. Price delta shown in option text.
CartCartItem.cs, CartController.AddCartItem gained VariantId? and VariantName?. Add action looks up variant, adjusts unit price accordingly.
Order itemOrderItem.csAdded VariantName? stored with the order for historical accuracy.

5 - Bulk Order Import (CSV)

🤖
CsvHelper - one product per row, validates stock, reports per-row errors
ComponentFileDetail
ControllerAreas/Admin/Controllers/ImportController.csGET /Admin/Import shows upload form; POST ImportCsv parses file, validates each row, creates orders and deducts stock in one SaveChangesAsync.
ViewAreas/Admin/Views/Import/Index.cshtmlFile upload card + CSV format reference table. Shows success count and per-row error list after import.
Row classImportController.OrderCsvRowMatches 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.


6 - Sales Report Dashboard

🤖
Chart.js 4 - three chart types + MTD comparison cards
ComponentFileDetail
ControllerAreas/Admin/Controllers/ReportsController.csQueries: daily revenue (30 days), top-5 products by revenue, order status breakdown, MTD vs prior month totals. Cancelled orders excluded from revenue.
ViewAreas/Admin/Views/Reports/Index.cshtmlMTD/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.


7 - Two-Factor Authentication (TOTP)

🤖
ASP.NET Core Identity TOTP - Google Authenticator / Authy / Microsoft Authenticator compatible
ComponentFileDetail
ControllerAccountController.TwoFactor GETShows setup page if disabled (key + QR code) or disable button if enabled.
EnableAccountController.EnableTwoFactor POSTCalls VerifyTwoFactorTokenAsync then SetTwoFactorEnabledAsync(true).
DisableAccountController.DisableTwoFactor POSTCalls SetTwoFactorEnabledAsync(false).
2FA login stepAccountController.TwoFactorLogin GET/POSTReached automatically when PasswordSignInAsync returns RequiresTwoFactor. Calls TwoFactorAuthenticatorSignInAsync.
ViewsViews/Account/TwoFactor.cshtmlQR code via api.qrserver.com; manual key display; verification form.
Views/Account/TwoFactorLogin.cshtmlSingle code input shown after password step when 2FA is required.
Profile linkViews/Account/Profile.cshtml"Two-Factor Auth" button added to Profile action bar.
QR URI formatAccountController.GenerateQrCodeUriotpauth://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.


8 - Shipping Address Book

🤖
Per-user saved addresses with default selection and checkout auto-fill
ComponentFileDetail
ModelModels/ShippingAddress.csId, UserId, Label, Company, Address, City, State, Zip, ContactName, ContactPhone, IsDefault, CreatedAt
ControllerControllers/AddressController.csIndex, Create, Edit, Delete, SetDefault. ClearDefaultAsync ensures only one default per user.
ViewsViews/Address/Index.cshtmlCard grid; default address highlighted in primary colour; Set Default / Delete buttons.
Views/Address/Create.cshtml, Edit.cshtmlBoth use _AddressForm.cshtml partial.
Checkout GETCartController.CheckoutLoads user's saved addresses; pre-fills form from the default address if one exists.
Checkout viewViews/Cart/Checkout.cshtmlAddress button strip at the top of the form. Clicking a button fills all fields via inline JavaScript (dataset.* attributes).
ProfileViews/Account/Profile.cshtml"My Addresses" button added to Profile action bar.

9 - Real-Time Inventory Alerts

🤖
SignalR push to all clients when stock drops below threshold after checkout
ComponentDetail
ThresholdCartController.LowStockThreshold = 10 (configurable constant). Also added "StockAlertThreshold": 10 in appsettings.json for reference.
Trigger pointAfter stock deduction in CartController.Checkout POST - collects all products whose new stock falls below threshold, then pushes one LowStockAlert SignalR message per product.
HubIHubContext<NotificationHub> injected into CartController. Clients.All.SendAsync("LowStockAlert", productSummary) broadcasts to all connected clients.
ClientAdmin dashboard already listens to NotificationHub. Frontend can add a connection.on("LowStockAlert", ...) handler in notifications.js to display a toast.

10 - External OAuth Login (Google, Microsoft)

🤖
Optional OAuth - no-op when ClientId is blank (matches SMTP pattern)
ComponentFileDetail
PackagesGuyStore.csprojMicrosoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4
RegistrationProgram.csReads Authentication:Google:ClientId / Authentication:Microsoft:ClientId. Only calls AddGoogle / AddMicrosoftAccount when the key is non-empty.
Config keysappsettings.jsonAdded Authentication.Google.{ClientId,ClientSecret} and Authentication.Microsoft.{ClientId,ClientSecret} - both blank by default.
ControllerAccountController.ExternalLogin POSTIssues Challenge(provider) with redirect to ExternalLoginCallback.
AccountController.ExternalLoginCallback GETSigns in existing linked account; or creates a new ApplicationUser (auto-approved, Registered role) from the OAuth claims (email, given name, surname).
Login viewViews/Account/Login.cshtmlOnly 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.


Stage 3 Test Suite

🤖
Test results - all pass
ProjectTypeTotal testsNew in Stage 3
GuyStore.Tests.UnitUnit (xUnit)53+15 (DiscountCode ×6, ProductReview ×3, ProductVariant ×3, other ×3)
GuyStore.Tests.IntegrationIntegration (HTTP)76+16 (reports, discounts, coupon apply, addresses, reviews, variants, import, 2FA, chat, inventory, OAuth)
GuyStore.Tests.E2EE2E (Playwright)37+8 (reports, discounts, addresses, reviews, variants, import, 2FA, checkout with address)

New Unit Tests

  • DiscountCodeTests (6) - IsValid: active unlimited, inactive, max uses reached, not reached, expired, not yet expired
  • ProductReviewTests (3) - Rating validation: valid range (1,3,5), out of range (0,6), body handling
  • ProductVariantTests (3) - Negative price adjustment, effective price calculation, zero delta

New Integration Tests (Stage3IntegrationTests.cs)

  • Reports - Admin can access; non-admin is redirected/forbidden
  • Discounts - Index returns 200; Create succeeds; Checkout with valid coupon applies discount
  • Addresses - Index returns 200; Create POST succeeds
  • Reviews - Shop Detail shows review section and write form for logged-in user
  • Variants - Variants page loads; AddVariant POST succeeds
  • Import - Page loads with upload form and format reference
  • 2FA - TwoFactor page loads and shows setup or status
  • Chat persistence - Support Inbox loads with DB-backed hub
  • Inventory alert - Checkout flow completes with alert logic wired in (no crash)
  • OAuth - Login page has no OAuth buttons when ClientId is blank

New E2E Tests (Stage3Tests.cs)

  • SalesReports - Admin sees the reports page
  • DiscountCodes - Admin sees discount list with New Code button
  • AddressBook - My Addresses page is accessible
  • ProductReviews - Detail page shows Customer Reviews section
  • ProductVariants - Admin can open Variants management page
  • BulkImport - Import page is accessible
  • TwoFactor - 2FA page accessible; Profile page has 2FA link
  • CheckoutWithAddress - Saved address label appears in checkout form

How to Run the Tests

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"

Key Technical Decisions (Stage 3)

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.

✅ Stage 3 Complete - 2026-05-01

ItemValue
Project locationC:\Guy\Notes\Claud Projects\Guy\GuyStore\
Run commanddotnet run --launch-profile http
Admin account[email protected] / Admin@123
AccountMgr account[email protected] / Acct@123
Build result0 errors, 2 warnings (QuestPDF version resolution - harmless)
Unit tests (C#)53 passed (38 Stage 1+2 + 15 new)
Integration tests76 passed (60 Stage 1+2 + 16 new)
E2E tests37 (29 Stage 1+2 + 8 new) - Playwright browser required
New packagesCsvHelper 33.0.1, Microsoft.AspNetCore.Authentication.Google 9.0.4, Microsoft.AspNetCore.Authentication.MicrosoftAccount 9.0.4
New DB tablesChatMessages, ProductReviews, DiscountCodes, ProductVariants, ShippingAddresses
New columnsOrders.CouponCode, Orders.DiscountAmount, OrderItems.VariantName
New config keysAuthentication.Google.{ClientId,ClientSecret}, Authentication.Microsoft.{ClientId,ClientSecret}, StockAlertThreshold
New admin sectionsPromotions (Discount Codes, Bulk Import), Analytics (Sales Reports)

Post-Stage 3 Q&A - 2FA How-To Guide

👤
User · 2026-05-01

How to get the Two-factor authentication (TOTP / SMS) work after stage 3?

🤖
Claude · 2026-05-01

TOTP (Google Authenticator, Authy, etc.) - Already Done

Everything is wired up in Stage 3:

ComponentDetail
AccountController.TwoFactor GETSetup page with QR code at /Account/TwoFactor
AccountController.EnableTwoFactor POSTVerifies the 6-digit code and activates 2FA
AccountController.TwoFactorLogin GET/POSTHandles the login challenge step
Profile.cshtml"Two-Factor Auth" button links directly to the setup page
Login POSTAlready handles result.RequiresTwoFactor and redirects automatically

To test it right now:

  1. Log in → Profile → Two-Factor Auth
  2. Scan the QR code with Google Authenticator or Authy
  3. Enter the 6-digit code → click Enable
  4. Log out, log in again - you will be redirected to the TOTP prompt

Bug Fix - Validation Summary Hidden on TwoFactorLogin

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 2FA - Not Yet Built

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.

Summary

FeatureStatus
TOTP setup + login✅ Done - fix hidden validation summary
SMS sender stubNeed to add ISmsSender service
SMS phone setup UINeed profile page additions
SMS login challengeNeed TwoFactorLoginSms action + view
Provider routing at loginNeed update to Login POST

Post-Stage 3 Q&A - OAuth Bug Review & How-To Guide

👤
User · 2026-05-01

Do the same for External OAuth Login (Google, Microsoft) feature and also check the corresponding implemented codes have bugs or not.

🤖
Claude · 2026-05-01

How OAuth Login Works (Stage 3)

To go live with Google:

  1. Create a project in Google Cloud Console, enable the Google Sign-In API.
  2. Under Credentials, create an OAuth 2.0 Client ID (Web application). Set Authorised redirect URI to https://yourdomain/Account/ExternalLoginCallback.
  3. Set Authentication:Google:ClientId and Authentication:Google:ClientSecret in appsettings.json (or user secrets / environment variables).
  4. The Google button appears automatically on the login page when the key is non-empty.

To go live with Microsoft:

  1. Register an app in the Azure portal → App registrations → New registration.
  2. Add a Redirect URI: https://yourdomain/Account/ExternalLoginCallback.
  3. Under Certificates & secrets, create a client secret.
  4. Set 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.

Bugs Found & Fixed

Four bugs were identified and fixed in Controllers/AccountController.cs.

#SeverityLocationBugFix
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.

Fixed Code - ExternalLoginCallback

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

Summary

ItemStatus
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

Post-Stage 3 Q&A - OAuth with localhost:7091

👤
User · 2026-05-01

Is it possible to use localhost:7091 for Google's https://yourdomain/Account/ExternalLoginCallback and same for Microsoft?

🤖
Claude · 2026-05-01

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

Google - Add localhost Redirect URI

  1. Go to Google Cloud Console → APIs & Services → Credentials.
  2. Open your OAuth 2.0 Client ID (or create one: Create Credentials → OAuth client ID → Web application).
  3. Under Authorised redirect URIs, add:
    https://localhost:7091/Account/ExternalLoginCallback
  4. Click Save. Changes take effect within seconds.
  5. Copy the Client ID and Client Secret into 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.

Microsoft - Add localhost Redirect URI

  1. Go to Azure Portal → Azure Active Directory → App registrations.
  2. Open your app (or click New registration, set Supported account types to Accounts in any organizational directory and personal Microsoft accounts).
  3. In the left menu, click Authentication → Add a platform → Web.
  4. Enter the redirect URI:
    https://localhost:7091/Account/ExternalLoginCallback
  5. Click Configure, then Save.
  6. Under Certificates & secrets → New client secret, create a secret and copy its value immediately (shown only once).
  7. Copy the Application (client) ID from the Overview page, and put both into 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.

When You Deploy to Production

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.

EnvironmentRedirect URI to register
Local HTTPS devhttps://localhost:7091/Account/ExternalLoginCallback
Local HTTP dev (optional)http://localhost:5090/Account/ExternalLoginCallback
Productionhttps://yourdomain.com/Account/ExternalLoginCallback

Security Note - Keep Secrets out of Source Control

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.