Files
Charon/docs/plans/current_spec.md
GitHub Actions 6777f6e8ff feat(auth): implement Bearer token fallback in fetchSessionUser for private network HTTP connections
- Expanded fetchSessionUser to include Bearer token from localStorage as a fallback for authentication when Secure cookies fail.
- Updated headers to conditionally include Authorization if a token is present.
- Ensured compatibility with the recent fix for the Secure cookie flag on private network connections.
2026-03-15 02:25:07 +00:00

14 KiB

Issue #825: User Cannot Login After Fresh Install

Date: 2026-03-14 Status: Root Cause Identified — Code Bug + Frontend Fragility Issue: Login API returns 200 but GET /api/v1/auth/me immediately returns 401 Previous Plan: Archived as docs/plans/telegram_remediation_spec.md


1. Introduction

A user reports that after a fresh install with remapped ports (82:80, 445:443, 8080:8080), accessing Charon via a separate external Caddy reverse proxy, the login succeeds (200) but the session validation (/auth/me) immediately fails (401).

Objectives

  1. Identify the root cause of the login→401 failure chain
  2. Determine whether this is a code bug or a user configuration issue
  3. Propose a targeted fix with minimal blast radius

2. Research Findings

2.1 Auth Login Flow

File: backend/internal/api/handlers/auth_handler.go (lines 172-189)

The Login handler:

  1. Validates email/password via authService.Login()
  2. Generates a JWT token (HS256, 24h expiry, includes user_id, role, session_version)
  3. Sets an auth_token HttpOnly cookie via setSecureCookie()
  4. Returns the token in the JSON response body: {"token": "<jwt>"}

The frontend (frontend/src/pages/Login.tsx, lines 43-46):

  1. POSTs to /auth/login via the axios client (which has withCredentials: true)
  2. Extracts the token from the response body
  3. Calls login(token) on the AuthContext

2.2 Frontend AuthContext Login Flow

File: frontend/src/context/AuthContext.tsx (lines 84-110)

The login() function:

  1. Stores the token in localStorage as charon_auth_token
  2. Sets the Authorization header on the axios client via setAuthToken(token)
  3. Calls fetchSessionUser() to validate the session

Critical finding — fetchSessionUser() uses raw fetch, NOT the axios client:

const fetchSessionUser = useCallback(async (): Promise<User> => {
    const response = await fetch('/api/v1/auth/me', {
      method: 'GET',
      credentials: 'include',
      headers: { Accept: 'application/json' },
    });
    // ...
}, []);

This means fetchSessionUser() does NOT include the Authorization: Bearer <token> header. It relies exclusively on the browser sending the auth_token cookie via credentials: 'include'.

File: backend/internal/api/handlers/auth_handler.go (lines 132-163)

func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
    scheme := requestScheme(c)
    secure := true                     // ← Defaults to true
    sameSite := http.SameSiteStrictMode

    if scheme != "https" {
        sameSite = http.SameSiteLaxMode
        if isLocalRequest(c) {         // ← Only sets secure=false for localhost/127.0.0.1
            secure = false
        }
    }
    // ...
}

isLocalHost() only matches localhost and loopback IPs:

func isLocalHost(host string) bool {
    if strings.EqualFold(host, "localhost") { return true }
    if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { return true }
    return false
}

This function does NOT match:

  • Private network IPs: 192.168.x.x, 10.x.x.x, 172.16.x.x
  • Custom hostnames: charon.local, myserver.home
  • Any non-loopback IP address

2.4 Auth Middleware (Protects /auth/me)

File: backend/internal/api/middleware/auth.go (lines 12-45)

The AuthMiddleware extracts tokens in priority order:

  1. Authorization: Bearer <token> header
  2. auth_token cookie (fallback)
  3. ?token=<token> query parameter (deprecated fallback)

If no token is found, it returns 401 {"error": "Authorization header required"}.

2.5 Route Registration

File: backend/internal/api/routes/routes.go (lines 260-267)

/auth/me is registered under the protected group which uses authMiddleware:

protected.GET("/auth/me", authHandler.Me)

2.6 Database Migration & Seeding

  • AutoMigrate runs on startup for all models including User, Setting, SecurityConfig
  • The seed command (backend/cmd/seed/main.go) is a separate CLI tool, not run during normal startup
  • Fresh install uses the /api/v1/setup endpoint to create the first admin user
  • The setup handler creates the user and an ACME email setting in a transaction
  • No missing migration or seeding is involved in this bug — tables are auto-migrated, and setup creates the user correctly

2.7 Trusted Proxy Configuration

File: backend/internal/server/server.go (lines 14-17)

_ = router.SetTrustedProxies(nil)

Gin's SetTrustedProxies(nil) disables trusting forwarded headers for c.ClientIP(). However, the requestScheme() function reads X-Forwarded-Proto directly from the request header, bypassing Gin's trust mechanism. This is intentional for scheme detection.

2.8 Existing Test Confirmation

File: backend/internal/api/handlers/auth_handler_test.go (lines 84-99)

The test TestSetSecureCookie_HTTP_Lax explicitly asserts the current (buggy) behavior:

// HTTP request from non-local IP 192.0.2.10
req := httptest.NewRequest("POST", "http://192.0.2.10/login", http.NoBody)
req.Header.Set("X-Forwarded-Proto", "http")
// ...
assert.True(t, c.Secure)  // ← Asserts Secure=true on HTTP!

Note: 192.0.2.10 is TEST-NET-1 (RFC 5737), a documentation address — NOT a private IP. This test is actually correct for public IPs and needs no change.

2.9 CORS Configuration

No CORS middleware was found in the backend. The frontend uses relative URLs (baseURL: '/api/v1'), so all API requests are same-origin. CORS is not a factor in this bug.


3. Root Cause Analysis

When a user accesses Charon from a LAN IP (e.g., 192.168.1.50:8080) over plain HTTP:

Step Function Value Result
1 requestScheme(c) "http" No X-Forwarded-Proto or TLS
2 secure default true
3 scheme != "https" true Enters HTTP branch
4 isLocalRequest(c) false Host is 192.168.1.50, not localhost/127.0.0.1
5 Final secure true Cookie marked Secure on HTTP connection

Result: The browser receives Set-Cookie: auth_token=...; Secure; HttpOnly; Path=/; SameSite=Lax over an HTTP connection. Per RFC 6265bis §5.4, browsers reject Secure cookies delivered over non-secure (HTTP) channels.

Secondary Root Cause: fetchSessionUser() has no fallback to Bearer token

Even though the JWT token is stored in localStorage and set on the axios client's Authorization header, fetchSessionUser() uses raw fetch() without the Authorization header. When the cookie is rejected, there is no fallback.

Failure Chain

Browser (HTTP to 192.168.x.x:8080)
  → POST /auth/login → 200 + Set-Cookie: auth_token=...; Secure
  → Browser REJECTS Secure cookie (connection is HTTP)
  → Frontend stores token in localStorage, sets it on axios client
  → fetchSessionUser() calls GET /auth/me via raw fetch (no Auth header, no cookie)
  → Auth middleware: no token found → 401
  → User sees login failure

External Caddy Scenario (likely works, but fragile)

When accessing via an external Caddy that terminates TLS:

  • If Caddy sends X-Forwarded-Proto: httpsscheme = "https"secure = true, sameSite = Strict
  • Browser sees HTTPS → accepts Secure cookie → /auth/me succeeds
  • But: If the user accesses directly on port 8080 for any reason, it breaks

4. Verdict

This is a code bug, not a user configuration issue.

The setSecureCookie function has a logic gap: when the scheme is HTTP and the request is from a non-loopback private IP, it still sets Secure: true. This makes it impossible to authenticate over HTTP from any non-localhost address, which is a valid and common deployment scenario (LAN access, Docker port mapping without TLS).

The secondary issue (frontend fetchSessionUser not sending a Bearer token) means there is no graceful fallback when the cookie is rejected — the user gets a hard 401 with no recovery path, even though the token is available in memory.


5. Technical Specification

5.1 Backend Fix: Expand isLocalHost to include RFC 1918 private IPs

WHEN the request scheme is HTTP, AND the request originates from a private network IP (RFC 1918/RFC 4193), THE SYSTEM SHALL set the Secure cookie flag to false.

File: backend/internal/api/handlers/auth_handler.go (line 80)

Change: Extend isLocalHost to also return true for RFC 1918 private IPs:

func isLocalHost(host string) bool {
    if strings.EqualFold(host, "localhost") {
        return true
    }

    ip := net.ParseIP(host)
    if ip == nil {
        return false
    }

    if ip.IsLoopback() {
        return true
    }

    if ip.IsPrivate() {
        return true
    }

    return false
}

net.IP.IsPrivate() (Go 1.17+) checks for:

  • 10.0.0.0/8
  • 172.16.0.0/12
  • 192.168.0.0/16
  • fc00::/7 (IPv6 ULA)

This does not change behavior for public IPs or HTTPS — Secure: true is preserved for all HTTPS connections and for public HTTP connections.

5.2 Frontend Fix: Add Bearer token to fetchSessionUser

File: frontend/src/context/AuthContext.tsx (line 12)

Change: Include the Authorization header in fetchSessionUser when a token is available in localStorage:

const fetchSessionUser = useCallback(async (): Promise<User> => {
    const headers: Record<string, string> = { Accept: 'application/json' };
    const stored = localStorage.getItem('charon_auth_token');
    if (stored) {
        headers['Authorization'] = `Bearer ${stored}`;
    }

    const response = await fetch('/api/v1/auth/me', {
        method: 'GET',
        credentials: 'include',
        headers,
    });

    if (!response.ok) {
        throw new Error('Session validation failed');
    }

    return response.json() as Promise<User>;
}, []);

This provides a belt-and-suspenders approach: the cookie is preferred (HttpOnly, auto-sent), but if the cookie is absent (rejected, cross-domain, etc.), the Bearer token from localStorage is used as a fallback.

5.3 Test Updates

Existing test TestSetSecureCookie_HTTP_Lax: Uses 192.0.2.10 (TEST-NET-1, RFC 5737) which is NOT a private IP → assertion unchanged (Secure: true).

New test cases needed:

Test Name Host Scheme Expected Secure Expected SameSite
TestSetSecureCookie_HTTP_PrivateIP_Insecure 192.168.1.50 http false Lax
TestSetSecureCookie_HTTP_10Network_Insecure 10.0.0.5 http false Lax
TestSetSecureCookie_HTTP_172Network_Insecure 172.16.0.1 http false Lax
TestSetSecureCookie_HTTPS_PrivateIP_Secure 192.168.1.50 https true Strict
TestSetSecureCookie_HTTP_PublicIP_Secure 203.0.113.5 http true Lax

isLocalHost unit test additions:

Input Expected
192.168.1.50 true (new)
10.0.0.1 true (new)
172.16.0.1 true (new)
203.0.113.5 false

6. Implementation Plan

  1. Modify isLocalHost in auth_handler.go to include ip.IsPrivate()
  2. Verify existing test TestSetSecureCookie_HTTP_Lax is unchanged (TEST-NET IP)
  3. Add new test cases per table in §5.3
  4. Add isLocalHost unit tests for private IPs

Phase 2: Frontend fetchSessionUser Fix

  1. Modify fetchSessionUser in AuthContext.tsx to include Authorization header from localStorage
  2. Verify existing frontend tests still pass

Phase 3: E2E Validation

  1. Rebuild E2E Docker environment
  2. Run the login/auth Playwright tests to validate no regressions

7. Acceptance Criteria

  • isLocalHost("192.168.1.50") returns true
  • isLocalHost("10.0.0.1") returns true
  • isLocalHost("172.16.0.1") returns true
  • isLocalHost("203.0.113.5") returns false (public IP unchanged)
  • HTTP login from a private LAN IP sets Secure: false on auth_token cookie
  • HTTPS login from a private LAN IP still sets Secure: true
  • fetchSessionUser() sends Authorization: Bearer <token> when token is in localStorage
  • All existing auth handler tests pass
  • New test cases from §5.3 pass
  • E2E login tests pass

8. Commit Slicing Strategy

Decision: Single PR

Rationale: Both changes are tightly coupled to the same authentication flow. The backend fix alone resolves the primary issue, and the frontend fix is a small defense-in-depth addition. Total change is ~20 lines of production code + ~60 lines of tests. Splitting would create unnecessary review overhead.

Scope:

  • backend/internal/api/handlers/auth_handler.go — Expand isLocalHost to include ip.IsPrivate()
  • backend/internal/api/handlers/auth_handler_test.go — Add new test cases, verify existing
  • frontend/src/context/AuthContext.tsx — Add Authorization header to fetchSessionUser

Validation Gates:

  • go test ./backend/internal/api/handlers/... — all pass
  • go test ./backend/internal/api/middleware/... — all pass
  • E2E Playwright login suite — all pass

Rollback: Revert the single commit. No database changes, no API contract changes.


9. Edge Cases & Risks

Risk Mitigation
net.IP.IsPrivate() requires Go 1.17+ Charon requires Go 1.21+, no risk
Public HTTP deployments now get Secure: true (no change) Intentional: public HTTP is insecure regardless
localStorage token exposed to XSS Existing risk (unchanged); primary auth remains HttpOnly cookie
isLocalHost name now misleading (covers private IPs) Consider renaming to isPrivateOrLocalHost in follow-up refactor
External reverse proxy without X-Forwarded-Proto Frontend Bearer fallback covers this case now