Skip to content

Commit 8d26dcd

Browse files
committed
fix(auth): pass through helper-identity requests so auto-enroll bootstrap works
1 parent 2d8cf04 commit 8d26dcd

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

backend/cmd/server/auth_middleware.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ func (a *app) requireAuth(next http.Handler) http.Handler {
9191
next.ServeHTTP(w, r)
9292
return
9393
}
94+
// Helper protocol: requests carrying both identity headers
95+
// (X-RSM-Device-Type + X-RSM-Fingerprint) are deferred to the
96+
// downstream handler (authorizeHelperSyncRequest in helper_auth.go),
97+
// which enforces either app-password validation or the
98+
// auto-enroll-window policy. Pre-empting that decision here would
99+
// break the bootstrap flow where a fresh helper has no key yet.
100+
if hasHelperIdentity(r) {
101+
next.ServeHTTP(w, r)
102+
return
103+
}
94104
if a.isAuthenticatedRequest(r) {
95105
next.ServeHTTP(w, r)
96106
return
@@ -103,6 +113,16 @@ func (a *app) requireAuth(next http.Handler) http.Handler {
103113
})
104114
}
105115

116+
// hasHelperIdentity reports whether r carries both helper-identity headers
117+
// (X-RSM-Device-Type + X-RSM-Fingerprint). The middleware uses this to
118+
// recognize the helper protocol and defer the auth decision to
119+
// authorizeHelperSyncRequest, which knows the auto-enroll-window policy.
120+
func hasHelperIdentity(r *http.Request) bool {
121+
dt := strings.TrimSpace(r.Header.Get("X-RSM-Device-Type"))
122+
fp := strings.TrimSpace(r.Header.Get("X-RSM-Fingerprint"))
123+
return dt != "" && fp != ""
124+
}
125+
106126
// isAuthenticatedRequest returns true if r presents any acceptable credential
107127
// when AUTH_MODE=enabled. See requireAuth for the accepted forms.
108128
func (a *app) isAuthenticatedRequest(r *http.Request) bool {

backend/cmd/server/auth_middleware_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package main
22

33
import (
4+
"bytes"
5+
"mime/multipart"
46
"net/http"
57
"net/http/httptest"
68
"strings"
@@ -124,3 +126,102 @@ func TestAuthMiddleware_Enabled_RejectsGarbageBearer(t *testing.T) {
124126
rr := h.do(req)
125127
assertStatus(t, rr, http.StatusUnauthorized)
126128
}
129+
130+
// --- AUTH_MODE=enabled — helper-identity defer-to-handler behavior ---
131+
//
132+
// These tests cover the bug where the middleware would 401 helper requests
133+
// that have identity headers but no app-password, breaking the auto-enroll
134+
// bootstrap flow. The middleware MUST defer to authorizeHelperSyncRequest
135+
// (in helper_auth.go), which enforces the actual policy.
136+
137+
// helperMultipartSaveRequest builds a POST /saves multipart request that
138+
// looks like a helper upload — identity carried via headers, not form fields,
139+
// so the middleware's hasHelperIdentity check is exercised. Optionally sets
140+
// an Authorization Bearer with the supplied app-password.
141+
func helperMultipartSaveRequest(t *testing.T, deviceType, fingerprint, appPassword string) *http.Request {
142+
t.Helper()
143+
144+
fields := map[string]string{
145+
"rom_sha1": "middleware-helper-rom",
146+
"slotName": "default",
147+
"system": "snes",
148+
"runtimeProfile": "snes/snes9x",
149+
}
150+
151+
var buf bytes.Buffer
152+
writer := multipart.NewWriter(&buf)
153+
for key, value := range fields {
154+
if err := writer.WriteField(key, value); err != nil {
155+
t.Fatalf("write multipart field %s: %v", key, err)
156+
}
157+
}
158+
part, err := writer.CreateFormFile("file", "Final Fantasy VI.srm")
159+
if err != nil {
160+
t.Fatalf("create multipart file: %v", err)
161+
}
162+
payload := normalizeTestUploadPayload(fields, "Final Fantasy VI.srm", []byte("helper-middleware-payload"))
163+
if _, err := part.Write(payload); err != nil {
164+
t.Fatalf("write multipart payload: %v", err)
165+
}
166+
if err := writer.Close(); err != nil {
167+
t.Fatalf("close multipart writer: %v", err)
168+
}
169+
170+
req := httptest.NewRequest(http.MethodPost, "/saves", &buf)
171+
req.Header.Set("Content-Type", writer.FormDataContentType())
172+
req.Header.Set("X-CSRF-Protection", "1")
173+
req.Header.Set("X-RSM-Device-Type", deviceType)
174+
req.Header.Set("X-RSM-Fingerprint", fingerprint)
175+
if appPassword != "" {
176+
req.Header.Set("Authorization", "Bearer "+appPassword)
177+
}
178+
return req
179+
}
180+
181+
func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollOpen_Allows(t *testing.T) {
182+
h := authMiddlewareHarness(t, true)
183+
// Open the auto-enroll window via the normal handler path.
184+
h.app.mu.Lock()
185+
h.app.enableAutoAppPasswordWindowLocked(15 * time.Minute)
186+
h.app.mu.Unlock()
187+
188+
req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-open", "")
189+
rr := h.do(req)
190+
assertStatus(t, rr, http.StatusOK)
191+
if got := rr.Header().Get("X-RSM-Auto-App-Password"); got == "" {
192+
t.Fatalf("expected auto-provisioned app password header on bootstrap; headers=%v", rr.Header())
193+
}
194+
}
195+
196+
func TestAuthMiddleware_Enabled_HelperHeadersNoKey_AutoEnrollClosed_Rejects(t *testing.T) {
197+
h := authMiddlewareHarness(t, true)
198+
// Default state: auto-enroll window is NOT open.
199+
200+
req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-closed", "")
201+
rr := h.do(req)
202+
// The middleware passes through; the downstream handler enforces the
203+
// no-key + closed-window policy with 401.
204+
assertStatus(t, rr, http.StatusUnauthorized)
205+
}
206+
207+
func TestAuthMiddleware_Enabled_HelperHeadersValidKey_Allows(t *testing.T) {
208+
h := authMiddlewareHarness(t, true)
209+
key := mintAppPassword(t, h)
210+
211+
req := helperMultipartSaveRequest(t, "linux-x86", "deck-middleware-key", key)
212+
rr := h.do(req)
213+
assertStatus(t, rr, http.StatusOK)
214+
}
215+
216+
// Regression test for the GET /api/saves + valid Bearer path. The empirical
217+
// repro that surfaced the bootstrap bug had an empty Bearer (enrollment had
218+
// failed), so this confirms the happy path independently.
219+
func TestAuthMiddleware_Enabled_GetApiSavesWithValidBearer_Allows(t *testing.T) {
220+
h := authMiddlewareHarness(t, true)
221+
key := mintAppPassword(t, h)
222+
223+
req := rawRequest(http.MethodGet, "/api/saves")
224+
req.Header.Set("Authorization", "Bearer "+key)
225+
rr := h.do(req)
226+
assertStatus(t, rr, http.StatusOK)
227+
}

0 commit comments

Comments
 (0)