# Frontend Audit Report — POS System **Auditor**: Senior Frontend Microservice Engineer **Date**: 2026-03-20 **Scope**: Blazor WASM apps (`web-client-tpos-net`, `web-client-base-net`), MudBlazor usage, component architecture, state management **Codebase**: `/apps/web-client-tpos-net` (195 Razor files, 248 C# files) --- ## Executive Summary The POS System frontend (`web-client-tpos-net`) has a solid architectural foundation with well-structured multi-layout design, multi-vertical POS support, and clean component separation. However, **3 critical security vulnerabilities** were identified (JWT in localStorage, hardcoded OAuth2 client secret, password grant flow), combined with near-zero test coverage (~1 test file, 36 lines) and ~20% of POS pages having incomplete backend integration. These issues must be resolved before production deployment. --- ## Critical Issues ### CRIT-01 — JWT Stored in localStorage (XSS Risk) **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs` lines 147–148 **Severity**: CRITICAL ```csharp await _js.InvokeVoidAsync("localStorage.setItem", TokenKey(role), token.AccessToken); ``` JWT tokens are stored in `localStorage`, making them accessible to JavaScript. Any XSS exploit will steal all active tokens and allow full account impersonation. **Fix**: Use `httpOnly` cookies via the BFF (WebClientTpos.Server). Add a `/api/auth/token` endpoint on the BFF that sets the token as a cookie rather than returning it in the response body. --- ### CRIT-02 — Client Secret Hardcoded in Client-Side Code **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs` lines 39–40 **Severity**: CRITICAL ```csharp private const string ClientId = "password-client"; private const string ClientSecret = "password-client-secret"; ``` Blazor WASM compiles to WebAssembly served to the browser. Any constant is extractable via browser developer tools or disassembly. If `password-client-secret` is a real secret used in Duende IdentityServer, it is already compromised. **Fix**: Move all OAuth2 token exchange calls to the BFF. The BFF holds the client secret server-side and proxies to IAM on behalf of the browser. --- ### CRIT-03 — Deprecated Password Grant (OAuth2) **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthService.cs` line 136 **Severity**: CRITICAL ```csharp new KeyValuePair("grant_type", "password"), ``` The Resource Owner Password Credentials grant is deprecated in OAuth 2.1. It bypasses consent screens and exposes credentials to the client application. It cannot support MFA flows properly. **Fix**: Migrate to Authorization Code Flow with PKCE. Use Duende IdentityServer's built-in OIDC endpoints. --- ### CRIT-04 — No CDN Subresource Integrity (SRI) **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/index.html` line 19 **Severity**: HIGH Lucide icons loaded from `unpkg.com` CDN without an `integrity` attribute. A CDN compromise would allow arbitrary JavaScript execution in all POS sessions. **Fix**: ```html ``` Use a pinned version and generate the SRI hash with `openssl dgst -sha384`. --- ## Warnings ### WARN-01 — Token Refresh Not Implemented **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/AuthStateService.cs` **Severity**: HIGH `AuthStateService` stores the access token but never checks expiry or calls the refresh token endpoint. When tokens expire, API calls silently fail with 401, and users are not redirected to login. **Fix**: Add a `TokenExpiry` property and a background timer that refreshes the token 60 seconds before expiry using the `refresh_token` from `OAuthTokenResponse`. --- ### WARN-02 — Global HttpClient Header Mutation (Race Condition) **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs` lines 40–47 **Severity**: HIGH ```csharp _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _authState.Token); ``` Mutating `DefaultRequestHeaders` on a shared `HttpClient` instance is not thread-safe. Under concurrent requests, one request may attach or overwrite the header mid-flight. **Fix**: Use `HttpRequestMessage` with per-request headers, or register a `DelegatingHandler` that injects the Bearer token automatically without touching global headers. --- ### WARN-03 — Lucide Re-Init on Every Render **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor` line 196 **Severity**: MEDIUM ```csharp protected override async Task OnAfterRenderAsync(bool firstRender) { try { await JS.InvokeVoidAsync("lucide.createIcons"); } catch { } ``` `createIcons()` traverses the entire DOM on every render cycle. `index.html` already sets up a `MutationObserver` for this purpose (lines 25–46). The explicit call is redundant and adds unnecessary overhead. **Fix**: Remove the `lucide.createIcons()` call from `OnAfterRenderAsync` entirely. The `MutationObserver` handles dynamic icon insertion. --- ### WARN-04 — MudBlazor ThemeProvider Declared in Multiple Layouts **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor` lines 17–20 **Severity**: MEDIUM ``, ``, ``, and `` are declared in each layout independently. This causes multiple instances in the component tree and can create CSS specificity conflicts and duplicate dialog/snackbar stacks. **Fix**: Move all four providers to `App.razor` once, outside the ``. Remove from individual layouts. --- ### WARN-05 — localStorage Logic Duplicated Across 5 Files **Severity**: MEDIUM Token read/write logic is repeated in: 1. `AuthService.cs` lines 147–148 2. `AuthService.cs` lines 230–236 3. `AdminLayout.razor` lines 215–220 4. `StaffLayout.razor` (similar pattern) 5. `LanguageSwitcher.razor` **Fix**: Extract a `LocalStorageService` with typed `GetAsync` / `SetAsync` / `RemoveAsync` methods. Register as singleton. --- ### WARN-06 — No Route Guards for Authenticated Pages **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor` **Severity**: HIGH Session restore happens in `OnAfterRenderAsync`, meaning the admin layout renders briefly before auth is checked. No `` or route guard redirects unauthenticated users before render. **Fix**: Add a `RedirectToLogin` component or use Blazor's `[Authorize]` attribute with a proper `AuthenticationStateProvider` backed by `AuthStateService`. --- ### WARN-07 — shopId Not Validated Against User Permissions **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Layout/AdminLayout.razor` lines 246–286 **Severity**: HIGH Shop context is detected by parsing the URL path (e.g., `/admin/shop/{shopId}/cafe/`). There is no check that the authenticated user has access to the given `shopId`. A user could navigate directly to another merchant's shop URL. **Fix**: After detecting `shopId` from URL, call the backend to verify the current user owns/manages that shop. Redirect to `/admin` with an error if unauthorized. --- ### WARN-08 — Fragile Multi-Format API Response Deserialization **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Services/PosDataService.cs` lines 122–185 **Severity**: MEDIUM The deserializer tries 5 different response shapes in order. At line 152–155, it falls back to selecting the **first array property** found in the JSON object. If a response contains multiple array properties (e.g., `{ "items": [...], "errors": [...] }`), it selects whichever comes first in the serialized JSON, which is non-deterministic. **Fix**: Standardize all API responses to a single envelope: `{ "success": bool, "data": T, "error": { "code": "", "message": "" } }`. Remove the multi-format fallback logic. --- ### WARN-09 — Hardcoded String in AuthInput Component **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Components/Auth/AuthInput.razor` line 56 **Severity**: LOW ```csharp public string ForgotPasswordText { get; set; } = "Quên mật khẩu?"; ``` Default value hardcoded in Vietnamese. English-culture users will see Vietnamese text. **Fix**: Use `@inject IStringLocalizer L` and default to `L["ForgotPassword"]`. --- ### WARN-10 — ~20% of POS Pages Have Incomplete Backend Integration **Severity**: HIGH 21 `TODO` comments indicate unimplemented API integration: | File | Missing Integration | |------|---------------------| | `MemberCard.razor` | Member visit history API | | `PartialPayment.razor` | Order Service API | | `TipEntry.razor` | Order Service API | | `GiftCardPayment.razor` | Order Service API | | `PaymentPending.razor` | Order Service API | | `CashDrawer.razor` | Merchant Service staff/shift | | `QuickSale.razor` | Merchant Service staff/shift | | `PendingOrders.razor` | Merchant Service staff/shift | | `ClockInOut.razor` | Merchant Service staff/shift | | `ShiftManagement.razor` | Merchant Service staff/shift | | `OrderCancel.razor` | DDD Value Object mapping fix | | `PriceCheck.razor` | DDD Value Object mapping fix | | `OrderEdit.razor` | DDD Value Object mapping fix | | `VoidRefund.razor` | DDD Value Object mapping fix | | `StockIn.razor` | DDD Value Object mapping fix | | `StockTransfer.razor` | DDD Value Object mapping fix | | `StockOut.razor` | DDD Value Object mapping fix | | `TreatmentPlan.razor` | Backend CRUD API | | `ConsentForm.razor` | Backend persistence API | --- ### WARN-11 — Incomplete vi-VN Translations **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/wwwroot/locales/vi-VN.json` **Severity**: LOW Some keys present in `en-US.json` are missing in `vi-VN.json`. When a key is missing, `JsonStringLocalizer` returns the key name as the display text. **Fix**: Run a diff between the two JSON files in CI. Fail the build on missing translation keys. --- ### WARN-12 — No IFormatProvider in JsonStringLocalizer **File**: `apps/web-client-tpos-net/src/WebClientTpos.Client/Localization/JsonStringLocalizer.cs` **Severity**: LOW `string.Format(format, arguments)` is called without a culture-aware `IFormatProvider`. Numbers and dates will be formatted using the thread's current culture, not the user's selected locale. **Fix**: ```csharp var value = string.Format(CultureInfo.CurrentUICulture, format ?? name, arguments); ``` --- ### WARN-13 — No IAsyncDisposable on Layout Components **Severity**: LOW `AdminLayout.razor` subscribes to `AuthStateService.OnChange` but does not implement `IAsyncDisposable` to unsubscribe. In long-lived SPA sessions with layout reloads (e.g., via forceLoad navigation), this will leak event handlers. **Fix**: ```csharp @implements IDisposable // ... public void Dispose() => _authState.OnChange -= StateHasChanged; ``` --- ## Improvements ### IMP-01 — Implement Authorization Code Flow + PKCE Replace the password grant with a proper OIDC flow. Duende IdentityServer supports this natively. This enables MFA, social login, and OAuth2 compliance. ### IMP-02 — Add Content Security Policy Add CSP header in BFF or via Traefik middleware: ``` Content-Security-Policy: default-src 'self'; script-src 'self' unpkg.com; style-src 'self' 'unsafe-inline' ``` ### IMP-03 — Add @key to List Renders All `@foreach` that render lists of components should use `@key` to prevent unnecessary DOM diffing: ```razor @foreach (var item in _items) { } ``` ### IMP-04 — Lazy Load POS Vertical Pages Each POS vertical (Karaoke, Restaurant, Spa, Cafe, Retail) should be lazy-loaded. Blazor WASM supports lazy assembly loading via `LazyAssemblyLoader`. This will reduce initial download size. ### IMP-05 — Extract AdminLayout Shop Context to Service `AdminLayout.razor` is 328 lines with complex shop context detection inline. Extract shop context detection to a `ShopContextService` (scoped) that parses the URL and validates permissions. ### IMP-06 — Add `@rendermode InteractiveWebAssembly` Explicitly With .NET 8+, pages should declare render mode explicitly to avoid confusion with static rendering. ### IMP-07 — Standardize DTO Naming Convention DTOs use mixed `Dto` vs no-suffix naming: - `MerchantRegisterDto` (has `Dto` suffix) vs `AuthTokenResponse` (no suffix) Standardize to always use `Dto` suffix for data transfer objects. ### IMP-08 — Add BFF Health Check Routes `WebClientTpos.Server/Program.cs` has no health check endpoints. The BFF should expose `/health/live` and `/health/ready` for K8s probes. ### IMP-09 — Pin Lucide Version Currently using `lucide@latest`. Pin to a specific version (e.g., `lucide@0.363.0`) to prevent breaking changes from CDN updates. --- ## Action Items Prioritized by severity: | Priority | Item | File(s) | Effort | |----------|------|---------|--------| | P0 | CRIT-02: Verify ClientSecret not exposed in prod | `AuthService.cs:40` | 1h | | P0 | CRIT-01: Migrate JWT to httpOnly cookie via BFF | `AuthService.cs`, BFF | 2d | | P0 | CRIT-03: Replace password grant with PKCE flow | `AuthService.cs` | 3d | | P0 | WARN-06: Add route guards / AuthorizeView | All layouts | 1d | | P0 | WARN-07: Validate shopId against user permissions | `AdminLayout.razor:246` | 4h | | P1 | CRIT-04: Add SRI hash to Lucide CDN | `index.html:19` | 30m | | P1 | WARN-01: Implement token refresh | `AuthStateService.cs` | 1d | | P1 | WARN-02: Fix global HttpClient header mutation | `PosDataService.cs:40` | 4h | | P1 | WARN-03: Remove redundant lucide.createIcons call | `AdminLayout.razor:196` | 30m | | P1 | WARN-04: Move MudBlazor providers to App.razor | All layouts | 1h | | P1 | WARN-10: Implement missing API integrations (21 TODOs) | Multiple POS pages | 5d | | P2 | WARN-05: Extract LocalStorageService | 5 files | 4h | | P2 | WARN-08: Standardize API response envelope | `PosDataService.cs:122` | 2d | | P2 | WARN-13: Add IDisposable to layouts | Layout files | 1h | | P2 | IMP-01: Write 50+ unit tests for services | `/tests/` | 3d | | P2 | IMP-04: Lazy load POS vertical assemblies | Program.cs | 1d | | P3 | WARN-09: Localize ForgotPasswordText | `AuthInput.razor:56` | 30m | | P3 | WARN-11: CI check for missing translation keys | CI pipeline | 2h | | P3 | WARN-12: Add IFormatProvider to localizer | `JsonStringLocalizer.cs` | 1h | | P3 | IMP-09: Pin Lucide version | `index.html:19` | 15m | --- *Generated by Senior Frontend Microservice Engineer audit — TEC-229*