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

373 lines
14 KiB
Markdown

# 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:**
```typescript
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)
```go
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:**
```go
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`:
```go
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)
```go
_ = 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:
```go
// 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/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:
```go
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:
```typescript
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
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.
### PR-1: Fix auth cookie Secure flag for private networks + frontend Bearer fallback
**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 |