| 1 | package connect |
| 2 | |
| 3 | import ( |
| 4 | "amadan.net/oficina/home/handoff" |
| 5 | "html/template" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func 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. |
| 62 | func 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. |
| 85 | func 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 | |
| 105 | func 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. |
| 124 | func 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 | |
| 188 | func 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 | |