---
phase: 19-local-auth-no-oidc-mode
audited: 2026-06-17
status: secured
asvs_level: 1
block_on: high
register_authored_at_plan_time: true
threats_total: 28
threats_closed: 28
threats_open: 0
threats_accepted: 2
supply_chain_checks: 2
---

# Phase 19 — Local Auth (No-OIDC Mode): Security Audit

**Audited:** 2026-06-17
**ASVS Level:** 1
**block_on:** high
**Compared against:** main..HEAD
**Audit type:** Retroactive threat-mitigation verification (declared register, no net-new scan)
**Branch:** `gsd/phase-19-local-auth-no-oidc-mode`
**Verdict:** SECURED — 28/28 threats closed (26 mitigate + 2 accept), 0 open, 0 unregistered flags

Implementation files were treated as READ-ONLY. No implementation file was modified by this audit.

---

## Threat Verification

| Threat ID | Category | Disposition | Status | Evidence (file:line) |
|-----------|----------|-------------|--------|----------------------|
| T-19-01 | Information Disclosure | mitigate | CLOSED | `apps/api/src/auth/localCredentials.ts:66` (16-byte randomBytes salt), `:114` timingSafeEqual, `:115-118` verify never throws; no password logged |
| T-19-02 | Spoofing | mitigate | CLOSED | `apps/api/src/auth/localSession.ts:58` Jwt.sign HS256 w/ LOCAL_SESSION_SECRET; `:90-94` verify returns null on tamper/expiry |
| T-19-03 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/lib/bootGuards.ts:53-66` assertLocalSessionSecretSet (exit 1 when unset/<32, exempt in bypass); wired `apps/api/src/index.ts:265` |
| T-19-04 | Tampering | mitigate | CLOSED | `.dockerignore:7` `apps/api/scripts/`, `:21` `apps/api/tests/`, `:23` `apps/pwa/e2e/` |
| T-19-SC(01) | Tampering | mitigate | CLOSED | `git diff main...HEAD` shows zero dependency-line changes in any package.json |
| T-19-05 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/admin.ts:47` `adminRouter.use('*', requireAdmin)` is first statement; test asserts 403 |
| T-19-06 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/admin.ts:75-79` noEchoHook on create/reset; `:148,:239` no-log comments honored |
| T-19-07 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/me.ts:240` resolveUserId from session, `:260-268` verifyPassword(current) before update |
| T-19-08 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/linkOidc.ts:65-74` preflight conflict before any write; backstop `uniq_oidc_identity` in `migrations/0000_baseline.sql` |
| T-19-09 | Tampering | mitigate | CLOSED | `apps/api/src/routes/me.ts:321-333` per-request nonce in signed HS256 state; `linkNonceStore.ts:41-48` single-use consume |
| T-19-10 | Tampering | mitigate | CLOSED | `apps/api/src/routes/admin.ts:165-185` db.transaction wraps users + local_credentials; `:202` 409 rolls back |
| T-19-11 | Elevation of Privilege | mitigate | CLOSED (deviation noted) | `apps/api/src/routes/localAuth.ts:162-176` 5→429 / 10→423; `:96-98`/admin.ts:262 admin reset clears. Keyed on **username** not IP (CR-04, documented) |
| T-19-12 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:128` dummyHashPromise, `:197-199` verifyPassword always run, `:210` identical 401 body |
| T-19-13 | Spoofing | mitigate | CLOSED | `apps/api/src/index.ts:191-197` OIDC guard wrapped to skip when `c.get('user')` set; `localAuthMiddleware.ts:45-101` populates it |
| T-19-14 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:47-51` noEchoHook on login route |
| T-19-15 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/index.ts:84-133` callback: nonce consume + BL-03 session-match + empty-iss/sub guard + linkOidcToUser conflict (409) |
| T-19-16 | Information Disclosure | accept→mitigate | CLOSED | D-06 applied; Phase-19-touched PWA files render no "Authelia" (see T-19-21) |
| T-19-17 | Spoofing | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:216` fresh issueLocalSessionCookie every success; `localSession.ts:50-55` exp claim bounds lifetime |
| T-19-18 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx` + `SettingsSheet.tsx` password in useState only; no localStorage/sessionStorage write for password fields |
| T-19-19 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx:291` single "Incorrect username or password." — no field-level blame |
| T-19-20 | Tampering | mitigate | CLOSED | No `dangerouslySetInnerHTML` in any PWA src (grep across `apps/pwa/src/` = 0 usages; only prohibition comments) |
| T-19-21 | Information Disclosure | mitigate | CLOSED | `grep -ci authelia` == 0 in LoginPage/AdminPage/BrandSlot; SettingsSheet's 1 hit is a copywriting-rule comment (line 828), not rendered |
| T-19-22 | Elevation of Privilege | accept | CLOSED | Documented accepted risk (below); server boundary verified at `admin.ts:47` requireAdmin |
| T-19-23 | Elevation of Privilege | mitigate | CLOSED | Seed only in `apps/pwa/e2e/global-setup.ts` (.dockerignore'd); zero seed in `migrations/` or `index.ts` |
| T-19-24 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/devBypass.ts:87,:122` NODE_ENV==='production' is FIRST check; `bootGuards.ts:26-34` assertNotDevBypassInProduction |
| T-19-25 | Tampering | mitigate | CLOSED | `.dockerignore:7` excludes `apps/api/scripts/`; `reset-admin.ts:26-32` NODE_ENV=production throw is first executable statement |
| T-19-26 | Information Disclosure | mitigate | CLOSED | `reset-admin.ts` logs only user id / status; no console statement emits the password value; `--dry-run` validates without writing (`:129-133`) |
| T-19-SC(05) | Tampering | mitigate | CLOSED | Zero new packages (same as T-19-SC(01)) |

---

## Deviation Note — T-19-11 (rate-limit key)

The register declares "per-IP rate-limit". The implementation (`localAuth.ts`, CR-04) keys the
limiter on the **submitted username**, not the client IP. This is a deliberate, documented
deviation: in this Pangolin-tunnel deployment all household traffic shares one X-Forwarded-For
first hop (so IP-keying let one actor lock out every member) and X-Forwarded-For is spoofable.
The declared security property — brute-force resistance via 5→429 and 10→423 with admin-reset
recovery and a self-healing TTL — is fully present. Treated as CLOSED. The register wording is
stale relative to the shipped (stronger-for-this-topology) mechanism.

---

## Accepted Risks Log

| Threat ID | Risk | Rationale |
|-----------|------|-----------|
| T-19-22 | Client `isAdmin` / `hasLocalCredential` flags are UX-only and trivially editable in the browser. | Accepted: these flags only gate PWA nav/affordances. The real authorization boundary is server-side `requireAdmin` on every `/api/admin/*` request (`admin.ts:47`) and session-derived `resolveUserId` on `/api/me/*`. Client gating is never the security boundary. Documented prior decision. |
| T-19-16 | "Authelia" provider name could leak infrastructure detail in UI/comments. | Low-severity hygiene (accept→mitigate). D-06 applied across Phase-19-touched surfaces; remaining occurrences are in the out-of-scope Phase 12 `SetupPage.tsx` wizard and in source comments, not on the local-auth surfaces this phase introduced. |

---

## Unregistered Flags

The two `## Threat Flags` entries in `19-04-SUMMARY.md`
(`threat_flag: credential-in-controlled-state` for `LoginPage.tsx` and `SettingsSheet.tsx`)
both map to existing register threats **T-19-18** (password in client storage). Informational
only — no unregistered attack surface. No WARNING raised.

---

## Out-of-Scope Observation (non-blocking, not a Phase 19 gap)

`apps/pwa/src/routes/SetupPage.tsx` (lines 379, 498, 627, 645, 695) renders the literal string
"Authelia" in the first-run setup wizard. This file was **not** modified in Phase 19
(`git diff main...HEAD` = no changes) — it is the pre-existing Phase 12 wizard, outside the
T-19-21 mitigation scope ("any PWA source touched by this plan"). It does not affect the local-auth
login/admin/settings surfaces. Flagged here for a future D-06 sweep of the setup wizard; it is
**not** an open Phase 19 threat.

---

## Security Audit 2026-06-17

| Metric | Count |
|--------|-------|
| Threats found | 28 |
| Closed | 28 |
| Open | 0 |
| Accepted | 2 |
| Supply-chain checks | 2 |
