|
Gate 5 — Quality & Security Audit (ticket #3269, forgot-password flow)
|
|
======================================================================
|
|
Generated: 2026-09-04
|
|
Feature files audited:
|
|
- middleware.ts
|
|
- app/(auth)/forgot-password/page.tsx
|
|
- app/(auth)/verify-otp/page.tsx
|
|
- app/(auth)/reset-password/page.tsx
|
|
- components/auth/ForgotPasswordForm.tsx
|
|
- components/auth/VerifyOtpForm.tsx
|
|
- components/auth/CompleteResetForm.tsx
|
|
|
|
Supporting files inspected (referenced by feature files):
|
|
- lib/actions/forgot-password.ts
|
|
- lib/queries/forgot-password.ts
|
|
- lib/forgot-password/index.ts
|
|
- lib/actions/shared.ts
|
|
- lib/validation/schemas.ts
|
|
- lib/actions/auth.ts (login/logout for comparison)
|
|
- components/auth/SignInForm.tsx (DRY baseline)
|
|
- components/auth/BrandPanel.tsx (DRY baseline)
|
|
- components/forms/ResetPasswordForm.tsx (DRY baseline — authenticated reset)
|
|
- app/(auth)/layout.tsx (session guard)
|
|
- app/(auth)/login/page.tsx (DRY page-shell baseline)
|
|
- next.config.mjs (security headers, CSRF origin)
|
|
|
|
Skill resolution (per gate instructions):
|
|
1. nextjs-tailwind-code-quality-audit — resolved via read_file (path 3)
|
|
cross-repo-orchestration/skills/nextjs-tailwind-code-quality-audit/SKILL.md
|
|
2. nextjs-tailwind-dry-architecture-audit — resolved via read_file (path 3)
|
|
cross-repo-orchestration/skills/nextjs-tailwind-dry-architecture-audit/SKILL.md
|
|
NOTE: skill file content partially corrupted/garbled (lines 21-70).
|
|
Audit applied based on the readable header description and standard DRY
|
|
analysis principles.
|
|
3. nextjs-tailwind-css-dry-architecture-audit — resolved via read_file (path 3)
|
|
cross-repo-orchestration/skills/nextjs-tailwind-css-dry-architecture-audit/SKILL.md
|
|
4. nextjs-tailwind-security-audit — resolved via read_file (path 3)
|
|
cross-repo-orchestration/skills/nextjs-tailwind-security-audit/SKILL.md
|
|
|
|
|
|
=======================================================================
|
|
AUDIT 1: [code-quality] — Code Quality & Maintainability
|
|
=======================================================================
|
|
|
|
Finding CQ-1 [code-quality]
|
|
File: components/auth/VerifyOtpForm.tsx
|
|
Location: lines 50-55, useEffect for verifyState
|
|
Category: dead-code
|
|
Priority: P3
|
|
Problem: The useEffect watching verifyState is a no-op. Its body contains
|
|
only a comment explaining why message-level errors are shown below the
|
|
field rather than in a toast — but it performs no side-effect at all.
|
|
This is a comment masquerading as an effect.
|
|
Evidence:
|
|
useEffect(() => {
|
|
if (verifyState !== initialVerifyState && !verifyState.ok && verifyState.message) {
|
|
// message-level errors ... shown below the field, not in the toast
|
|
}
|
|
}, [verifyState]);
|
|
Why it matters: A reader seeing a useEffect must trace its body to
|
|
determine it does nothing. This costs comprehension time on every read.
|
|
Recommended fix: Delete the entire useEffect block. Move the comment to
|
|
the JSX location where message-level errors are rendered (line 110-112)
|
|
as a standalone code comment.
|
|
Risk of change: None — the effect has no side-effects. Removing it cannot
|
|
change observable behavior.
|
|
Fixed: false (would change the file)
|
|
|
|
Finding CQ-2 [code-quality]
|
|
File: lib/actions/forgot-password.ts + lib/queries/forgot-password.ts
|
|
Location: maskMobile function — line 113 (actions) and line 34 (queries)
|
|
Category: dead-code / duplication
|
|
Priority: P3
|
|
Problem: The maskMobile function is independently defined in two files with
|
|
identical logic (mask all but last 4 chars with '*'). This is technically
|
|
dead code in one or the other depending on perspective — each file uses
|
|
only its own local copy.
|
|
Evidence:
|
|
actions/forgot-password.ts:113 — function maskMobile(mobile: string): string {
|
|
queries/forgot-password.ts:34 — function maskMobile(mobile: string): string {
|
|
Why it matters: A bug fix or policy change to masking logic must be applied
|
|
in two places. Easily missed.
|
|
Recommended fix: Extract to a single shared utility (e.g.
|
|
lib/forgot-password/utils.ts) and import in both files.
|
|
Risk of change: Low — behavior-identical consolidation.
|
|
Fixed: false
|
|
|
|
Finding CQ-3 [code-quality]
|
|
File: components/auth/CompleteResetForm.tsx + components/forms/ResetPasswordForm.tsx
|
|
Location: RULES array and STRENGTH_LABELS constant (both files, top-level)
|
|
Category: duplication
|
|
Priority: P3
|
|
Problem: The password policy rules array (5 rules, each with label + test
|
|
function) and the STRENGTH_LABELS array are defined identically in both
|
|
CompleteResetForm (unauthenticated reset) and ResetPasswordForm
|
|
(authenticated reset). Both files note they are "the same set" — this is
|
|
an intentional same-concept duplication.
|
|
Evidence:
|
|
CompleteResetForm.tsx:18-24 — const RULES: { label: string; test: ... }[]
|
|
ResetPasswordForm.tsx:23-29 — const RULES: { label: string; test: ... }[]
|
|
(identical content, identical labels, identical test functions)
|
|
Why it matters: A policy change (e.g. minimum length goes from 8 to 10)
|
|
needs updating in two places. The comment in CompleteResetForm explicitly
|
|
notes they should be "the same set" — so a shared import is the intent.
|
|
Recommended fix: Extract RULES and STRENGTH_LABELS to a shared module
|
|
(e.g. lib/validation/password-rules.ts) and import in both components.
|
|
Risk of change: None — pure data, same values.
|
|
Fixed: false
|
|
|
|
Areas checked and found clean:
|
|
- No unused imports in any feature file.
|
|
- No unreachable code in any feature file.
|
|
- Component sizes are appropriate — none of the 7 files exceeds ~180
|
|
lines, and each handles a single responsibility.
|
|
- TypeScript usage is clean — no `any`, no unsafe assertions, no
|
|
@ts-ignore/@ts-expect-error in the feature files.
|
|
- Error handling in Server Actions is thorough: every Prisma call is
|
|
inside a well-structured flow with explicit error returns.
|
|
- No deep nesting — early returns and guard clauses are used consistently.
|
|
- No inefficient algorithms — the password-history check iterates at most
|
|
3 rows (current + 2 history), which is constant-bounded.
|
|
|
|
|
|
=======================================================================
|
|
AUDIT 2: [dry-architecture] — Cross-file Logic Duplication
|
|
=======================================================================
|
|
|
|
NOTE: The nextjs-tailwind-dry-architecture-audit skill file is partially
|
|
corrupted (garbled text from line 21 onward). Audit applied based on the
|
|
readable header/description and standard DRY analysis methodology.
|
|
|
|
Finding DRY-1 [dry-architecture]
|
|
File: lib/actions/forgot-password.ts + lib/queries/forgot-password.ts
|
|
Location: RESEND_COOLDOWN_MS constant — line 58 (actions) and line 29 (queries)
|
|
Category: duplicate-constants
|
|
Priority: P2
|
|
Problem: The RESEND_COOLDOWN_MS constant (60_000) is defined independently
|
|
in two files. The queries file includes a "must match" comment pointing
|
|
at the actions file, but there is no compile-time enforcement — a future
|
|
edit to one file that forgets the other introduces a subtle behavioral
|
|
discrepancy (the countdown shown to the user would disagree with the
|
|
server-enforced cooldown).
|
|
Evidence:
|
|
lib/actions/forgot-password.ts:58 — const RESEND_COOLDOWN_MS = 60_000;
|
|
lib/queries/forgot-password.ts:29 — const RESEND_COOLDOWN_MS = 60_000;
|
|
// 60 seconds, must match lib/actions/forgot-password.ts
|
|
Why these are the same concept: Both represent the identical "minimum
|
|
interval between OTP sends" constraint — the action enforces it, and the
|
|
query computes the countdown from it.
|
|
Recommended fix: Export RESEND_COOLDOWN_MS from a single shared location
|
|
(e.g. lib/forgot-password/constants.ts or export from the actions file
|
|
and import in queries). Removes the "must match" comment because the
|
|
source of truth becomes singular.
|
|
Risk of change: None — same value, just one canonical definition.
|
|
Fixed: false
|
|
|
|
Finding DRY-2 [dry-architecture]
|
|
File: lib/actions/forgot-password.ts + lib/queries/forgot-password.ts
|
|
Location: maskMobile function (same as CQ-2)
|
|
Category: duplicate-utility
|
|
Priority: P3
|
|
Problem: Same as CQ-2 above — maskMobile is duplicated across files.
|
|
Cross-referencing with code-quality finding. The overlap is real: both
|
|
audits independently flag this because it's simultaneously dead-code-adjacent
|
|
(each file has a copy only it uses) and a DRY violation (same concept).
|
|
Recommended fix: Same as CQ-2 — extract to shared utility.
|
|
Fixed: false
|
|
|
|
Finding DRY-3 [dry-architecture]
|
|
File: components/auth/CompleteResetForm.tsx + components/forms/ResetPasswordForm.tsx
|
|
Location: RULES, STRENGTH_LABELS, strength-bar computation, policy-card JSX
|
|
Category: duplicate-business-logic + duplicate-UI
|
|
Priority: P2
|
|
Problem: The entire password-strength UI block — RULES array, STRENGTH_LABELS,
|
|
strengthBars/metCount computation, the policy-card JSX with its SVG
|
|
checkmarks — is duplicated between these two components. The only
|
|
functional difference is:
|
|
- CompleteResetForm has no currentPassword field (unauthenticated flow)
|
|
- CompleteResetForm has no Cancel button
|
|
- ResetPasswordForm uses bg-[rgb(230,230,230)] where CompleteResetForm
|
|
uses bg-border-subtle for the inactive strength bar (token drift)
|
|
- ResetPasswordForm uses text-[rgb(163,163,163)] where CompleteResetForm
|
|
uses text-text-faint (same concept, drifted tokens)
|
|
The form shells are different enough to not consolidate, but the
|
|
strength meter + policy card are a perfect extraction target.
|
|
Evidence (strength computation — identical):
|
|
CompleteResetForm:48-49 — const strengthBars = ... Math.ceil((metCount / RULES.length) * 4)
|
|
ResetPasswordForm:53 — const strengthBars = ... Math.ceil((metCount / RULES.length) * 4)
|
|
Evidence (policy card — structurally identical, token drift):
|
|
CompleteResetForm:116-163 — bg-accent-subtle, text-text-faint
|
|
ResetPasswordForm:126-141 — bg-accent-subtle, text-[rgb(163,163,163)]
|
|
Recommended fix: Extract a <PasswordStrengthMeter> and <PasswordPolicyCard>
|
|
component pair (or a combined <PasswordPolicyPanel>) that accepts RULES
|
|
as a prop or imports from the shared module (DRY-3 from code-quality).
|
|
Both forms import and compose it. The token drift in the strength bar
|
|
inactive state (bg-[rgb(230,230,230)] vs bg-border-subtle) gets resolved
|
|
to one canonical token during extraction.
|
|
Risk of change: Low — pure extraction. Visual output identical once the
|
|
drifted token is unified.
|
|
Fixed: false
|
|
|
|
Areas checked and found clean:
|
|
- The three page files (forgot-password, verify-otp, reset-password)
|
|
share a page shell pattern (BrandPanel + centered form). This was
|
|
evaluated for extraction — but the shell is a single div with a
|
|
BrandPanel and a flex container, totaling 4-5 lines. It already IS the
|
|
shared abstraction (BrandPanel is a shared component). The outer div's
|
|
slight variations (gap-0 vs overflow-y-auto vs plain center) reflect
|
|
real layout differences per step. A wrapper component would gain almost
|
|
nothing and add indirection. NOT flagged.
|
|
- Server Action patterns (validate → load flow → guard → act) are
|
|
consistent but not copy-paste-identical — each action has different
|
|
business logic in the "act" phase. Consolidation would over-abstract.
|
|
- The barrel file (lib/forgot-password/index.ts) correctly re-exports
|
|
without duplicating logic.
|
|
|
|
|
|
=======================================================================
|
|
AUDIT 3: [css-dry] — Tailwind CSS Component Architecture
|
|
=======================================================================
|
|
|
|
Finding CSSDRY-1 [css-dry]
|
|
File: app/(auth)/forgot-password/page.tsx, verify-otp/page.tsx,
|
|
reset-password/page.tsx, login/page.tsx
|
|
Location: Page-level wrapper div
|
|
Category: repeated-class-combination
|
|
Priority: P3
|
|
Problem: The auth page shell class string
|
|
"fixed inset-0 z-[100] flex overflow-hidden bg-surface" is repeated
|
|
verbatim in all four auth page files.
|
|
Evidence:
|
|
forgot-password/page.tsx:14 — className="fixed inset-0 z-[100] flex overflow-hidden bg-surface"
|
|
verify-otp/page.tsx:35 — className="fixed inset-0 z-[100] flex overflow-hidden bg-surface"
|
|
reset-password/page.tsx:35 — className="fixed inset-0 z-[100] flex overflow-hidden bg-surface"
|
|
login/page.tsx:6 — className="fixed inset-0 z-[100] flex overflow-hidden bg-surface"
|
|
Why these are the same concept: All are the full-screen auth-page
|
|
container. A visual change to the auth page background would need
|
|
updating in 4 places.
|
|
Recommended fix: This is borderline — the wrapper is 1 line in each file,
|
|
and BrandPanel already extracts the main shared UI. A lightweight
|
|
<AuthPageShell> component wrapping children in this div + BrandPanel
|
|
would reduce each page to just the form. Alternatively, move this
|
|
pattern into the (auth) layout itself (currently a bare fragment
|
|
returning children). The layout approach is the more idiomatic Next.js
|
|
solution — the layout already exists and currently does nothing
|
|
presentational.
|
|
Risk of change: Low — pure markup extraction. However moving BrandPanel
|
|
into the layout changes where the component renders in the tree; verify
|
|
each page's form centering still works.
|
|
Fixed: false
|
|
|
|
Finding CSSDRY-2 [css-dry]
|
|
File: components/auth/CompleteResetForm.tsx + components/forms/ResetPasswordForm.tsx
|
|
Location: Strength meter bar markup + policy card markup
|
|
Category: copy-pasted-UI
|
|
Priority: P2
|
|
Problem: The strength meter (4 bars with clsx conditional coloring) and
|
|
the password policy card (SVG checkmarks, rule list) are copy-pasted
|
|
between CompleteResetForm and ResetPasswordForm with minor token drift.
|
|
This is the CSS/markup dimension of DRY-3.
|
|
Evidence (strength bars — identical structure):
|
|
CompleteResetForm:97-112 — flex max-w-[180px] flex-grow gap-[3px], h-[5px]
|
|
ResetPasswordForm:113-119 — identical structure
|
|
Evidence (policy card drift):
|
|
CompleteResetForm:103 — bg-border-subtle (inactive bar)
|
|
ResetPasswordForm:117 — bg-[rgb(230,230,230)] (hardcoded, same color intent)
|
|
CompleteResetForm:139 — text-text-faint (inactive rule text)
|
|
ResetPasswordForm:133 — text-[rgb(163,163,163)] (hardcoded, same intent)
|
|
Recommended fix: Extract shared <PasswordStrengthMeter> and
|
|
<PasswordPolicyCard> components (same recommendation as DRY-3). During
|
|
extraction, unify the drifted tokens to the design-system version
|
|
(bg-border-subtle, text-text-faint).
|
|
Risk of change: Low. The token drift fix means ResetPasswordForm's
|
|
hardcoded rgb values change to the semantic token — verify the computed
|
|
color matches (likely identical; bg-border-subtle resolves to the same
|
|
rgb in the theme).
|
|
Fixed: false
|
|
|
|
Areas checked and found clean:
|
|
- Form class patterns across auth components use existing shared
|
|
components (PasswordField, SubmitButton, PhoneInput) — good reuse.
|
|
- The inline SVG checkmark in the policy card is a valid candidate for
|
|
a tiny component, but it's a single <svg> element used only in the
|
|
policy card context. Extracting it gains little; flagged only as part
|
|
of the broader policy-card extraction (CSSDRY-2).
|
|
- Button variants: ForgotPasswordForm uses <SubmitButton>, VerifyOtpForm
|
|
uses <SubmitButton> + a plain <button> for resend. The resend button
|
|
is intentionally different (text-link style, not a primary button) —
|
|
not a duplication issue.
|
|
|
|
|
|
=======================================================================
|
|
AUDIT 4: [security] — Security Vulnerabilities
|
|
=======================================================================
|
|
|
|
Finding SEC-1 [security]
|
|
Severity: Low
|
|
File: middleware.ts
|
|
Location: line 46 (matcher), line 58 (loginUrl with ?next=)
|
|
Category: input-validation (open redirect — mitigated)
|
|
Priority: P3
|
|
Problem: The middleware sets a `next` query parameter on the login redirect
|
|
URL using the current pathname. This is the standard "return to the page
|
|
you were trying to reach" pattern. However, the `next` parameter is
|
|
currently never consumed — the login action (lib/actions/auth.ts)
|
|
hard-redirects to /orders unconditionally (line 100). This means:
|
|
(a) The return-to-original-page feature is incomplete/unused.
|
|
(b) If a future change wires up the `next` param for post-login
|
|
redirect without an allow-list, it becomes an open-redirect vector.
|
|
The current state is safe — the param is set but never read.
|
|
Attack scenario: None currently exploitable. Potential future risk only.
|
|
Impact: None today.
|
|
Recommended remediation: Either remove the `next` param from the redirect
|
|
(dead code cleanup), or — when implementing the return-to feature —
|
|
validate that `next` starts with "/" and contains no protocol/host
|
|
prefix (i.e. `if (!next.startsWith('/') || next.startsWith('//'))
|
|
next = '/orders'`).
|
|
Fixed: false
|
|
|
|
Finding SEC-2 [security]
|
|
Severity: Low
|
|
File: components/auth/VerifyOtpForm.tsx
|
|
Location: line 93-104, input element for OTP code
|
|
Category: input-validation (client-side only)
|
|
Priority: P3
|
|
Problem: The OTP input field uses `maxLength={6}` and
|
|
`inputMode="numeric"` as client-side hints, but has no `pattern`
|
|
attribute and no `required` attribute. A direct form submission (e.g.
|
|
via JavaScript or modified HTML) could send a non-numeric or empty
|
|
string. This is fully mitigated by the server-side verifyOtpSchema
|
|
(Zod: /^\d{6}$/) which rejects any non-6-digit input, so this is a
|
|
defense-in-depth observation, not an exploitable finding.
|
|
Attack scenario: Not exploitable — server validation catches all invalid
|
|
input.
|
|
Impact: None.
|
|
Recommended remediation: Add `required` and `pattern="[0-9]{6}"` to the
|
|
input for consistent client-side validation UX (shows native browser
|
|
validation before the round-trip). Optional improvement, not a security
|
|
gap.
|
|
Fixed: false
|
|
|
|
Areas checked and confirmed secure:
|
|
- Authentication bypass: The middleware matcher correctly excludes
|
|
forgot-password, verify-otp, and reset-password from the session gate.
|
|
The (auth)/layout.tsx bounces authenticated users to /orders. Both
|
|
directions are covered.
|
|
- Server Action authorization: All four Server Actions (requestResetCode,
|
|
resendResetCode, verifyResetCode, completeReset) are intentionally
|
|
unauthenticated (they're the forgot-password flow for users who CAN'T
|
|
log in). Each re-validates the flow state from the DB on every call —
|
|
no action trusts client state or the UI to gate access.
|
|
- Input validation: All four actions use Zod schemas
|
|
(forgotPasswordSchema, verifyOtpSchema, newPasswordSchema) before
|
|
touching any data. No raw user input reaches Prisma queries — all
|
|
queries use parameterized ORM methods, never string interpolation.
|
|
- Injection: No dangerouslySetInnerHTML, eval, new Function, or raw SQL
|
|
anywhere in the feature files or their action layer.
|
|
- Secrets exposure: No NEXT_PUBLIC_* variables used. No hardcoded secrets.
|
|
OTP is stored as SHA-256 hash only; plaintext never persisted or logged.
|
|
- Cookie security: oms_reset_flow cookie is HttpOnly, Secure in
|
|
production, SameSite=lax, path="/", with a TTL matching OTP_TTL_MS.
|
|
Same flags on the main session cookie. Correct.
|
|
- CSRF: Next.js Server Actions have built-in CSRF protection (Origin
|
|
header check); next.config.mjs allowedOrigins is configured. Covered.
|
|
- Timing attacks: OTP comparison uses timingSafeHexEqual with
|
|
constant-time bitwise XOR over the full hash length. Correct.
|
|
- Brute force: MAX_ATTEMPTS=5 with full flow deletion on exhaustion (not
|
|
just locking). 10-minute TTL. Adequate for a 6-digit OTP.
|
|
- Account enumeration: requestResetCode returns identical success
|
|
message/behavior whether or not the mobile exists. Correct.
|
|
- Session revocation on reset: completeReset deletes ALL sessions for the
|
|
user in the same transaction as the password update. Correct and
|
|
important — prevents an attacker holding a session from surviving a
|
|
password reset.
|
|
- Password history: Checks current hash + 2 most recent history entries
|
|
(3 total), matching the authenticated changeMyPassword flow. Correct.
|
|
- Error leakage: No stack traces or internal details in any error
|
|
response. All errors are user-friendly messages.
|
|
- Open redirect: middleware sets ?next= but it's never consumed (SEC-1).
|
|
completeReset redirects to hard-coded /login. verifyResetCode redirects
|
|
to hard-coded /reset-password. No user-controlled redirect targets.
|
|
- No dependencies with known security issues introduced by these files.
|
|
|
|
|
|
=======================================================================
|
|
MERGED FINDING SUMMARY
|
|
=======================================================================
|
|
|
|
Total findings: 7 (0 P1, 2 P2, 5 P3)
|
|
|
|
P2 findings (reported, not blocking — no P1):
|
|
DRY-1 [dry-architecture] RESEND_COOLDOWN_MS duplicated constant
|
|
DRY-3 [dry-architecture] Password strength UI logic fully duplicated
|
|
(also flagged as CSSDRY-2 from the CSS lens — overlap noted,
|
|
both kept as they represent different audit perspectives on the
|
|
same underlying issue)
|
|
|
|
P3 findings:
|
|
CQ-1 [code-quality] No-op useEffect in VerifyOtpForm
|
|
CQ-2 [code-quality] Duplicated maskMobile utility
|
|
(also flagged as DRY-2 — overlap noted)
|
|
CQ-3 [code-quality] Duplicated RULES/STRENGTH_LABELS
|
|
(subset of DRY-3/CSSDRY-2 — overlap noted)
|
|
CSSDRY-1 [css-dry] Auth page shell class string repeated 4x
|
|
SEC-1 [security] Unused ?next= param (dormant open-redirect seed)
|
|
SEC-2 [security] OTP input missing required/pattern (server-mitigated)
|
|
|
|
P1 findings: 0
|
|
Auto-fixes applied: 0
|
|
(No findings met the "clearly safe, no behavior change" auto-fix bar.
|
|
CQ-1 is the closest candidate but involves removing code from a file
|
|
this gate has read-only access to.)
|
|
|
|
GATE STATUS: PASS (zero unresolved P1 findings)
|