oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package connect
2
3import (
4 "amadan.net/oficina/home/handoff"
5 "html/template"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10 "time"
11)
12
13func TestRouteGuardExemptsOnlyCallbackAndPreservesPolicy(t *testing.T) {
14 b := &Bridge{render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }}
15 h := &Routes{bridge: b, cfg: RoutesConfig{Origin: "https://slides.example.com", Issuer: "https://home.example.com", SessionCookie: "__Host-session", FormPolicy: "default-src 'none'; form-action 'self' https://home.example.com", Protect: func(next http.Handler) http.Handler {
16 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17 if r.Method == "POST" {
18 w.WriteHeader(403)
19 return
20 }
21 next.ServeHTTP(w, r)
22 })
23 }}}
24 called := 0
25 handler := h.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called++; w.WriteHeader(204) }))
26 for _, tc := range []struct {
27 method, origin, path string
28 dup bool
29 want int
30 }{{"POST", h.cfg.Origin, handoff.CallbackPath, false, 204}, {"POST", h.cfg.Origin, handoff.CallbackPath + "/", false, 403}, {"POST", h.cfg.Origin, "/account/home", false, 403}, {"GET", "https://wrong.example.com", handoff.CallbackPath, false, 404}, {"GET", h.cfg.Origin, "/", true, 400}, {"GET", h.cfg.Origin, "/", false, 204}} {
31 r := httptest.NewRequest(tc.method, tc.origin+tc.path, nil)
32 if tc.dup {
33 r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "a"})
34 r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "b"})
35 }
36 w := httptest.NewRecorder()
37 handler.ServeHTTP(w, r)
38 if w.Code != tc.want {
39 t.Fatalf("%s %s: %d", tc.method, tc.path, w.Code)
40 }
41 if w.Header().Get("Content-Security-Policy") != h.cfg.FormPolicy {
42 t.Fatal("app policy replaced")
43 }
44 }
45 if called != 2 {
46 t.Fatal("guard bypass", called)
47 }
48 localCalls := 0
49 local := h.LocalGuard(func(w http.ResponseWriter, r *http.Request) { localCalls++ })
50 r := httptest.NewRequest("GET", h.cfg.Origin+"/auth/verify", nil)
51 r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "a"})
52 r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "b"})
53 w := httptest.NewRecorder()
54 local(w, r)
55 if w.Code != 400 || localCalls != 0 {
56 t.Fatal("ambiguous local pending reached plugin")
57 }
58}
59
60// TestSignoutEndsLocallyAndClearsHandoffPointers: with no issuer (the
61// standalone shape) sign-out ends here, as it always did.
62func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) {
63 revoked := false
64 h := &Routes{cfg: RoutesConfig{SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}}
65 w := httptest.NewRecorder()
66 h.Signout(w, httptest.NewRequest("POST", "https://slides.example.com/signout", nil))
67 if !revoked || w.Code != 303 || w.Header().Get("Location") != "/signed-out" {
68 t.Fatal("signout restarted Home exchange")
69 }
70 assertSignoutCleared(t, w)
71}
72
73// TestSignoutChainsToTheIssuer is spec shared-chrome §9.5: an app's Sign
74// out cleared only the app's session, so its "Sign in" passed silently
75// through the live 30-day id.* session. With Home configured it now ends
76// the app session and hands the same POST on to {Issuer}/signout, which
77// ends the id.* session and returns to this origin's /signed-out.
78//
79// 307, not 303, so the hop stays a POST: a GET sign-out is one any <img>
80// can trigger. The Referrer-Policy is set on the 307 itself because the
81// browser nulls Origin on the redirected POST and Referer, which this
82// policy makes exactly the app's origin, is the evidence id.* admits it
83// by. FormPolicy already names the issuer in form-action, which browsers
84// enforce on a form's redirect target.
85func TestSignoutChainsToTheIssuer(t *testing.T) {
86 revoked := false
87 h := &Routes{cfg: RoutesConfig{Origin: "https://blue.slides.oficina.works", Issuer: "https://id.oficina.works", SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}}
88 w := httptest.NewRecorder()
89 h.Signout(w, httptest.NewRequest("POST", "https://blue.slides.oficina.works/signout?next=https://evil.example", nil))
90 if !revoked {
91 t.Fatal("app session survived")
92 }
93 if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "https://id.oficina.works/signout" {
94 t.Fatalf("sign-out not chained: %d %q", w.Code, w.Header().Get("Location"))
95 }
96 if w.Header().Get("Referrer-Policy") != "strict-origin-when-cross-origin" {
97 t.Fatal("chained hop may lose its only evidence", w.Header().Get("Referrer-Policy"))
98 }
99 assertSignoutCleared(t, w)
100 if !strings.Contains(FormPolicy(h.cfg.Issuer), "form-action 'self' https://id.oficina.works") {
101 t.Fatal("form-action would block the chained hop")
102 }
103}
104
105func assertSignoutCleared(t *testing.T, w *httptest.ResponseRecorder) {
106 t.Helper()
107 if w.Header().Get("Cache-Control") != "no-store" {
108 t.Fatal("sign-out cacheable")
109 }
110 if len(w.Result().Cookies()) != 6 {
111 t.Fatal("handoff pointers left behind")
112 }
113 for _, c := range w.Result().Cookies() {
114 if c.MaxAge != -1 || !c.Secure || !c.HttpOnly {
115 t.Fatal("cookie not cleared safely")
116 }
117 }
118}
119
120// TestSigninWhenAlreadySignedIn is spec shared-chrome §9.4: GET /signin
121// with a live session goes straight to the validated return_to, or /,
122// instead of starting another assertion exchange. Without a session, or
123// with an explicit local state (sent, err, force), nothing changes.
124func TestSigninWhenAlreadySignedIn(t *testing.T) {
125 _, n := navigationFixture(t)
126 locals := 0
127 h := &Routes{bridge: &Bridge{render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }}, navigation: n, cfg: RoutesConfig{Origin: navigationOrigin, SessionCookie: "__Host-rastrillo_session", ReadSession: func(r *http.Request) (*Session, error) {
128 c, err := r.Cookie("__Host-rastrillo_session")
129 if err != nil || (c.Value != "verified" && c.Value != "guest") {
130 return nil, nil
131 }
132 method := HomeMethod
133 if c.Value == "guest" {
134 method = "guest"
135 }
136 return &Session{Subject: "pr_live", Method: method, At: time.Now()}, nil
137 }}}
138 h.cfg.LocalSignin = func(w http.ResponseWriter, r *http.Request) { locals++; w.WriteHeader(299) }
139 live := &http.Cookie{Name: "__Host-rastrillo_session", Value: "verified"}
140 stale := &http.Cookie{Name: "__Host-rastrillo_session", Value: "revoked"}
141 for _, tc := range []struct {
142 query string
143 cookies []*http.Cookie
144 code int
145 want string
146 }{
147 {"?return_to=%2Fd%2F42%3Fview%3Dcompact", []*http.Cookie{live}, 303, "/d/42?view=compact"},
148 {"", []*http.Cookie{live}, 303, "/"},
149 {"?return_to=https%3A%2F%2Fevil.example%2F", []*http.Cookie{live}, 303, "/"},
150 {"?return_to=%2F%2Fevil.example", []*http.Cookie{live}, 303, "/"},
151 {"?return_to=%2Fsignin", []*http.Cookie{live}, 303, "/"},
152 {"?return_to=%2Fa&return_to=%2Fb", []*http.Cookie{live}, 303, "/"},
153 // No session, a stale one, or an ambiguous pair: start the exchange.
154 {"?return_to=%2Fd%2F42", nil, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
155 {"?return_to=%2Fd%2F42", []*http.Cookie{stale}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
156 {"?return_to=%2Fd%2F42", []*http.Cookie{live, live}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
157 // A Calls guest (an anonymous, per-meeting session) is not signed
158 // in for this purpose: it still starts the exchange, as before,
159 // rather than bouncing to a root that 404s for guests.
160 {"?return_to=%2Fd%2F42", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"},
161 {"", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2F"},
162 // Explicit local states still reach the local page.
163 {"?force=1", []*http.Cookie{live}, 299, ""},
164 {"?err=removed", []*http.Cookie{live}, 299, ""},
165 {"?sent=1", []*http.Cookie{live}, 299, ""},
166 } {
167 r := httptest.NewRequest("GET", navigationOrigin+"/signin"+tc.query, nil)
168 for _, c := range tc.cookies {
169 r.AddCookie(c)
170 }
171 w := httptest.NewRecorder()
172 h.Signin(w, r)
173 if w.Code != tc.code || w.Header().Get("Location") != tc.want {
174 t.Fatalf("%q with %d cookies: %d %q", tc.query, len(tc.cookies), w.Code, w.Header().Get("Location"))
175 }
176 if tc.code == 303 && w.Header().Get("Cache-Control") != "no-store" {
177 t.Fatalf("%q: cacheable", tc.query)
178 }
179 if tc.code == 303 && !strings.HasPrefix(tc.want, handoff.StartPath) && len(w.Result().Cookies()) != 0 {
180 t.Fatalf("%q: a signed-in redirect set a cookie", tc.query)
181 }
182 }
183 if locals != 3 {
184 t.Fatal("local states", locals)
185 }
186}
187
188func TestStaleSessionStillRefusesAndOffersExplicitLocalRecovery(t *testing.T) {
189 f := newBridgeFixture(t)
190 templates := template.Must(template.ParseFS(Templates(), "templates/*.html"))
191 f.b.render = func(w http.ResponseWriter, r *http.Request, state, label string, status int) {
192 w.WriteHeader(status)
193 if err := templates.ExecuteTemplate(w, "home-connect", Page(state, label)); err != nil {
194 t.Fatal(err)
195 }
196 }
197 token, err := f.sessions.Mint(Session{Subject: "retired-session", Method: "magiclink"})
198 if err != nil {
199 t.Fatal(err)
200 }
201 delete(f.sessions.values, token)
202 r := bridgeRequest("GET", "/auth/start", []*http.Cookie{{Name: f.sessions.cookie, Value: token}})
203 w := httptest.NewRecorder()
204 f.b.Start(w, r)
205 if w.Code != 403 || w.Header().Get("Location") != "" {
206 t.Fatal("stale cookie guard relaxed", w.Code)
207 }
208 if !strings.Contains(w.Body.String(), `href="/signin/local"`) || !strings.Contains(w.Body.String(), English["local_action"]) {
209 t.Fatal("no explicit local recovery", w.Body.String())
210 }
211 if len(w.Result().Cookies()) != 0 || len(f.sessions.values) != 0 {
212 t.Fatal("failure changed session state")
213 }
214}
215