| 1 | package docs |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/sha256" |
| 6 | "encoding/base64" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "reflect" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "amadan.net/rastrillo/rastrillo/assertion" |
| 18 | assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| 19 | "amadan.net/rastrillo/rastrillo/sessions" |
| 20 | ) |
| 21 | |
| 22 | type routerHomeFixture struct { |
| 23 | app *App |
| 24 | key *assertioncrypto.Keypair |
| 25 | keyID string |
| 26 | sessions *sessions.Sessions |
| 27 | } |
| 28 | type routerBrowser struct { |
| 29 | f *routerHomeFixture |
| 30 | cookies map[string]*http.Cookie |
| 31 | } |
| 32 | |
| 33 | func newRouterHomeFixture(t *testing.T) *routerHomeFixture { |
| 34 | t.Helper() |
| 35 | key, err := assertioncrypto.Generate() |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | digest := sha256.Sum256(key.SignPub()) |
| 40 | kid := hex.EncodeToString(digest[:]) |
| 41 | manifest, err := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(key.SignPub())}) |
| 42 | if err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | cfg := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))} |
| 46 | app, err := NewForTestWith(t, Config{Origin: bridgeOrigin, Home: &cfg, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}}) |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | sess, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin}) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | return &routerHomeFixture{app: app, key: key, keyID: kid, sessions: sess} |
| 55 | } |
| 56 | func (f *routerHomeFixture) browser() *routerBrowser { |
| 57 | return &routerBrowser{f: f, cookies: map[string]*http.Cookie{}} |
| 58 | } |
| 59 | func (b *routerBrowser) request(method, path, body string) *http.Request { |
| 60 | r := httptest.NewRequest(method, bridgeOrigin+path, strings.NewReader(body)) |
| 61 | for _, c := range b.cookies { |
| 62 | r.AddCookie(c) |
| 63 | } |
| 64 | if method == "POST" { |
| 65 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 66 | r.Header.Set("Origin", bridgeOrigin) |
| 67 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 68 | } |
| 69 | return r |
| 70 | } |
| 71 | func (b *routerBrowser) serve(r *http.Request) *httptest.ResponseRecorder { |
| 72 | rec := httptest.NewRecorder() |
| 73 | var w http.ResponseWriter = rec |
| 74 | if b.f.app.store.team != nil { |
| 75 | w = &teamDeadlineRecorder{ResponseRecorder: rec} |
| 76 | } |
| 77 | b.f.app.Handler().ServeHTTP(w, r) |
| 78 | for _, c := range rec.Result().Cookies() { |
| 79 | if c.MaxAge < 0 { |
| 80 | delete(b.cookies, c.Name) |
| 81 | } else { |
| 82 | b.cookies[c.Name] = c |
| 83 | } |
| 84 | } |
| 85 | return rec |
| 86 | } |
| 87 | func (b *routerBrowser) do(method, path, body string) *httptest.ResponseRecorder { |
| 88 | return b.serve(b.request(method, path, body)) |
| 89 | } |
| 90 | func routerRedirect(t *testing.T, rec *httptest.ResponseRecorder, want string) string { |
| 91 | t.Helper() |
| 92 | location := rec.Header().Get("Location") |
| 93 | if rec.Code != 303 || (want != "" && location != want) { |
| 94 | t.Fatalf("router redirect status=%d target=%q want=%q", rec.Code, location, want) |
| 95 | } |
| 96 | return location |
| 97 | } |
| 98 | func (b *routerBrowser) localLink(t *testing.T, address string) string { |
| 99 | t.Helper() |
| 100 | b.f.app.signinMail.wait() |
| 101 | b.f.app.mailRecorder.mu.Lock() |
| 102 | before := b.f.app.mailRecorder.sends |
| 103 | b.f.app.mailRecorder.mu.Unlock() |
| 104 | rec := b.do("POST", "/signin", url.Values{"address": {address}, "force": {"1"}}.Encode()) |
| 105 | if rec.Code != 303 || !strings.Contains(rec.Header().Get("Location"), "sent=1") { |
| 106 | t.Fatal("local mail begin failed", rec.Code) |
| 107 | } |
| 108 | b.f.app.signinMail.wait() // the link is sent after the response |
| 109 | b.f.app.mailRecorder.mu.Lock() |
| 110 | to, body, after := b.f.app.mailRecorder.lastTo, b.f.app.mailRecorder.lastBody, b.f.app.mailRecorder.sends |
| 111 | b.f.app.mailRecorder.mu.Unlock() |
| 112 | if after != before+1 || to != address { |
| 113 | t.Fatal("actual local mail not captured") |
| 114 | } |
| 115 | link := magicLinkPattern.FindString(body) |
| 116 | path, err := linkPath(link) |
| 117 | if err != nil || path == "" { |
| 118 | t.Fatal("captured verification link missing") |
| 119 | } |
| 120 | return path |
| 121 | } |
| 122 | |
| 123 | // localSignin completes a local email sign-in. It lands on the list, or |
| 124 | // on landing when the browser was sent to sign in from a page (spec |
| 125 | // §9.4: the remembered return path, not "/"). |
| 126 | func (b *routerBrowser) localSignin(t *testing.T, address string, landing ...string) { |
| 127 | t.Helper() |
| 128 | want := "/" |
| 129 | if len(landing) == 1 { |
| 130 | want = landing[0] |
| 131 | } |
| 132 | path := b.localLink(t, address) |
| 133 | verifyPath, verifyForm := confirmForm(path) |
| 134 | rec := b.do("POST", verifyPath, verifyForm) |
| 135 | routerRedirect(t, rec, localLinkCompletePath) |
| 136 | routerRedirect(t, b.do("GET", localLinkCompletePath, ""), want) |
| 137 | if _, ok := b.cookies[b.f.sessions.CookieName()]; !ok { |
| 138 | t.Fatal("local verification did not set real session") |
| 139 | } |
| 140 | } |
| 141 | func (b *routerBrowser) start(t *testing.T, path, maxAge string) string { |
| 142 | t.Helper() |
| 143 | rec := b.do("GET", path, "") |
| 144 | target, err := url.Parse(routerRedirect(t, rec, "")) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if target.Scheme+"://"+target.Host != bridgeIssuer || target.Path != "/authorize" || target.Query().Get("consumer") != "router-docs" || target.Query().Get("max_age") != maxAge || target.Query().Get("nonce") == "" { |
| 149 | t.Fatal("router selected wrong issuer/freshness") |
| 150 | } |
| 151 | return target.Query().Get("nonce") |
| 152 | } |
| 153 | func (f *routerHomeFixture) token(t *testing.T, nonce, subject string, auth time.Time, change func(*assertion.Claims)) string { |
| 154 | t.Helper() |
| 155 | id, err := assertion.RandomID() |
| 156 | if err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | now := time.Now().Unix() |
| 160 | c := assertion.Claims{Version: 1, Issuer: bridgeIssuer, Audience: bridgeOrigin, Subject: subject, KeyID: f.keyID, ID: id, Nonce: nonce, IssuedAt: now, ExpiresAt: now + 120, AuthTime: auth.Unix()} |
| 161 | if change != nil { |
| 162 | change(&c) |
| 163 | } |
| 164 | token, err := assertion.Sign(f.key, c) |
| 165 | if err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | return token |
| 169 | } |
| 170 | func (b *routerBrowser) callback(token, origin string) *httptest.ResponseRecorder { |
| 171 | // The issuer POST deliberately carries no browser cookies, as with Lax cookies. |
| 172 | r := httptest.NewRequest("POST", bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| 173 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 174 | r.Header.Set("Origin", origin) |
| 175 | r.Header.Set("Sec-Fetch-Site", "cross-site") |
| 176 | return b.serve(r) |
| 177 | } |
| 178 | func (b *routerBrowser) exchange(t *testing.T, nonce, subject string, auth time.Time) *httptest.ResponseRecorder { |
| 179 | t.Helper() |
| 180 | routerRedirect(t, b.callback(b.f.token(t, nonce, subject, auth, nil), bridgeIssuer), "/auth/finish") |
| 181 | return b.do("GET", "/auth/finish", "") |
| 182 | } |
| 183 | func (b *routerBrowser) linkProof(t *testing.T, subject, address string) { |
| 184 | t.Helper() |
| 185 | page := b.do("GET", "/account/home", "") |
| 186 | if page.Code != 200 || !strings.Contains(page.Body.String(), address) { |
| 187 | t.Fatal("explicit initiation page did not identify local account", page.Code) |
| 188 | } |
| 189 | next := routerRedirect(t, b.do("POST", "/account/home", ""), "") |
| 190 | nonce := b.start(t, next, "300") |
| 191 | routerRedirect(t, b.exchange(t, nonce, subject, time.Now().Add(-time.Minute)), "/account/home/confirm") |
| 192 | confirm := b.do("GET", "/account/home/confirm", "") |
| 193 | if confirm.Code != 200 || !strings.Contains(confirm.Body.String(), address) { |
| 194 | t.Fatal("confirmation did not identify local account", confirm.Code) |
| 195 | } |
| 196 | } |
| 197 | func routerRows(t *testing.T, f *routerHomeFixture, table string) int { |
| 198 | t.Helper() |
| 199 | var n int |
| 200 | if err := f.app.db.Writer().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | return n |
| 204 | } |
| 205 | |
| 206 | func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *testing.T) { |
| 207 | f := newRouterHomeFixture(t) |
| 208 | s := f.app.store |
| 209 | ctx := t.Context() |
| 210 | alice, err := s.EnsurePerson(ctx, "alice@example.com") |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | bob, err := s.EnsurePerson(ctx, "bob@example.com") |
| 215 | if err != nil { |
| 216 | t.Fatal(err) |
| 217 | } |
| 218 | own, err := s.Create(ctx, alice, "Alice original") |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | shared, err := s.Create(ctx, bob, "Bob shared") |
| 223 | if err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | hidden, err := s.Create(ctx, bob, "Bob private") |
| 227 | if err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if err = s.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | for _, doc := range []*Document{own, shared} { |
| 234 | owner := alice |
| 235 | address := "alice@example.com" |
| 236 | if doc.ID == shared.ID { |
| 237 | owner = bob |
| 238 | address = "bob@example.com" |
| 239 | } |
| 240 | if _, err = s.ApplyUpdate(ctx, doc, localActor(owner, address), ReadFixture(t, "hello")); err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | } |
| 244 | var originalDocs []Document |
| 245 | var originalUpdates []DocUpdate |
| 246 | var originalGrants []Grant |
| 247 | if err = s.db.Order("id").Find(&originalDocs).Error; err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | if err = s.db.Order("id").Find(&originalUpdates).Error; err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if err = s.db.Order("id").Find(&originalGrants).Error; err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | b := f.browser() |
| 257 | resource := "/d/" + own.Sqid |
| 258 | signin := routerRedirect(t, b.do("GET", resource, ""), "") |
| 259 | parsed, err := url.Parse(signin) |
| 260 | if err != nil || parsed.Path != "/signin" || parsed.Query().Get("return_to") != resource { |
| 261 | t.Fatal("unsigned document lost original destination") |
| 262 | } |
| 263 | start := routerRedirect(t, b.do("GET", signin, ""), "") |
| 264 | nonce := b.start(t, start, "") |
| 265 | unknown := b.exchange(t, nonce, "opaque-alice", time.Now().Add(-time.Hour)) |
| 266 | if unknown.Code != 403 || !strings.Contains(unknown.Body.String(), "/signin/local") { |
| 267 | t.Fatal("unknown Home subject did not show local guidance", unknown.Code) |
| 268 | } |
| 269 | if routerRows(t, f, "people") != 2 || routerRows(t, f, "documents") != 3 || routerRows(t, f, "docs_identity_links") != 0 || b.cookies[f.sessions.CookieName()] != nil { |
| 270 | t.Fatal("unknown Home subject created an account or session") |
| 271 | } |
| 272 | // The local link lands on the document this browser was sent to sign |
| 273 | // in from, not the list (spec §9.4). |
| 274 | b.localSignin(t, "alice@example.com", resource) |
| 275 | localCookie := b.cookies[f.sessions.CookieName()].Value |
| 276 | b.linkProof(t, "opaque-alice", "alice@example.com") |
| 277 | if b.cookies[f.sessions.CookieName()].Value != localCookie || routerRows(t, f, "docs_identity_links") != 0 { |
| 278 | t.Fatal("proof replaced local session or linked before confirmation") |
| 279 | } |
| 280 | routerRedirect(t, b.do("POST", "/account/home/confirm", ""), resource) |
| 281 | if b.cookies[f.sessions.CookieName()].Value != localCookie || routerRows(t, f, "docs_identity_links") != 1 { |
| 282 | t.Fatal("confirmation failed local session retention") |
| 283 | } |
| 284 | if rec := b.do("GET", resource, ""); rec.Code != 200 { |
| 285 | t.Fatal("linked local account cannot read original document", rec.Code) |
| 286 | } |
| 287 | signoutChained(t, b.do("POST", "/signout", "")) |
| 288 | signedOut := b.do("GET", "/signed-out", "") |
| 289 | if signedOut.Code != http.StatusOK || signedOut.Header().Get("Location") != "" || !strings.Contains(signedOut.Body.String(), `href="/signin"`) { |
| 290 | t.Fatal("signout automatically resumed Home authentication") |
| 291 | } |
| 292 | if b.cookies[f.sessions.CookieName()] != nil { |
| 293 | t.Fatal("actual signout retained session cookie") |
| 294 | } |
| 295 | oldRequest := httptest.NewRequest("GET", bridgeOrigin+resource, nil) |
| 296 | oldRequest.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: localCookie}) |
| 297 | oldRec := httptest.NewRecorder() |
| 298 | f.app.Handler().ServeHTTP(oldRec, oldRequest) |
| 299 | if oldRec.Code != 303 { |
| 300 | t.Fatal("actual signout did not revoke old session") |
| 301 | } |
| 302 | signin = routerRedirect(t, b.do("GET", resource, ""), "") |
| 303 | start = routerRedirect(t, b.do("GET", signin, ""), "") |
| 304 | nonce = b.start(t, start, "") |
| 305 | originalAuth := time.Now().Add(-2 * time.Hour).Truncate(time.Second) |
| 306 | routerRedirect(t, b.exchange(t, nonce, "opaque-alice", originalAuth), resource) |
| 307 | session, ok := f.sessions.From(b.request("GET", resource, "")) |
| 308 | if !ok || session.Method != homeAssertionMethod || session.Subject != "opaque-alice" || !session.AuthTime.Equal(originalAuth) { |
| 309 | t.Fatal("ordinary Home session lost actual authentication time") |
| 310 | } |
| 311 | for _, doc := range []*Document{own, shared} { |
| 312 | rec := b.do("GET", "/d/"+doc.Sqid, "") |
| 313 | if rec.Code != 200 { |
| 314 | t.Fatal("Home session lost owned/shared access", rec.Code) |
| 315 | } |
| 316 | } |
| 317 | if rec := b.do("GET", "/d/"+hidden.Sqid, ""); rec.Code != 404 { |
| 318 | t.Fatal("Home link widened another owner's private access", rec.Code) |
| 319 | } |
| 320 | var afterDocs []Document |
| 321 | var afterUpdates []DocUpdate |
| 322 | var afterGrants []Grant |
| 323 | if err = s.db.Order("id").Find(&afterDocs).Error; err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | if err = s.db.Order("id").Find(&afterUpdates).Error; err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | if err = s.db.Order("id").Find(&afterGrants).Error; err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | if !reflect.DeepEqual(originalDocs, afterDocs) || !reflect.DeepEqual(originalUpdates, afterUpdates) || !reflect.DeepEqual(originalGrants, afterGrants) || routerRows(t, f, "people") != 2 { |
| 333 | t.Fatal("account link changed owners/grants/content encoding or bytes") |
| 334 | } |
| 335 | // A different current local account cannot be silently replaced by Alice's SSO. |
| 336 | other := f.browser() |
| 337 | other.localSignin(t, "bob@example.com") |
| 338 | before := other.cookies[f.sessions.CookieName()].Value |
| 339 | nonce = other.start(t, "/auth/start?return_to=%2F", "") |
| 340 | if rec := other.exchange(t, nonce, "opaque-alice", time.Now()); rec.Code != 403 { |
| 341 | t.Fatal("different local account was replaced", rec.Code) |
| 342 | } |
| 343 | if other.cookies[f.sessions.CookieName()].Value != before { |
| 344 | t.Fatal("conflict rotated local account") |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | func TestHomeRouterCSRFGuardsBothLinkMutations(t *testing.T) { |
| 349 | f := newRouterHomeFixture(t) |
| 350 | b := f.browser() |
| 351 | b.localSignin(t, "alice@example.com") |
| 352 | if rec := b.do("GET", "/account/home", ""); rec.Code != 200 { |
| 353 | t.Fatal("missing local initiation page") |
| 354 | } |
| 355 | for _, fetch := range []string{"cross-site", ""} { |
| 356 | r := b.request("POST", "/account/home", "") |
| 357 | r.Header.Set("Origin", "https://evil.example") |
| 358 | r.Header.Set("Sec-Fetch-Site", fetch) |
| 359 | rec := b.serve(r) |
| 360 | if rec.Code != 403 || routerRows(t, f, "docs_link_flows") != 0 { |
| 361 | t.Fatal("actual router permitted foreign link initiation", rec.Code) |
| 362 | } |
| 363 | } |
| 364 | b.linkProof(t, "opaque-alice", "alice@example.com") |
| 365 | for _, fetch := range []string{"cross-site", ""} { |
| 366 | r := b.request("POST", "/account/home/confirm", "") |
| 367 | r.Header.Set("Origin", "https://evil.example") |
| 368 | r.Header.Set("Sec-Fetch-Site", fetch) |
| 369 | rec := b.serve(r) |
| 370 | if rec.Code != 403 || routerRows(t, f, "docs_identity_links") != 0 || routerRows(t, f, "docs_link_flows") != 1 { |
| 371 | t.Fatal("actual router permitted foreign confirmation or consumed valid proof", rec.Code) |
| 372 | } |
| 373 | } |
| 374 | routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/") |
| 375 | if routerRows(t, f, "docs_identity_links") != 1 { |
| 376 | t.Fatal("valid same-origin confirmation failed after refusal") |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | func TestHomeRouterRejectsDuplicateCookiesBeforeActualEmailVerification(t *testing.T) { |
| 381 | f := newRouterHomeFixture(t) |
| 382 | b := f.browser() |
| 383 | b.localSignin(t, "alice@example.com") |
| 384 | old := b.cookies[f.sessions.CookieName()] |
| 385 | path := b.localLink(t, "alice@example.com") |
| 386 | for _, name := range []string{f.sessions.CookieName(), "__Host-rastrillo_pending"} { |
| 387 | verifyPath, verifyForm := confirmForm(path) |
| 388 | r := b.request("POST", verifyPath, verifyForm) |
| 389 | if name == f.sessions.CookieName() { |
| 390 | r.AddCookie(old) |
| 391 | } else { |
| 392 | r.AddCookie(&http.Cookie{Name: name, Value: "one"}) |
| 393 | r.AddCookie(&http.Cookie{Name: name, Value: "two"}) |
| 394 | } |
| 395 | rec := b.serve(r) |
| 396 | if rec.Code != 400 { |
| 397 | t.Fatal("duplicate cookie reached actual Verify", name, rec.Code) |
| 398 | } |
| 399 | if b.cookies[f.sessions.CookieName()].Value != old.Value { |
| 400 | t.Fatal("refused duplicate rotated browser session") |
| 401 | } |
| 402 | if _, ok := f.sessions.From(b.request("GET", "/", "")); !ok { |
| 403 | t.Fatal("refused duplicate revoked actual session") |
| 404 | } |
| 405 | } |
| 406 | // Both refusals happened before CompleteMagicLink consumed the token. |
| 407 | verifyPath, verifyForm := confirmForm(path) |
| 408 | routerRedirect(t, b.do("POST", verifyPath, verifyForm), localLinkCompletePath) |
| 409 | if b.cookies[f.sessions.CookieName()].Value == old.Value { |
| 410 | t.Fatal("clean real Verify did not rotate cookie") |
| 411 | } |
| 412 | r := httptest.NewRequest("GET", bridgeOrigin+"/", nil) |
| 413 | r.AddCookie(old) |
| 414 | if _, ok := f.sessions.From(r); ok { |
| 415 | t.Fatal("successful real Verify did not revoke old session") |
| 416 | } |
| 417 | routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/") |
| 418 | } |
| 419 | |
| 420 | func TestHomeRouterStaleLocalGestureRestartsWithoutResumingProof(t *testing.T) { |
| 421 | f := newRouterHomeFixture(t) |
| 422 | b := f.browser() |
| 423 | b.localSignin(t, "alice@example.com") |
| 424 | b.linkProof(t, "opaque-alice", "alice@example.com") |
| 425 | oldProof := b.cookies[homeProofCookie] |
| 426 | oldSession := b.cookies[f.sessions.CookieName()] |
| 427 | if oldProof == nil || routerRows(t, f, "docs_link_flows") != 1 { |
| 428 | t.Fatal("pending proof fixture missing") |
| 429 | } |
| 430 | // Move only the actual stored local verification time into the past. The |
| 431 | // old pending proof remains present so its post-rotation refusal is exercised. |
| 432 | if _, err := f.app.db.Writer().Exec(`UPDATE sessions SET created_at=? WHERE token_hash=?`, time.Now().Add(-6*time.Minute).UTC().Format(time.RFC3339), sessions.HashToken(oldSession.Value)); err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | routerRedirect(t, b.do("GET", "/account/home", ""), "/signin/local") |
| 436 | if b.cookies[localLinkNavigationCookie] == nil || b.cookies[homeInitCookie] != nil || b.cookies[homeProofCookie] != nil { |
| 437 | t.Fatal("stale local restart retained link browser proof") |
| 438 | } |
| 439 | if rec := b.do("GET", "/signin/local", ""); rec.Code != 200 { |
| 440 | t.Fatal("signed-in stale visitor could not see local signin") |
| 441 | } |
| 442 | path := b.localLink(t, "alice@example.com") |
| 443 | verifyPath, verifyForm := confirmForm(path) |
| 444 | routerRedirect(t, b.do("POST", verifyPath, verifyForm), localLinkCompletePath) |
| 445 | routerRedirect(t, b.do("GET", localLinkCompletePath, ""), "/account/home") |
| 446 | if b.cookies[f.sessions.CookieName()].Value == oldSession.Value || b.cookies[homeProofCookie] != nil || b.cookies[homeInitCookie] != nil || routerRows(t, f, "docs_identity_links") != 0 { |
| 447 | t.Fatal("navigation minted or resumed link authority") |
| 448 | } |
| 449 | if rec := b.do("GET", "/account/home", ""); rec.Code != 200 { |
| 450 | t.Fatal("fresh local gesture did not reach initiation page") |
| 451 | } |
| 452 | // A copied old proof cannot attach to the freshly rotated local session. |
| 453 | b.cookies[homeProofCookie] = oldProof |
| 454 | if rec := b.do("POST", "/account/home/confirm", ""); rec.Code != 403 { |
| 455 | t.Fatal("old proof resumed after actual session rotation", rec.Code) |
| 456 | } |
| 457 | if routerRows(t, f, "docs_identity_links") != 0 || routerRows(t, f, "docs_link_flows") != 0 { |
| 458 | t.Fatal("old proof failure created link or retained reusable proof") |
| 459 | } |
| 460 | oldRequest := httptest.NewRequest("GET", bridgeOrigin+"/", nil) |
| 461 | oldRequest.AddCookie(oldSession) |
| 462 | if _, ok := f.sessions.From(oldRequest); ok { |
| 463 | t.Fatal("old local browser token survived reverification") |
| 464 | } |
| 465 | // Only this new explicit POST creates a new initiation, not the email or GET. |
| 466 | routerRedirect(t, b.do("POST", "/account/home", ""), "/auth/start?return_to=%2Faccount%2Fhome%2Fconfirm") |
| 467 | if routerRows(t, f, "docs_link_flows") != 1 || routerRows(t, f, "docs_identity_links") != 0 || b.cookies[homeProofCookie] != nil { |
| 468 | t.Fatal("new explicit initiation state wrong") |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | func TestHomeRouterCallbackChecksExactOriginIssuerAndAudience(t *testing.T) { |
| 473 | f := newRouterHomeFixture(t) |
| 474 | b := f.browser() |
| 475 | nonce := b.start(t, "/auth/start?return_to=%2F", "") |
| 476 | for _, tc := range []struct { |
| 477 | name, origin string |
| 478 | change func(*assertion.Claims) |
| 479 | }{ |
| 480 | {"wrong origin", "https://evil.example", nil}, |
| 481 | {"missing origin", "", nil}, |
| 482 | {"wrong signed issuer", bridgeIssuer, func(c *assertion.Claims) { c.Issuer = "https://other-id.example" }}, |
| 483 | {"wrong signed audience", bridgeIssuer, func(c *assertion.Claims) { c.Audience = "https://other.docs.oficina.works" }}, |
| 484 | } { |
| 485 | t.Run(tc.name, func(t *testing.T) { |
| 486 | rec := b.callback(f.token(t, nonce, "opaque", time.Now(), tc.change), tc.origin) |
| 487 | if rec.Code != 400 || routerRows(t, f, "oficina_handoff_inbox") != 0 || b.cookies[f.sessions.CookieName()] != nil { |
| 488 | t.Fatal("invalid callback created inbox/session", rec.Code) |
| 489 | } |
| 490 | }) |
| 491 | } |
| 492 | r := b.request("GET", "/auth/start?return_to=%2F", "") |
| 493 | r.Host = "other.docs.oficina.works" |
| 494 | if rec := b.serve(r); rec.Code != 404 { |
| 495 | t.Fatal("router accepted another team host", rec.Code) |
| 496 | } |
| 497 | // A valid issuer callback still crosses the outer CSRF boundary and reaches |
| 498 | // the shared consumer. Duplicate local cookies cannot poison that callback. |
| 499 | token := f.token(t, nonce, "opaque", time.Now(), nil) |
| 500 | r = httptest.NewRequest("POST", bridgeOrigin+"/auth/assertion", strings.NewReader(url.Values{"assertion": {token}}.Encode())) |
| 501 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 502 | r.Header.Set("Origin", bridgeIssuer) |
| 503 | r.Header.Set("Sec-Fetch-Site", "cross-site") |
| 504 | r.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: "bad"}) |
| 505 | r.AddCookie(&http.Cookie{Name: f.sessions.CookieName(), Value: "bad"}) |
| 506 | routerRedirect(t, b.serve(r), "/auth/finish") |
| 507 | if rec := b.do("GET", "/auth/finish", ""); rec.Code != 403 || !strings.Contains(rec.Body.String(), "/signin/local") { |
| 508 | t.Fatal("unknown verified identity not guided after valid callback") |
| 509 | } |
| 510 | if routerRows(t, f, "people") != 0 || routerRows(t, f, "docs_identity_links") != 0 { |
| 511 | t.Fatal("unknown callback created person/link") |
| 512 | } |
| 513 | } |
| 514 | |