package handoff import ( "database/sql" "errors" "net/http" "net/http/httptest" "net/url" "sync" "testing" "time" "amadan.net/rastrillo/rastrillo/assertion" ) func (f fixture) timedToken(t *testing.T, nonce string, auth int64) string { t.Helper() id, e := assertion.RandomID() if e != nil { t.Fatal(e) } issued := f.now.Unix() if auth > issued { issued = auth } token, e := assertion.Sign(f.k, assertion.Claims{Version: 1, Issuer: f.s.issuer, Audience: f.s.audience, Subject: "person_123", KeyID: "k1", ID: id, Nonce: nonce, IssuedAt: issued, ExpiresAt: issued + 120, AuthTime: auth}) if e != nil { t.Fatal(e) } return token } func TestFreshProofWindowAndConsumption(t *testing.T) { for _, tc := range []struct { name string age, fence int64 accept bool }{ {"now", 0, 0, true}, {"boundary", 300, 0, true}, {"old", 301, 0, false}, {"future", -1, 0, false}, {"rollback boundary", 280, 20, true}, {"rollback stale", 281, 20, false}, } { t.Run(tc.name, func(t *testing.T) { f := fresh(t) secret, nonce, e := f.s.StartFresh(t.Context(), "/documents/private-canary", "", f.now) if e != nil { t.Fatal(e) } token := f.timedToken(t, nonce, f.now.Unix()-tc.age) handle, e := f.s.Receive(t.Context(), token, f.now) if e != nil { t.Fatal(e) } if e = f.s.Sweep(t.Context(), f.now.Add(time.Duration(tc.fence)*time.Second)); e != nil { t.Fatal(e) } claims, path, e := f.s.Finish(t.Context(), secret, handle, "", f.now) if tc.accept { if e != nil || path != "/documents/private-canary" || claims.AuthTime != f.now.Unix()-tc.age { t.Fatalf("fresh proof changed or refused: %v", e) } } else if e != ErrRefused || path != "" || claims.Subject != "" || claims.AuthTime != 0 { t.Fatal("unusable freshness returned identity") } if count(t, f.db, "oficina_handoff_pending") != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 || count(t, f.db, "oficina_handoff_replay") != 1 { t.Fatal("fresh proof not consumed before refusal") } if e = f.db.Close(); e != nil { t.Fatal(e) } db, e := sql.Open("sqlite", f.dbpath) if e != nil { t.Fatal(e) } defer db.Close() f.db = db f.s, e = NewStore(db, f.s.sealer, f.s.issuer, f.s.audience, map[string][]byte{"k1": f.k.SignPub()}) if e != nil { t.Fatal(e) } later := f.now.Add(30 * time.Second) failFinish(t, f, secret, handle, "", later, ErrRefused) if _, e = f.s.Receive(t.Context(), token, later); e != ErrRefused { t.Fatal("proof revived after restart/wait", e) } }) } } func TestFreshIntentCompatibilityAndPreconsumptionRefusals(t *testing.T) { f := fresh(t) old, _, e := f.s.Start(t.Context(), "/ordinary", "", f.now) if e != nil { t.Fatal(e) } secret, nonce, e := f.s.StartFresh(t.Context(), "/sensitive", "", f.now) if e != nil { t.Fatal(e) } for _, tc := range []struct { secret, want string oldReadable bool }{{old, "/ordinary", true}, {secret, freshPathPrefix + "/sensitive", false}} { var b []byte if e = f.db.QueryRow(`SELECT sealed_path FROM oficina_handoff_pending WHERE secret_hash=?`, digest(tc.secret)).Scan(&b); e != nil { t.Fatal(e) } plain, e := f.s.sealer.Open("handoff.path."+digest(tc.secret), b) if e != nil || string(plain) != tc.want || SafePath(string(plain)) != tc.oldReadable { t.Fatal("old-reader compatibility lost") } } handle, e := f.s.Receive(t.Context(), f.timedToken(t, nonce, f.now.Unix()-900), f.now) if e != nil { t.Fatal(e) } failFinish(t, f, secret, handle, "different_person", f.now, ErrRefused) if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 { t.Fatal("subject refusal consumed proof") } for _, bad := range []string{"oficina-handoff-fresh-v2:/sensitive", freshPathPrefix + "//foreign.example"} { sealed, e := f.s.sealer.Seal("handoff.path."+digest(secret), []byte(bad)) if e != nil { t.Fatal(e) } if _, e = f.db.Exec(`UPDATE oficina_handoff_pending SET sealed_path=? WHERE secret_hash=?`, sealed, digest(secret)); e != nil { t.Fatal(e) } failFinish(t, f, secret, handle, "", f.now, ErrRefused) if count(t, f.db, "oficina_handoff_replay") != 0 || count(t, f.db, "oficina_handoff_inbox") != 1 { t.Fatal("path refusal consumed proof") } } } func TestSensitiveHTTPRejectsDowngrade(t *testing.T) { f := fresh(t) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { t.Fatal("stale proof reached Complete") return nil }) c.cfg.RequireFreshAuth = true var bound string c.cfg.BindStart = func(r *http.Request, nonce string) error { bound = nonce; return nil } start := httptest.NewRecorder() c.Start(start, request("GET", StartPath+"?return_to=%2Fsensitive", "")) if start.Code != 303 { t.Fatal(start.Code) } target, e := url.Parse(start.Header().Get("Location")) if e != nil { t.Fatal(e) } if bound == "" || bound != target.Query().Get("nonce") || target.Query().Get("max_age") != "300" { t.Fatal("freshness or nonce binding missing") } // An issuer request with max_age stripped can return an ordinary stale token. callback := httptest.NewRecorder() c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {f.token(t, bound)}}.Encode())) if callback.Code != 303 { t.Fatal(callback.Code) } c.cfg.RequireFreshAuth = false c.cfg.BindStart = nil r := request("GET", FinishPath, "") r.AddCookie(matchingCookie(t, start, pendingCookie)) r.AddCookie(matchingCookie(t, callback, inboxCookie)) finish := httptest.NewRecorder() c.Finish(finish, r) if finish.Code != 400 || count(t, f.db, "oficina_handoff_replay") != 1 { t.Fatal("ordinary handler downgraded fresh intent") } } func TestBindStartRejectsBeforePublishingAndCancelsOnlyNewRow(t *testing.T) { for _, evict := range []bool{false, true} { t.Run(map[bool]string{false: "present", true: "already evicted"}[evict], func(t *testing.T) { f := fresh(t) other, _ := f.pending(t) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) calls := 0 c.cfg.BindStart = func(r *http.Request, nonce string) error { calls++ // This DB operation also proves the callback is outside the store transaction. var n int if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)).Scan(&n); e != nil || n != 1 { t.Fatal("binding ran before pending commit", e) } if evict { if _, e := f.db.Exec(`DELETE FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(nonce)); e != nil { t.Fatal(e) } } return errors.New("private app rejection") } f.db.SetMaxOpenConns(1) rec := httptest.NewRecorder() c.Start(rec, request("GET", StartPath, "")) if rec.Code != 400 || rec.Header().Get("Location") != "" || len(rec.Result().Cookies()) != 0 || calls != 1 { t.Fatal("rejected initiation published") } if rec.Body.String() != "Unable to sign in. Start again.\n" || count(t, f.db, "oficina_handoff_pending") != 1 { t.Fatal("rejection leaked detail or failed cleanup") } var n int if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE secret_hash=?`, digest(other)).Scan(&n); e != nil || n != 1 { t.Fatal("cancel removed another browser") } r := request("GET", StartPath, "") r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other}) r.AddCookie(&http.Cookie{Name: pendingCookie, Value: other}) rec = httptest.NewRecorder() c.Start(rec, r) if rec.Code != 400 || calls != 1 { t.Fatal("duplicate cookies reached binding") } }) } } func TestConcurrentAppInitiationHasOneBindingWinner(t *testing.T) { f := fresh(t) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) var mu sync.Mutex bound := "" wins := 0 c.cfg.BindStart = func(r *http.Request, nonce string) error { mu.Lock() defer mu.Unlock() if bound != "" { return ErrRefused } bound = nonce return nil } var wg sync.WaitGroup for range 8 { wg.Add(1) go func() { defer wg.Done() rec := httptest.NewRecorder() c.Start(rec, request("GET", StartPath, "")) if rec.Code == 303 { mu.Lock() wins++ mu.Unlock() } else if rec.Code != 400 { t.Errorf("unexpected status %d", rec.Code) } }() } wg.Wait() if wins != 1 || count(t, f.db, "oficina_handoff_pending") != 1 { t.Fatal("app binding race had multiple winners") } var n int if e := f.db.QueryRow(`SELECT COUNT(*) FROM oficina_handoff_pending WHERE nonce_hash=?`, digest(bound)).Scan(&n); e != nil || n != 1 { t.Fatal("winning nonce missing") } } func TestBindingCancelFailureIsGenericAndBounded(t *testing.T) { f := fresh(t) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) c.cfg.BindStart = func(r *http.Request, nonce string) error { // Force only the compensating delete to fail, leaving its bounded row. _, e := f.db.Exec(`CREATE TRIGGER refuse_cancel BEFORE DELETE ON oficina_handoff_pending BEGIN SELECT RAISE(ABORT,'private failure'); END`) if e != nil { t.Fatal(e) } return errors.New("private initiation") } rec := httptest.NewRecorder() c.Start(rec, request("GET", StartPath, "")) if rec.Code != 503 || len(rec.Result().Cookies()) != 0 || rec.Header().Get("Location") != "" || rec.Body.String() != "Unable to sign in. Start again.\n" { t.Fatal("cancellation failure exposed or published") } if count(t, f.db, "oficina_handoff_pending") != 1 { t.Fatal("missing orphan fixture") } if _, e := f.db.Exec(`DROP TRIGGER refuse_cancel`); e != nil { t.Fatal(e) } if e := f.s.Sweep(t.Context(), f.now.Add(601*time.Second)); e != nil { t.Fatal(e) } if count(t, f.db, "oficina_handoff_pending") != 0 { t.Fatal("orphan survived expiry") } }