package httpapi import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" "commilitia.net/cdrop/internal/config" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" ) const qrTestSecret = "qr-test-session-secret-at-least-32-bytes" // newQRTestServer builds a Server with only the fields the scan-login handlers // touch, backed by a fresh file-based sqlite (an in-memory DB would give each // pooled connection its own empty schema). func newQRTestServer(t *testing.T) *Server { t.Helper() conn, err := db.Open(filepath.Join(t.TempDir(), "qr.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = conn.Close() }) if err := db.Bootstrap(context.Background(), conn); err != nil { t.Fatalf("bootstrap: %v", err) } q := db.New(conn) return &Server{ cfg: &config.Config{ AuthMode: "prod", SessionSecret: qrTestSecret, QRLoginEnabled: true, QRRequestTTLSeconds: 120, QRGuestTTLSeconds: 3600, QRPersistTTLHours: 168, SessionTokenTTLSeconds: 900, }, queries: q, hub: hub.New(q), sessionKey: deriveSessionKey(qrTestSecret), sessionTokenKey: jwtauth.DeriveSessionTokenKey(qrTestSecret), siteOrigin: "https://drop.example.net", } } func qrStart(t *testing.T, s *Server, deviceName string) (qrStartResp, string) { t.Helper() body := fmt.Sprintf(`{"device_name":%q,"device_type":"browser"}`, deviceName) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/start", strings.NewReader(body)) w := httptest.NewRecorder() s.handleQRStart(w, r) if w.Code != http.StatusOK { t.Fatalf("qr/start: %d %s", w.Code, w.Body.String()) } var resp qrStartResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode start: %v", err) } u, err := url.Parse(resp.QRPayload) if err != nil { t.Fatalf("qr_payload not a url: %v", err) } return resp, u.Query().Get("c") } func qrApprove(t *testing.T, s *Server, requestID, code, persist, approver string) int { t.Helper() body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q,"scope":"guest","persist":%q}`, requestID, code, persist) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: approver, SessionScope: "full"})) w := httptest.NewRecorder() s.handleQRApprove(w, r) return w.Code } func qrStatus(s *Server, requestID, pollSecret string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "/api/auth/qr/status?request_id="+url.QueryEscape(requestID), nil) r.Header.Set("X-Poll-Secret", pollSecret) w := httptest.NewRecorder() s.handleQRStatus(w, r) return w } func decodeStatus(t *testing.T, w *httptest.ResponseRecorder) qrStatusResp { t.Helper() var resp qrStatusResp if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode status: %v (%s)", err, w.Body.String()) } return resp } // The happy path: a new device starts a request, the approver authorises it as a // one-time guest, and the new device collects a live guest session — cookie set, // access token minted, request single-use thereafter. func TestQRFlow_ApproveCollectGuestSingleUse(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Borrowed Laptop") if c := qrApprove(t, s, start.RequestID, code, "once", "approver-1"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } w := qrStatus(s, start.RequestID, start.PollSecret) if w.Code != http.StatusOK { t.Fatalf("status: %d %s", w.Code, w.Body.String()) } got := decodeStatus(t, w) if got.Status != "approved" || got.AccessToken == "" || got.DeviceName != "Borrowed Laptop" { t.Fatalf("collected status wrong: %+v", got) } if got.ExpiresIn != 900 { t.Errorf("expires_in: got %d, want 900", got.ExpiresIn) } var hasCookie bool for _, ck := range w.Result().Cookies() { if ck.Name == sessionCookieName && ck.Value != "" { hasCookie = true } } if !hasCookie { t.Error("collection must set the session cookie on the new device's response") } // The minted token is a properly signed guest session token. if scope := selfTokenScope(t, got.AccessToken); scope != "guest" { t.Fatalf("minted token scope: got %q, want guest", scope) } // A guest (one-time) session row exists for the approver and is non-sliding. rows, err := s.queries.ListWebSessionsByUser(context.Background(), "approver-1") if err != nil || len(rows) != 1 { t.Fatalf("expected one session row, got %d (err=%v)", len(rows), err) } if rows[0].Kind != "guest" || rows[0].Scope != "guest" { t.Errorf("session kind/scope: got %s/%s, want guest/guest", rows[0].Kind, rows[0].Scope) } // Single use: a second collection finds the request consumed. if got2 := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got2.Status != "expired" { t.Errorf("second collection: got %q, want expired (consumed)", got2.Status) } } // A persistent ("trust this device") approval yields a sliding self session. func TestQRFlow_PersistYieldsSelfSession(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Home PC") if c := qrApprove(t, s, start.RequestID, code, "persist", "approver-2"); c != http.StatusNoContent { t.Fatalf("approve: got %d, want 204", c) } if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { t.Fatalf("collect: %+v", got) } rows, _ := s.queries.ListWebSessionsByUser(context.Background(), "approver-2") if len(rows) != 1 || rows[0].Kind != "self" { t.Fatalf("persistent approval must create a kind=self session, got %+v", rows) } } // The poll_secret is the new device's only credential: a wrong one is rejected // even for a real, approved request, so a QR photographer can't collect it. func TestQRFlow_WrongPollSecretRejected(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") _ = qrApprove(t, s, start.RequestID, code, "once", "approver-3") if w := qrStatus(s, start.RequestID, "not-the-secret"); w.Code != http.StatusForbidden { t.Errorf("wrong poll secret: got %d, want 403", w.Code) } // The real secret still works afterwards (the bad attempt didn't consume it). if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "approved" { t.Errorf("real secret after a bad attempt: got %q, want approved", got.Status) } } // Denial propagates to the polling device. func TestQRFlow_Deny(t *testing.T) { s := newQRTestServer(t) start, code := qrStart(t, s, "Laptop") body := fmt.Sprintf(`{"request_id":%q,"approval_code":%q}`, start.RequestID, code) r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/deny", strings.NewReader(body)) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "approver-4", SessionScope: "full"})) w := httptest.NewRecorder() s.handleQRDeny(w, r) if w.Code != http.StatusNoContent { t.Fatalf("deny: got %d, want 204", w.Code) } if got := decodeStatus(t, qrStatus(s, start.RequestID, start.PollSecret)); got.Status != "denied" { t.Errorf("status after deny: got %q, want denied", got.Status) } } // With step-up enabled, approving without a fresh re-auth is refused (the gate // rejects an empty step-up proof before any IdP call). AUTH.md §6. func TestQRFlow_StepUpRequiredRejectsWithoutReauth(t *testing.T) { s := newQRTestServer(t) s.cfg.StepUpEnabled = true start, code := qrStart(t, s, "Laptop") if c := qrApprove(t, s, start.RequestID, code, "once", "approver-su"); c != http.StatusForbidden { t.Errorf("approve without step-up proof: got %d, want 403", c) } } // A pending request long-polls then reports pending for the client to re-poll. func TestQRFlow_PendingLongPoll(t *testing.T) { saved := qrStatusPollWindow qrStatusPollWindow = 50 * time.Millisecond defer func() { qrStatusPollWindow = saved }() s := newQRTestServer(t) start, _ := qrStart(t, s, "Laptop") w := qrStatus(s, start.RequestID, start.PollSecret) if got := decodeStatus(t, w); got.Status != "pending" { t.Errorf("pending long-poll: got %q, want pending", got.Status) } } // noopAuthStore satisfies jwtauth.Store for middleware wiring in tests (no device // upsert happens without an X-Device-Name header). type noopAuthStore struct{} func (noopAuthStore) UpsertDevice(context.Context, db.UpsertDeviceParams) error { return nil } func (noopAuthStore) GetShortcutToken(context.Context, string) (db.ShortcutToken, error) { return db.ShortcutToken{}, fmt.Errorf("none") } func (noopAuthStore) TouchShortcutTokenUsed(context.Context, db.TouchShortcutTokenUsedParams) error { return nil } func (noopAuthStore) GetWebSession(_ context.Context, id string) (db.WebSession, error) { return db.WebSession{ID: id}, nil // session always live in these tests } // End-to-end through the REAL chain (auth.Middleware → requireFullSession): a // guest session token is rejected 403 on an account-management route, a full one // passes. This is the backend enforcement behind AUTH.md §3.2 (guest can't approve // devices / remove devices / mint tokens). func TestRequireFullSession_GuestBlockedFullPasses(t *testing.T) { secret := "test-session-secret-at-least-32-bytes-ok" srv := &Server{ cfg: &config.Config{SessionTokenTTLSeconds: 900}, sessionTokenKey: jwtauth.DeriveSessionTokenKey(secret), } guest, _, err := srv.mintSessionToken("u", "sid", "guest") if err != nil { t.Fatalf("mint guest: %v", err) } full, _, err := srv.mintSessionToken("u", "sid", "full") if err != nil { t.Fatalf("mint full: %v", err) } a := jwtauth.New(&config.Config{AuthMode: "prod", SessionSecret: secret}, noopAuthStore{}) handler := a.Middleware(requireFullSession(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))) probe := func(tok string) int { r := httptest.NewRequest(http.MethodPost, "/api/auth/qr/approve", nil) r.Header.Set("Authorization", "Bearer "+tok) w := httptest.NewRecorder() handler.ServeHTTP(w, r) return w.Code } if c := probe(guest); c != http.StatusForbidden { t.Errorf("guest token on requireFullSession route: got %d, want 403", c) } if c := probe(full); c != http.StatusOK { t.Errorf("full token on requireFullSession route: got %d, want 200", c) } } // Revoking a session is sensitive: with step-up enabled it is refused (403 // step_up_required) until the caller's session has recently stepped up, then it // proceeds (here to 400 missing-id, i.e. past the gate). Step-up state is keyed on // the cookie session, so it is per-device (AUTH.md §1/§6). func TestSessionRevoke_RequiresStepUp(t *testing.T) { s := newQRTestServer(t) s.cfg.StepUpEnabled = true s.cfg.StepUpMaxAgeSeconds = 300 raw, id, err := newSessionToken() if err != nil { t.Fatalf("token: %v", err) } now := time.Now().Unix() if err := s.queries.CreateSelfSession(context.Background(), db.CreateSelfSessionParams{ ID: id, UserID: "u", DeviceName: "", UserAgent: "", Kind: "oidc", Scope: "full", GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, }); err != nil { t.Fatalf("create session: %v", err) } revoke := func() int { r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/x", nil) r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: raw}) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", SessionScope: "full"})) w := httptest.NewRecorder() s.handleSessionRevoke(w, r) return w.Code } // Not stepped up → blocked before any deletion. if c := revoke(); c != http.StatusForbidden { t.Fatalf("revoke without step-up: got %d, want 403", c) } // Record step-up on this (cookie) session → gate now passes (400 = missing id, // reached past the step-up check). if err := s.queries.SetSessionSteppedUp(context.Background(), db.SetSessionSteppedUpParams{ SteppedUpAt: now, ID: id, }); err != nil { t.Fatalf("set stepped up: %v", err) } if c := revoke(); c == http.StatusForbidden { t.Errorf("revoke after step-up still 403; gate did not honour stepped_up_at") } } // selfTokenScope verifies a self-signed session token under the test's session // key (proving the signature) and returns its scope claim. func selfTokenScope(t *testing.T, token string) string { t.Helper() parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.HS256}) if err != nil { t.Fatalf("parse token: %v", err) } var std jwt.Claims custom := map[string]any{} if err := parsed.Claims(jwtauth.DeriveSessionTokenKey(qrTestSecret), &std, &custom); err != nil { t.Fatalf("verify token signature: %v", err) } if typ, _ := custom["typ"].(string); typ != "session" { t.Fatalf("token typ: got %q, want session", typ) } scope, _ := custom["scope"].(string) return scope } // revokeByID drives handleSessionRevoke with the chi {id} param and full claims, // step-up off (the gate is exercised separately). func revokeByID(s *Server, id, userID string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodDelete, "/api/auth/sessions/"+id, nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", id) ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: userID, SessionScope: "full"}) w := httptest.NewRecorder() s.handleSessionRevoke(w, r.WithContext(ctx)) return w } // Revoking a web session removes its device registration too, so a revoked device // disappears from the device list at once (the user can no longer remove devices // by hand). AUTH.md §4. func TestSessionRevoke_DeletesDevice(t *testing.T) { s := newQRTestServer(t) // step-up off by default ctx := context.Background() now := time.Now().Unix() _, id, err := newSessionToken() if err != nil { t.Fatalf("token: %v", err) } if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ ID: id, UserID: "u", DeviceName: "Laptop", UserAgent: "", Kind: "oidc", Scope: "full", GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, }); err != nil { t.Fatalf("create session: %v", err) } if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ UserID: "u", Name: "Laptop", Type: "browser", LastSeen: now, }); err != nil { t.Fatalf("upsert device: %v", err) } if w := revokeByID(s, id, "u"); w.Code != http.StatusNoContent { t.Fatalf("revoke: got %d %s, want 204", w.Code, w.Body.String()) } if _, err := s.queries.GetWebSession(ctx, id); err == nil { t.Errorf("session still present after revoke") } devs, _ := s.queries.ListDevicesByUser(ctx, "u") for _, d := range devs { if d.Name == "Laptop" { t.Errorf("device 'Laptop' not deleted on session revoke") } } } // The session list unifies web_sessions with native client devices (desktop / iOS) // that have no web_session, while never double-listing a device that already has a // session and excluding shortcut-token devices. AUTH.md §4. func TestSessionsList_IncludesNativeDevices(t *testing.T) { s := newQRTestServer(t) ctx := context.Background() now := time.Now().Unix() _, id, err := newSessionToken() if err != nil { t.Fatalf("token: %v", err) } if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ ID: id, UserID: "u", DeviceName: "Web", UserAgent: "", Kind: "oidc", Scope: "full", GrantedBy: "", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, }); err != nil { t.Fatalf("create session: %v", err) } for _, d := range []struct{ name, typ string }{ {"Web", "browser"}, // has a web_session — must not be double-listed {"Desk", "macos"}, // native, no session — must appear as native {"iShortcut", "shortcut"}, // scoped token — excluded from session list } { if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ UserID: "u", Name: d.name, Type: d.typ, LastSeen: now, }); err != nil { t.Fatalf("upsert device %s: %v", d.name, err) } } r := httptest.NewRequest(http.MethodGet, "/api/auth/sessions", nil) r = r.WithContext(jwtauth.ContextWithClaims(r.Context(), &jwtauth.Claims{UserID: "u", SessionScope: "full"})) w := httptest.NewRecorder() s.handleSessionsList(w, r) if w.Code != http.StatusOK { t.Fatalf("sessions list: got %d %s", w.Code, w.Body.String()) } var resp struct { Sessions []sessionView `json:"sessions"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } var web, webNativeDup, desk, shortcut bool for _, v := range resp.Sessions { switch { case v.DeviceName == "Web" && !v.Native && v.Kind == "oidc": web = true case v.DeviceName == "Web" && v.Native: webNativeDup = true case v.DeviceName == "Desk" && v.Native && v.Kind == "macos": desk = true case v.DeviceName == "iShortcut": shortcut = true } } if !web { t.Errorf("web session for 'Web' missing") } if webNativeDup { t.Errorf("'Web' double-listed as a native device") } if !desk { t.Errorf("native device 'Desk' missing from session list") } if shortcut { t.Errorf("shortcut device leaked into session list") } } // The sweeper drops browser devices whose web_session is gone (orphans) while // keeping browser devices that still have a live session and all native devices. func TestDeleteOrphanBrowserDevices(t *testing.T) { s := newQRTestServer(t) ctx := context.Background() now := time.Now().Unix() // Browser "Kept" has a live session; browser "Orphan" has none; native "Desk" // (macos) legitimately has no session and must survive. _, id, err := newSessionToken() if err != nil { t.Fatalf("token: %v", err) } if err := s.queries.CreateSelfSession(ctx, db.CreateSelfSessionParams{ ID: id, UserID: "u", DeviceName: "Kept", Kind: "oidc", Scope: "full", CreatedAt: now, LastUsedAt: now, ExpiresAt: now + 3600, }); err != nil { t.Fatalf("create session: %v", err) } for _, d := range []struct{ name, typ string }{ {"Kept", "browser"}, {"Orphan", "browser"}, {"Desk", "macos"}, } { if err := s.queries.UpsertDevice(ctx, db.UpsertDeviceParams{ UserID: "u", Name: d.name, Type: d.typ, LastSeen: now, }); err != nil { t.Fatalf("upsert %s: %v", d.name, err) } } if _, err := s.queries.DeleteOrphanBrowserDevices(ctx, now); err != nil { t.Fatalf("sweep: %v", err) } devs, _ := s.queries.ListDevicesByUser(ctx, "u") got := map[string]bool{} for _, d := range devs { got[d.Name] = true } if !got["Kept"] { t.Errorf("browser 'Kept' with a live session was wrongly swept") } if got["Orphan"] { t.Errorf("orphan browser 'Orphan' was not swept") } if !got["Desk"] { t.Errorf("native 'Desk' was wrongly swept (it keeps no web_session)") } } // Removing a native device (the session list's logout path for desktop / iOS) is // sensitive and requires a recent step-up, mirroring web-session revocation. func TestDeleteDevice_RequiresStepUp(t *testing.T) { s := newQRTestServer(t) s.cfg.StepUpEnabled = true s.cfg.StepUpMaxAgeSeconds = 300 r := httptest.NewRequest(http.MethodDelete, "/api/devices/Desk", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("name", "Desk") ctx := context.WithValue(r.Context(), chi.RouteCtxKey, rctx) ctx = jwtauth.ContextWithClaims(ctx, &jwtauth.Claims{UserID: "u", SessionScope: "full"}) w := httptest.NewRecorder() s.handleDeleteDevice(w, r.WithContext(ctx)) if w.Code != http.StatusForbidden { t.Fatalf("delete device without step-up: got %d, want 403", w.Code) } }