Skip to content

feat: production hardening + Supabase MFA (TOTP)#256

Merged
Systemsaholic merged 4 commits intomainfrom
feature/production-hardening-mfa
Apr 27, 2026
Merged

feat: production hardening + Supabase MFA (TOTP)#256
Systemsaholic merged 4 commits intomainfrom
feature/production-hardening-mfa

Conversation

@Systemsaholic
Copy link
Copy Markdown
Owner

@Systemsaholic Systemsaholic commented Apr 27, 2026

Summary

Production readiness: security hardening + two-factor authentication for all admin users.

Phase 1: Security Hardening

  • Replace console.* with NestJS Logger in bootstrap
  • sendDefaultPii: false in Sentry (stops capturing IP/cookies)
  • Rate limiting (5 req/min) on credential reveal endpoint
  • Harden HttpExceptionFilter — generic message for 5xx errors
  • Security headers on admin (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) — exempt /auth/callback

Phase 2: MFA Frontend

  • useMfa hook wrapping Supabase mfa.* APIs
  • /auth/mfa-verify page — TOTP code entry with Phoenix Voyages branding
  • /auth/mfa-enroll page — QR code setup flow (scan, verify, complete)
  • MFA_REQUIRED feature flag (off by default = grace period)
  • Auth provider: exposes aal + mfaEnrolled, moves record-login to post-aal2
  • Middleware: redirects aal1 users to verify/enroll when enforcement on
  • Login form: post-login check redirects to mfa-verify if factors enrolled

Phase 3: MFA API Enforcement

  • MfaGuard: rejects aal1 tokens when MFA_REQUIRED=true
  • @BypassMfa() decorator for MFA management endpoints
  • aal added to AuthContext + JwtPayload types + JWT strategy extraction
  • Guard order: JwtAuth → MFA → Impersonation → Roles → UserStatus → ActiveUser

Env Vars (new)

  • MFA_REQUIRED — server-side enforcement (API + middleware)
  • NEXT_PUBLIC_MFA_REQUIRED — client-side display
  • Both default to false (grace period)

Test plan

  • Login works normally (enforcement off)
  • MFA enroll page accessible at /auth/mfa-enroll
  • MFA verify page accessible at /auth/mfa-verify
  • Security headers present on admin responses
  • Sentry PII disabled
  • Credential reveal rate-limited
  • 5xx errors return generic message

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added two-factor authentication (MFA) enrollment and verification for enhanced account security.
    • Implemented security headers to protect against common web vulnerabilities.
    • Added rate limiting to API credential reveal operations.
  • Security

    • Disabled personal data collection in error tracking to improve privacy.
  • Improvements

    • Enhanced error logging for better system diagnostics.

Systemsaholic and others added 3 commits April 27, 2026 10:13
- Replace console.* with NestJS Logger in bootstrap (main.ts)
- Disable sendDefaultPii in Sentry (instrument.ts)
- Add rate limiting (5 req/min) to credential reveal endpoint
- Harden HttpExceptionFilter: generic message for 5xx, log details
- Add security headers to admin (X-Frame-Options, X-Content-Type-Options,
  Referrer-Policy, Permissions-Policy) — exempt /auth/callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…e gating

- useMfa hook: wraps Supabase mfa.* APIs (enroll, verify, challenge, unenroll)
- /auth/mfa-verify page: TOTP code entry with Phoenix Voyages branding
- /auth/mfa-enroll page: QR code setup flow with manual secret + verify
- Feature flag: MFA_REQUIRED (NEXT_PUBLIC + server-side MFA_REQUIRED)
- Auth provider: exposes aal + mfaEnrolled, moves record-login to post-aal2
- Middleware: redirects aal1 users to verify/enroll when enforcement on
- Login form: post-login check redirects to mfa-verify if factors enrolled

MFA enforcement is OFF by default (grace period). Flip MFA_REQUIRED=true
to enforce.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MfaGuard: checks aal claim, rejects aal1 when MFA_REQUIRED=true
- @BypassMfa() decorator for MFA management endpoints
- aal added to AuthContext and JwtPayload types
- JWT strategy extracts aal from payload
- Guard registered in main.ts: JwtAuth → MFA → Impersonation → Roles
- Skips: @public routes, @BypassMfa routes, pending users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tailfire-client Ready Ready Preview, Comment Apr 27, 2026 3:46pm
tailfire-ota Ready Ready Preview, Comment Apr 27, 2026 3:46pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@Systemsaholic has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 23 minutes and 7 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 995fb5b5-13f4-490b-aa5d-6ae3b51f611c

📥 Commits

Reviewing files that changed from the base of the PR and between 823b335 and 87c26c7.

📒 Files selected for processing (2)
  • apps/admin/src/app/auth/mfa-enroll/page.tsx
  • apps/admin/src/app/auth/mfa-verify/page.tsx
📝 Walkthrough

Walkthrough

Implements multi-factor authentication (MFA) support across admin and API applications, including enrollment and verification UI pages, MFA state hooks, backend JWT claims, middleware-based enforcement with redirects, API guards, and security hardening through headers, improved error logging, and PII redaction.

Changes

Cohort / File(s) Summary
Admin App: Security & Configuration
next.config.ts, src/lib/feature-flags.ts
Adds restrictive HTTP security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) and introduces FEATURE_FLAGS.MFA_REQUIRED flag to gate MFA enforcement behavior.
Admin App: MFA UI Pages
src/app/auth/mfa-enroll/page.tsx, src/app/auth/mfa-verify/page.tsx
New client pages implementing MFA enrollment (QR code, secret display, copy-to-clipboard) and verification (6-digit code validation) with loading states, error handling, and post-MFA navigation.
Admin App: MFA Logic & State
src/hooks/use-mfa.ts, src/providers/auth-provider.tsx
Introduces useMfa() hook managing TOTP enrollment/verification and a recordLogin() callback; augments AuthContextType with MFA state (aal, mfaEnrolled) and new checkMfaLevel() flow that defers login recording until post-MFA completion.
Admin App: Auth Flow & Routing
src/app/auth/login-form.tsx, src/middleware.ts, src/lib/supabase/middleware.ts
Login form now checks MFA assurance level and conditionally redirects to MFA verification; middleware enforces MFA enrollment/verification redirects based on MFA_REQUIRED flag; session update helper decodes JWT aal claim and retrieves MFA factor status.
API: Authentication & Authorization
src/auth/auth.types.ts, src/auth/decorators/bypass-mfa.decorator.ts, src/auth/guards/mfa.guard.ts, src/auth/strategies/jwt.strategy.ts
Extends AuthContext and JwtPayload types with aal claim; adds @BypassMfa() decorator and global-ready MfaGuard that enforces aal === 'aal2' when enabled, excluding @Public() and @BypassMfa() handlers; JWT strategy extracts aal from token payload.
API: Infrastructure & Logging
src/common/filters/http-exception.filter.ts, src/main.ts, src/instrument.ts
Improves error handling by logging 5xx errors server-side without exposing details to clients; replaces ad-hoc console output with NestJS Logger; registers MfaGuard in global guard chain after JwtAuthGuard; disables Sentry default PII sending.
API: Endpoint Hardening
src/api-credentials/api-credentials.controller.ts
Adds rate limiting (ThrottlerGuard with 5 requests per 60s) to API credential reveal endpoint and updates documentation.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Client as Admin Client
    participant AuthAPI as Supabase Auth
    participant AppServer as App Server
    
    User->>Client: Enter login credentials
    Client->>AuthServer: Submit login form
    AuthServer->>AuthAPI: Authenticate with email/password
    AuthAPI-->>AuthServer: Return session + aal='aal1'
    AuthServer->>AuthServer: Check mfa state
    alt MFA enrolled & aal='aal1'
        AuthServer-->>Client: Redirect to /auth/mfa-verify
        User->>Client: Enter 6-digit TOTP code
        Client->>AuthAPI: Verify MFA challenge
        AuthAPI-->>Client: MFA verified, aal='aal2'
        Client->>AppServer: Record login after MFA
        AppServer-->>Client: Navigate to original destination
    else MFA not enrolled & MFA_REQUIRED
        AuthServer-->>Client: Redirect to /auth/mfa-enroll
        User->>Client: Initiate MFA setup
        Client->>AuthAPI: Enroll TOTP factor
        AuthAPI-->>Client: Return QR + secret
        User->>Client: Scan QR & enter 6-digit code
        Client->>AuthAPI: Verify enrollment code
        AuthAPI-->>Client: Factor enrolled successfully
        Client->>AppServer: Record login after enrollment
        AppServer-->>Client: Navigate to original destination
    else No MFA required
        AuthServer-->>Client: Record login immediately
        AppServer-->>Client: Navigate to dashboard
    end
Loading
sequenceDiagram
    participant Client as API Client
    participant AppGateway as API Gateway<br/>(Global Guards)
    participant JWTGuard as JwtAuthGuard
    participant MFAGuard as MfaGuard
    participant Endpoint as Protected Endpoint
    
    Client->>AppGateway: HTTP request with JWT
    AppGateway->>JWTGuard: Validate & extract JWT claims
    JWTGuard->>JWTGuard: Decode token, extract aal
    JWTGuard-->>AppGateway: Return AuthContext with aal
    AppGateway->>MFAGuard: Check if MFA_REQUIRED enabled
    alt MFA_REQUIRED == true
        MFAGuard->>MFAGuard: Read `@Public/`@BypassMfa metadata
        alt Handler is `@Public` or `@BypassMfa`
            MFAGuard-->>Endpoint: Allow request to proceed
        else Handler requires MFA
            alt aal == 'aal2'
                MFAGuard-->>Endpoint: Allow request to proceed
            else aal != 'aal2'
                MFAGuard-->>Client: ForbiddenException: MFA verification required
            end
        end
    else MFA_REQUIRED == false
        MFAGuard-->>Endpoint: Allow request to proceed
    end
    Endpoint->>Endpoint: Process request
    Endpoint-->>Client: Return response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • RBAC Hardening & Expansion #60 — Introduces RBAC and ImpersonationGuard; this PR coordinates MfaGuard placement within the global guard execution chain relative to ImpersonationGuard.

Poem

🐰 Hops through the auth, with factors so fine,
TOTP codes glow in a QR design,
Guards at the gate now check aal with care,
Two-factor hopping—security everywhere!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the PR's main changes: production security hardening and MFA implementation via Supabase TOTP.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/production-hardening-mfa

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (3)
apps/admin/src/hooks/use-mfa.ts (1)

38-62: Don't silently swallow MFA errors — report to Sentry.

refreshState, enroll, verify, and unenroll all swallow errors with empty catches or by returning null/false. That hides genuine outages (network errors, Supabase outages, malformed responses) from operators and from the admin Sentry stream. Per the admin Sentry guideline, runtime errors here should be captured (tagged with NEXT_PUBLIC_SENTRY_ENVIRONMENT).

At minimum, consider Sentry.captureException(error) in the catch blocks while keeping the user-facing fallback behavior. As per coding guidelines: "Admin application must capture runtime errors with Sentry. Errors must be tagged with NEXT_PUBLIC_SENTRY_ENVIRONMENT as development, preview, or production."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/hooks/use-mfa.ts` around lines 38 - 62, refreshState, enroll,
verify, and unenroll currently swallow exceptions; update each catch block to
call Sentry.captureException(error) (or captureException(e)) before returning
the existing fallback so errors appear in Sentry. Include the
NEXT_PUBLIC_SENTRY_ENVIRONMENT value as a tag/extra on the capture (e.g., using
Sentry.setContext or passing { tags: { environment:
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT } } with the capture) so the error is
attributed to dev/preview/production, and keep the original user-facing return
behavior (null/false) after capturing.
apps/admin/src/providers/auth-provider.tsx (2)

119-140: Duplicate getAuthenticatorAssuranceLevel() call per SIGNED_IN event.

When event === 'SIGNED_IN' with a session, checkMfaLevel() (line 120) already issues a Supabase MFA round-trip; then lines 130-139 immediately issue a second getAuthenticatorAssuranceLevel() to decide whether to record login. Two back-to-back identical network calls per sign-in. Reusing the data from checkMfaLevel (e.g. by having it return { currentLevel, nextLevel }) would remove the second call and consolidate the decision logic.

Also note: this branch hand-rolls the record-login fetch instead of calling the new recordLogin() callback, which then bypasses the loginRecorded guard's symmetry (it sets loginRecorded(true) on line 138 directly). Folding the non-MFA path into recordLogin() (or a shared helper) would keep one source of truth.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/providers/auth-provider.tsx` around lines 119 - 140,
checkMfaLevel currently calls supabase.auth.mfa.getAuthenticatorAssuranceLevel()
but its result is discarded; modify checkMfaLevel to return the MFA assurance
info (e.g., { currentLevel, nextLevel }) and then in the SIGNED_IN branch reuse
that returned value instead of calling getAuthenticatorAssuranceLevel() a second
time; move the non‑MFA "record login" logic into the existing recordLogin()
callback (or a shared helper) and call recordLogin() when nextLevel !== 'aal2',
removing the direct fetch and the direct setLoginRecorded(true) so the
loginRecorded guard remains symmetric and single-sourced.

87-98: Wrap recordLogin in useCallback.

recordLogin is recreated on every AuthProvider render and exposed via context. Consumers (e.g. mfa-verify / mfa-enroll pages) typically call this from a useEffect; if recordLogin ends up in their dependency arrays it triggers an effect every render. The internal loginRecorded flag prevents duplicate POSTs but won't prevent the re-run cycle.

♻️ Suggested fix
-  const recordLogin = () => {
+  const recordLogin = useCallback(() => {
     if (loginRecorded) return
     const token = session?.access_token
     if (!token) return
 
     setLoginRecorded(true)
     const apiUrl = process.env.NEXT_PUBLIC_API_URL || '/api/v1'
     fetch(`${apiUrl}/user-profiles/me/record-login`, {
       method: 'POST',
       headers: { Authorization: `Bearer ${token}` },
     }).catch(() => {/* non-critical */})
-  }
+  }, [loginRecorded, session?.access_token])

(Add useCallback to the existing react import.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/providers/auth-provider.tsx` around lines 87 - 98, The
recordLogin function inside AuthProvider is recreated on every render and should
be memoized to avoid causing consumer useEffect re-runs; wrap recordLogin with
React's useCallback (add useCallback to the react import) and have it depend
only on the stable values it uses (session?.access_token, loginRecorded, and
setLoginRecorded) so that recordLogin, loginRecorded, session, setLoginRecorded
remain the referenced symbols and the fetch call to
`${process.env.NEXT_PUBLIC_API_URL || '/api/v1'}/user-profiles/me/record-login`
still executes unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/admin/next.config.ts`:
- Around line 82-95: The current headers() config uses a broad
negative-lookahead source pattern '/((?!auth/callback).*)' which unintentionally
exempts paths like '/auth/callbackfoo' or '/auth/callback/something'; tighten
the pattern to only exempt the exact route by changing the source used in the
headers() return (the object with source property) so it matches the literal
'/auth/callback' only (while leaving all other header entries like
'X-Frame-Options', 'X-Content-Type-Options', 'Referrer-Policy', and
'Permissions-Policy' unchanged).

In `@apps/admin/src/app/auth/mfa-enroll/page.tsx`:
- Around line 72-76: The handleCopySecret currently calls
navigator.clipboard.writeText(secret) without awaiting or handling rejection and
immediately sets setSecretCopied(true) and a timeout, which can show success
when copy failed and can fire after unmount; change handleCopySecret to be
async/await (or use .then/.catch), await navigator.clipboard.writeText(secret),
only call setSecretCopied(true) when the Promise resolves, handle errors by
showing a user-visible failure state (e.g., set an error flag or fallback UI)
and do not mark success on rejection, and replace the bare setTimeout with a
timerId stored in a ref (e.g., copyTimerRef) that you clear in a useEffect
cleanup to avoid calling setSecretCopied after unmount.
- Line 36: The MFA enrollment page uses the hardcoded TOTP issuer string
"Tailfire" (see call to enroll('Tailfire')) and the completion button text
"Continue to Tailfire", but the PR description and other UI elements use
"Phoenix Voyages"; decide which public-facing brand should appear in the admin
UI, then update the strings accordingly: change the enroll(...) issuer argument
and any button/label text in apps/admin/src/app/auth/mfa-enroll/page.tsx (and
related UI strings in that component) to the chosen brand (either "Phoenix
Voyages" or "Tailfire") so implementation matches the PR description, and update
the PR description to reflect the final choice.

In `@apps/admin/src/app/auth/mfa-verify/page.tsx`:
- Around line 36-40: The page currently dead-ends when no MFA factors exist;
instead of setError('No authenticator...') replace that branch to
navigate/redirect to /auth/mfa-enroll with the current redirectTo param (same
pattern as login-form.tsx) — note factors and factor come from useMfa which
already filters verified factors so no extra status guard needed. Also add
Sentry error capture: wrap the handleVerify logic and the refreshState call in
try/catch blocks and call Sentry.captureException(err) inside the catches
(update the handleVerify and refreshState invocations in this page accordingly)
so runtime errors are reported.

In `@apps/admin/src/lib/supabase/middleware.ts`:
- Around line 61-70: Avoid calling
supabase.auth.mfa.getAuthenticatorAssuranceLevel() on every request: only
perform the MFA HTTP lookup when MFA is actually required and when we haven't
already determined the session is at aal2. Modify the block that sets
hasMfaFactors so it first checks the server flag (mfaRequired) and that aal !==
'aal2' (and user exists); only then call getAuthenticatorAssuranceLevel() and
set hasMfaFactors from data?.nextLevel === 'aal2'. Ensure existing try/catch
remains to ignore non-critical failures and return the same shape { user,
userStatus, aal, hasMfaFactors, supabaseResponse }.

In `@apps/admin/src/middleware.ts`:
- Around line 80-98: The MFA check currently allows requests through when aal is
null (decode failure); update the logic in the middleware (the block using
mfaRequired, mfaExemptRoutes, hasMfaFactors, and aal) to fail-closed by treating
anything other than aal2 as requiring MFA: replace the condition that checks for
"hasMfaFactors && aal === 'aal1'" with a predicate that redirects whenever
hasMfaFactors is true and aal !== 'aal2' (so null/undefined also triggers the
verify redirect), keeping the existing enroll redirect when !hasMfaFactors; no
other behavior should change and this uses the same NextResponse.redirect flow
and verify/enroll URLs.

In `@apps/api/src/api-credentials/api-credentials.controller.ts`:
- Around line 94-98: The doc comment on the reveal endpoint is inaccurate: the
current `@UseGuards`(ThrottlerGuard) + `@Throttle` uses IP-based tracking (not
per-user), so either update the comment to say "5 reveals per minute per IP" or
implement a per-user throttle guard; to implement, add a UserThrottleGuard
subclassing ThrottlerGuard (similar to CatalogThrottleGuard) that overrides
getTracker(req) to return the authenticated user's id (e.g.
`user:${req.user.userId}`) falling back to `ip:${req.ip}` and replace
`@UseGuards`(ThrottlerGuard) on the :id/reveal handler with
`@UseGuards`(UserThrottleGuard).

In `@apps/api/src/auth/auth.types.ts`:
- Line 15: The impersonated AuthContext created in ImpersonationGuard omits the
required aal field, causing a type-contract violation and potential runtime
undefined reads; update the impersonation logic in impersonation.guard.ts (the
code that sets request.user / constructs the impersonated AuthContext) to copy
the admin's aal value (from the original request.user or admin context validated
by MfaGuard) into the new impersonated object so that the resulting request.user
implements AuthContext.aal (e.g., ensure the impersonated context includes aal:
adminUser.aal) and remove any unsafe casts that hide the missing property.

---

Nitpick comments:
In `@apps/admin/src/hooks/use-mfa.ts`:
- Around line 38-62: refreshState, enroll, verify, and unenroll currently
swallow exceptions; update each catch block to call
Sentry.captureException(error) (or captureException(e)) before returning the
existing fallback so errors appear in Sentry. Include the
NEXT_PUBLIC_SENTRY_ENVIRONMENT value as a tag/extra on the capture (e.g., using
Sentry.setContext or passing { tags: { environment:
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT } } with the capture) so the error is
attributed to dev/preview/production, and keep the original user-facing return
behavior (null/false) after capturing.

In `@apps/admin/src/providers/auth-provider.tsx`:
- Around line 119-140: checkMfaLevel currently calls
supabase.auth.mfa.getAuthenticatorAssuranceLevel() but its result is discarded;
modify checkMfaLevel to return the MFA assurance info (e.g., { currentLevel,
nextLevel }) and then in the SIGNED_IN branch reuse that returned value instead
of calling getAuthenticatorAssuranceLevel() a second time; move the non‑MFA
"record login" logic into the existing recordLogin() callback (or a shared
helper) and call recordLogin() when nextLevel !== 'aal2', removing the direct
fetch and the direct setLoginRecorded(true) so the loginRecorded guard remains
symmetric and single-sourced.
- Around line 87-98: The recordLogin function inside AuthProvider is recreated
on every render and should be memoized to avoid causing consumer useEffect
re-runs; wrap recordLogin with React's useCallback (add useCallback to the react
import) and have it depend only on the stable values it uses
(session?.access_token, loginRecorded, and setLoginRecorded) so that
recordLogin, loginRecorded, session, setLoginRecorded remain the referenced
symbols and the fetch call to `${process.env.NEXT_PUBLIC_API_URL ||
'/api/v1'}/user-profiles/me/record-login` still executes unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 651d7180-79fb-48da-93f7-09362de3c979

📥 Commits

Reviewing files that changed from the base of the PR and between 1f722ca and 823b335.

📒 Files selected for processing (17)
  • apps/admin/next.config.ts
  • apps/admin/src/app/auth/mfa-enroll/page.tsx
  • apps/admin/src/app/auth/mfa-verify/page.tsx
  • apps/admin/src/components/auth/login-form.tsx
  • apps/admin/src/hooks/use-mfa.ts
  • apps/admin/src/lib/feature-flags.ts
  • apps/admin/src/lib/supabase/middleware.ts
  • apps/admin/src/middleware.ts
  • apps/admin/src/providers/auth-provider.tsx
  • apps/api/src/api-credentials/api-credentials.controller.ts
  • apps/api/src/auth/auth.types.ts
  • apps/api/src/auth/decorators/bypass-mfa.decorator.ts
  • apps/api/src/auth/guards/mfa.guard.ts
  • apps/api/src/auth/strategies/jwt.strategy.ts
  • apps/api/src/common/filters/http-exception.filter.ts
  • apps/api/src/instrument.ts
  • apps/api/src/main.ts

Comment thread apps/admin/next.config.ts
Comment on lines +82 to +95
// Security headers (exempt /auth/callback which uses inline scripts)
async headers() {
return [
{
source: '/((?!auth/callback).*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
]
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Next.js headers() config source pattern with negative lookahead syntax

💡 Result:

In Next.js, the headers function in next.config.js uses path-to-regexp for matching the source pattern. Negative lookahead regex syntax is supported but must follow path-to-regexp conventions: wrap the regex in parentheses as an unnamed parameter after a path segment, typically /:path((?!excluded).). Example to match all paths except those containing "api": module.exports = { async headers { return [ { source: '/:path((?!api).)', headers: [ { key: 'X-Custom-Header', value: 'value' } ] } ] } } More precise exclusion of paths starting with "/api": { source: '/((?!api|_next/static|_next/image|favicon.ico).)', headers: [...] } Common patterns from Next.js GitHub discussions and docs: - Exclude specific paths: '/((?!abc).)' [9,19] - Exclude multiple paths: '/((?!_next|foo$|bar$).)' [9] - For catch-all with exclusion: '/((?!books).*)' [10] Direct ^(?!...) or standalone negative lookaheads fail with "Pattern cannot start with '?'" because Next.js wraps the source in ^/(?:(...))$/i [15,22]. Use the parameter-wrapped syntax instead. Official docs confirm regex support via parentheses [2,3,13], and path-to-regexp handles lookaheads [22]. This works for headers, redirects, and rewrites.

Citations:


🏁 Script executed:

# Verify the file exists and examine the relevant lines
if [ -f "apps/admin/next.config.ts" ]; then
  echo "=== File exists, examining lines 82-95 ==="
  sed -n '80,100p' apps/admin/next.config.ts
else
  echo "File not found at apps/admin/next.config.ts"
  # Try to locate it
  find . -name "next.config.ts" -o -name "next.config.js" | head -5
fi

Repository: Systemsaholic/tailfire

Length of output: 737


🏁 Script executed:

# Check if there's an auth/callback page with inline scripts
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) | xargs grep -l "auth.*callback" | head -10

Repository: Systemsaholic/tailfire

Length of output: 445


🏁 Script executed:

# Check the auth callback route implementation in apps/admin
cat -n apps/admin/src/app/auth/callback/route.ts

Repository: Systemsaholic/tailfire

Length of output: 7935


Exemption is justified, but pattern can be tightened for precision.

The exemption is actually sound: the /auth/callback route returns HTML with inline <script type="module"> and inline <style> blocks (see getHashHandlerHtml() in the route). The route code even includes a comment noting "If CSP is enforced, this page needs 'unsafe-inline' for scripts," which indicates this exemption is future-proofing for when Content-Security-Policy may be added.

However, the negative lookahead pattern /((?!auth/callback).*) is broader than intended—it also excludes paths like /auth/callbackfoo or /auth/callback-reset. To match only the literal route, tighten the boundary:

- source: '/((?!auth/callback).*)',
+ source: '/((?!auth/callback(?:/|$)).*)',

This ensures /auth/callback/something and /auth/callbackfoo still receive security headers.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Security headers (exempt /auth/callback which uses inline scripts)
async headers() {
return [
{
source: '/((?!auth/callback).*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
]
},
// Security headers (exempt /auth/callback which uses inline scripts)
async headers() {
return [
{
source: '/((?!auth/callback(?:/|$)).*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
]
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/next.config.ts` around lines 82 - 95, The current headers() config
uses a broad negative-lookahead source pattern '/((?!auth/callback).*)' which
unintentionally exempts paths like '/auth/callbackfoo' or
'/auth/callback/something'; tighten the pattern to only exempt the exact route
by changing the source used in the headers() return (the object with source
property) so it matches the literal '/auth/callback' only (while leaving all
other header entries like 'X-Frame-Options', 'X-Content-Type-Options',
'Referrer-Policy', and 'Permissions-Policy' unchanged).

setIsLoading(true)
setError('')

const result = await enroll('Tailfire')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look at all visible brand strings in admin UI to confirm consistency.
rg -nP --type=tsx --type=ts -C1 -i 'Tailfire|Phoenix\s*Voyages'

Repository: Systemsaholic/tailfire

Length of output: 94


🏁 Script executed:

#!/bin/bash
# Search for Tailfire and Phoenix Voyages mentions in TS/TSX files
echo "=== Searching for branding mentions ==="
rg -nP -i 'Tailfire|Phoenix.*Voyages' --glob='*.{ts,tsx}' -C 2

echo ""
echo "=== Reading the specific file mentioned in the review ==="
cat -n apps/admin/src/app/auth/mfa-enroll/page.tsx | head -n 220 | tail -n 190

Repository: Systemsaholic/tailfire

Length of output: 50381


🏁 Script executed:

cat -n apps/admin/src/app/auth/mfa-enroll/page.tsx | sed -n '1,220p'

Repository: Systemsaholic/tailfire

Length of output: 10056


Confirm branding alignment between PR description and implementation.

The MFA enrollment page uses "Tailfire" as the TOTP issuer (line 36) and in the completion button text (line 205: "Continue to Tailfire"), but the PR description states "Phoenix Voyages branding". Across the codebase, Phoenix Voyages is the public-facing brand (Footer, social links, email), while Tailfire is internal technical branding (monorepo, package scopes, internal domains). Clarify whether the admin UI should display Tailfire or Phoenix Voyages, and update the PR description to reflect the actual implementation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/auth/mfa-enroll/page.tsx` at line 36, The MFA enrollment
page uses the hardcoded TOTP issuer string "Tailfire" (see call to
enroll('Tailfire')) and the completion button text "Continue to Tailfire", but
the PR description and other UI elements use "Phoenix Voyages"; decide which
public-facing brand should appear in the admin UI, then update the strings
accordingly: change the enroll(...) issuer argument and any button/label text in
apps/admin/src/app/auth/mfa-enroll/page.tsx (and related UI strings in that
component) to the chosen brand (either "Phoenix Voyages" or "Tailfire") so
implementation matches the PR description, and update the PR description to
reflect the final choice.

Comment on lines +72 to +76
const handleCopySecret = () => {
navigator.clipboard.writeText(secret)
setSecretCopied(true)
setTimeout(() => setSecretCopied(false), 2000)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

navigator.clipboard.writeText silently fails; secret may not actually be copied.

writeText returns a Promise that can reject (permission denied, insecure HTTP context, sandboxed iframe, focus/transient activation rules). The current code unconditionally flips secretCopied to true, so the user sees the "✓" indicator even when nothing landed on the clipboard — a real risk during MFA setup where the secret is the only recovery path. Also, the setTimeout will fire on an unmounted component if the user navigates away.

🛡️ Suggested fix
-  const handleCopySecret = () => {
-    navigator.clipboard.writeText(secret)
-    setSecretCopied(true)
-    setTimeout(() => setSecretCopied(false), 2000)
-  }
+  const handleCopySecret = async () => {
+    try {
+      await navigator.clipboard.writeText(secret)
+      setSecretCopied(true)
+      setTimeout(() => setSecretCopied(false), 2000)
+    } catch {
+      toast({
+        title: 'Could not copy',
+        description: 'Select the code and copy it manually.',
+        variant: 'destructive',
+      })
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/auth/mfa-enroll/page.tsx` around lines 72 - 76, The
handleCopySecret currently calls navigator.clipboard.writeText(secret) without
awaiting or handling rejection and immediately sets setSecretCopied(true) and a
timeout, which can show success when copy failed and can fire after unmount;
change handleCopySecret to be async/await (or use .then/.catch), await
navigator.clipboard.writeText(secret), only call setSecretCopied(true) when the
Promise resolves, handle errors by showing a user-visible failure state (e.g.,
set an error flag or fallback UI) and do not mark success on rejection, and
replace the bare setTimeout with a timerId stored in a ref (e.g., copyTimerRef)
that you clear in a useEffect cleanup to avoid calling setSecretCopied after
unmount.

Comment on lines +36 to +40
const factor = factors[0]
if (!factor) {
setError('No authenticator found. Please contact support.')
return
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect what the useMfa hook returns for `factors` to confirm shape and whether unverified factors are included.
fd -t f 'use-mfa' apps/admin/src
rg -nP --type=ts --type=tsx -C5 'export\s+function\s+useMfa|factors\s*[:=]'

Repository: Systemsaholic/tailfire

Length of output: 144


🏁 Script executed:

cat -n apps/admin/src/hooks/use-mfa.ts

Repository: Systemsaholic/tailfire

Length of output: 4278


🏁 Script executed:

cat -n apps/admin/src/app/auth/mfa-verify/page.tsx | head -50

Repository: Systemsaholic/tailfire

Length of output: 2051


🏁 Script executed:

fd -t f 'mfa-enroll' apps/admin/src

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

find apps/admin/src -type d -name '*enroll*'

Repository: Systemsaholic/tailfire

Length of output: 101


🏁 Script executed:

find apps/admin/src -type f -name '*enroll*'

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

rg 'mfa-enroll|auth/mfa' apps/admin/src --type ts

Repository: Systemsaholic/tailfire

Length of output: 516


🏁 Script executed:

cat -n apps/admin/src/app/auth/mfa-verify/page.tsx

Repository: Systemsaholic/tailfire

Length of output: 5629


🏁 Script executed:

rg 'Sentry|captureException|sentry' apps/admin/src/app/auth/mfa-verify/page.tsx

Repository: Systemsaholic/tailfire

Length of output: 48


First concern is unfounded; second concern valid plus missing Sentry error capture.

  1. factors[0] does not pick unverified factors. The useMfa hook explicitly filters to verified factors only (line 46 of use-mfa.ts: const verifiedFactors = totpFactors.filter(f => f.status === 'verified')), so the array is already safe. The suggested f.status === 'verified' guard is redundant.

  2. Empty factors should redirect to enroll, not dead-end. When a user reaches this page with no MFA set up, they see "Please contact support." Instead, redirect to /auth/mfa-enroll with the redirectTo param so they can set up an authenticator (pattern already used in login-form.tsx).

  3. Add Sentry error capture. Per the coding guidelines, admin pages must capture runtime errors with Sentry. The handleVerify function and refreshState call have no error monitoring; wrap them in try-catch and call Sentry.captureException.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/app/auth/mfa-verify/page.tsx` around lines 36 - 40, The page
currently dead-ends when no MFA factors exist; instead of setError('No
authenticator...') replace that branch to navigate/redirect to /auth/mfa-enroll
with the current redirectTo param (same pattern as login-form.tsx) — note
factors and factor come from useMfa which already filters verified factors so no
extra status guard needed. Also add Sentry error capture: wrap the handleVerify
logic and the refreshState call in try/catch blocks and call
Sentry.captureException(err) inside the catches (update the handleVerify and
refreshState invocations in this page accordingly) so runtime errors are
reported.

Comment on lines +61 to +70
// Check if user has MFA factors enrolled (for middleware gating)
let hasMfaFactors = false
if (user) {
try {
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
hasMfaFactors = data?.nextLevel === 'aal2'
} catch { /* ignore — MFA check non-critical */ }
}

return { user, userStatus, aal, hasMfaFactors, supabaseResponse }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid the MFA round-trip on every authenticated middleware request.

getAuthenticatorAssuranceLevel() is an HTTP call to Supabase Auth and runs for every authenticated request hitting middleware, even when MFA_REQUIRED=false (the documented grace-period default). The caller only consults hasMfaFactors under the mfaRequired branch, so the round-trip is wasted on every other request. On Edge middleware this adds noticeable per-navigation latency.

Consider gating the lookup on the server flag (and short-circuiting when aal === 'aal2'):

♻️ Suggested gating
   // Check if user has MFA factors enrolled (for middleware gating)
   let hasMfaFactors = false
-  if (user) {
+  const mfaRequired = process.env.MFA_REQUIRED === 'true'
+  if (user && mfaRequired && aal !== 'aal2') {
     try {
       const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
       hasMfaFactors = data?.nextLevel === 'aal2'
     } catch { /* ignore — MFA check non-critical */ }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Check if user has MFA factors enrolled (for middleware gating)
let hasMfaFactors = false
if (user) {
try {
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
hasMfaFactors = data?.nextLevel === 'aal2'
} catch { /* ignore — MFA check non-critical */ }
}
return { user, userStatus, aal, hasMfaFactors, supabaseResponse }
// Check if user has MFA factors enrolled (for middleware gating)
let hasMfaFactors = false
const mfaRequired = process.env.MFA_REQUIRED === 'true'
if (user && mfaRequired && aal !== 'aal2') {
try {
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
hasMfaFactors = data?.nextLevel === 'aal2'
} catch { /* ignore — MFA check non-critical */ }
}
return { user, userStatus, aal, hasMfaFactors, supabaseResponse }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/lib/supabase/middleware.ts` around lines 61 - 70, Avoid
calling supabase.auth.mfa.getAuthenticatorAssuranceLevel() on every request:
only perform the MFA HTTP lookup when MFA is actually required and when we
haven't already determined the session is at aal2. Modify the block that sets
hasMfaFactors so it first checks the server flag (mfaRequired) and that aal !==
'aal2' (and user exists); only then call getAuthenticatorAssuranceLevel() and
set hasMfaFactors from data?.nextLevel === 'aal2'. Ensure existing try/catch
remains to ignore non-critical failures and return the same shape { user,
userStatus, aal, hasMfaFactors, supabaseResponse }.

Comment on lines +80 to 98
// MFA enforcement (only when MFA_REQUIRED is true)
const mfaRequired = process.env.MFA_REQUIRED === 'true'
if (mfaRequired) {
const isMfaExempt = mfaExemptRoutes.some((route) => pathname.startsWith(route))
if (!isMfaExempt) {
if (hasMfaFactors && aal === 'aal1') {
// Has factors but hasn't verified yet → verify page
const verifyUrl = new URL('/auth/mfa-verify', request.url)
verifyUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(verifyUrl)
}
if (!hasMfaFactors) {
// No factors enrolled → enrollment page
const enrollUrl = new URL('/auth/mfa-enroll', request.url)
enrollUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(enrollUrl)
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

MFA bypass when aal cannot be decoded.

updateSession swallows JWT-decode errors and returns aal: null. When MFA_REQUIRED=true, hasMfaFactors=true, and aal is null, the first branch (aal === 'aal1') is false and the second branch (!hasMfaFactors) is also false — so the request proceeds without MFA. A user with enrolled factors can therefore reach protected routes at less than aal2 if the access token decode failed for any reason.

Invert the predicate to fail-closed: redirect whenever aal !== 'aal2'.

🛡️ Suggested fix
-      if (hasMfaFactors && aal === 'aal1') {
+      if (hasMfaFactors && aal !== 'aal2') {
         // Has factors but hasn't verified yet → verify page
         const verifyUrl = new URL('/auth/mfa-verify', request.url)
         verifyUrl.searchParams.set('redirectTo', pathname)
         return NextResponse.redirect(verifyUrl)
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// MFA enforcement (only when MFA_REQUIRED is true)
const mfaRequired = process.env.MFA_REQUIRED === 'true'
if (mfaRequired) {
const isMfaExempt = mfaExemptRoutes.some((route) => pathname.startsWith(route))
if (!isMfaExempt) {
if (hasMfaFactors && aal === 'aal1') {
// Has factors but hasn't verified yet → verify page
const verifyUrl = new URL('/auth/mfa-verify', request.url)
verifyUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(verifyUrl)
}
if (!hasMfaFactors) {
// No factors enrolled → enrollment page
const enrollUrl = new URL('/auth/mfa-enroll', request.url)
enrollUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(enrollUrl)
}
}
}
// MFA enforcement (only when MFA_REQUIRED is true)
const mfaRequired = process.env.MFA_REQUIRED === 'true'
if (mfaRequired) {
const isMfaExempt = mfaExemptRoutes.some((route) => pathname.startsWith(route))
if (!isMfaExempt) {
if (hasMfaFactors && aal !== 'aal2') {
// Has factors but hasn't verified yet → verify page
const verifyUrl = new URL('/auth/mfa-verify', request.url)
verifyUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(verifyUrl)
}
if (!hasMfaFactors) {
// No factors enrolled → enrollment page
const enrollUrl = new URL('/auth/mfa-enroll', request.url)
enrollUrl.searchParams.set('redirectTo', pathname)
return NextResponse.redirect(enrollUrl)
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin/src/middleware.ts` around lines 80 - 98, The MFA check currently
allows requests through when aal is null (decode failure); update the logic in
the middleware (the block using mfaRequired, mfaExemptRoutes, hasMfaFactors, and
aal) to fail-closed by treating anything other than aal2 as requiring MFA:
replace the condition that checks for "hasMfaFactors && aal === 'aal1'" with a
predicate that redirects whenever hasMfaFactors is true and aal !== 'aal2' (so
null/undefined also triggers the verify redirect), keeping the existing enroll
redirect when !hasMfaFactors; no other behavior should change and this uses the
same NextResponse.redirect flow and verify/enroll URLs.

Comment on lines +94 to +98
* Rate limited: 5 reveals per minute per user.
*/
@Post(':id/reveal')
@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 60000 } })
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect JWT strategy validate() return shape and AuthContext.
fd -t f 'jwt.strategy.ts' apps/api/src | xargs -I {} sh -c 'echo "=== {} ==="; cat {}'
ast-grep --pattern $'validate($_) {
  $$$
}'

Repository: Systemsaholic/tailfire

Length of output: 7973


🏁 Script executed:

#!/bin/bash
# Check the api-credentials controller structure and auth setup
fd -t f 'api-credentials.controller.ts' apps/api/src | xargs -I {} sh -c 'echo "=== {} ==="; head -120 {}'

Repository: Systemsaholic/tailfire

Length of output: 3440


🏁 Script executed:

#!/bin/bash
# Find AdminOnly decorator and verify which endpoints it applies to
rg -A 10 -B 5 '@AdminOnly\|class ApiCredentialsController' apps/api/src/api-credentials/

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

#!/bin/bash
# Check the ThrottlerGuard implementation to confirm it uses IP-based tracking
fd -t f 'throttler.guard.ts' | xargs -I {} sh -c 'echo "=== {} ==="; cat {}'

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

#!/bin/bash
# Check if there's a custom throttle guard in the codebase
fd -t f 'throttle' apps/api/src | grep -i guard

Repository: Systemsaholic/tailfire

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Check how AdminOnly is implemented and what guards it uses
fd -t f 'admin-only.decorator.ts' apps/api/src | xargs -I {} sh -c 'echo "=== {} ==="; cat {}'

Repository: Systemsaholic/tailfire

Length of output: 538


🏁 Script executed:

#!/bin/bash
# Verify NestJS ThrottlerGuard behavior by checking dependencies and documentation in package.json
cd apps/api && cat package.json | grep -A 2 -B 2 throttler

Repository: Systemsaholic/tailfire

Length of output: 234


🏁 Script executed:

#!/bin/bash
# Check the CatalogThrottleGuard implementation to see how custom tracking works
cat apps/api/src/common/guards/catalog-throttle.guard.ts

Repository: Systemsaholic/tailfire

Length of output: 1657


🏁 Script executed:

#!/bin/bash
# Verify if req.user is populated with the JWT payload in guards context
fd -t f 'admin-role.guard.ts' apps/api/src | xargs -I {} sh -c 'echo "=== {} ==="; cat {}'

Repository: Systemsaholic/tailfire

Length of output: 816


🏁 Script executed:

#!/bin/bash
# Check if there are any other uses of ThrottlerGuard to understand the pattern
rg -B 3 -A 3 '@UseGuards.*ThrottlerGuard|ThrottlerGuard' apps/api/src

Repository: Systemsaholic/tailfire

Length of output: 11251


Rate limit is per-IP, not per-user — the doc comment is inaccurate.

The default ThrottlerGuard tracks requests by client IP. The doc on line 94 says "5 reveals per minute per user," but two admins behind the same office NAT will share the 5/min budget, while a compromised JWT rotated across IPs bypasses the limit. Given this endpoint reveals decrypted secrets, per-user rate limiting is the stronger guarantee.

Two options:

  1. Fix the doc to reflect actual behavior (per-IP).
  2. Implement a per-user tracker by subclassing ThrottlerGuard (similar to CatalogThrottleGuard in apps/api/src/common/guards/catalog-throttle.guard.ts) and overriding getTracker() to return the authenticated user's ID:
♻️ Sketch of a per-user throttle guard
// apps/api/src/common/guards/user-throttle.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common'
import { ThrottlerGuard } from '@nestjs/throttler'

`@Injectable`()
export class UserThrottleGuard extends ThrottlerGuard {
  protected async getTracker(req: Record<string, any>): Promise<string> {
    const userId = req.user?.userId
    return userId ? `user:${userId}` : `ip:${req.ip}`
  }
}
-  `@UseGuards`(ThrottlerGuard)
+  `@UseGuards`(UserThrottleGuard)
   `@Throttle`({ default: { limit: 5, ttl: 60000 } })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/api-credentials/api-credentials.controller.ts` around lines 94 -
98, The doc comment on the reveal endpoint is inaccurate: the current
`@UseGuards`(ThrottlerGuard) + `@Throttle` uses IP-based tracking (not per-user), so
either update the comment to say "5 reveals per minute per IP" or implement a
per-user throttle guard; to implement, add a UserThrottleGuard subclassing
ThrottlerGuard (similar to CatalogThrottleGuard) that overrides getTracker(req)
to return the authenticated user's id (e.g. `user:${req.user.userId}`) falling
back to `ip:${req.ip}` and replace `@UseGuards`(ThrottlerGuard) on the :id/reveal
handler with `@UseGuards`(UserThrottleGuard).

agencyId: string
role: 'admin' | 'user'
userStatus: UserStatus
aal: 'aal1' | 'aal2'
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find every place an AuthContext is constructed and verify aal is set.
# The `as AuthContext` cast hides missing fields, so we need to inspect literals.
rg -nP --type=ts -C5 'as\s+AuthContext\b'
rg -nP --type=ts -C2 '\brequest\.user\s*=\s*\{'

Repository: Systemsaholic/tailfire

Length of output: 8073


🏁 Script executed:

# Check the AuthContext type definition to confirm aal field requirement
cat -n apps/api/src/auth/auth.types.ts | head -30

Repository: Systemsaholic/tailfire

Length of output: 804


🏁 Script executed:

# Get complete context around impersonation.guard.ts request.user assignment
cat -n apps/api/src/auth/guards/impersonation.guard.ts | sed -n '110,140p'

Repository: Systemsaholic/tailfire

Length of output: 1157


🏁 Script executed:

# Search for usage of user.aal or request.user.aal downstream
rg -n '\buser\.aal\b|\brequest\.user\.aal\b' apps/api/src/

Repository: Systemsaholic/tailfire

Length of output: 138


🏁 Script executed:

# Check MfaGuard for how it reads aal
cat -n apps/api/src/auth/guards/mfa.guard.ts | sed -n '35,75p'

Repository: Systemsaholic/tailfire

Length of output: 904


🏁 Script executed:

# Check guard order in main.ts or app module setup
rg -n "useGlobalGuards|@UseGuards" apps/api/src/main.ts apps/api/src/app.module.ts apps/api/src/app.controller.ts | head -20

Repository: Systemsaholic/tailfire

Length of output: 114


🏁 Script executed:

# Find where guards are registered globally
fd -e ts -e module.ts apps/api/src/ | xargs rg -l "ImpersonationGuard|MfaGuard|JwtAuthGuard" | head -10

Repository: Systemsaholic/tailfire

Length of output: 937


🏁 Script executed:

# Check app.module.ts to see guard order
cat -n apps/api/src/app.module.ts 2>/dev/null || echo "Trying alternative paths..." && find apps/api/src -name "*.module.ts" -type f | head -3

Repository: Systemsaholic/tailfire

Length of output: 11898


🏁 Script executed:

# Verify what happens at runtime: if user.aal is undefined, does 'undefined !== 'aal2'' throw or pass?
node -e "const u = { aal: undefined }; console.log(u.aal !== 'aal2'); const v = {}; console.log(v.aal !== 'aal2')"

Repository: Systemsaholic/tailfire

Length of output: 76


🏁 Script executed:

# Check if targetUser comes from DB with an aal field or if it's missing
rg -n "targetUser\s*=" apps/api/src/auth/guards/impersonation.guard.ts -A3 -B3

Repository: Systemsaholic/tailfire

Length of output: 276


🏁 Script executed:

# Check main.ts around line 106 for full guard setup
cat -n apps/api/src/main.ts | sed -n '95,120p'

Repository: Systemsaholic/tailfire

Length of output: 1021


🏁 Script executed:

# Find where MfaGuard is applied (route decorator level, not global)
rg -n "@UseGuards.*MfaGuard|MfaGuard\)" apps/api/src/ -A2 -B2 | head -30

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

# Check auth.module.ts to see if guards are registered there
cat -n apps/api/src/auth/auth.module.ts 2>/dev/null | head -50

Repository: Systemsaholic/tailfire

Length of output: 1830


🏁 Script executed:

# See if targetUser has aal field - check userProfiles schema
rg -n "aal" apps/api/src/auth/guards/impersonation.guard.ts

Repository: Systemsaholic/tailfire

Length of output: 48


🏁 Script executed:

# Verify jwt.strategy.ts to confirm aal comes from JWT and default behavior
cat -n apps/api/src/auth/strategies/jwt.strategy.ts | head -60

Repository: Systemsaholic/tailfire

Length of output: 2661


🏁 Script executed:

# Check if there are any other files that might read user.aal that we missed
rg -n "\.aal\b" apps/api/src/ --type=ts

Repository: Systemsaholic/tailfire

Length of output: 221


Required aal field is missing from impersonated AuthContext.

apps/api/src/auth/guards/impersonation.guard.ts constructs the impersonated request.user without including the aal field, which is required on AuthContext. The as AuthContext cast suppresses the TypeScript error, creating a type contract violation.

While MfaGuard (which checks user.aal !== 'aal2') runs before ImpersonationGuard in the global chain and thus validates the admin's credentials before impersonation occurs, the impersonated user object lacks the required aal property. This creates a latent bug: any code added downstream that reads user.aal after impersonation will encounter undefined.

Propagate the admin's aal into the impersonated context:

Suggested fix in impersonation.guard.ts
 request.user = {
   userId: targetUser.id,
   email: targetUser.email,
   agencyId: user.agencyId,
   role: targetUser.role,
   userStatus: targetUser.status,
+  aal: user.aal,
 } as AuthContext
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
aal: 'aal1' | 'aal2'
request.user = {
userId: targetUser.id,
email: targetUser.email,
agencyId: user.agencyId,
role: targetUser.role,
userStatus: targetUser.status,
aal: user.aal,
} as AuthContext
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/auth.types.ts` at line 15, The impersonated AuthContext
created in ImpersonationGuard omits the required aal field, causing a
type-contract violation and potential runtime undefined reads; update the
impersonation logic in impersonation.guard.ts (the code that sets request.user /
constructs the impersonated AuthContext) to copy the admin's aal value (from the
original request.user or admin context validated by MfaGuard) into the new
impersonated object so that the resulting request.user implements
AuthContext.aal (e.g., ensure the impersonated context includes aal:
adminUser.aal) and remove any unsafe casts that hide the missing property.

useSearchParams requires Suspense wrapper in Next.js 15 App Router.
Split MFA pages into wrapper (default export) + content component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant