- 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.
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
- Identify the root cause of the login→401 failure chain
- Determine whether this is a code bug or a user configuration issue
- 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:
- Validates email/password via
authService.Login() - Generates a JWT token (HS256, 24h expiry, includes
user_id,role,session_version) - Sets an
auth_tokenHttpOnly cookie viasetSecureCookie() - Returns the token in the JSON response body:
{"token": "<jwt>"}
The frontend (frontend/src/pages/Login.tsx, lines 43-46):
- POSTs to
/auth/loginvia the axios client (which haswithCredentials: true) - Extracts the token from the response body
- Calls
login(token)on the AuthContext
2.2 Frontend AuthContext Login Flow
File: frontend/src/context/AuthContext.tsx (lines 84-110)
The login() function:
- Stores the token in
localStorageascharon_auth_token - Sets the
Authorizationheader on the axios client viasetAuthToken(token) - 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'.
2.3 Cookie Secure Flag Logic
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:
Authorization: Bearer <token>headerauth_tokencookie (fallback)?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
AutoMigrateruns on startup for all models includingUser,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/setupendpoint 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
Primary Root Cause: Secure cookie flag set to true on non-HTTPS, non-local connections
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: https→scheme = "https"→secure = true,sameSite = Strict - Browser sees HTTPS → accepts Secure cookie →
/auth/mesucceeds - 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/8172.16.0.0/12192.168.0.0/16fc00::/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
Phase 1: Backend Cookie Fix
- Modify
isLocalHostinauth_handler.goto includeip.IsPrivate() - Verify existing test
TestSetSecureCookie_HTTP_Laxis unchanged (TEST-NET IP) - Add new test cases per table in §5.3
- Add
isLocalHostunit tests for private IPs
Phase 2: Frontend fetchSessionUser Fix
- Modify
fetchSessionUserinAuthContext.tsxto includeAuthorizationheader from localStorage - Verify existing frontend tests still pass
Phase 3: E2E Validation
- Rebuild E2E Docker environment
- Run the login/auth Playwright tests to validate no regressions
7. Acceptance Criteria
isLocalHost("192.168.1.50")returnstrueisLocalHost("10.0.0.1")returnstrueisLocalHost("172.16.0.1")returnstrueisLocalHost("203.0.113.5")returnsfalse(public IP unchanged)- HTTP login from a private LAN IP sets
Secure: falseonauth_tokencookie - HTTPS login from a private LAN IP still sets
Secure: true fetchSessionUser()sendsAuthorization: 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.
PR-1: Fix auth cookie Secure flag for private networks + frontend Bearer fallback
Scope:
backend/internal/api/handlers/auth_handler.go— ExpandisLocalHostto includeip.IsPrivate()backend/internal/api/handlers/auth_handler_test.go— Add new test cases, verify existingfrontend/src/context/AuthContext.tsx— Add Authorization header tofetchSessionUser
Validation Gates:
go test ./backend/internal/api/handlers/...— all passgo 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 |