| new file mode 100644 |
| index 0000000..f9a0050 |
| --- /dev/null |
| +++ b/internal/home/signout_test.go |
| @@ -0,0 +1,200 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/sealtest" |
| +) |
| + |
| +// signedInPair walks a full sign-in through both roles and returns the |
| +// credential-origin session and the directory session it produced. |
| +func signedInPair(t *testing.T, p exchangePair) (credential, directory *http.Cookie) { |
| + t.Helper() |
| + credential = signInFully(t, p.credential, sealtest.CanaryAddress) |
| + pending, intent := p.start(t) |
| + token, _ := p.mint(t, intent, credential) |
| + return credential, p.finish(t, token, pending) |
| +} |
| + |
| +// resumeMints reports whether the credential origin would mint an |
| +// assertion for this session without asking anything: the silent |
| +// re-entry that made "Sign out" a lie (polish audit B2). |
| +func resumeMints(t *testing.T, p exchangePair, credential *http.Cookie) bool { |
| + t.Helper() |
| + _, intent := p.start(t) |
| + rec := exchangeRequest(p.credential.App, "GET", Origin+"/authorize/resume", "", nil, intent, credential) |
| + switch { |
| + case rec.Code == 200 && strings.Contains(rec.Body.String(), `name="assertion"`): |
| + return true |
| + case rec.Code == 303 && strings.HasPrefix(rec.Header().Get("Location"), "/signin"): |
| + return false |
| + } |
| + t.Fatalf("resume answered %d %q", rec.Code, rec.Header().Get("Location")) |
| + return false |
| +} |
| + |
| +// signoutHop posts to the credential sign-out with the given evidence |
| +// headers; an empty value leaves that header out. |
| +func signoutHop(p exchangePair, origin, fetchSite, referer string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", Origin+"/signout", nil) |
| + for k, v := range map[string]string{"Origin": origin, "Sec-Fetch-Site": fetchSite, "Referer": referer} { |
| + if v != "" { |
| + r.Header.Set(k, v) |
| + } |
| + } |
| + for _, c := range cookies { |
| + r.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + p.credential.Router().ServeHTTP(rec, r) |
| + return rec |
| +} |
| + |
| +func clearsCookie(rec interface{ Result() *http.Response }, name string) bool { |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Name == name && c.Value == "" && c.MaxAge < 0 { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// TestSignOutEndsTheCredentialSession is B2: signing out of the |
| +// directory must also end the id.* session, so the next Sign in asks for |
| +// the email (or passkey) step instead of bouncing straight back in. |
| +func TestSignOutEndsTheCredentialSession(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential, directory := signedInPair(t, p) |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("precondition: a live credential session mints silently") |
| + } |
| + |
| + // The directory ends its own session, then hands the same POST on to |
| + // the credential origin. 307, not 303: the method has to survive so |
| + // the credential origin receives a POST it can origin-check, rather |
| + // than a GET anyone could link to. |
| + out := exchangeRequest(p.directory, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}, directory) |
| + if out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != Origin+"/signout" { |
| + t.Fatalf("directory sign-out: %d %q", out.Code, out.Header().Get("Location")) |
| + } |
| + if !clearsCookie(out, directory.Name) { |
| + t.Fatal("directory sign-out left its cookie") |
| + } |
| + if page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directory); strings.Contains(page.Body.String(), "You are signed in.") { |
| + t.Fatal("directory session survived sign-out") |
| + } |
| + |
| + // The browser re-sends the POST to id.*. These are the headers |
| + // Chromium really sends on that hop (captured live, polish-audit B2): |
| + // the 307 nulls Origin, so the directory is named by Referer alone. |
| + end := signoutHop(p, "null", "same-site", directoryOrigin+"/", credential) |
| + if end.Code != http.StatusSeeOther || end.Header().Get("Location") != directoryOrigin+"/signed-out" { |
| + t.Fatalf("credential sign-out: %d %q", end.Code, end.Header().Get("Location")) |
| + } |
| + if !clearsCookie(end, credential.Name) { |
| + t.Fatal("credential sign-out left its cookie") |
| + } |
| + if end.Header().Get("Cache-Control") != "no-store" { |
| + t.Fatal("sign-out response cacheable") |
| + } |
| + |
| + // The cookie is gone from the browser, but a copy of it must not work |
| + // either: the row is revoked, not merely forgotten. |
| + if resumeMints(t, p, credential) { |
| + t.Fatal("credential session survived sign-out: Sign in re-enters without a prompt") |
| + } |
| + var rows int |
| + if err := p.credential.db.Writer().QueryRow(`SELECT count(*) FROM sessions`).Scan(&rows); err != nil || rows != 0 { |
| + t.Fatal("credential session row not revoked", rows, err) |
| + } |
| +} |
| + |
| +// TestCredentialSignOutRefusesOtherOrigins: the credential sign-out is |
| +// outside csrf.Protect (the directory's hand-off arrives same-site, not |
| +// same-origin), so it carries its own exact origin check. Anything other |
| +// than the directory or the credential origin itself is refused and the |
| +// session survives, and a GET does nothing at all. |
| +func TestCredentialSignOutRefusesOtherOrigins(t *testing.T) { |
| + p := newExchangePair(t) |
| + credential, _ := signedInPair(t, p) |
| + for _, c := range []struct{ origin, site, referer string }{ |
| + {"", "", ""}, |
| + {"null", "", ""}, |
| + {"null", "same-site", ""}, |
| + {"https://evil.invalid", "cross-site", directoryOrigin + "/"}, |
| + {"https://blue.docs.oficina.works", "same-site", ""}, |
| + {"https://blue.docs.oficina.works", "same-site", directoryOrigin + "/"}, |
| + {"http://oficina.works", "cross-site", ""}, |
| + {"https://oficina.works.evil.invalid", "cross-site", ""}, |
| + {"null", "same-site", "https://blue.docs.oficina.works/"}, |
| + {"null", "cross-site", directoryOrigin + "/"}, |
| + {"null", "same-site", "http://oficina.works/"}, |
| + {"null", "same-site", "https://oficina.works.evil.invalid/"}, |
| + {"null", "same-site", "https://user@oficina.works/"}, |
| + {"", "same-site", "https://evil.invalid/"}, |
| + } { |
| + rec := signoutHop(p, c.origin, c.site, c.referer, credential) |
| + if rec.Code != http.StatusForbidden || clearsCookie(rec, credential.Name) { |
| + t.Fatalf("%+v: %d", c, rec.Code) |
| + } |
| + } |
| + // Both shapes of the directory's own hand-off pass: Origin named (the |
| + // Fetch spec's reading), or nulled with Referer (Chromium's). |
| + for _, c := range []struct{ origin, site, referer string }{ |
| + {directoryOrigin, "same-site", ""}, |
| + {"null", "same-site", directoryOrigin + "/"}, |
| + {"", "", directoryOrigin + "/"}, |
| + } { |
| + fresh, _ := signedInPair(t, p) |
| + if rec := signoutHop(p, c.origin, c.site, c.referer, fresh); rec.Code != http.StatusSeeOther || resumeMints(t, p, fresh) { |
| + t.Fatalf("directory hand-off %+v refused or left the session: %d", c, rec.Code) |
| + } |
| + } |
| + if rec := exchangeRequest(p.credential.App, "GET", Origin+"/signout", "", nil, credential); rec.Code/100 == 3 || clearsCookie(rec, credential.Name) { |
| + t.Fatal("GET signed out", rec.Code) |
| + } |
| + if !resumeMints(t, p, credential) { |
| + t.Fatal("refused sign-out still ended the session") |
| + } |
| + // Its own origin may sign out too, for a future button on id.* pages. |
| + if rec := exchangeRequest(p.credential.App, "POST", Origin+"/signout", Origin, url.Values{}, credential); rec.Code != http.StatusSeeOther { |
| + t.Fatal("same-origin sign-out refused", rec.Code) |
| + } |
| + if resumeMints(t, p, credential) { |
| + t.Fatal("same-origin sign-out left the session") |
| + } |
| + // Neither role answers the other's host: the credential sign-out is |
| + // id.*-only, and the directory's is the directory's. |
| + if rec := exchangeRequest(p.directory, "POST", Origin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| + t.Fatal("directory served credential sign-out", rec.Code) |
| + } |
| + if rec := exchangeRequest(p.credential.App, "POST", directoryOrigin+"/signout", directoryOrigin, url.Values{}); rec.Code != 404 { |
| + t.Fatal("credential served directory sign-out", rec.Code) |
| + } |
| +} |
| + |
| +// TestDirectoryPolicyAdmitsTheSignOutHandOff: CSP form-action is enforced |
| +// across a form's redirects, so without the credential origin in the |
| +// directory's form-action the 307 is blocked in the browser and B2 comes |
| +// back with nothing on screen but a console error. |
| +func TestDirectoryPolicyAdmitsTheSignOutHandOff(t *testing.T) { |
| + p := newExchangePair(t) |
| + policy := p.directory.SecurityPolicy() |
| + var formAction string |
| + for _, d := range strings.Split(policy, ";") { |
| + if d = strings.TrimSpace(d); strings.HasPrefix(d, "form-action ") { |
| + formAction = d |
| + } |
| + } |
| + found := false |
| + for _, source := range strings.Fields(formAction) { |
| + found = found || source == Origin |
| + } |
| + if !found { |
| + t.Fatalf("directory form-action omits %s: %q", Origin, formAction) |
| + } |
| +} |