| 1 | package handoff |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "amadan.net/rastrillo/rastrillo/assertion" |
| 13 | ) |
| 14 | |
| 15 | func newConsumer(t *testing.T, f fixture, done func(http.ResponseWriter, *http.Request, assertion.Claims) error) *Consumer { |
| 16 | t.Helper() |
| 17 | c, e := NewConsumer(ConsumerConfig{Store: f.s, ConsumerID: "directory", Now: func() time.Time { return f.now }, Complete: done}) |
| 18 | if e != nil { |
| 19 | t.Fatal(e) |
| 20 | } |
| 21 | return c |
| 22 | } |
| 23 | func request(method, path, body string) *http.Request { |
| 24 | r := httptest.NewRequest(method, "https://app.example.com"+path, strings.NewReader(body)) |
| 25 | if method == "POST" { |
| 26 | r.Header.Set("Origin", "https://id.example.com") |
| 27 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 28 | } |
| 29 | return r |
| 30 | } |
| 31 | func matchingCookie(t *testing.T, r *httptest.ResponseRecorder, name string) *http.Cookie { |
| 32 | t.Helper() |
| 33 | for _, c := range r.Result().Cookies() { |
| 34 | if c.Name == name { |
| 35 | return c |
| 36 | } |
| 37 | } |
| 38 | t.Fatal("cookie missing", name) |
| 39 | return nil |
| 40 | } |
| 41 | func TestHTTPExchangeWithoutCallbackCookie(t *testing.T) { |
| 42 | f := fresh(t) |
| 43 | completed := 0 |
| 44 | c := newConsumer(t, f, func(w http.ResponseWriter, r *http.Request, a assertion.Claims) error { |
| 45 | completed++ |
| 46 | if a.Subject != "person_123" || a.AuthTime != f.now.Unix()-900 { |
| 47 | t.Fatal("identity/freshness lost") |
| 48 | } |
| 49 | return nil |
| 50 | }) |
| 51 | start := httptest.NewRecorder() |
| 52 | c.Start(start, request("GET", StartPath+"?return_to=%2Fdocuments%2Fx%3Fteam%3Dblue", "")) |
| 53 | if start.Code != 303 { |
| 54 | t.Fatal(start.Code) |
| 55 | } |
| 56 | target, e := url.Parse(start.Header().Get("Location")) |
| 57 | if e != nil { |
| 58 | t.Fatal(e) |
| 59 | } |
| 60 | if target.Host != "id.example.com" || target.Path != "/authorize" || target.Query().Get("consumer") != "directory" { |
| 61 | t.Fatal("bad issuer target") |
| 62 | } |
| 63 | secret := matchingCookie(t, start, pendingCookie) |
| 64 | token := f.token(t, target.Query().Get("nonce")) |
| 65 | callback := httptest.NewRecorder() |
| 66 | c.Callback(callback, request("POST", CallbackPath, url.Values{"assertion": {token}}.Encode())) |
| 67 | if callback.Code != 303 || completed != 0 { |
| 68 | t.Fatal("callback minted session") |
| 69 | } |
| 70 | handle := matchingCookie(t, callback, inboxCookie) |
| 71 | for _, cookie := range []*http.Cookie{secret, handle} { |
| 72 | if !cookie.Secure || !cookie.HttpOnly || cookie.Path != "/" || cookie.Domain != "" || cookie.SameSite != http.SameSiteLaxMode || !strings.HasPrefix(cookie.Name, "__Host-") { |
| 73 | t.Fatal("cookie isolation lost") |
| 74 | } |
| 75 | } |
| 76 | if strings.Contains(callback.Header().Get("Location"), token) || callback.Header().Get("Location") != FinishPath { |
| 77 | t.Fatal("token in navigation") |
| 78 | } |
| 79 | finish := httptest.NewRecorder() |
| 80 | r := request("GET", FinishPath, "") |
| 81 | r.AddCookie(secret) |
| 82 | r.AddCookie(handle) |
| 83 | c.Finish(finish, r) |
| 84 | if completed != 1 || finish.Code != 303 || finish.Header().Get("Location") != "/documents/x?team=blue" { |
| 85 | t.Fatal("finish failed") |
| 86 | } |
| 87 | for _, rec := range []*httptest.ResponseRecorder{start, callback, finish} { |
| 88 | if rec.Header().Get("Cache-Control") != "no-store" { |
| 89 | t.Fatal("cacheable auth response") |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | func TestCallbackRefusalsNeverSetCookies(t *testing.T) { |
| 94 | f := fresh(t) |
| 95 | _, nonce := f.pending(t) |
| 96 | token := f.token(t, nonce) |
| 97 | body := url.Values{"assertion": {token}}.Encode() |
| 98 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { |
| 99 | t.Fatal("invalid callback completed") |
| 100 | return nil |
| 101 | }) |
| 102 | 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) { |
| 103 | r.Header.Set("Origin", "https://evil.example.com") |
| 104 | r.Header.Set("Sec-Fetch-Site", "same-site") |
| 105 | }, "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" }} |
| 106 | for name, change := range cases { |
| 107 | t.Run(name, func(t *testing.T) { |
| 108 | r := request("POST", CallbackPath, body) |
| 109 | change(r) |
| 110 | rec := httptest.NewRecorder() |
| 111 | c.Callback(rec, r) |
| 112 | if rec.Code < 400 || len(rec.Result().Cookies()) != 0 || count(t, f.db, "oficina_handoff_inbox") != 0 { |
| 113 | t.Fatal("callback accepted") |
| 114 | } |
| 115 | }) |
| 116 | } |
| 117 | for _, bad := range []string{body + "&" + body, body + "&extra=1", strings.Repeat("x", 12*1024+1), "assertion=%zz", ""} { |
| 118 | rec := httptest.NewRecorder() |
| 119 | c.Callback(rec, request("POST", CallbackPath, bad)) |
| 120 | if rec.Code != 400 || len(rec.Result().Cookies()) != 0 { |
| 121 | t.Fatal("bad body accepted") |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | func TestFinishRefusesMissingDuplicateOrWrongBrowserCookies(t *testing.T) { |
| 126 | f := fresh(t) |
| 127 | secret, nonce := f.pending(t) |
| 128 | handle, _ := f.received(t, nonce) |
| 129 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { |
| 130 | t.Fatal("wrong browser completed") |
| 131 | return nil |
| 132 | }) |
| 133 | other, _, e := f.s.Start(t.Context(), "/", "", f.now) |
| 134 | if e != nil { |
| 135 | t.Fatal(e) |
| 136 | } |
| 137 | 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}}} { |
| 138 | r := request("GET", FinishPath, "") |
| 139 | for _, cookie := range cookies { |
| 140 | r.AddCookie(cookie) |
| 141 | } |
| 142 | rec := httptest.NewRecorder() |
| 143 | c.Finish(rec, r) |
| 144 | if rec.Code != 400 { |
| 145 | t.Fatal("bad finish accepted") |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | func TestSessionFailureKeepsAssertionConsumed(t *testing.T) { |
| 150 | f := fresh(t) |
| 151 | secret, nonce := f.pending(t) |
| 152 | handle, token := f.received(t, nonce) |
| 153 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { |
| 154 | return errors.New("synthetic failure") |
| 155 | }) |
| 156 | r := request("GET", FinishPath, "") |
| 157 | r.AddCookie(&http.Cookie{Name: pendingCookie, Value: secret}) |
| 158 | r.AddCookie(&http.Cookie{Name: inboxCookie, Value: handle}) |
| 159 | rec := httptest.NewRecorder() |
| 160 | c.Finish(rec, r) |
| 161 | if rec.Code != 503 || count(t, f.db, "oficina_handoff_replay") != 1 { |
| 162 | t.Fatal("failed session unconsumed assertion") |
| 163 | } |
| 164 | if _, e := f.s.Receive(t.Context(), token, f.now); e != ErrRefused { |
| 165 | t.Fatal("failed session replayed") |
| 166 | } |
| 167 | } |
| 168 | func TestStartRefusesAmbiguousIntent(t *testing.T) { |
| 169 | f := fresh(t) |
| 170 | c := newConsumer(t, f, func(http.ResponseWriter, *http.Request, assertion.Claims) error { return nil }) |
| 171 | for _, query := range []string{"?other=1", "?return_to=/&return_to=/documents", "?return_to=//evil.example", "?return_to=/auth/start", "?return_to=%zz"} { |
| 172 | rec := httptest.NewRecorder() |
| 173 | c.Start(rec, request("GET", StartPath+query, "")) |
| 174 | if rec.Code != 400 || len(rec.Result().Cookies()) != 0 { |
| 175 | t.Fatal(query) |
| 176 | } |
| 177 | } |
| 178 | r := request("GET", StartPath, "") |
| 179 | r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)}) |
| 180 | r.AddCookie(&http.Cookie{Name: pendingCookie, Value: strings.Repeat("A", 43)}) |
| 181 | rec := httptest.NewRecorder() |
| 182 | c.Start(rec, r) |
| 183 | if rec.Code != 400 { |
| 184 | t.Fatal("duplicate cookie accepted") |
| 185 | } |
| 186 | } |
| 187 | |