Skip to content

Commit 2d8cf04

Browse files
committed
feat(auth): enforce AUTH_MODE=enabled on all non-public endpoints
1 parent fa689a8 commit 2d8cf04

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
)
7+
8+
// publicAuthPaths lists the canonical (un-prefixed) endpoint paths that must
9+
// remain reachable without authentication even when AUTH_MODE=enabled. These
10+
// are the bootstrap endpoints — without them a fresh client could never log
11+
// in, fetch runtime config, or complete a device-pairing flow.
12+
//
13+
// Paths are matched after stripping any of the well-known route prefixes
14+
// (/api/v1, /api, /v1) via stripRoutePrefix, so each entry needs to be listed
15+
// only once.
16+
var publicAuthPaths = map[string]struct{}{
17+
"/healthz": {},
18+
"/runtime-config": {},
19+
"/auth/login": {},
20+
"/auth/signup": {},
21+
"/auth/token": {},
22+
"/auth/token/app-password": {},
23+
"/auth/resend-verification": {},
24+
"/auth/verify-email": {},
25+
"/auth/forgot-password": {},
26+
"/auth/reset-password": {},
27+
"/auth/device": {},
28+
"/auth/device/token": {},
29+
"/auth/device/verify": {},
30+
"/auth/device/confirm": {},
31+
"/auth/2fa/verify": {},
32+
"/auth/2fa/setup/totp": {},
33+
"/auth/2fa/verify-setup": {},
34+
}
35+
36+
// stripRoutePrefix removes one of the well-known router prefixes
37+
// (/api/v1, /api, /v1) from p, returning the canonical sub-path. The
38+
// longest prefix is tried first so that /api/v1/... is not misread as
39+
// /api/... with a stray /v1 segment.
40+
func stripRoutePrefix(p string) string {
41+
for _, prefix := range []string{"/api/v1", "/api", "/v1"} {
42+
if p == prefix {
43+
return "/"
44+
}
45+
if strings.HasPrefix(p, prefix+"/") {
46+
return p[len(prefix):]
47+
}
48+
}
49+
return p
50+
}
51+
52+
// isPublicAuthPath reports whether p is one of the bootstrap endpoints that
53+
// must remain reachable without authentication. The check is prefix-aware so
54+
// that, e.g., /api/v1/auth/login matches the canonical /auth/login entry.
55+
func isPublicAuthPath(p string) bool {
56+
canonical := stripRoutePrefix(p)
57+
if _, ok := publicAuthPaths[canonical]; ok {
58+
return true
59+
}
60+
return false
61+
}
62+
63+
// requireAuth enforces AUTH_MODE=enabled on all non-public endpoints. When
64+
// AUTH_MODE is disabled (the default) the middleware is a no-op so existing
65+
// behavior — and the existing test suite — is preserved verbatim.
66+
//
67+
// When AUTH_MODE=enabled the request is allowed through if ANY of the
68+
// following are true:
69+
//
70+
// 1. The path is on the bootstrap allowlist (isPublicAuthPath).
71+
// 2. The request carries a valid helper app-password (Bearer token,
72+
// X-RSM-App-Password header, or app_password form field), validated
73+
// against the existing app-password store.
74+
// 3. The request carries a non-empty Remote-User header set by an upstream
75+
// forwardAuth reverse proxy (e.g. Authelia, oauth2-proxy). The mere
76+
// presence is trusted — the proxy is responsible for the verification.
77+
// 4. The request carries a non-empty `session` cookie. This matches the
78+
// existing /auth/login handler which sets such a cookie without a
79+
// server-side store. TODO: replace with a real session store backed by
80+
// security_state.
81+
//
82+
// Otherwise the request is rejected with 401 Unauthorized + an apiError
83+
// JSON body.
84+
func (a *app) requireAuth(next http.Handler) http.Handler {
85+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86+
if authMode() != "enabled" {
87+
next.ServeHTTP(w, r)
88+
return
89+
}
90+
if isPublicAuthPath(r.URL.Path) {
91+
next.ServeHTTP(w, r)
92+
return
93+
}
94+
if a.isAuthenticatedRequest(r) {
95+
next.ServeHTTP(w, r)
96+
return
97+
}
98+
writeJSON(w, http.StatusUnauthorized, apiError{
99+
Error: "Unauthorized",
100+
Message: "authentication required",
101+
StatusCode: http.StatusUnauthorized,
102+
})
103+
})
104+
}
105+
106+
// isAuthenticatedRequest returns true if r presents any acceptable credential
107+
// when AUTH_MODE=enabled. See requireAuth for the accepted forms.
108+
func (a *app) isAuthenticatedRequest(r *http.Request) bool {
109+
// 1. Reverse-proxy forwardAuth (Authelia, oauth2-proxy, etc).
110+
if strings.TrimSpace(r.Header.Get("Remote-User")) != "" {
111+
return true
112+
}
113+
114+
// 2. Session cookie set by /auth/login. The current login handler does
115+
// not persist a server-side session, so any non-empty value is treated
116+
// as authenticated — this matches existing behavior. See TODO above.
117+
if cookie, err := r.Cookie("session"); err == nil && strings.TrimSpace(cookie.Value) != "" {
118+
return true
119+
}
120+
121+
// 3. Helper app-password (Bearer / X-RSM-App-Password / form field).
122+
// extractHelperAppPassword returns the raw caller-supplied value.
123+
// We normalize and look it up against the app-password store.
124+
raw := extractHelperAppPassword(r, nil)
125+
if strings.TrimSpace(raw) != "" {
126+
if _, compact, ok := normalizeAppPasswordInput(raw); ok {
127+
a.mu.Lock()
128+
_, found := a.findAppPasswordByCompactLocked(compact)
129+
a.mu.Unlock()
130+
if found {
131+
return true
132+
}
133+
}
134+
}
135+
136+
return false
137+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
"time"
9+
)
10+
11+
// authMiddlewareHarness builds a contract harness, optionally toggling
12+
// AUTH_MODE=enabled. Because t.Setenv resets the value at test end, each
13+
// subtest can opt in or out of enforcement independently.
14+
func authMiddlewareHarness(t *testing.T, enabled bool) *contractHarness {
15+
t.Helper()
16+
h := newContractHarness(t)
17+
if enabled {
18+
t.Setenv("AUTH_MODE", "enabled")
19+
}
20+
return h
21+
}
22+
23+
// mintAppPassword creates a real app-password record on the harness app and
24+
// returns the compact (XXX-XXX-formatted) plain-text key. The HTTP layer is
25+
// not used so the call works regardless of AUTH_MODE.
26+
func mintAppPassword(t *testing.T, h *contractHarness) string {
27+
t.Helper()
28+
h.app.mu.Lock()
29+
_, key := h.app.createAppPasswordLocked("middleware-test", time.Now().UTC())
30+
h.app.mu.Unlock()
31+
if _, _, ok := normalizeAppPasswordInput(key); !ok {
32+
t.Fatalf("created app password has unexpected format: %q", key)
33+
}
34+
return formatAppPasswordCompact(key)
35+
}
36+
37+
func rawRequest(method, path string) *http.Request {
38+
req := httptest.NewRequest(method, path, nil)
39+
req.Header.Set("X-CSRF-Protection", "1")
40+
return req
41+
}
42+
43+
// --- AUTH_MODE=disabled (baseline — must keep existing behavior) ---
44+
45+
func TestAuthMiddleware_Disabled_AllowsApiSavesWithoutAuth(t *testing.T) {
46+
h := authMiddlewareHarness(t, false)
47+
rr := h.do(rawRequest(http.MethodGet, "/api/saves"))
48+
assertStatus(t, rr, http.StatusOK)
49+
}
50+
51+
// --- AUTH_MODE=enabled — enforcement ---
52+
53+
func TestAuthMiddleware_Enabled_BlocksApiSavesWithoutAuth(t *testing.T) {
54+
h := authMiddlewareHarness(t, true)
55+
rr := h.do(rawRequest(http.MethodGet, "/api/saves"))
56+
assertStatus(t, rr, http.StatusUnauthorized)
57+
assertJSONContentType(t, rr)
58+
}
59+
60+
func TestAuthMiddleware_Enabled_AllowsValidBearerAppPassword(t *testing.T) {
61+
h := authMiddlewareHarness(t, true)
62+
key := mintAppPassword(t, h)
63+
req := rawRequest(http.MethodGet, "/api/saves")
64+
req.Header.Set("Authorization", "Bearer "+key)
65+
rr := h.do(req)
66+
assertStatus(t, rr, http.StatusOK)
67+
}
68+
69+
func TestAuthMiddleware_Enabled_AllowsValidXRSMAppPasswordHeader(t *testing.T) {
70+
h := authMiddlewareHarness(t, true)
71+
key := mintAppPassword(t, h)
72+
req := rawRequest(http.MethodGet, "/api/saves")
73+
req.Header.Set("X-RSM-App-Password", key)
74+
rr := h.do(req)
75+
assertStatus(t, rr, http.StatusOK)
76+
}
77+
78+
func TestAuthMiddleware_Enabled_AllowsRemoteUserHeader(t *testing.T) {
79+
h := authMiddlewareHarness(t, true)
80+
req := rawRequest(http.MethodGet, "/api/saves")
81+
req.Header.Set("Remote-User", "alice")
82+
rr := h.do(req)
83+
assertStatus(t, rr, http.StatusOK)
84+
}
85+
86+
func TestAuthMiddleware_Enabled_AllowsNonEmptySessionCookie(t *testing.T) {
87+
h := authMiddlewareHarness(t, true)
88+
req := rawRequest(http.MethodGet, "/api/saves")
89+
req.AddCookie(&http.Cookie{Name: "session", Value: "anything"})
90+
rr := h.do(req)
91+
assertStatus(t, rr, http.StatusOK)
92+
}
93+
94+
// --- AUTH_MODE=enabled — allowlist (bootstrap endpoints) ---
95+
96+
func TestAuthMiddleware_Enabled_AllowsHealthzWithoutAuth(t *testing.T) {
97+
h := authMiddlewareHarness(t, true)
98+
rr := h.do(rawRequest(http.MethodGet, "/healthz"))
99+
assertStatus(t, rr, http.StatusOK)
100+
}
101+
102+
func TestAuthMiddleware_Enabled_AllowsRuntimeConfigWithoutAuth(t *testing.T) {
103+
h := authMiddlewareHarness(t, true)
104+
rr := h.do(rawRequest(http.MethodGet, "/runtime-config"))
105+
assertStatus(t, rr, http.StatusOK)
106+
}
107+
108+
func TestAuthMiddleware_Enabled_AllowsAuthLoginWithoutAuth(t *testing.T) {
109+
h := authMiddlewareHarness(t, true)
110+
req := httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(`{}`))
111+
req.Header.Set("Content-Type", "application/json")
112+
req.Header.Set("X-CSRF-Protection", "1")
113+
rr := h.do(req)
114+
assertStatus(t, rr, http.StatusOK)
115+
}
116+
117+
// --- AUTH_MODE=enabled — bad credentials still rejected ---
118+
119+
func TestAuthMiddleware_Enabled_RejectsGarbageBearer(t *testing.T) {
120+
h := authMiddlewareHarness(t, true)
121+
req := rawRequest(http.MethodGet, "/api/saves")
122+
// Format-valid (six A-Z0-9 chars) but not bound to any real password.
123+
req.Header.Set("Authorization", "Bearer ZZZZZZ")
124+
rr := h.do(req)
125+
assertStatus(t, rr, http.StatusUnauthorized)
126+
}

backend/cmd/server/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ func newRouter(app *app) http.Handler {
1414
r.Use(middleware.RealIP)
1515
r.Use(middleware.Recoverer)
1616
r.Use(middleware.Logger)
17+
r.Use(app.requireAuth)
1718
staticFrontend := newFrontendStaticHandler()
1819

1920
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)