package connect import ( "amadan.net/oficina/home/handoff" "html/template" "net/http" "net/http/httptest" "strings" "testing" "time" ) func TestRouteGuardExemptsOnlyCallbackAndPreservesPolicy(t *testing.T) { b := &Bridge{render: func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) }} 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 { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { w.WriteHeader(403) return } next.ServeHTTP(w, r) }) }}} called := 0 handler := h.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called++; w.WriteHeader(204) })) for _, tc := range []struct { method, origin, path string dup bool want int }{{"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}} { r := httptest.NewRequest(tc.method, tc.origin+tc.path, nil) if tc.dup { r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "a"}) r.AddCookie(&http.Cookie{Name: h.cfg.SessionCookie, Value: "b"}) } w := httptest.NewRecorder() handler.ServeHTTP(w, r) if w.Code != tc.want { t.Fatalf("%s %s: %d", tc.method, tc.path, w.Code) } if w.Header().Get("Content-Security-Policy") != h.cfg.FormPolicy { t.Fatal("app policy replaced") } } if called != 2 { t.Fatal("guard bypass", called) } localCalls := 0 local := h.LocalGuard(func(w http.ResponseWriter, r *http.Request) { localCalls++ }) r := httptest.NewRequest("GET", h.cfg.Origin+"/auth/verify", nil) r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "a"}) r.AddCookie(&http.Cookie{Name: "__Host-rastrillo_pending", Value: "b"}) w := httptest.NewRecorder() local(w, r) if w.Code != 400 || localCalls != 0 { t.Fatal("ambiguous local pending reached plugin") } } // TestSignoutEndsLocallyAndClearsHandoffPointers: with no issuer (the // standalone shape) sign-out ends here, as it always did. func TestSignoutEndsLocallyAndClearsHandoffPointers(t *testing.T) { revoked := false h := &Routes{cfg: RoutesConfig{SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}} w := httptest.NewRecorder() h.Signout(w, httptest.NewRequest("POST", "https://slides.example.com/signout", nil)) if !revoked || w.Code != 303 || w.Header().Get("Location") != "/signed-out" { t.Fatal("signout restarted Home exchange") } assertSignoutCleared(t, w) } // TestSignoutChainsToTheIssuer is spec shared-chrome §9.5: an app's Sign // out cleared only the app's session, so its "Sign in" passed silently // through the live 30-day id.* session. With Home configured it now ends // the app session and hands the same POST on to {Issuer}/signout, which // ends the id.* session and returns to this origin's /signed-out. // // 307, not 303, so the hop stays a POST: a GET sign-out is one any // can trigger. The Referrer-Policy is set on the 307 itself because the // browser nulls Origin on the redirected POST and Referer, which this // policy makes exactly the app's origin, is the evidence id.* admits it // by. FormPolicy already names the issuer in form-action, which browsers // enforce on a form's redirect target. func TestSignoutChainsToTheIssuer(t *testing.T) { revoked := false h := &Routes{cfg: RoutesConfig{Origin: "https://blue.slides.oficina.works", Issuer: "https://id.oficina.works", SignOut: func(http.ResponseWriter, *http.Request) { revoked = true }}} w := httptest.NewRecorder() h.Signout(w, httptest.NewRequest("POST", "https://blue.slides.oficina.works/signout?next=https://evil.example", nil)) if !revoked { t.Fatal("app session survived") } if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != "https://id.oficina.works/signout" { t.Fatalf("sign-out not chained: %d %q", w.Code, w.Header().Get("Location")) } if w.Header().Get("Referrer-Policy") != "strict-origin-when-cross-origin" { t.Fatal("chained hop may lose its only evidence", w.Header().Get("Referrer-Policy")) } assertSignoutCleared(t, w) if !strings.Contains(FormPolicy(h.cfg.Issuer), "form-action 'self' https://id.oficina.works") { t.Fatal("form-action would block the chained hop") } } func assertSignoutCleared(t *testing.T, w *httptest.ResponseRecorder) { t.Helper() if w.Header().Get("Cache-Control") != "no-store" { t.Fatal("sign-out cacheable") } if len(w.Result().Cookies()) != 6 { t.Fatal("handoff pointers left behind") } for _, c := range w.Result().Cookies() { if c.MaxAge != -1 || !c.Secure || !c.HttpOnly { t.Fatal("cookie not cleared safely") } } } // TestSigninWhenAlreadySignedIn is spec shared-chrome §9.4: GET /signin // with a live session goes straight to the validated return_to, or /, // instead of starting another assertion exchange. Without a session, or // with an explicit local state (sent, err, force), nothing changes. func TestSigninWhenAlreadySignedIn(t *testing.T) { _, n := navigationFixture(t) locals := 0 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) { c, err := r.Cookie("__Host-rastrillo_session") if err != nil || (c.Value != "verified" && c.Value != "guest") { return nil, nil } method := HomeMethod if c.Value == "guest" { method = "guest" } return &Session{Subject: "pr_live", Method: method, At: time.Now()}, nil }}} h.cfg.LocalSignin = func(w http.ResponseWriter, r *http.Request) { locals++; w.WriteHeader(299) } live := &http.Cookie{Name: "__Host-rastrillo_session", Value: "verified"} stale := &http.Cookie{Name: "__Host-rastrillo_session", Value: "revoked"} for _, tc := range []struct { query string cookies []*http.Cookie code int want string }{ {"?return_to=%2Fd%2F42%3Fview%3Dcompact", []*http.Cookie{live}, 303, "/d/42?view=compact"}, {"", []*http.Cookie{live}, 303, "/"}, {"?return_to=https%3A%2F%2Fevil.example%2F", []*http.Cookie{live}, 303, "/"}, {"?return_to=%2F%2Fevil.example", []*http.Cookie{live}, 303, "/"}, {"?return_to=%2Fsignin", []*http.Cookie{live}, 303, "/"}, {"?return_to=%2Fa&return_to=%2Fb", []*http.Cookie{live}, 303, "/"}, // No session, a stale one, or an ambiguous pair: start the exchange. {"?return_to=%2Fd%2F42", nil, 303, handoff.StartPath + "?return_to=%2Fd%2F42"}, {"?return_to=%2Fd%2F42", []*http.Cookie{stale}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"}, {"?return_to=%2Fd%2F42", []*http.Cookie{live, live}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"}, // A Calls guest (an anonymous, per-meeting session) is not signed // in for this purpose: it still starts the exchange, as before, // rather than bouncing to a root that 404s for guests. {"?return_to=%2Fd%2F42", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2Fd%2F42"}, {"", []*http.Cookie{{Name: "__Host-rastrillo_session", Value: "guest"}}, 303, handoff.StartPath + "?return_to=%2F"}, // Explicit local states still reach the local page. {"?force=1", []*http.Cookie{live}, 299, ""}, {"?err=removed", []*http.Cookie{live}, 299, ""}, {"?sent=1", []*http.Cookie{live}, 299, ""}, } { r := httptest.NewRequest("GET", navigationOrigin+"/signin"+tc.query, nil) for _, c := range tc.cookies { r.AddCookie(c) } w := httptest.NewRecorder() h.Signin(w, r) if w.Code != tc.code || w.Header().Get("Location") != tc.want { t.Fatalf("%q with %d cookies: %d %q", tc.query, len(tc.cookies), w.Code, w.Header().Get("Location")) } if tc.code == 303 && w.Header().Get("Cache-Control") != "no-store" { t.Fatalf("%q: cacheable", tc.query) } if tc.code == 303 && !strings.HasPrefix(tc.want, handoff.StartPath) && len(w.Result().Cookies()) != 0 { t.Fatalf("%q: a signed-in redirect set a cookie", tc.query) } } if locals != 3 { t.Fatal("local states", locals) } } func TestStaleSessionStillRefusesAndOffersExplicitLocalRecovery(t *testing.T) { f := newBridgeFixture(t) templates := template.Must(template.ParseFS(Templates(), "templates/*.html")) f.b.render = func(w http.ResponseWriter, r *http.Request, state, label string, status int) { w.WriteHeader(status) if err := templates.ExecuteTemplate(w, "home-connect", Page(state, label)); err != nil { t.Fatal(err) } } token, err := f.sessions.Mint(Session{Subject: "retired-session", Method: "magiclink"}) if err != nil { t.Fatal(err) } delete(f.sessions.values, token) r := bridgeRequest("GET", "/auth/start", []*http.Cookie{{Name: f.sessions.cookie, Value: token}}) w := httptest.NewRecorder() f.b.Start(w, r) if w.Code != 403 || w.Header().Get("Location") != "" { t.Fatal("stale cookie guard relaxed", w.Code) } if !strings.Contains(w.Body.String(), `href="/signin/local"`) || !strings.Contains(w.Body.String(), English["local_action"]) { t.Fatal("no explicit local recovery", w.Body.String()) } if len(w.Result().Cookies()) != 0 || len(f.sessions.values) != 0 { t.Fatal("failure changed session state") } }