oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package handoff
2
3import (
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
15func 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}
23func 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}
31func 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}
41func 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}
93func 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}
125func 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}
149func 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}
168func 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