package handoff import ( "errors" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "amadan.net/rastrillo/rastrillo/assertion" ) func newConsumer(t *testing.T, f fixture, done func(http.ResponseWriter, *http.Request, assertion.Claims) error) *Consumer { t.Helper() c, e := NewConsumer(ConsumerConfig{Store: f.s, ConsumerID: "directory", Now: func() time.Time { return f.now }, Complete: done}) if e != nil { t.Fatal(e) } return c } func request(method, path, body string) *http.Request { r := httptest.NewRequest(method, "https://app.example.com"+path, strings.NewReader(body)) if method == "POST" { r.Header.Set("Origin", "https://id.example.com") r.Header.Set("Content-Type", "application/x-www-form-urlencoded") } return r } func matchingCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie { t.Helper() for _, c := range r.Result().Cookies() { if c.Name == name { return c } } t.Fatal("cookie missing", name) return nil } func TestHTTPExchangeWithoutCallbackCookie(t *testing.T) { f := fresh(t) completed := 0 c := newConsumer(t, f, func(w http.ResponseWriter, r *http.Request, a assertion.Claims) error { completed++ if a.Subject != "person_123" || a.AuthTime != f.now.Unix()-900 { t.Fatal("identity/freshness lost") } return nil }) start := httptest.NewRecorder() c.Start(start, request("GET", StartPath+"?return_to=%2Fdocuments%2Fx%3Fteam%3Dblue", "")) if start.Code != 303 { t.Fatal(start.Code) } target, e := url.Parse(start.Header().Get("Location")) if e != nil { t.Fatal(e) } if target.Host != "id.example.com" || target.Path != "/authorize" || target.Query().Get("consumer") != "directory" { t.Fatal("bad issuer target") } secret := matchingCookie(t, start, pendingCookie) token := f.token(t, target.Query().Get("nonce")) callback := httptest.NewRecorder() c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {token}}.Encode())) if callback.Code != 303 || completed != 0 { t.Fatal("callback minted session") } handle := matchingCookie(t, callback, inboxCookie) for _, cookie := range []*http.Cookie{secret, handle} { if !cookie.Secure || !cookie.HttpOnly || cookie.Path != "/" || cookie.Domain != "" || cookie.SameSite != http.SameSiteLaxMode || !strings.HasPrefix(cookie.Name, "__Host-") { t.Fatal("cookie isolation lost") } } if strings.Contains(callback.Header().Get("Location"), token) || callback.Header().Get("Location") != FinishPath { t.Fatal("token in navigation") } finish := httptest.NewRecorder() r := request("GET", FinishPath, "") r.AddCookie(secret) r.AddCookie(handle) c.Finish(finish, r) if completed != 1 || finish.Code != 303 || finish.Header().Get("Location") != "/documents/x?team=blue" { t.Fatal("finish failed") } for _, rec := range []*httptest.ResponseRecorder{start, callback, finish} { if rec.Header().Get("Cache-Control") != "no-store" { t.Fatal("cacheable auth response") } } } func TestCallbackRefusalsNeverSetCookies(t *testing.T) { f := fresh(t) _, nonce := f.pending(t) token := f.token(t, nonce) body := url.Values{"assertion": {token}}.Encode() c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { t.Fatal("invalid callback completed") return nil }) cases := map[string]func(*http.Request){"missing origin": func(r *http.Request) { r.Header.Del("Origin") }, "null origin": func(r *http.Request) { r.Header.Set("Origin", "null") }, "foreign same site": func(r *http.Request) { r.Header.Set("Origin", "https://evil.example.com") r.Header.Set("Sec-Fetch-Site", "same-site") }, "duplicate origin": func(r *http.Request) { r.Header.Add("Origin", "https://id.example.com") }, "JSON": func(r *http.Request) { r.Header.Set("Content-Type", "application/json") }, "query": func(r *http.Request) { r.URL.RawQuery = "assertion=" + token }, "wrong host": func(r *http.Request) { r.Host = "other.example.com" }, "GET": func(r *http.Request) { r.Method = "GET" }, "wrong path": func(r *http.Request) { r.URL.Path = "/other" }} for name, change := range cases { t.Run(name, func(t *testing.T) { r := request("POST", CallbackPath, body) change(r) rec := httptest.NewRecorder() c.Callback(rec, r) if rec.Code < 400 || len(rec.Result().Cookies()) != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 { t.Fatal("callback accepted") } }) } for _, bad := range []string{body + "&" + body, body + "&extra=1", strings.Repeat("x", 12*1024+1), "assertion=%zz", ""} { rec := httptest.NewRecorder() c.Callback(rec, request("POST", CallbackPath, bad)) if rec.Code != 400 || len(rec.Result().Cookies()) != 0 { t.Fatal("bad body accepted") } } } func TestFinishRefusesMissingDuplicateOrWrongBrowserCookies(t *testing.T) { f := fresh(t) secret, nonce := f.pending(t) handle, _ := f.received(t, nonce) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { t.Fatal("wrong browser completed") return nil }) other, _, e := f.s.Start(t.Context(), "/", "", f.now) if e != nil { t.Fatal(e) } for _, cookies := range [][]*http.Cookie{nil, {{Name: pendingCookie, Value: secret}}, {{Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: other}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}}, {{Name: pendingCookie, Value: secret}, {Name: inboxCookie, Value: handle}, {Name: inboxCookie, Value: handle}}} { r := request("GET", FinishPath, "") for _, cookie := range cookies { r.AddCookie(cookie) } rec := httptest.NewRecorder() c.Finish(rec, r) if rec.Code != 400 { t.Fatal("bad finish accepted") } } } func TestSessionFailureKeepsAssertionConsumed(t *testing.T) { f := fresh(t) secret, nonce := f.pending(t) handle, token := f.received(t, nonce) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return errors.New("synthetic failure") }) r := request("GET", FinishPath, "") r.AddCookie(&http.Cookie{Name: pendingCookie, Value: secret}) r.AddCookie(&http.Cookie{Name: inboxCookie, Value: handle}) rec := httptest.NewRecorder() c.Finish(rec, r) if rec.Code != 503 || count(t, f.db, "oficina_handoff_replay") != 1 { t.Fatal("failed session unconsumed assertion") } if _, e := f.s.Receive(t.Context(), token, f.now); e != ErrRefused { t.Fatal("failed session replayed") } } func TestStartRefusesAmbiguousIntent(t *testing.T) { f := fresh(t) c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) for _, query := range []string{"?other=1", "?return_to=/&return_to=/documents", "?return_to=//evil.example", "?return_to=/auth/start", "?return_to=%zz"} { rec := httptest.NewRecorder() c.Start(rec, request("GET", StartPath+query, "")) if rec.Code != 400 || len(rec.Result().Cookies()) != 0 { t.Fatal(query) } } r := request("GET", StartPath, "") r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)}) r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)}) rec := httptest.NewRecorder() c.Start(rec, r) if rec.Code != 400 { t.Fatal("duplicate cookie accepted") } }